Unify FunZone voucher sync on the accounting documents page.

Consolidate sync buttons into one flow on ????? ????????, deduplicate treasury vs invoice and customer vouchers, add FunZone inventory lifecycle views, and improve stat card number display.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-07 15:13:18 +03:30
parent f6b6e27294
commit 11dfe28623
27 changed files with 1017 additions and 384 deletions

View File

@@ -1,50 +1,36 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react' import { useCallback, useRef, useState } from 'react'
import { apiGet } from '../../api/client' import { apiGet } from '../api/client'
import { ENDPOINTS } from '../../api/config' import { ENDPOINTS } from '../api/config'
import type { ApiPaymentOrRefund } from '../../api/types' import type { ApiPaymentOrRefund } from '../api/types'
import type { AppData, Invoice } from '../../types' import type { AppData, Invoice } from '../types'
import { useStore } from '../../store/AppStore' import { useStore } from '../store/AppStore'
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync' import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync'
import { invoiceSideEffects } from './invoiceEffects' import { invoiceSideEffects } from '../pages/sales/invoiceEffects'
import { reconcileInvoicePaymentStatus } from './salesUtils' import { reconcileInvoicePaymentStatus } from '../pages/sales/salesUtils'
import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from './funzoneSalesSync' import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from '../pages/sales/funzoneSalesSync'
import { supersededVoucherIds } from './voucherDedupe'
export interface FunZoneSalesSyncStats { export interface UnifiedFunZoneSyncStats {
treasuryCreated: number treasuryCreated: number
treasuryUpdated: number treasuryUpdated: number
treasurySkipped: number treasurySkipped: number
treasuryRemoved: number
invoicesCreated: number invoicesCreated: number
invoicesUpdated: number invoicesUpdated: number
invoicesSkipped: number invoicesSkipped: number
duplicatesRemoved: number
unmatchedCustomers: number unmatchedCustomers: number
unmatchedOwners: number
funzonePayments: number funzonePayments: number
funzoneCancellations: number funzoneCancellations: number
} }
interface SalesSyncContextValue { export function useUnifiedFunZoneVoucherSync() {
loading: boolean
error: string | null
stats: FunZoneSalesSyncStats | null
lastSyncedAt: string | null
sync: () => Promise<void>
}
const SalesSyncContext = createContext<SalesSyncContextValue | null>(null)
export function useSalesFunZoneSync(): SalesSyncContextValue {
const ctx = useContext(SalesSyncContext)
if (!ctx) {
throw new Error('useSalesFunZoneSync must be used within SalesSyncProvider')
}
return ctx
}
export function SalesSyncProvider({ children }: { children: ReactNode }) {
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore() const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
const treasury = useFunZoneTreasurySync() const treasury = useFunZoneTreasurySync()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<FunZoneSalesSyncStats | null>(null) const [stats, setStats] = useState<UnifiedFunZoneSyncStats | null>(null)
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null) const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
const syncingRef = useRef(false) const syncingRef = useRef(false)
const dataRef = useRef(data) const dataRef = useRef(data)
@@ -90,25 +76,6 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
[upsertInvoice, upsertVoucher, removeVoucher], [upsertInvoice, upsertVoucher, removeVoucher],
) )
const persistSalesBatch = useCallback(
(baseData: AppData, items: Invoice[]) => {
const saved = assignInvoiceIds(items)
let created = 0
let updated = 0
let working = { ...baseData }
for (const invoice of saved) {
const isNew = !baseData.invoices.some((i) => i.id === invoice.id)
if (isNew) created += 1
else updated += 1
working = applyInvoiceIfChanged(working, invoice)
}
return { created, updated }
},
[applyInvoiceIfChanged],
)
const syncSalesInvoices = useCallback(async () => { const syncSalesInvoices = useCallback(async () => {
const current = dataRef.current const current = dataRef.current
const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>( const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
@@ -118,7 +85,17 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } = const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } =
buildFunZoneSalesSync(current, customerPayments) buildFunZoneSalesSync(current, customerPayments)
const { created, updated } = persistSalesBatch(current, toUpsert) const saved = assignInvoiceIds(toUpsert)
let created = 0
let updated = 0
let working = { ...current }
for (const invoice of saved) {
const isNew = !current.invoices.some((i) => i.id === invoice.id)
if (isNew) created += 1
else updated += 1
working = applyInvoiceIfChanged(working, invoice)
}
return { return {
invoicesCreated: created, invoicesCreated: created,
@@ -128,7 +105,7 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
funzonePayments: paymentCount, funzonePayments: paymentCount,
funzoneCancellations: cancellationCount, funzoneCancellations: cancellationCount,
} }
}, [persistSalesBatch]) }, [applyInvoiceIfChanged])
const sync = useCallback(async () => { const sync = useCallback(async () => {
if (syncingRef.current) return if (syncingRef.current) return
@@ -137,27 +114,36 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
setError(null) setError(null)
try { try {
const treasuryStats = await treasury.sync() const treasuryStats = await treasury.sync()
const freshAfterTreasury = await reload()
if (freshAfterTreasury) dataRef.current = freshAfterTreasury
const salesResult = await syncSalesInvoices() const salesResult = await syncSalesInvoices()
const freshAfterSales = await reload()
const working = freshAfterSales ?? dataRef.current
const duplicateIds = supersededVoucherIds(working)
for (const voucherId of duplicateIds) {
removeVoucher(voucherId)
}
await reload()
setStats({ setStats({
treasuryCreated: treasuryStats?.created ?? 0, treasuryCreated: treasuryStats?.created ?? 0,
treasuryUpdated: treasuryStats?.updated ?? 0, treasuryUpdated: treasuryStats?.updated ?? 0,
treasurySkipped: treasuryStats?.skipped ?? 0, treasurySkipped: treasuryStats?.skipped ?? 0,
treasuryRemoved: treasuryStats?.removed ?? 0,
...salesResult, ...salesResult,
duplicatesRemoved: duplicateIds.length,
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
}) })
setLastSyncedAt(new Date().toISOString()) setLastSyncedAt(new Date().toISOString())
reload()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی فروش با فان‌زون') setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی اسناد از فان‌زون')
} finally { } finally {
syncingRef.current = false syncingRef.current = false
setLoading(false) setLoading(false)
} }
}, [treasury, syncSalesInvoices, reload]) }, [treasury, syncSalesInvoices, reload, removeVoucher])
return ( return { sync, loading, error, stats, lastSyncedAt }
<SalesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
{children}
</SalesSyncContext.Provider>
)
} }

View File

@@ -0,0 +1,56 @@
import type { AppData, Voucher } from '../types'
import {
treasurySourceCustomerPayment,
treasurySourceOwnerTxn,
treasurySourceWalletPayment,
} from '../pages/treasury/funzoneTreasurySync'
import { duplicateFunzoneInvoiceVoucherIds } from '../pages/sales/funzoneVoucherLink'
import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
function treasuryVoucherForPaymentSource(
data: Pick<AppData, 'treasury' | 'vouchers'>,
paymentSource: string,
): Voucher | undefined {
const txn = data.treasury.find((t) => t.source === paymentSource)
if (!txn) return undefined
return data.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
}
/** Voucher ids superseded by a more complete treasury or invoice-linked record. */
export function supersededVoucherIds(data: AppData): string[] {
const remove = new Set<string>(duplicateFunzoneInvoiceVoucherIds(data))
for (const voucher of data.vouchers) {
const custMatch = voucher.source?.match(/^funzone:cust:([^:]+)$/)
if (custMatch) {
const paymentId = custMatch[1]
const treasuryVoucher =
treasuryVoucherForPaymentSource(data, treasurySourceCustomerPayment(paymentId)) ??
treasuryVoucherForPaymentSource(data, treasurySourceWalletPayment(paymentId))
if (treasuryVoucher && treasuryVoucher.id !== voucher.id) {
remove.add(voucher.id)
}
continue
}
const ownerMatch = voucher.source?.match(/^funzone:owner-txn:([^:]+)$/)
if (ownerMatch) {
const txnId = ownerMatch[1]
const treasuryVoucher = treasuryVoucherForPaymentSource(data, treasurySourceOwnerTxn(txnId))
if (treasuryVoucher && treasuryVoucher.id !== voucher.id) {
remove.add(voucher.id)
}
}
}
return [...remove]
}
export function isSupersededVoucher(voucher: Voucher, data: AppData): boolean {
return supersededVoucherIds(data).includes(voucher.id)
}
export function visibleVouchers(vouchers: Voucher[], data: AppData): Voucher[] {
const hidden = new Set(supersededVoucherIds(data))
return vouchers.filter((v) => !hidden.has(v.id))
}

View File

@@ -1,6 +1,10 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types' import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
import { treasurySourceOwnerTxn } from '../pages/treasury/funzoneTreasurySync' import {
treasurySourceCustomerPayment,
treasurySourceOwnerTxn,
treasurySourceWalletPayment,
} from '../pages/treasury/funzoneTreasurySync'
import { voucherSourceForTreasury } from '../pages/sales/salesUtils' import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
@@ -66,6 +70,23 @@ function ownerTxnAlreadyBookedViaTreasury(
return vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id)) return vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))
} }
function customerPaymentAlreadyBookedViaTreasury(
treasury: TreasuryTxn[],
vouchers: Voucher[],
paymentId: string,
): boolean {
for (const source of [
treasurySourceCustomerPayment(paymentId),
treasurySourceWalletPayment(paymentId),
]) {
const treasuryTxn = treasury.find((t) => t.source === source)
if (treasuryTxn && vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))) {
return true
}
}
return false
}
function buildOwnerVouchers( function buildOwnerVouchers(
records: ApiOwnerTransaction[], records: ApiOwnerTransaction[],
map: VoucherAccountMap, map: VoucherAccountMap,
@@ -141,6 +162,8 @@ function buildCustomerVouchers(
startNumber: number, startNumber: number,
invoices: Invoice[], invoices: Invoice[],
parties: Party[], parties: Party[],
treasury: TreasuryTxn[],
existingVouchers: Voucher[],
): { vouchers: Voucher[]; skipped: number } { ): { vouchers: Voucher[]; skipped: number } {
const vouchers: Voucher[] = [] const vouchers: Voucher[] = []
let skipped = 0 let skipped = 0
@@ -151,7 +174,10 @@ function buildCustomerVouchers(
if (amount <= 0) continue if (amount <= 0) continue
const source = `funzone:cust:${item.id}` const source = `funzone:cust:${item.id}`
if (existingSources.has(source)) { if (
existingSources.has(source) ||
customerPaymentAlreadyBookedViaTreasury(treasury, existingVouchers, item.id)
) {
skipped += 1 skipped += 1
continue continue
} }
@@ -242,9 +268,18 @@ export function useVoucherSync() {
const syncCustomerActivity = useCallback( const syncCustomerActivity = useCallback(
(records: ApiPaymentOrRefund[]): Promise<SyncResult> => (records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
run((map, sources, start) => run((map, sources, start) =>
buildCustomerVouchers(records, map, sources, start, data.invoices, data.parties), buildCustomerVouchers(
records,
map,
sources,
start,
data.invoices,
data.parties,
data.treasury,
data.vouchers,
),
), ),
[run, data.invoices, data.parties], [run, data.invoices, data.parties, data.treasury, data.vouchers],
) )
return { syncOwnerTransactions, syncCustomerActivity } return { syncOwnerTransactions, syncCustomerActivity }

View File

@@ -33,6 +33,9 @@ export const ENDPOINTS = {
ADMIN_LOGIN: '/auth/token/admin/', ADMIN_LOGIN: '/auth/token/admin/',
OWNERS: '/owners/', OWNERS: '/owners/',
CUSTOMERS: '/customers/', CUSTOMERS: '/customers/',
EVENTS: '/events/',
EVENT_CATEGORIES: '/event-categories/',
RESERVATIONS: '/reservations/?status=all',
OWNER_TRANSACTIONS: (ownerId: string) => OWNER_TRANSACTIONS: (ownerId: string) =>
`/owners/wallet/admin/transactions/?owner_id=${ownerId}`, `/owners/wallet/admin/transactions/?owner_id=${ownerId}`,
ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/', ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/',

View File

@@ -61,6 +61,9 @@ export interface ApiPaymentOrRefund {
ref_num: string ref_num: string
amount: number amount: number
event_name: string event_name: string
category_name?: string | null
tickets_count?: number | null
payment_method?: 'gateway' | 'wallet'
event_date: string | null event_date: string | null
event_time: string | null event_time: string | null
created_at: string created_at: string

View File

@@ -105,7 +105,7 @@ export function StatCard({
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-slate-500">{label}</p> <p className="text-sm text-slate-500">{label}</p>
<p className="mt-2 truncate text-2xl font-bold text-slate-800 tabular-nums">{value}</p> <p className="mt-2 break-words text-base font-bold leading-tight text-slate-800 tabular-nums">{value}</p>
{hint && <p className="mt-1 text-xs text-slate-400">{hint}</p>} {hint && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
</div> </div>
{icon && ( {icon && (

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { visibleVouchers } from '../accounting/voucherDedupe'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { Badge, Card, PageHeader, StatCard } from '../components/ui' import { Badge, Card, PageHeader, StatCard } from '../components/ui'
import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format' import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format'
@@ -51,7 +52,8 @@ export function Dashboard() {
const { data } = useStore() const { data } = useStore()
const metrics = useMemo(() => { const metrics = useMemo(() => {
const balances = trialBalance(data.accounts, data.vouchers) const vouchers = visibleVouchers(data.vouchers, data)
const balances = trialBalance(data.accounts, vouchers)
const liquidity = balances const liquidity = balances
.filter((b) => ['1001', '1002', '1003'].includes(b.account.code)) .filter((b) => ['1001', '1002', '1003'].includes(b.account.code))
.reduce((sum, b) => sum + b.balance, 0) .reduce((sum, b) => sum + b.balance, 0)

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { import {
Badge, Badge,
@@ -8,14 +8,63 @@ import {
DataTable, DataTable,
Field, Field,
Input, Input,
JalaliDateInput,
Modal, Modal,
PageHeader, PageHeader,
Select,
StatCard, StatCard,
type Column, type Column,
} from '../components/ui' } from '../components/ui'
import type { Product } from '../types' import type { Product } from '../types'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { formatMoney, sumAmounts, toAmount, toFa } from '../utils/format' import { formatDateTime, formatMoney, sumAmounts, toAmount, toFa } from '../utils/format'
import { apiGet } from '../api/client'
import { ENDPOINTS } from '../api/config'
import type { ApiCustomer } from '../api/types'
import { customerFullName } from '../api/types'
import { partyDisplayName } from '../parties/funzonePartySync'
import {
buildBuyerRows,
buildCategoryInventoryRows,
type BuyerRow,
type BuyerSort,
type CategoryInventoryRow,
type EventLifecycle,
type InventoryEvent,
type InventoryReservation,
} from './inventory/funzoneInventory'
interface ApiCategory {
id: string
name: string
}
function toApiPath(nextUrl: string): string {
if (!nextUrl.startsWith('http')) return nextUrl
const parsed = new URL(nextUrl)
const apiIndex = parsed.pathname.indexOf('/api/')
if (apiIndex >= 0) {
return `${parsed.pathname.slice(apiIndex + 4)}${parsed.search}`
}
return `${parsed.pathname}${parsed.search}`
}
async function getAllPages<T>(path: string): Promise<T[]> {
const items: T[] = []
let nextPath: string | null = path
while (nextPath) {
const response = await apiGet<unknown>(nextPath)
if (Array.isArray(response)) {
items.push(...(response as T[]))
break
}
if (!response || typeof response !== 'object') break
const record = response as { results?: T[]; next?: string | null }
items.push(...(record.results ?? []))
nextPath = record.next ? toApiPath(record.next) : null
}
return items
}
const emptyDraft = (): Product => ({ const emptyDraft = (): Product => ({
id: '', id: '',
@@ -34,6 +83,62 @@ export function Inventory() {
const [draft, setDraft] = useState<Product | null>(null) const [draft, setDraft] = useState<Product | null>(null)
const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null) const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null)
const [toDelete, setToDelete] = useState<Product | null>(null) const [toDelete, setToDelete] = useState<Product | null>(null)
const [funZoneCategories, setFunZoneCategories] = useState<ApiCategory[]>([])
const [funZoneEvents, setFunZoneEvents] = useState<InventoryEvent[]>([])
const [funZoneReservations, setFunZoneReservations] = useState<InventoryReservation[]>([])
const [funZoneCustomers, setFunZoneCustomers] = useState<ApiCustomer[]>([])
const [funZoneLoading, setFunZoneLoading] = useState(false)
const [funZoneError, setFunZoneError] = useState<string | null>(null)
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [selectedLifecycle, setSelectedLifecycle] = useState<EventLifecycle | 'all'>('all')
const [buyerDateFrom, setBuyerDateFrom] = useState('')
const [buyerDateTo, setBuyerDateTo] = useState('')
const [buyerSearch, setBuyerSearch] = useState('')
const [buyerSort, setBuyerSort] = useState<BuyerSort>('mostTickets')
useEffect(() => {
let cancelled = false
const loadFunZoneInventory = async () => {
setFunZoneLoading(true)
setFunZoneError(null)
try {
const [categories, events, reservations, customers] = await Promise.all([
getAllPages<ApiCategory>(ENDPOINTS.EVENT_CATEGORIES),
getAllPages<InventoryEvent>(ENDPOINTS.EVENTS),
getAllPages<InventoryReservation>(ENDPOINTS.RESERVATIONS),
getAllPages<ApiCustomer>(ENDPOINTS.CUSTOMERS),
])
if (cancelled) return
setFunZoneCategories(categories)
setFunZoneEvents(events)
setFunZoneReservations(reservations)
setFunZoneCustomers(customers)
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : 'خطا در دریافت داده‌های دسته‌بندی فان‌زون'
setFunZoneError(message)
} finally {
if (!cancelled) setFunZoneLoading(false)
}
}
void loadFunZoneInventory()
return () => {
cancelled = true
}
}, [])
const customerNameById = useMemo(() => {
const map = new Map<string, string>()
for (const customer of funZoneCustomers) {
map.set(customer.id, customerFullName(customer))
}
for (const party of data.parties) {
if (party.externalSource === 'funzone_customer' && party.externalId) {
map.set(party.externalId, partyDisplayName(party))
}
}
return map
}, [funZoneCustomers, data.parties])
const summary = useMemo(() => { const summary = useMemo(() => {
const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice))) const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice)))
@@ -49,6 +154,128 @@ export function Inventory() {
) )
}, [data.products, search]) }, [data.products, search])
const categoryInventoryRows = useMemo(
() => buildCategoryInventoryRows(funZoneCategories, funZoneEvents, funZoneReservations),
[funZoneCategories, funZoneEvents, funZoneReservations],
)
const selectedCategory = useMemo(
() => categoryInventoryRows.find((row) => row.id === selectedCategoryId) ?? null,
[categoryInventoryRows, selectedCategoryId],
)
const buyerRows = useMemo(
() =>
selectedCategory
? buildBuyerRows(
selectedCategory.id,
funZoneEvents,
funZoneReservations,
customerNameById,
{
lifecycle: selectedLifecycle,
dateFrom: buyerDateFrom,
dateTo: buyerDateTo,
search: buyerSearch,
sort: buyerSort,
},
)
: [],
[
selectedCategory,
funZoneEvents,
funZoneReservations,
customerNameById,
selectedLifecycle,
buyerDateFrom,
buyerDateTo,
buyerSearch,
buyerSort,
],
)
const buyerColumns: Array<Column<BuyerRow>> = [
{ key: 'name', header: 'خریدار', render: (row) => <span className="font-medium text-slate-800">{row.customerName}</span> },
{ key: 'tickets', header: 'تعداد بلیت', align: 'center', render: (row) => <span className="tabular-nums font-semibold text-brand-700">{toFa(row.tickets)}</span> },
{ key: 'purchases', header: 'دفعات خرید', align: 'center', render: (row) => <span className="tabular-nums">{toFa(row.purchases)}</span> },
{ key: 'amount', header: 'مبلغ کل', align: 'end', render: (row) => <span className="tabular-nums">{formatMoney(row.totalAmount)}</span> },
{ key: 'last', header: 'آخرین خرید', align: 'center', render: (row) => <span className="text-slate-600">{formatDateTime(row.lastBuyAt)}</span> },
]
const openBuyers = (categoryId: string, lifecycle: EventLifecycle | 'all') => {
setSelectedCategoryId(categoryId)
setSelectedLifecycle(lifecycle)
setBuyerSearch('')
setBuyerDateFrom('')
setBuyerDateTo('')
setBuyerSort('mostTickets')
}
const categoryColumns: Array<Column<CategoryInventoryRow>> = [
{
key: 'name',
header: 'کالا',
render: (row) => <span className="font-medium text-slate-800">{row.name}</span>,
},
{
key: 'inProcessCount',
header: 'در حال فرآیند',
align: 'center',
render: (row) => (
<button
type="button"
onClick={() => openBuyers(row.id, 'inProcess')}
className="tabular-nums font-semibold text-brand-700 underline decoration-dotted underline-offset-4 hover:text-brand-800"
>
{toFa(row.inProcessCount)}
</button>
),
},
{
key: 'completedCount',
header: 'تکمیل‌شده',
align: 'center',
render: (row) => (
<button
type="button"
onClick={() => openBuyers(row.id, 'completed')}
className="tabular-nums font-semibold text-emerald-700 underline decoration-dotted underline-offset-4 hover:text-emerald-800"
>
{toFa(row.completedCount)}
</button>
),
},
{
key: 'canceledCount',
header: 'لغوشده',
align: 'center',
render: (row) => (
<button
type="button"
onClick={() => openBuyers(row.id, 'canceled')}
className="tabular-nums font-semibold text-rose-700 underline decoration-dotted underline-offset-4 hover:text-rose-800"
title={row.canceledTickets > 0 ? `${toFa(row.canceledTickets)} بلیت لغوشده` : undefined}
>
{toFa(row.canceledCount)}
</button>
),
},
{
key: 'boughtTickets',
header: 'تعداد بلیت خریداری‌شده',
align: 'center',
render: (row) => (
<button
type="button"
onClick={() => openBuyers(row.id, 'all')}
className="tabular-nums text-brand-700 underline decoration-dotted underline-offset-4 hover:text-brand-800"
>
{toFa(row.boughtTickets)}
</button>
),
},
]
const handleSave = () => { const handleSave = () => {
if (!draft || !draft.name.trim() || !draft.code.trim()) return if (!draft || !draft.name.trim() || !draft.code.trim()) return
upsertProduct({ ...draft, id: draft.id || createId('prod-') }) upsertProduct({ ...draft, id: draft.id || createId('prod-') })
@@ -117,6 +344,24 @@ export function Inventory() {
<StatCard label="کالاهای کمتر از حد سفارش" value={toFa(summary.lowStock)} icon="⚠️" tone={summary.lowStock > 0 ? 'rose' : 'green'} /> <StatCard label="کالاهای کمتر از حد سفارش" value={toFa(summary.lowStock)} icon="⚠️" tone={summary.lowStock > 0 ? 'rose' : 'green'} />
</div> </div>
<Card className="p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">موجودی بر اساس دستهبندی فانزون</h2>
{funZoneLoading ? (
<Badge tone="slate">در حال بروزرسانی</Badge>
) : (
<Badge tone="blue">{toFa(categoryInventoryRows.length)} دستهبندی</Badge>
)}
</div>
{funZoneError ? <p className="mb-3 text-sm text-rose-600">{funZoneError}</p> : null}
<DataTable
columns={categoryColumns}
rows={categoryInventoryRows}
rowKey={(row) => row.id}
emptyTitle="دسته‌بندی‌ای برای نمایش یافت نشد"
/>
</Card>
<Card className="p-4"> <Card className="p-4">
<Input <Input
placeholder="جستجوی کالا…" placeholder="جستجوی کالا…"
@@ -127,6 +372,50 @@ export function Inventory() {
<DataTable columns={columns} rows={filtered} rowKey={(p) => p.id} emptyTitle="کالایی یافت نشد" /> <DataTable columns={columns} rows={filtered} rowKey={(p) => p.id} emptyTitle="کالایی یافت نشد" />
</Card> </Card>
<Modal
open={selectedCategory !== null}
title={selectedCategory ? `خریداران بلیت - ${selectedCategory.name}` : 'خریداران بلیت'}
onClose={() => setSelectedCategoryId(null)}
size="xl"
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label="از تاریخ">
<JalaliDateInput value={buyerDateFrom} onChange={setBuyerDateFrom} placeholder="انتخاب تاریخ" />
</Field>
<Field label="تا تاریخ">
<JalaliDateInput value={buyerDateTo} onChange={setBuyerDateTo} placeholder="انتخاب تاریخ" />
</Field>
<Field label="مرتب‌سازی">
<Select value={buyerSort} onChange={(e) => setBuyerSort(e.target.value as BuyerSort)}>
<option value="mostTickets">بیشترین بلیت</option>
<option value="mostPurchases">بیشترین دفعات خرید</option>
<option value="mostAmount">بیشترین مبلغ خرید</option>
<option value="newest">جدیدترین خرید</option>
<option value="oldest">قدیمیترین خرید</option>
</Select>
</Field>
<Field label="وضعیت رویداد">
<Select value={selectedLifecycle} onChange={(e) => setSelectedLifecycle(e.target.value as EventLifecycle | 'all')}>
<option value="all">همه (فعال + تکمیلشده)</option>
<option value="inProcess">در حال فرآیند</option>
<option value="completed">تکمیلشده</option>
<option value="canceled">لغوشده</option>
</Select>
</Field>
<Field label="جستجوی خریدار">
<Input value={buyerSearch} onChange={(e) => setBuyerSearch(e.target.value)} placeholder="نام مشتری…" />
</Field>
</div>
<DataTable
columns={buyerColumns}
rows={buyerRows}
rowKey={(row) => row.customerId}
emptyTitle="خریداری برای این دسته‌بندی یافت نشد"
/>
</div>
</Modal>
<Modal <Modal
open={draft !== null} open={draft !== null}
title={draft?.id ? 'ویرایش کالا' : 'تعریف کالای جدید'} title={draft?.id ? 'ویرایش کالا' : 'تعریف کالای جدید'}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { visibleVouchers } from '../accounting/voucherDedupe'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { Card, DataTable, PageHeader, Select, StatCard, type Column } from '../components/ui' import { Card, DataTable, PageHeader, Select, StatCard, type Column } from '../components/ui'
import { formatDate, formatMoney } from '../utils/format' import { formatDate, formatMoney } from '../utils/format'
@@ -24,10 +25,10 @@ export function Reports() {
const { data } = useStore() const { data } = useStore()
const [tab, setTab] = useState<ReportTab>('trial') const [tab, setTab] = useState<ReportTab>('trial')
const balances = useMemo( const balances = useMemo(() => {
() => trialBalance(data.accounts, data.vouchers), const vouchers = visibleVouchers(data.vouchers, data)
[data.accounts, data.vouchers], return trialBalance(data.accounts, vouchers)
) }, [data])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -103,10 +104,10 @@ function LedgerReport() {
const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '') const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '')
const account = leafAccounts.find((a) => a.id === accountId) const account = leafAccounts.find((a) => a.id === accountId)
const rows = useMemo( const rows = useMemo(() => {
() => accountLedger(accountId, account, data.vouchers), const vouchers = visibleVouchers(data.vouchers, data)
[accountId, account, data.vouchers], return accountLedger(accountId, account, vouchers)
) }, [accountId, account, data])
const columns: Array<Column<LedgerRow>> = [ const columns: Array<Column<LedgerRow>> = [
{ key: 'number', header: 'سند', render: (r) => <span className="font-mono">{r.voucherNumber}</span> }, { key: 'number', header: 'سند', render: (r) => <span className="font-mono">{r.voucherNumber}</span> },

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
@@ -44,8 +45,6 @@ import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, supplie
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync' import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
import { useFunZoneTreasurySync } from './treasury/useFunZoneTreasurySync'
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' } const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
@@ -58,8 +57,6 @@ export function Treasury() {
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore() const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
const { sync, loading: syncLoading, error: syncError, stats: syncStats } = useFunZoneTreasurySync()
const [draft, setDraft] = useState<TreasuryTxn | null>(null) const [draft, setDraft] = useState<TreasuryTxn | null>(null)
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null) const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
@@ -344,12 +341,6 @@ export function Treasury() {
<> <>
<Button variant="secondary" icon="↻" onClick={() => void sync()} disabled={syncLoading}>
{syncLoading ? 'همگام‌سازی…' : 'همگام‌سازی فان‌زون'}
</Button>
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}> <Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
دریافت دستی دریافت دستی
@@ -383,47 +374,19 @@ export function Treasury() {
<li> تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ سود در حساب درآمد (۴۰۰۱)</li> <li> تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ سود در حساب درآمد (۴۰۰۱)</li>
<li> <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li> <li> <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
<li> واریز کیف پول مالک و سهم رزرو در خزانه نیست یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li> <li> واریز کیف پول مالک و سهم رزرو در خزانه نیست یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
<li>
برای همگامسازی خودکار از فانزون به{' '}
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
اسناد حسابداری
</Link>{' '}
بروید.
</li>
</ul> </ul>
</div> </div>
{syncStats && (
<div className="text-sm text-slate-600">
<p>
آخرین همگامسازی: {toFa(syncStats.created)} جدید، {toFa(syncStats.updated)} بهروز،{' '}
{toFa(syncStats.removed)} حذف تکراری، {toFa(syncStats.skipped)} بدون تغییر
{syncStats.excludedOwnerTxns > 0 &&
`${toFa(syncStats.excludedOwnerTxns)} تراکنش داخلی مالک نادیده گرفته شد`}
</p>
{(syncStats.unmatchedCustomers > 0 || syncStats.unmatchedOwners > 0) && (
<p className="mt-1 text-amber-700">
{syncStats.unmatchedCustomers > 0 &&
`${toFa(syncStats.unmatchedCustomers)} پرداخت مشتری بدون طرف حساب — ابتدا مشتریان را همگام کنید. `}
{syncStats.unmatchedOwners > 0 &&
`${toFa(syncStats.unmatchedOwners)} تراکنش مالک بدون طرف حساب — ابتدا تأمین‌کنندگان را همگام کنید.`}
</p>
)}
</div>
)}
</div> </div>
{syncError && <p className="mt-3 text-sm text-rose-600">{syncError}</p>}
</Card> </Card>

View File

@@ -1,4 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useUnifiedFunZoneVoucherSync } from '../accounting/unifiedVoucherSync'
import { visibleVouchers } from '../accounting/voucherDedupe'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import { import {
Badge, Badge,
@@ -17,7 +19,7 @@ import {
} from '../components/ui' } from '../components/ui'
import type { Voucher, VoucherLine } from '../types' import type { Voucher, VoucherLine } from '../types'
import { createId, nextNumber } from '../utils/id' import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format' import { formatDate, formatDateTime, formatMoney, todayISO, toFa } from '../utils/format'
import { voucherBalance } from '../utils/accounting' import { voucherBalance } from '../utils/accounting'
import { resolveVoucherRegarding } from '../utils/voucherRegarding' import { resolveVoucherRegarding } from '../utils/voucherRegarding'
import { normalizeEventName } from '../utils/eventName' import { normalizeEventName } from '../utils/eventName'
@@ -55,6 +57,8 @@ function createDraft(items: Voucher[]): Voucher {
export function Vouchers() { export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore() const { data, upsertVoucher, removeVoucher } = useStore()
const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } =
useUnifiedFunZoneVoucherSync()
const [draft, setDraft] = useState<Voucher | null>(null) const [draft, setDraft] = useState<Voucher | null>(null)
const [toDelete, setToDelete] = useState<Voucher | null>(null) const [toDelete, setToDelete] = useState<Voucher | null>(null)
const [category, setCategory] = useState<VoucherCategoryFilter>('all') const [category, setCategory] = useState<VoucherCategoryFilter>('all')
@@ -70,10 +74,15 @@ export function Vouchers() {
) )
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—' const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
const summary = useMemo(() => voucherFilterSummary(data.vouchers, data), [data]) const displayedVouchers = useMemo(
() => visibleVouchers(data.vouchers, data),
[data.vouchers, data],
)
const summary = useMemo(() => voucherFilterSummary(displayedVouchers, data), [displayedVouchers, data])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const rows = filterVouchers(data.vouchers, data, { const rows = filterVouchers(displayedVouchers, data, {
...defaultVoucherFilters, ...defaultVoucherFilters,
category, category,
status: statusFilter, status: statusFilter,
@@ -83,7 +92,7 @@ export function Vouchers() {
search, search,
}) })
return rows.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number) return rows.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
}, [data, category, statusFilter, origin, dateFrom, dateTo, search]) }, [data, displayedVouchers, category, statusFilter, origin, dateFrom, dateTo, search])
const hasActiveFilters = const hasActiveFilters =
category !== 'all' || category !== 'all' ||
@@ -220,14 +229,49 @@ export function Vouchers() {
title="اسناد حسابداری" title="اسناد حسابداری"
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار" subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
actions={ actions={
<Button icon="" onClick={() => setDraft(createDraft(data.vouchers))}> <>
سند جدید <Button
</Button> variant="secondary"
icon="↻"
disabled={syncLoading}
onClick={() => void sync()}
>
{syncLoading ? 'در حال همگام‌سازی…' : 'همگام‌سازی اسناد از فان‌زون'}
</Button>
<Button icon="" onClick={() => setDraft(createDraft(data.vouchers))}>
سند جدید
</Button>
</>
} }
/> />
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
<p className="mt-1 text-sm leading-relaxed text-slate-600">
یکبار همگام‌سازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری بدون تکرار.
برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته میشود.
</p>
{lastSyncedAt ? (
<p className="mt-2 text-xs text-slate-500">
آخرین همگامسازی: {formatDateTime(lastSyncedAt)}
</p>
) : null}
{syncStats ? (
<p className="mt-2 text-xs text-emerald-700">
دریافت/پرداخت جدید: {toFa(syncStats.treasuryCreated)} · فاکتور جدید:{' '}
{toFa(syncStats.invoicesCreated)} · اسناد تکراری حذفشده:{' '}
{toFa(syncStats.duplicatesRemoved)}
</p>
) : null}
{syncError ? <p className="mt-2 text-sm text-rose-600">{syncError}</p> : null}
</div>
</div>
</Card>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="همه اسناد" value={toFa(data.vouchers.length)} /> <StatCard label="همه اسناد" value={toFa(displayedVouchers.length)} />
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" /> <StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
<StatCard label="تأمین‌کنندگان" value={toFa(summary.supplier)} tone="amber" /> <StatCard label="تأمین‌کنندگان" value={toFa(summary.supplier)} tone="amber" />
<StatCard label="دستی" value={toFa(summary.manual)} /> <StatCard label="دستی" value={toFa(summary.manual)} />
@@ -279,7 +323,7 @@ export function Vouchers() {
</div> </div>
<p className="mb-3 text-sm text-slate-500"> <p className="mb-3 text-sm text-slate-500">
نمایش {toFa(filtered.length)} از {toFa(data.vouchers.length)} سند نمایش {toFa(filtered.length)} از {toFa(displayedVouchers.length)} سند
</p> </p>
<DataTable <DataTable

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
buildBuyerRows,
buildCategoryInventoryRows,
eventLifecycle,
type InventoryEvent,
type InventoryReservation,
} from './funzoneInventory'
const categories = [{ id: 'cat-1', name: 'بازی' }]
const events: InventoryEvent[] = [
{
id: 'evt-active',
name: 'Active Event',
minimum: 5,
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2099-01-01T10:00:00Z',
},
{
id: 'evt-done',
name: 'Done Event',
minimum: 2,
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2020-01-01T10:00:00Z',
},
{
id: 'evt-failed',
name: 'Failed Event',
minimum: 10,
price: 100,
category: { id: 'cat-1', name: 'بازی' },
start_time: '2020-01-01T10:00:00Z',
},
]
const reservations: InventoryReservation[] = [
{
id: 'r1',
customer: 'c1',
customer_name: 'علی رضایی',
number_of_people: 3,
total_amount: 300,
status: 'confirmed',
reservation_date: '2024-01-01T10:00:00Z',
event: 'evt-active',
},
{
id: 'r2',
customer: 'c2',
customer_name: 'سارا محمدی',
number_of_people: 2,
total_amount: 200,
status: 'completed',
reservation_date: '2024-02-01T10:00:00Z',
event: 'evt-done',
},
{
id: 'r3',
customer: 'c3',
customer_name: 'رضا کریمی',
number_of_people: 4,
total_amount: 400,
status: 'confirmed',
reservation_date: '2024-03-01T10:00:00Z',
event: 'evt-failed',
},
]
describe('funzoneInventory', () => {
it('classifies lifecycle by start time and minimum seats', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
expect(eventLifecycle(events[0], 3, now)).toBe('inProcess')
expect(eventLifecycle(events[1], 2, now)).toBe('completed')
expect(eventLifecycle(events[2], 4, now)).toBe('canceled')
})
it('aggregates category counts and bought/canceled tickets', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
const rows = buildCategoryInventoryRows(categories, events, reservations, now)
expect(rows).toHaveLength(1)
expect(rows[0].inProcessCount).toBe(1)
expect(rows[0].completedCount).toBe(1)
expect(rows[0].canceledCount).toBe(1)
expect(rows[0].boughtTickets).toBe(5)
expect(rows[0].canceledTickets).toBe(4)
})
it('shows confirmed buyers for failed events in canceled lifecycle', () => {
const now = Date.parse('2025-01-01T00:00:00Z')
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
lifecycle: 'canceled',
dateFrom: '',
dateTo: '',
search: '',
sort: 'mostTickets',
nowMs: now,
})
expect(buyers).toHaveLength(1)
expect(buyers[0].customerName).toBe('رضا کریمی')
expect(buyers[0].tickets).toBe(4)
})
})

View File

@@ -0,0 +1,253 @@
import { toAmount } from '../../utils/format'
export interface InventoryCategory {
id: string
name: string
}
export interface InventoryEvent {
id: string
name?: string
minimum?: number
price?: number
category?: { id?: string; name?: string } | null
start_time?: string | null
}
export interface InventoryReservation {
id: string
customer: string
customer_name?: string
number_of_people: number
total_amount?: number | null
status: string
reservation_date: string
event?: string
}
export type EventLifecycle = 'inProcess' | 'completed' | 'canceled'
export interface CategoryInventoryRow {
id: string
name: string
inProcessCount: number
completedCount: number
canceledCount: number
boughtTickets: number
canceledTickets: number
}
export interface BuyerRow {
customerId: string
customerName: string
tickets: number
purchases: number
totalAmount: number
firstBuyAt: string
lastBuyAt: string
}
export type BuyerSort = 'mostTickets' | 'mostPurchases' | 'mostAmount' | 'newest' | 'oldest'
const PURCHASE_STATUSES = new Set(['confirmed', 'completed'])
const CANCELED_PURCHASE_STATUSES = new Set(['confirmed', 'completed', 'cancelled'])
const MINIMUM_STATUSES = new Set(['pending', 'confirmed', 'completed'])
export function eventLifecycle(
event: InventoryEvent,
seatsForMinimum: number,
nowMs: number = Date.now(),
): EventLifecycle {
const startMs = event.start_time ? Date.parse(event.start_time) : NaN
const started = Number.isFinite(startMs) && startMs <= nowMs
if (!started) return 'inProcess'
const minimum = toAmount(event.minimum ?? 0)
return seatsForMinimum >= minimum ? 'completed' : 'canceled'
}
export function buildTicketMaps(reservations: InventoryReservation[]) {
const seatsForMinimum = new Map<string, number>()
const purchased = new Map<string, number>()
const canceledPurchases = new Map<string, number>()
for (const reservation of reservations) {
const eventId = reservation.event
if (!eventId) continue
const count = Math.max(0, toAmount(reservation.number_of_people))
if (MINIMUM_STATUSES.has(reservation.status)) {
seatsForMinimum.set(eventId, (seatsForMinimum.get(eventId) ?? 0) + count)
}
if (PURCHASE_STATUSES.has(reservation.status)) {
purchased.set(eventId, (purchased.get(eventId) ?? 0) + count)
}
if (reservation.status === 'cancelled') {
canceledPurchases.set(eventId, (canceledPurchases.get(eventId) ?? 0) + count)
}
}
return { seatsForMinimum, purchased, canceledPurchases }
}
export function buildCategoryInventoryRows(
categories: InventoryCategory[],
events: InventoryEvent[],
reservations: InventoryReservation[],
nowMs: number = Date.now(),
): CategoryInventoryRow[] {
const { seatsForMinimum, purchased, canceledPurchases } = buildTicketMaps(reservations)
const lifecycleByEvent = new Map<string, EventLifecycle>()
for (const event of events) {
lifecycleByEvent.set(
event.id,
eventLifecycle(event, seatsForMinimum.get(event.id) ?? 0, nowMs),
)
}
const byCategory = new Map<string, CategoryInventoryRow>()
for (const category of categories) {
byCategory.set(category.id, {
id: category.id,
name: category.name,
inProcessCount: 0,
completedCount: 0,
canceledCount: 0,
boughtTickets: 0,
canceledTickets: 0,
})
}
for (const event of events) {
const categoryId = event.category?.id
if (!categoryId) continue
const row = byCategory.get(categoryId) ?? {
id: categoryId,
name: event.category?.name ?? 'بدون دسته‌بندی',
inProcessCount: 0,
completedCount: 0,
canceledCount: 0,
boughtTickets: 0,
canceledTickets: 0,
}
const lifecycle = lifecycleByEvent.get(event.id) ?? 'inProcess'
if (lifecycle === 'inProcess') row.inProcessCount += 1
else if (lifecycle === 'completed') row.completedCount += 1
else row.canceledCount += 1
const sold = purchased.get(event.id) ?? 0
if (lifecycle === 'completed' || lifecycle === 'inProcess') {
row.boughtTickets += sold
} else {
// Failed events: count purchased tickets as canceled inventory.
row.canceledTickets += sold + (canceledPurchases.get(event.id) ?? 0)
}
byCategory.set(categoryId, row)
}
return Array.from(byCategory.values()).sort(
(a, b) =>
b.inProcessCount +
b.completedCount +
b.canceledCount -
(a.inProcessCount + a.completedCount + a.canceledCount),
)
}
function reservationMatchesLifecycle(
reservation: InventoryReservation,
lifecycle: EventLifecycle,
): boolean {
if (lifecycle === 'canceled') {
return CANCELED_PURCHASE_STATUSES.has(reservation.status)
}
return PURCHASE_STATUSES.has(reservation.status)
}
export function buildBuyerRows(
categoryId: string,
events: InventoryEvent[],
reservations: InventoryReservation[],
customerNameById: Map<string, string>,
options: {
lifecycle: EventLifecycle | 'all'
dateFrom: string
dateTo: string
search: string
sort: BuyerSort
nowMs?: number
},
): BuyerRow[] {
const { seatsForMinimum } = buildTicketMaps(reservations)
const eventById = new Map(events.map((event) => [event.id, event]))
const lifecycleByEvent = new Map<string, EventLifecycle>()
for (const event of events) {
lifecycleByEvent.set(
event.id,
eventLifecycle(event, seatsForMinimum.get(event.id) ?? 0, options.nowMs),
)
}
const query = options.search.trim().toLowerCase()
const byCustomer = new Map<string, BuyerRow>()
for (const reservation of reservations) {
const eventId = reservation.event
if (!eventId) continue
const event = eventById.get(eventId)
if (!event || event.category?.id !== categoryId) continue
const lifecycle = lifecycleByEvent.get(eventId) ?? 'inProcess'
if (options.lifecycle !== 'all' && lifecycle !== options.lifecycle) continue
if (options.lifecycle === 'all' && lifecycle === 'canceled') continue
if (!reservationMatchesLifecycle(reservation, lifecycle)) continue
const day = reservation.reservation_date.slice(0, 10)
if (options.dateFrom && day < options.dateFrom) continue
if (options.dateTo && day > options.dateTo) continue
const customerName =
reservation.customer_name?.trim() ||
customerNameById.get(reservation.customer) ||
'بدون نام'
if (query && !customerName.toLowerCase().includes(query)) continue
const tickets = Math.max(0, toAmount(reservation.number_of_people))
const amount = toAmount(reservation.total_amount ?? tickets * toAmount(event.price ?? 0))
const existing = byCustomer.get(reservation.customer) ?? {
customerId: reservation.customer,
customerName,
tickets: 0,
purchases: 0,
totalAmount: 0,
firstBuyAt: reservation.reservation_date,
lastBuyAt: reservation.reservation_date,
}
existing.customerName = customerName
existing.tickets += tickets
existing.purchases += 1
existing.totalAmount += amount
if (reservation.reservation_date < existing.firstBuyAt) {
existing.firstBuyAt = reservation.reservation_date
}
if (reservation.reservation_date > existing.lastBuyAt) {
existing.lastBuyAt = reservation.reservation_date
}
byCustomer.set(reservation.customer, existing)
}
const rows = Array.from(byCustomer.values())
rows.sort((a, b) => {
if (options.sort === 'mostTickets') return b.tickets - a.tickets
if (options.sort === 'mostPurchases') return b.purchases - a.purchases
if (options.sort === 'mostAmount') return b.totalAmount - a.totalAmount
if (options.sort === 'oldest') return a.firstBuyAt.localeCompare(b.firstBuyAt)
return b.lastBuyAt.localeCompare(a.lastBuyAt)
})
return rows
}

View File

@@ -1,46 +1,17 @@
import { useMemo } from 'react' import { Link } from 'react-router-dom'
import { Button, Card } from '../../components/ui' import { Card } from '../../components/ui'
import { useStore } from '../../store/AppStore'
import { countFunZoneSupplierDocuments } from '../../utils/voucherFilters'
import { formatDateTime, toFa } from '../../utils/format'
import { usePurchasesFunZoneSync } from './PurchasesSyncContext'
export function FunZonePurchasesSyncBanner() { export function FunZonePurchasesSyncBanner() {
const { loading, error, stats, lastSyncedAt, sync } = usePurchasesFunZoneSync()
const { data } = useStore()
const syncedCount = useMemo(() => countFunZoneSupplierDocuments(data), [data])
return ( return (
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4"> <Card className="border-slate-200 bg-slate-50/80 p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
<div className="min-w-0 flex-1"> <p className="mt-1 text-sm leading-relaxed text-slate-600">
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3> برای دریافت برداشتها و بازپرداختهای مالکان و صدور سند حسابداری، از صفحه{' '}
<p className="mt-1 text-sm leading-relaxed text-slate-600"> <Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
برای دریافت برداشتها و بازپرداختهای مالکان از فانزون و تبدیل آنها به پرداخت و سند حسابداری، اسناد حسابداری
دکمه همگامسازی را بزنید. </Link>{' '}
</p> دکمه «همگامسازی اسناد از فانزون» را بزنید.
<div className="mt-3 flex flex-wrap gap-2 text-xs"> </p>
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
{toFa(syncedCount)} تراکنش همگامشده
</span>
{lastSyncedAt ? (
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
</span>
) : null}
{stats ? (
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
پرداخت جدید: {toFa(stats.treasuryCreated)} · سند جدید: {toFa(stats.ownerVouchersCreated)}
</span>
) : null}
</div>
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
</div>
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
{loading ? 'در حال همگام‌سازی…' : 'همگام‌سازی با فان‌زون'}
</Button>
</div>
</Card> </Card>
) )
} }

View File

@@ -1,6 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { PageHeader } from '../../components/ui' import { PageHeader } from '../../components/ui'
import { PurchasesSyncProvider } from './PurchasesSyncContext'
const purchaseNavItems = [ const purchaseNavItems = [
{ to: '/purchases/suppliers', label: 'تأمین‌کنندگان', icon: '🏢', end: false }, { to: '/purchases/suppliers', label: 'تأمین‌کنندگان', icon: '🏢', end: false },
@@ -10,7 +9,6 @@ const purchaseNavItems = [
export function PurchasesLayout() { export function PurchasesLayout() {
return ( return (
<PurchasesSyncProvider>
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="تأمین‌کنندگان و خرید" title="تأمین‌کنندگان و خرید"
@@ -48,6 +46,5 @@ export function PurchasesLayout() {
</div> </div>
</div> </div>
</div> </div>
</PurchasesSyncProvider>
) )
} }

View File

@@ -1,86 +0,0 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
import { apiGet, asList } from '../../api/client'
import { ENDPOINTS } from '../../api/config'
import type { ApiOwnerTransaction } from '../../api/types'
import { useVoucherSync } from '../../accounting/voucherSync'
import { useStore } from '../../store/AppStore'
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync'
export interface FunZonePurchasesSyncStats {
treasuryCreated: number
treasuryUpdated: number
treasurySkipped: number
treasuryRemoved: number
ownerVouchersCreated: number
ownerVouchersSkipped: number
unmatchedOwners: number
ownerWithdrawCount: number
}
interface PurchasesSyncContextValue {
loading: boolean
error: string | null
stats: FunZonePurchasesSyncStats | null
lastSyncedAt: string | null
sync: () => Promise<void>
}
const PurchasesSyncContext = createContext<PurchasesSyncContextValue | null>(null)
export function usePurchasesFunZoneSync(): PurchasesSyncContextValue {
const ctx = useContext(PurchasesSyncContext)
if (!ctx) {
throw new Error('usePurchasesFunZoneSync must be used within PurchasesSyncProvider')
}
return ctx
}
export function PurchasesSyncProvider({ children }: { children: ReactNode }) {
const { reload } = useStore()
const treasury = useFunZoneTreasurySync()
const { syncOwnerTransactions } = useVoucherSync()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<FunZonePurchasesSyncStats | null>(null)
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
const syncingRef = useRef(false)
const sync = useCallback(async () => {
if (syncingRef.current) return
syncingRef.current = true
setLoading(true)
setError(null)
try {
const treasuryStats = await treasury.sync()
const ownerTransactions = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(
asList<ApiOwnerTransaction>,
)
const ownerVoucherResult = await syncOwnerTransactions(ownerTransactions)
setStats({
treasuryCreated: treasuryStats?.created ?? 0,
treasuryUpdated: treasuryStats?.updated ?? 0,
treasurySkipped: treasuryStats?.skipped ?? 0,
treasuryRemoved: treasuryStats?.removed ?? 0,
ownerVouchersCreated: ownerVoucherResult.created,
ownerVouchersSkipped: ownerVoucherResult.skipped,
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
ownerWithdrawCount: treasuryStats?.ownerTxnCount ?? 0,
})
setLastSyncedAt(new Date().toISOString())
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی تأمین‌کنندگان با فان‌زون')
} finally {
syncingRef.current = false
setLoading(false)
}
}, [treasury, syncOwnerTransactions, reload])
return (
<PurchasesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
{children}
</PurchasesSyncContext.Provider>
)
}

View File

@@ -1,50 +1,17 @@
import { Button, Card } from '../../components/ui' import { Link } from 'react-router-dom'
import { formatDateTime, toFa } from '../../utils/format' import { Card } from '../../components/ui'
import { useSalesFunZoneSync } from './SalesSyncContext'
import { useStore } from '../../store/AppStore'
import { useMemo } from 'react'
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
import { salesDocuments } from './salesUtils'
export function FunZoneSalesSyncBanner() { export function FunZoneSalesSyncBanner() {
const { loading, error, stats, lastSyncedAt, sync } = useSalesFunZoneSync()
const { data } = useStore()
const funzoneDocCount = useMemo(
() => salesDocuments(data.invoices).filter(isFunZoneSalesInvoice).length,
[data.invoices],
)
return ( return (
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4"> <Card className="border-slate-200 bg-slate-50/80 p-4">
<div className="flex flex-wrap items-start justify-between gap-3"> <h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
<div className="min-w-0 flex-1"> <p className="mt-1 text-sm leading-relaxed text-slate-600">
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3> برای دریافت پرداختهای رویداد، فاکتور فروش و سند حسابداری از فانزون، از صفحه{' '}
<p className="mt-1 text-sm leading-relaxed text-slate-600"> <Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
برای دریافت پرداختهای رویداد و لغو رزرو از فانزون و تبدیل آنها به فاکتور فروش/برگشتی و اسناد حسابداری
دریافت و پرداخت، دکمه همگامسازی را بزنید. </Link>{' '}
</p> دکمه «همگامسازی اسناد از فانزون» را بزنید.
<div className="mt-3 flex flex-wrap gap-2 text-xs"> </p>
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
{toFa(funzoneDocCount)} سند همگامشده در فروش
</span>
{lastSyncedAt ? (
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
</span>
) : null}
{stats ? (
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
فاکتور جدید: {toFa(stats.invoicesCreated)} · دریافت/پرداخت: {toFa(stats.treasuryCreated)}
</span>
) : null}
</div>
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
</div>
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
{loading ? 'در حال همگام‌سازی…' : 'همگام‌سازی با فان‌زون'}
</Button>
</div>
</Card> </Card>
) )
} }

View File

@@ -1,12 +1,10 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { PageHeader } from '../../components/ui' import { PageHeader } from '../../components/ui'
import { salesNavItems } from './salesNavigation' import { salesNavItems } from './salesNavigation'
import { SalesSyncProvider } from './SalesSyncContext'
export function SalesLayout() { export function SalesLayout() {
return ( return (
<SalesSyncProvider> <div className="space-y-6">
<div className="space-y-6">
<PageHeader <PageHeader
title="مشتریان و فروش" title="مشتریان و فروش"
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیه‌ها و مرور فروش" subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیه‌ها و مرور فروش"
@@ -43,6 +41,5 @@ export function SalesLayout() {
</div> </div>
</div> </div>
</div> </div>
</SalesSyncProvider>
) )
} }

View File

@@ -4,13 +4,16 @@ import { findExistingParty } from '../../parties/funzonePartySync'
import { createId, nextNumber } from '../../utils/id' import { createId, nextNumber } from '../../utils/id'
import { isEventCustomerPayment } from '../../utils/funzoneFees' import { isEventCustomerPayment } from '../../utils/funzoneFees'
import { invoiceTotals } from '../../utils/accounting' import { invoiceTotals } from '../../utils/accounting'
import { eventNameWithCategory } from '../../utils/eventName'
import { byDocumentType, isWithdrawalReturn } from './salesUtils' import { byDocumentType, isWithdrawalReturn } from './salesUtils'
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:' export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:' export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
export function funzonePaymentNoteTag(paymentId: string): string { export function funzonePaymentNoteTag(paymentId: string, wallet = false): string {
return `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}` return wallet
? `${FUNZONE_PAYMENT_NOTE_PREFIX}wallet:${paymentId}`
: `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}`
} }
export function funzoneCancellationNoteTag(paymentId: string): string { export function funzoneCancellationNoteTag(paymentId: string): string {
@@ -75,7 +78,7 @@ export function buildSalesInvoiceFromCustomerPayment(
if (item.type === 'payment') { if (item.type === 'payment') {
if (!isEventCustomerPayment(item.event_name)) return null if (!isEventCustomerPayment(item.event_name)) return null
const tag = funzonePaymentNoteTag(item.id) const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag) const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
return { return {
@@ -95,7 +98,7 @@ export function buildSalesInvoiceFromCustomerPayment(
unitPrice: amount, unitPrice: amount,
discount: 0, discount: 0,
taxRate: 0, taxRate: 0,
eventName: item.event_name, eventName: eventNameWithCategory(item.event_name, item.category_name),
}, },
], ],
} }
@@ -127,7 +130,7 @@ export function buildSalesInvoiceFromCustomerPayment(
unitPrice: amount, unitPrice: amount,
discount: 0, discount: 0,
taxRate: 0, taxRate: 0,
eventName: item.event_name, eventName: eventNameWithCategory(item.event_name, item.category_name),
}, },
], ],
relatedInvoiceId: prior?.relatedInvoiceId, relatedInvoiceId: prior?.relatedInvoiceId,
@@ -167,7 +170,7 @@ export function buildFunZoneSalesSync(
const tag = const tag =
item.type === 'payment' item.type === 'payment'
? funzonePaymentNoteTag(item.id) ? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
: funzoneCancellationNoteTag(item.id) : funzoneCancellationNoteTag(item.id)
const existing = findInvoiceByNoteTag(data.invoices, tag) const existing = findInvoiceByNoteTag(data.invoices, tag)
const built = buildSalesInvoiceFromCustomerPayment( const built = buildSalesInvoiceFromCustomerPayment(

View File

@@ -0,0 +1,59 @@
import type { AppData, Invoice } from '../../types'
import {
treasurySourceCustomerPayment,
treasurySourceWalletPayment,
} from '../treasury/funzoneTreasurySync'
import { findVoucherForInvoice, voucherSourceForTreasury } from './salesUtils'
import { isFunZoneTicketSalesInvoice } from './funzoneSalesSync'
/** Parses FunZone payment id embedded in a synced sales invoice note. */
export function funzonePaymentRefFromInvoice(
invoice: Invoice,
): { method: 'gateway' | 'wallet'; id: string } | null {
if (!isFunZoneTicketSalesInvoice(invoice)) return null
const walletMatch = invoice.note.match(/funzone:pay:wallet:([^\s\n]+)/)
if (walletMatch?.[1]) return { method: 'wallet', id: walletMatch[1] }
const gatewayMatch = invoice.note.match(/funzone:pay:([^\s\n]+)/)
if (gatewayMatch?.[1] && !gatewayMatch[1].startsWith('wallet:')) {
return { method: 'gateway', id: gatewayMatch[1] }
}
return null
}
export function treasurySourceForFunzoneInvoice(invoice: Invoice): string | null {
const ref = funzonePaymentRefFromInvoice(invoice)
if (!ref) return null
return ref.method === 'wallet'
? treasurySourceWalletPayment(ref.id)
: treasurySourceCustomerPayment(ref.id)
}
/** Event ticket sales are booked once via treasury (fee split); skip duplicate invoice vouchers. */
export function shouldSkipInvoiceVoucherForFunzoneTicket(
invoice: Invoice,
data: Pick<AppData, 'treasury'>,
): boolean {
const source = treasurySourceForFunzoneInvoice(invoice)
if (!source) return false
return data.treasury.some((txn) => txn.source === source)
}
/** Removes invoice vouchers when a treasury voucher already exists for the same FunZone payment. */
export function duplicateFunzoneInvoiceVoucherIds(data: AppData): string[] {
const toRemove: string[] = []
for (const invoice of data.invoices) {
if (!isFunZoneTicketSalesInvoice(invoice)) continue
const source = treasurySourceForFunzoneInvoice(invoice)
if (!source) continue
const treasuryTxn = data.treasury.find((txn) => txn.source === source)
if (!treasuryTxn) continue
const treasuryVoucher = data.vouchers.find(
(v) => v.source === voucherSourceForTreasury(treasuryTxn.id),
)
const invoiceVoucher = findVoucherForInvoice(data.vouchers, invoice)
if (treasuryVoucher && invoiceVoucher && treasuryVoucher.id !== invoiceVoucher.id) {
toRemove.push(invoiceVoucher.id)
}
}
return toRemove
}

View File

@@ -10,6 +10,7 @@ import {
stockDeltas, stockDeltas,
voucherSourceForInvoice, voucherSourceForInvoice,
} from './salesUtils' } from './salesUtils'
import { shouldSkipInvoiceVoucherForFunzoneTicket } from './funzoneVoucherLink'
/** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */ /** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */
export function invoiceSideEffects( export function invoiceSideEffects(
@@ -23,7 +24,10 @@ export function invoiceSideEffects(
} { } {
const products = applyStockChanges(data.products, saved, previous ?? null) const products = applyStockChanges(data.products, saved, previous ?? null)
const existingId = findVoucherForInvoice(data.vouchers, saved)?.id const existingId = findVoucherForInvoice(data.vouchers, saved)?.id
const voucher = buildInvoiceVoucher(saved, data) let voucher = buildInvoiceVoucher(saved, data)
if (voucher && shouldSkipInvoiceVoucherForFunzoneTicket(saved, data)) {
voucher = null
}
return { products, voucher, removeVoucherId: existingId } return { products, voucher, removeVoucherId: existingId }
} }

View File

@@ -456,10 +456,13 @@ export function buildTreasuryVoucher(
): Voucher | null { ): Voucher | null {
if (txn.amount <= 0) return null if (txn.amount <= 0) return null
const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId } = data.settings.voucherAccounts const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId, customerPayableAccountId } =
data.settings.voucherAccounts
const bankId = accountByCode(data.accounts, '1002', bankAccountId) const bankId = accountByCode(data.accounts, '1002', bankAccountId)
if (!bankId) return null if (!bankId) return null
const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined
const counterAccountId = const counterAccountId =
party?.kind === 'supplier' party?.kind === 'supplier'
@@ -491,6 +494,10 @@ export function buildTreasuryVoucher(
if (hasEventSplit) { if (hasEventSplit) {
const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description) const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description)
const debitAccountId =
txn.method === 'cash' && customerPayableId ? customerPayableId : bankId
const debitLabel =
txn.method === 'cash' ? 'پرداخت از کیف پول مشتری' : 'واریز بابت رویداد'
return { return {
id: createId('v-'), id: createId('v-'),
number: nextNumber(data.vouchers), number: nextNumber(data.vouchers),
@@ -500,7 +507,7 @@ export function buildTreasuryVoucher(
status: 'posted', status: 'posted',
source: voucherSourceForTreasury(txn.id), source: voucherSourceForTreasury(txn.id),
lines: [ lines: [
makeLine(bankId, amount, 0, `واریز بابت رویداد - ${label}`), makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`), makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`), makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`), makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),

View File

@@ -19,6 +19,10 @@ export function treasurySourceCustomerPayment(id: string): string {
return `funzone:cust-pay:${id}` return `funzone:cust-pay:${id}`
} }
export function treasurySourceWalletPayment(id: string): string {
return `funzone:wallet-pay:${id}`
}
export function treasurySourceOwnerTxn(id: string): string { export function treasurySourceOwnerTxn(id: string): string {
return `funzone:owner-txn:${id}` return `funzone:owner-txn:${id}`
} }
@@ -57,15 +61,18 @@ export function buildTreasuryFromCustomerPayment(
if (amount <= 0) return null if (amount <= 0) return null
const isPayment = item.type === 'payment' const isPayment = item.type === 'payment'
const fromWallet = item.payment_method === 'wallet'
const kind = isPayment ? 'receipt' : 'payment' const kind = isPayment ? 'receipt' : 'payment'
const isEvent = isPayment && isEventCustomerPayment(item.event_name) const isEvent = isPayment && isEventCustomerPayment(item.event_name)
const split = isEvent ? splitEventPayment(amount) : null const split = isEvent ? splitEventPayment(amount) : null
const lines = [ const lines = [
isPayment isPayment
? `دریافت از مشتری — ${item.customer_name}` ? fromWallet
? `پرداخت از کیف پول — ${item.customer_name}`
: `دریافت از مشتری — ${item.customer_name}`
: `بازپرداخت به مشتری — ${item.customer_name}`, : `بازپرداخت به مشتری — ${item.customer_name}`,
`نوع: ${isPayment ? 'پرداخت مشتری' : 'لغو/بازپرداخت'}`, `نوع: ${isPayment ? (fromWallet ? 'پرداخت کیف پول' : 'پرداخت مشتری') : 'لغو/بازپرداخت'}`,
`رویداد: ${eventLabel(item.event_name)}`, `رویداد: ${eventLabel(item.event_name)}`,
split split
? [ ? [
@@ -87,7 +94,7 @@ export function buildTreasuryFromCustomerPayment(
id: existing?.id ?? '', id: existing?.id ?? '',
number: existing?.number ?? number, number: existing?.number ?? number,
kind, kind,
method: 'bank', method: fromWallet ? 'cash' : 'bank',
date: isoDateFromApi(item.created_at), date: isoDateFromApi(item.created_at),
partyId, partyId,
amount, amount,
@@ -97,7 +104,10 @@ export function buildTreasuryFromCustomerPayment(
reference: item.trace_no || item.ref_num || item.id.slice(0, 8), reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
description: lines.join('\n'), description: lines.join('\n'),
eventName: item.event_name || undefined, eventName: item.event_name || undefined,
source: treasurySourceCustomerPayment(item.id), source:
isPayment && fromWallet
? treasurySourceWalletPayment(item.id)
: treasurySourceCustomerPayment(item.id),
} }
} }
@@ -176,7 +186,10 @@ export function buildFunZoneTreasurySync(
continue continue
} }
const source = treasurySourceCustomerPayment(item.id) const source =
item.type === 'payment' && item.payment_method === 'wallet'
? treasurySourceWalletPayment(item.id)
: treasurySourceCustomerPayment(item.id)
const existing = findTreasuryBySource(data.treasury, source) const existing = findTreasuryBySource(data.treasury, source)
const built = buildTreasuryFromCustomerPayment( const built = buildTreasuryFromCustomerPayment(
item, item,

View File

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
import { apiGet, asList } from '../api/client' import { apiGet, asList } from '../api/client'
import { ENDPOINTS } from '../api/config' import { ENDPOINTS } from '../api/config'
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
import { useVoucherSync } from '../accounting/voucherSync'
import { useStore } from '../store/AppStore' import { useStore } from '../store/AppStore'
import type { Party } from '../types' import type { Party } from '../types'
import { import {
@@ -40,13 +39,10 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
const { data, upsertPartyAsync } = useStore() const { data, upsertPartyAsync } = useStore()
const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config) const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config)
const withdrawals = usePartyWithdrawals(config.kind) const withdrawals = usePartyWithdrawals(config.kind)
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Party | null>(null) const [selected, setSelected] = useState<Party | null>(null)
const [editAccounting, setEditAccounting] = useState<Party | null>(null) const [editAccounting, setEditAccounting] = useState<Party | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const query = search.trim().toLowerCase() const query = search.trim().toLowerCase()
@@ -67,28 +63,6 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
return { count: parties.length, totalWallet } return { count: parties.length, totalWallet }
}, [parties]) }, [parties])
const handleBulkSync = async () => {
setSyncBusy(true)
setSyncMsg(null)
try {
if (config.kind === 'supplier') {
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
const res = await syncOwnerTransactions(all)
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
} else {
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
).then((res) => res.payments_and_refunds ?? [])
const res = await syncCustomerActivity(all)
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
}
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
} finally {
setSyncBusy(false)
}
}
const handleSaveAccounting = async () => { const handleSaveAccounting = async () => {
if (!editAccounting?.id) return if (!editAccounting?.id) return
await upsertPartyAsync(editAccounting) await upsertPartyAsync(editAccounting)
@@ -259,16 +233,12 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
<p className="text-sm text-slate-500">{config.subtitle}</p> <p className="text-sm text-slate-500">{config.subtitle}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنش‌ها'}
</Button>
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}> <Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
{syncing ? 'همگام‌سازی…' : 'بروزرسانی'} {syncing ? 'همگام‌سازی…' : 'بروزرسانی'}
</Button> </Button>
</div> </div>
</div> </div>
{syncMsg && <p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{syncMsg}</p>}
{error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>} {error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -352,9 +322,6 @@ function PartyDetail({
onBack: () => void onBack: () => void
}) { }) {
const { data } = useStore() const { data } = useStore()
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
const [syncMsg, setSyncMsg] = useState<string | null>(null)
const [syncBusy, setSyncBusy] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([]) const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([])
const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([]) const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([])
@@ -390,23 +357,6 @@ function PartyDetail({
const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId) const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId)
const handleSync = async () => {
setSyncBusy(true)
try {
if (config.kind === 'supplier') {
const res = await syncOwnerTransactions(ownerTxns)
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
} else {
const res = await syncCustomerActivity(customerActivity)
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
}
} catch (err) {
setSyncMsg(err instanceof Error ? err.message : 'خطا')
} finally {
setSyncBusy(false)
}
}
const invoiceCount = const invoiceCount =
party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0 party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0
@@ -422,13 +372,8 @@ function PartyDetail({
<p className="text-sm text-slate-500">{toFa(party.phone)}</p> <p className="text-sm text-slate-500">{toFa(party.phone)}</p>
</div> </div>
</div> </div>
<Button variant="primary" icon="📑" disabled={syncBusy || loading} onClick={() => void handleSync()}>
{syncBusy ? 'در حال صدور…' : 'صدور سند'}
</Button>
</div> </div>
{syncMsg && <Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" /> <StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" />
<StatCard <StatCard

View File

@@ -90,7 +90,7 @@ interface AppStoreValue {
loading: boolean loading: boolean
error: string | null error: string | null
ready: boolean ready: boolean
reload: () => void reload: () => Promise<AppData | null>
upsertAccount: (item: Account) => void upsertAccount: (item: Account) => void
removeAccount: (id: string) => void removeAccount: (id: string) => void
upsertVoucher: (item: Voucher) => void upsertVoucher: (item: Voucher) => void
@@ -163,7 +163,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
[disconnect], [disconnect],
) )
const load = useCallback(async () => { const load = useCallback(async (): Promise<AppData | null> => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@@ -181,11 +181,14 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
acct.get(ENDPOINT.payslips).then(asList<Payslip>), acct.get(ENDPOINT.payslips).then(asList<Payslip>),
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS), acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
]) ])
setData({ accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings }) const loaded = { accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings }
setData(loaded)
setReady(true) setReady(true)
return loaded
} catch (err) { } catch (err) {
setReady(false) setReady(false)
handleApiError(err) handleApiError(err)
return null
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -279,9 +282,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
loading, loading,
error, error,
ready, ready,
reload: () => { reload: load,
void load()
},
upsertAccount: (item) => void upsert('accounts', item), upsertAccount: (item) => void upsert('accounts', item),
removeAccount: (id) => void remove('accounts', id), removeAccount: (id) => void remove('accounts', id),
upsertVoucher: (item) => void upsert('vouchers', item), upsertVoucher: (item) => void upsert('vouchers', item),

View File

@@ -20,3 +20,16 @@ export function eventNameFromTreasuryDescription(description: string): string |
if (!raw || raw === '—') return undefined if (!raw || raw === '—') return undefined
return raw === 'کیف پول' ? 'wallet' : raw return raw === 'کیف پول' ? 'wallet' : raw
} }
/** Event label with category context for accounting "regarding" fields. */
export function eventNameWithCategory(
eventName: string | null | undefined,
categoryName: string | null | undefined,
): string {
const rawEvent = eventName?.trim() ?? ''
const rawCategory = categoryName?.trim() ?? ''
if (!rawEvent) return ''
if (rawEvent.toLowerCase() === 'wallet') return 'wallet'
if (!rawCategory) return rawEvent
return `${rawEvent} (${rawCategory})`
}

View File

@@ -1,5 +1,6 @@
import type { AppData, Voucher } from '../types' import type { AppData, Voucher } from '../types'
import { resolveVoucherRegarding } from './voucherRegarding' import { resolveVoucherRegarding } from './voucherRegarding'
import { visibleVouchers } from '../accounting/voucherDedupe'
export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual' export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
export type VoucherStatusFilter = 'all' | 'draft' | 'posted' export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
@@ -96,8 +97,9 @@ export function filterVouchers(
filters: VoucherFilters, filters: VoucherFilters,
): Voucher[] { ): Voucher[] {
const query = filters.search.trim().toLowerCase() const query = filters.search.trim().toLowerCase()
const rows = visibleVouchers(vouchers, data)
return vouchers.filter((voucher) => { return rows.filter((voucher) => {
if (filters.category !== 'all' && classifyVoucher(voucher, data) !== filters.category) return false if (filters.category !== 'all' && classifyVoucher(voucher, data) !== filters.category) return false
if (filters.status !== 'all' && voucher.status !== filters.status) return false if (filters.status !== 'all' && voucher.status !== filters.status) return false
@@ -125,7 +127,7 @@ export function voucherFilterSummary(
data: AppData, data: AppData,
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> { ): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
const summary = { customer: 0, supplier: 0, manual: 0 } const summary = { customer: 0, supplier: 0, manual: 0 }
for (const voucher of vouchers) { for (const voucher of visibleVouchers(vouchers, data)) {
summary[classifyVoucher(voucher, data)] += 1 summary[classifyVoucher(voucher, data)] += 1
} }
return summary return summary