From 11dfe2862375a5feea433f6dd043cdfc4ba259c6 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Tue, 7 Jul 2026 15:13:18 +0330 Subject: [PATCH] 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 --- .../unifiedVoucherSync.ts} | 106 +++---- src/accounting/voucherDedupe.ts | 56 ++++ src/accounting/voucherSync.ts | 43 ++- src/api/config.ts | 3 + src/api/types.ts | 3 + src/components/ui/primitives.tsx | 2 +- src/pages/Dashboard.tsx | 4 +- src/pages/Inventory.tsx | 293 +++++++++++++++++- src/pages/Reports.tsx | 17 +- src/pages/Treasury.tsx | 53 +--- src/pages/Vouchers.tsx | 62 +++- src/pages/inventory/funzoneInventory.test.ts | 105 +++++++ src/pages/inventory/funzoneInventory.ts | 253 +++++++++++++++ .../purchases/FunZonePurchasesSyncBanner.tsx | 51 +-- src/pages/purchases/PurchasesLayout.tsx | 3 - src/pages/purchases/PurchasesSyncContext.tsx | 86 ----- src/pages/sales/FunZoneSalesSyncBanner.tsx | 55 +--- src/pages/sales/SalesLayout.tsx | 5 +- src/pages/sales/funzoneSalesSync.ts | 15 +- src/pages/sales/funzoneVoucherLink.ts | 59 ++++ src/pages/sales/invoiceEffects.ts | 6 +- src/pages/sales/salesUtils.ts | 11 +- src/pages/treasury/funzoneTreasurySync.ts | 23 +- src/parties/FunZonePartySection.tsx | 55 ---- src/store/AppStore.tsx | 13 +- src/utils/eventName.ts | 13 + src/utils/voucherFilters.ts | 6 +- 27 files changed, 1017 insertions(+), 384 deletions(-) rename src/{pages/sales/SalesSyncContext.tsx => accounting/unifiedVoucherSync.ts} (62%) create mode 100644 src/accounting/voucherDedupe.ts create mode 100644 src/pages/inventory/funzoneInventory.test.ts create mode 100644 src/pages/inventory/funzoneInventory.ts delete mode 100644 src/pages/purchases/PurchasesSyncContext.tsx create mode 100644 src/pages/sales/funzoneVoucherLink.ts diff --git a/src/pages/sales/SalesSyncContext.tsx b/src/accounting/unifiedVoucherSync.ts similarity index 62% rename from src/pages/sales/SalesSyncContext.tsx rename to src/accounting/unifiedVoucherSync.ts index 6c8e057..7353bdb 100644 --- a/src/pages/sales/SalesSyncContext.tsx +++ b/src/accounting/unifiedVoucherSync.ts @@ -1,50 +1,36 @@ -import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react' -import { apiGet } from '../../api/client' -import { ENDPOINTS } from '../../api/config' -import type { ApiPaymentOrRefund } from '../../api/types' -import type { AppData, Invoice } from '../../types' -import { useStore } from '../../store/AppStore' -import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync' -import { invoiceSideEffects } from './invoiceEffects' -import { reconcileInvoicePaymentStatus } from './salesUtils' -import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from './funzoneSalesSync' +import { useCallback, useRef, useState } from 'react' +import { apiGet } from '../api/client' +import { ENDPOINTS } from '../api/config' +import type { ApiPaymentOrRefund } from '../api/types' +import type { AppData, Invoice } from '../types' +import { useStore } from '../store/AppStore' +import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync' +import { invoiceSideEffects } from '../pages/sales/invoiceEffects' +import { reconcileInvoicePaymentStatus } from '../pages/sales/salesUtils' +import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from '../pages/sales/funzoneSalesSync' +import { supersededVoucherIds } from './voucherDedupe' -export interface FunZoneSalesSyncStats { +export interface UnifiedFunZoneSyncStats { treasuryCreated: number treasuryUpdated: number treasurySkipped: number + treasuryRemoved: number invoicesCreated: number invoicesUpdated: number invoicesSkipped: number + duplicatesRemoved: number unmatchedCustomers: number + unmatchedOwners: number funzonePayments: number funzoneCancellations: number } -interface SalesSyncContextValue { - loading: boolean - error: string | null - stats: FunZoneSalesSyncStats | null - lastSyncedAt: string | null - sync: () => Promise -} - -const SalesSyncContext = createContext(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 }) { +export function useUnifiedFunZoneVoucherSync() { const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore() const treasury = useFunZoneTreasurySync() const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [stats, setStats] = useState(null) + const [stats, setStats] = useState(null) const [lastSyncedAt, setLastSyncedAt] = useState(null) const syncingRef = useRef(false) const dataRef = useRef(data) @@ -90,25 +76,6 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) { [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 current = dataRef.current const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>( @@ -118,7 +85,17 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) { const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } = 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 { invoicesCreated: created, @@ -128,7 +105,7 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) { funzonePayments: paymentCount, funzoneCancellations: cancellationCount, } - }, [persistSalesBatch]) + }, [applyInvoiceIfChanged]) const sync = useCallback(async () => { if (syncingRef.current) return @@ -137,27 +114,36 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) { setError(null) try { const treasuryStats = await treasury.sync() + const freshAfterTreasury = await reload() + if (freshAfterTreasury) dataRef.current = freshAfterTreasury + 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({ treasuryCreated: treasuryStats?.created ?? 0, treasuryUpdated: treasuryStats?.updated ?? 0, treasurySkipped: treasuryStats?.skipped ?? 0, + treasuryRemoved: treasuryStats?.removed ?? 0, ...salesResult, + duplicatesRemoved: duplicateIds.length, + unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0, }) setLastSyncedAt(new Date().toISOString()) - reload() } catch (err) { - setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی فروش با فان‌زون') + setError(err instanceof Error ? err.message : 'خطا در همگام‌سازی اسناد از فان‌زون') } finally { syncingRef.current = false setLoading(false) } - }, [treasury, syncSalesInvoices, reload]) + }, [treasury, syncSalesInvoices, reload, removeVoucher]) - return ( - - {children} - - ) + return { sync, loading, error, stats, lastSyncedAt } } diff --git a/src/accounting/voucherDedupe.ts b/src/accounting/voucherDedupe.ts new file mode 100644 index 0000000..0cb7f27 --- /dev/null +++ b/src/accounting/voucherDedupe.ts @@ -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, + 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(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)) +} diff --git a/src/accounting/voucherSync.ts b/src/accounting/voucherSync.ts index 03e57e6..ea4ac33 100644 --- a/src/accounting/voucherSync.ts +++ b/src/accounting/voucherSync.ts @@ -1,6 +1,10 @@ import { useCallback } from 'react' 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 type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' import { useStore } from '../store/AppStore' @@ -66,6 +70,23 @@ function ownerTxnAlreadyBookedViaTreasury( 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( records: ApiOwnerTransaction[], map: VoucherAccountMap, @@ -141,6 +162,8 @@ function buildCustomerVouchers( startNumber: number, invoices: Invoice[], parties: Party[], + treasury: TreasuryTxn[], + existingVouchers: Voucher[], ): { vouchers: Voucher[]; skipped: number } { const vouchers: Voucher[] = [] let skipped = 0 @@ -151,7 +174,10 @@ function buildCustomerVouchers( if (amount <= 0) continue const source = `funzone:cust:${item.id}` - if (existingSources.has(source)) { + if ( + existingSources.has(source) || + customerPaymentAlreadyBookedViaTreasury(treasury, existingVouchers, item.id) + ) { skipped += 1 continue } @@ -242,9 +268,18 @@ export function useVoucherSync() { const syncCustomerActivity = useCallback( (records: ApiPaymentOrRefund[]): Promise => 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 } diff --git a/src/api/config.ts b/src/api/config.ts index 848e47d..f89b41a 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -33,6 +33,9 @@ export const ENDPOINTS = { ADMIN_LOGIN: '/auth/token/admin/', OWNERS: '/owners/', CUSTOMERS: '/customers/', + EVENTS: '/events/', + EVENT_CATEGORIES: '/event-categories/', + RESERVATIONS: '/reservations/?status=all', OWNER_TRANSACTIONS: (ownerId: string) => `/owners/wallet/admin/transactions/?owner_id=${ownerId}`, ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/', diff --git a/src/api/types.ts b/src/api/types.ts index 60941c2..1b710e3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -61,6 +61,9 @@ export interface ApiPaymentOrRefund { ref_num: string amount: number event_name: string + category_name?: string | null + tickets_count?: number | null + payment_method?: 'gateway' | 'wallet' event_date: string | null event_time: string | null created_at: string diff --git a/src/components/ui/primitives.tsx b/src/components/ui/primitives.tsx index c7afed2..6790ab0 100644 --- a/src/components/ui/primitives.tsx +++ b/src/components/ui/primitives.tsx @@ -105,7 +105,7 @@ export function StatCard({

{label}

-

{value}

+

{value}

{hint &&

{hint}

}
{icon && ( diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index dea3806..5379af5 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { Link } from 'react-router-dom' +import { visibleVouchers } from '../accounting/voucherDedupe' import { useStore } from '../store/AppStore' import { Badge, Card, PageHeader, StatCard } from '../components/ui' import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format' @@ -51,7 +52,8 @@ export function Dashboard() { const { data } = useStore() 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 .filter((b) => ['1001', '1002', '1003'].includes(b.account.code)) .reduce((sum, b) => sum + b.balance, 0) diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx index 49c3bcb..9fc580c 100644 --- a/src/pages/Inventory.tsx +++ b/src/pages/Inventory.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useStore } from '../store/AppStore' import { Badge, @@ -8,14 +8,63 @@ import { DataTable, Field, Input, + JalaliDateInput, Modal, PageHeader, + Select, StatCard, type Column, } from '../components/ui' import type { Product } from '../types' 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(path: string): Promise { + const items: T[] = [] + let nextPath: string | null = path + while (nextPath) { + const response = await apiGet(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 => ({ id: '', @@ -34,6 +83,62 @@ export function Inventory() { const [draft, setDraft] = useState(null) const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null) const [toDelete, setToDelete] = useState(null) + const [funZoneCategories, setFunZoneCategories] = useState([]) + const [funZoneEvents, setFunZoneEvents] = useState([]) + const [funZoneReservations, setFunZoneReservations] = useState([]) + const [funZoneCustomers, setFunZoneCustomers] = useState([]) + const [funZoneLoading, setFunZoneLoading] = useState(false) + const [funZoneError, setFunZoneError] = useState(null) + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [selectedLifecycle, setSelectedLifecycle] = useState('all') + const [buyerDateFrom, setBuyerDateFrom] = useState('') + const [buyerDateTo, setBuyerDateTo] = useState('') + const [buyerSearch, setBuyerSearch] = useState('') + const [buyerSort, setBuyerSort] = useState('mostTickets') + + useEffect(() => { + let cancelled = false + const loadFunZoneInventory = async () => { + setFunZoneLoading(true) + setFunZoneError(null) + try { + const [categories, events, reservations, customers] = await Promise.all([ + getAllPages(ENDPOINTS.EVENT_CATEGORIES), + getAllPages(ENDPOINTS.EVENTS), + getAllPages(ENDPOINTS.RESERVATIONS), + getAllPages(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() + 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 value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice))) @@ -49,6 +154,128 @@ export function Inventory() { ) }, [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> = [ + { key: 'name', header: 'خریدار', render: (row) => {row.customerName} }, + { key: 'tickets', header: 'تعداد بلیت', align: 'center', render: (row) => {toFa(row.tickets)} }, + { key: 'purchases', header: 'دفعات خرید', align: 'center', render: (row) => {toFa(row.purchases)} }, + { key: 'amount', header: 'مبلغ کل', align: 'end', render: (row) => {formatMoney(row.totalAmount)} }, + { key: 'last', header: 'آخرین خرید', align: 'center', render: (row) => {formatDateTime(row.lastBuyAt)} }, + ] + + const openBuyers = (categoryId: string, lifecycle: EventLifecycle | 'all') => { + setSelectedCategoryId(categoryId) + setSelectedLifecycle(lifecycle) + setBuyerSearch('') + setBuyerDateFrom('') + setBuyerDateTo('') + setBuyerSort('mostTickets') + } + + const categoryColumns: Array> = [ + { + key: 'name', + header: 'کالا', + render: (row) => {row.name}, + }, + { + key: 'inProcessCount', + header: 'در حال فرآیند', + align: 'center', + render: (row) => ( + + ), + }, + { + key: 'completedCount', + header: 'تکمیل‌شده', + align: 'center', + render: (row) => ( + + ), + }, + { + key: 'canceledCount', + header: 'لغوشده', + align: 'center', + render: (row) => ( + + ), + }, + { + key: 'boughtTickets', + header: 'تعداد بلیت خریداری‌شده', + align: 'center', + render: (row) => ( + + ), + }, + ] + const handleSave = () => { if (!draft || !draft.name.trim() || !draft.code.trim()) return upsertProduct({ ...draft, id: draft.id || createId('prod-') }) @@ -117,6 +344,24 @@ export function Inventory() { 0 ? 'rose' : 'green'} />
+ +
+

موجودی بر اساس دسته‌بندی فان‌زون

+ {funZoneLoading ? ( + در حال بروزرسانی… + ) : ( + {toFa(categoryInventoryRows.length)} دسته‌بندی + )} +
+ {funZoneError ?

{funZoneError}

: null} + row.id} + emptyTitle="دسته‌بندی‌ای برای نمایش یافت نشد" + /> +
+ p.id} emptyTitle="کالایی یافت نشد" /> + setSelectedCategoryId(null)} + size="xl" + > +
+
+ + + + + + + + + + + + + + setBuyerSearch(e.target.value)} placeholder="نام مشتری…" /> + +
+ row.customerId} + emptyTitle="خریداری برای این دسته‌بندی یافت نشد" + /> +
+
+ ('trial') - const balances = useMemo( - () => trialBalance(data.accounts, data.vouchers), - [data.accounts, data.vouchers], - ) + const balances = useMemo(() => { + const vouchers = visibleVouchers(data.vouchers, data) + return trialBalance(data.accounts, vouchers) + }, [data]) return (
@@ -103,10 +104,10 @@ function LedgerReport() { const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '') const account = leafAccounts.find((a) => a.id === accountId) - const rows = useMemo( - () => accountLedger(accountId, account, data.vouchers), - [accountId, account, data.vouchers], - ) + const rows = useMemo(() => { + const vouchers = visibleVouchers(data.vouchers, data) + return accountLedger(accountId, account, vouchers) + }, [accountId, account, data]) const columns: Array> = [ { key: 'number', header: 'سند', render: (r) => {r.voucherNumber} }, diff --git a/src/pages/Treasury.tsx b/src/pages/Treasury.tsx index 7af3a66..26a4cda 100644 --- a/src/pages/Treasury.tsx +++ b/src/pages/Treasury.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { useStore } from '../store/AppStore' @@ -44,8 +45,6 @@ import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, supplie import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync' -import { useFunZoneTreasurySync } from './treasury/useFunZoneTreasurySync' - const kindLabels: Record = { receipt: 'دریافت', payment: 'پرداخت' } @@ -58,8 +57,6 @@ export function Treasury() { const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore() - const { sync, loading: syncLoading, error: syncError, stats: syncStats } = useFunZoneTreasurySync() - const [draft, setDraft] = useState(null) const [toDelete, setToDelete] = useState(null) @@ -344,12 +341,6 @@ export function Treasury() { <> - -
- {syncStats && ( - -
- -

- - آخرین همگام‌سازی: {toFa(syncStats.created)} جدید، {toFa(syncStats.updated)} به‌روز،{' '} - {toFa(syncStats.removed)} حذف تکراری، {toFa(syncStats.skipped)} بدون تغییر - {syncStats.excludedOwnerTxns > 0 && - ` — ${toFa(syncStats.excludedOwnerTxns)} تراکنش داخلی مالک نادیده گرفته شد`} - -

- - {(syncStats.unmatchedCustomers > 0 || syncStats.unmatchedOwners > 0) && ( - -

- - {syncStats.unmatchedCustomers > 0 && - - `${toFa(syncStats.unmatchedCustomers)} پرداخت مشتری بدون طرف حساب — ابتدا مشتریان را همگام کنید. `} - - {syncStats.unmatchedOwners > 0 && - - `${toFa(syncStats.unmatchedOwners)} تراکنش مالک بدون طرف حساب — ابتدا تأمین‌کنندگان را همگام کنید.`} - -

- - )} - -
- - )} - - {syncError &&

{syncError}

} - diff --git a/src/pages/Vouchers.tsx b/src/pages/Vouchers.tsx index 5359490..3c22068 100644 --- a/src/pages/Vouchers.tsx +++ b/src/pages/Vouchers.tsx @@ -1,4 +1,6 @@ import { useMemo, useState } from 'react' +import { useUnifiedFunZoneVoucherSync } from '../accounting/unifiedVoucherSync' +import { visibleVouchers } from '../accounting/voucherDedupe' import { useStore } from '../store/AppStore' import { Badge, @@ -17,7 +19,7 @@ import { } from '../components/ui' import type { Voucher, VoucherLine } from '../types' 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 { resolveVoucherRegarding } from '../utils/voucherRegarding' import { normalizeEventName } from '../utils/eventName' @@ -55,6 +57,8 @@ function createDraft(items: Voucher[]): Voucher { export function Vouchers() { const { data, upsertVoucher, removeVoucher } = useStore() + const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } = + useUnifiedFunZoneVoucherSync() const [draft, setDraft] = useState(null) const [toDelete, setToDelete] = useState(null) const [category, setCategory] = useState('all') @@ -70,10 +74,15 @@ export function Vouchers() { ) 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 rows = filterVouchers(data.vouchers, data, { + const rows = filterVouchers(displayedVouchers, data, { ...defaultVoucherFilters, category, status: statusFilter, @@ -83,7 +92,7 @@ export function Vouchers() { search, }) 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 = category !== 'all' || @@ -220,14 +229,49 @@ export function Vouchers() { title="اسناد حسابداری" subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار" actions={ - + <> + + + } /> + +
+
+

صدور خودکار از فان‌زون

+

+ یک‌بار همگام‌سازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری — بدون تکرار. + برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته می‌شود. +

+ {lastSyncedAt ? ( +

+ آخرین همگام‌سازی: {formatDateTime(lastSyncedAt)} +

+ ) : null} + {syncStats ? ( +

+ دریافت/پرداخت جدید: {toFa(syncStats.treasuryCreated)} · فاکتور جدید:{' '} + {toFa(syncStats.invoicesCreated)} · اسناد تکراری حذف‌شده:{' '} + {toFa(syncStats.duplicatesRemoved)} +

+ ) : null} + {syncError ?

{syncError}

: null} +
+
+
+
- + @@ -279,7 +323,7 @@ export function Vouchers() {

- نمایش {toFa(filtered.length)} از {toFa(data.vouchers.length)} سند + نمایش {toFa(filtered.length)} از {toFa(displayedVouchers.length)} سند

{ + 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) + }) +}) diff --git a/src/pages/inventory/funzoneInventory.ts b/src/pages/inventory/funzoneInventory.ts new file mode 100644 index 0000000..a86e650 --- /dev/null +++ b/src/pages/inventory/funzoneInventory.ts @@ -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() + const purchased = new Map() + const canceledPurchases = new Map() + + 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() + for (const event of events) { + lifecycleByEvent.set( + event.id, + eventLifecycle(event, seatsForMinimum.get(event.id) ?? 0, nowMs), + ) + } + + const byCategory = new Map() + 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, + 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() + 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() + + 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 +} diff --git a/src/pages/purchases/FunZonePurchasesSyncBanner.tsx b/src/pages/purchases/FunZonePurchasesSyncBanner.tsx index 7bfd208..13eb9f0 100644 --- a/src/pages/purchases/FunZonePurchasesSyncBanner.tsx +++ b/src/pages/purchases/FunZonePurchasesSyncBanner.tsx @@ -1,46 +1,17 @@ -import { useMemo } from 'react' -import { Button, Card } from '../../components/ui' -import { useStore } from '../../store/AppStore' -import { countFunZoneSupplierDocuments } from '../../utils/voucherFilters' -import { formatDateTime, toFa } from '../../utils/format' -import { usePurchasesFunZoneSync } from './PurchasesSyncContext' +import { Link } from 'react-router-dom' +import { Card } from '../../components/ui' export function FunZonePurchasesSyncBanner() { - const { loading, error, stats, lastSyncedAt, sync } = usePurchasesFunZoneSync() - const { data } = useStore() - - const syncedCount = useMemo(() => countFunZoneSupplierDocuments(data), [data]) - return ( - -
-
-

همگام‌سازی با فان‌زون

-

- برای دریافت برداشت‌ها و بازپرداخت‌های مالکان از فان‌زون و تبدیل آن‌ها به پرداخت و سند حسابداری، - دکمه همگام‌سازی را بزنید. -

-
- - {toFa(syncedCount)} تراکنش همگام‌شده - - {lastSyncedAt ? ( - - آخرین بروزرسانی: {formatDateTime(lastSyncedAt)} - - ) : null} - {stats ? ( - - پرداخت جدید: {toFa(stats.treasuryCreated)} · سند جدید: {toFa(stats.ownerVouchersCreated)} - - ) : null} -
- {error ?

{error}

: null} -
- -
+ +

اسناد حسابداری فان‌زون

+

+ برای دریافت برداشت‌ها و بازپرداخت‌های مالکان و صدور سند حسابداری، از صفحه{' '} + + اسناد حسابداری + {' '} + دکمه «همگام‌سازی اسناد از فان‌زون» را بزنید. +

) } diff --git a/src/pages/purchases/PurchasesLayout.tsx b/src/pages/purchases/PurchasesLayout.tsx index b3d2e36..bcc963a 100644 --- a/src/pages/purchases/PurchasesLayout.tsx +++ b/src/pages/purchases/PurchasesLayout.tsx @@ -1,6 +1,5 @@ import { NavLink, Outlet } from 'react-router-dom' import { PageHeader } from '../../components/ui' -import { PurchasesSyncProvider } from './PurchasesSyncContext' const purchaseNavItems = [ { to: '/purchases/suppliers', label: 'تأمین‌کنندگان', icon: '🏢', end: false }, @@ -10,7 +9,6 @@ const purchaseNavItems = [ export function PurchasesLayout() { return ( -
-
) } diff --git a/src/pages/purchases/PurchasesSyncContext.tsx b/src/pages/purchases/PurchasesSyncContext.tsx deleted file mode 100644 index 28e8bf8..0000000 --- a/src/pages/purchases/PurchasesSyncContext.tsx +++ /dev/null @@ -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 -} - -const PurchasesSyncContext = createContext(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(null) - const [stats, setStats] = useState(null) - const [lastSyncedAt, setLastSyncedAt] = useState(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(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then( - asList, - ) - 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 ( - - {children} - - ) -} diff --git a/src/pages/sales/FunZoneSalesSyncBanner.tsx b/src/pages/sales/FunZoneSalesSyncBanner.tsx index 4a5857d..7b094fa 100644 --- a/src/pages/sales/FunZoneSalesSyncBanner.tsx +++ b/src/pages/sales/FunZoneSalesSyncBanner.tsx @@ -1,50 +1,17 @@ -import { Button, Card } from '../../components/ui' -import { formatDateTime, toFa } from '../../utils/format' -import { useSalesFunZoneSync } from './SalesSyncContext' -import { useStore } from '../../store/AppStore' -import { useMemo } from 'react' -import { isFunZoneSalesInvoice } from './funzoneSalesSync' -import { salesDocuments } from './salesUtils' +import { Link } from 'react-router-dom' +import { Card } from '../../components/ui' 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 ( - -
-
-

همگام‌سازی با فان‌زون

-

- برای دریافت پرداخت‌های رویداد و لغو رزرو از فان‌زون و تبدیل آن‌ها به فاکتور فروش/برگشتی و - دریافت و پرداخت، دکمه همگام‌سازی را بزنید. -

-
- - {toFa(funzoneDocCount)} سند همگام‌شده در فروش - - {lastSyncedAt ? ( - - آخرین بروزرسانی: {formatDateTime(lastSyncedAt)} - - ) : null} - {stats ? ( - - فاکتور جدید: {toFa(stats.invoicesCreated)} · دریافت/پرداخت: {toFa(stats.treasuryCreated)} - - ) : null} -
- {error ?

{error}

: null} -
- -
+ +

اسناد حسابداری فان‌زون

+

+ برای دریافت پرداخت‌های رویداد، فاکتور فروش و سند حسابداری از فان‌زون، از صفحه{' '} + + اسناد حسابداری + {' '} + دکمه «همگام‌سازی اسناد از فان‌زون» را بزنید. +

) } diff --git a/src/pages/sales/SalesLayout.tsx b/src/pages/sales/SalesLayout.tsx index 06ac37c..44641ba 100644 --- a/src/pages/sales/SalesLayout.tsx +++ b/src/pages/sales/SalesLayout.tsx @@ -1,12 +1,10 @@ import { NavLink, Outlet } from 'react-router-dom' import { PageHeader } from '../../components/ui' import { salesNavItems } from './salesNavigation' -import { SalesSyncProvider } from './SalesSyncContext' export function SalesLayout() { return ( - -
+
-
) } diff --git a/src/pages/sales/funzoneSalesSync.ts b/src/pages/sales/funzoneSalesSync.ts index 7ce5be4..8a90bfc 100644 --- a/src/pages/sales/funzoneSalesSync.ts +++ b/src/pages/sales/funzoneSalesSync.ts @@ -4,13 +4,16 @@ import { findExistingParty } from '../../parties/funzonePartySync' import { createId, nextNumber } from '../../utils/id' import { isEventCustomerPayment } from '../../utils/funzoneFees' import { invoiceTotals } from '../../utils/accounting' +import { eventNameWithCategory } from '../../utils/eventName' import { byDocumentType, isWithdrawalReturn } from './salesUtils' export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:' export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:' -export function funzonePaymentNoteTag(paymentId: string): string { - return `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}` +export function funzonePaymentNoteTag(paymentId: string, wallet = false): string { + return wallet + ? `${FUNZONE_PAYMENT_NOTE_PREFIX}wallet:${paymentId}` + : `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}` } export function funzoneCancellationNoteTag(paymentId: string): string { @@ -75,7 +78,7 @@ export function buildSalesInvoiceFromCustomerPayment( if (item.type === 'payment') { 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) return { @@ -95,7 +98,7 @@ export function buildSalesInvoiceFromCustomerPayment( unitPrice: amount, discount: 0, taxRate: 0, - eventName: item.event_name, + eventName: eventNameWithCategory(item.event_name, item.category_name), }, ], } @@ -127,7 +130,7 @@ export function buildSalesInvoiceFromCustomerPayment( unitPrice: amount, discount: 0, taxRate: 0, - eventName: item.event_name, + eventName: eventNameWithCategory(item.event_name, item.category_name), }, ], relatedInvoiceId: prior?.relatedInvoiceId, @@ -167,7 +170,7 @@ export function buildFunZoneSalesSync( const tag = item.type === 'payment' - ? funzonePaymentNoteTag(item.id) + ? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet') : funzoneCancellationNoteTag(item.id) const existing = findInvoiceByNoteTag(data.invoices, tag) const built = buildSalesInvoiceFromCustomerPayment( diff --git a/src/pages/sales/funzoneVoucherLink.ts b/src/pages/sales/funzoneVoucherLink.ts new file mode 100644 index 0000000..bb44ed8 --- /dev/null +++ b/src/pages/sales/funzoneVoucherLink.ts @@ -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, +): 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 +} diff --git a/src/pages/sales/invoiceEffects.ts b/src/pages/sales/invoiceEffects.ts index 39c9977..10f53bf 100644 --- a/src/pages/sales/invoiceEffects.ts +++ b/src/pages/sales/invoiceEffects.ts @@ -10,6 +10,7 @@ import { stockDeltas, voucherSourceForInvoice, } from './salesUtils' +import { shouldSkipInvoiceVoucherForFunzoneTicket } from './funzoneVoucherLink' /** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */ export function invoiceSideEffects( @@ -23,7 +24,10 @@ export function invoiceSideEffects( } { const products = applyStockChanges(data.products, saved, previous ?? null) 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 } } diff --git a/src/pages/sales/salesUtils.ts b/src/pages/sales/salesUtils.ts index 0f627ad..e381754 100644 --- a/src/pages/sales/salesUtils.ts +++ b/src/pages/sales/salesUtils.ts @@ -456,10 +456,13 @@ export function buildTreasuryVoucher( ): Voucher | 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) 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 counterAccountId = party?.kind === 'supplier' @@ -491,6 +494,10 @@ export function buildTreasuryVoucher( if (hasEventSplit) { const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description) + const debitAccountId = + txn.method === 'cash' && customerPayableId ? customerPayableId : bankId + const debitLabel = + txn.method === 'cash' ? 'پرداخت از کیف پول مشتری' : 'واریز بابت رویداد' return { id: createId('v-'), number: nextNumber(data.vouchers), @@ -500,7 +507,7 @@ export function buildTreasuryVoucher( status: 'posted', source: voucherSourceForTreasury(txn.id), lines: [ - makeLine(bankId, amount, 0, `واریز بابت رویداد - ${label}`), + makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`), makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`), makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`), makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`), diff --git a/src/pages/treasury/funzoneTreasurySync.ts b/src/pages/treasury/funzoneTreasurySync.ts index 74bdcc6..f4e93cc 100644 --- a/src/pages/treasury/funzoneTreasurySync.ts +++ b/src/pages/treasury/funzoneTreasurySync.ts @@ -19,6 +19,10 @@ export function treasurySourceCustomerPayment(id: string): string { return `funzone:cust-pay:${id}` } +export function treasurySourceWalletPayment(id: string): string { + return `funzone:wallet-pay:${id}` +} + export function treasurySourceOwnerTxn(id: string): string { return `funzone:owner-txn:${id}` } @@ -57,15 +61,18 @@ export function buildTreasuryFromCustomerPayment( if (amount <= 0) return null const isPayment = item.type === 'payment' + const fromWallet = item.payment_method === 'wallet' const kind = isPayment ? 'receipt' : 'payment' const isEvent = isPayment && isEventCustomerPayment(item.event_name) const split = isEvent ? splitEventPayment(amount) : null const lines = [ isPayment - ? `دریافت از مشتری — ${item.customer_name}` + ? fromWallet + ? `پرداخت از کیف پول — ${item.customer_name}` + : `دریافت از مشتری — ${item.customer_name}` : `بازپرداخت به مشتری — ${item.customer_name}`, - `نوع: ${isPayment ? 'پرداخت مشتری' : 'لغو/بازپرداخت'}`, + `نوع: ${isPayment ? (fromWallet ? 'پرداخت کیف پول' : 'پرداخت مشتری') : 'لغو/بازپرداخت'}`, `رویداد: ${eventLabel(item.event_name)}`, split ? [ @@ -87,7 +94,7 @@ export function buildTreasuryFromCustomerPayment( id: existing?.id ?? '', number: existing?.number ?? number, kind, - method: 'bank', + method: fromWallet ? 'cash' : 'bank', date: isoDateFromApi(item.created_at), partyId, amount, @@ -97,7 +104,10 @@ export function buildTreasuryFromCustomerPayment( reference: item.trace_no || item.ref_num || item.id.slice(0, 8), description: lines.join('\n'), 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 } - 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 built = buildTreasuryFromCustomerPayment( item, diff --git a/src/parties/FunZonePartySection.tsx b/src/parties/FunZonePartySection.tsx index 7131227..dae39c1 100644 --- a/src/parties/FunZonePartySection.tsx +++ b/src/parties/FunZonePartySection.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react' import { apiGet, asList } from '../api/client' import { ENDPOINTS } from '../api/config' import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' -import { useVoucherSync } from '../accounting/voucherSync' import { useStore } from '../store/AppStore' import type { Party } from '../types' import { @@ -40,13 +39,10 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig }) const { data, upsertPartyAsync } = useStore() const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config) const withdrawals = usePartyWithdrawals(config.kind) - const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync() const [search, setSearch] = useState('') const [selected, setSelected] = useState(null) const [editAccounting, setEditAccounting] = useState(null) - const [syncBusy, setSyncBusy] = useState(false) - const [syncMsg, setSyncMsg] = useState(null) const filtered = useMemo(() => { const query = search.trim().toLowerCase() @@ -67,28 +63,6 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig }) return { count: parties.length, totalWallet } }, [parties]) - const handleBulkSync = async () => { - setSyncBusy(true) - setSyncMsg(null) - try { - if (config.kind === 'supplier') { - const all = await apiGet(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList) - 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 () => { if (!editAccounting?.id) return await upsertPartyAsync(editAccounting) @@ -259,16 +233,12 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })

{config.subtitle}

-
- {syncMsg &&

{syncMsg}

} {error &&

{error}

}
@@ -352,9 +322,6 @@ function PartyDetail({ onBack: () => void }) { const { data } = useStore() - const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync() - const [syncMsg, setSyncMsg] = useState(null) - const [syncBusy, setSyncBusy] = useState(false) const [loading, setLoading] = useState(true) const [ownerTxns, setOwnerTxns] = useState([]) const [customerActivity, setCustomerActivity] = useState([]) @@ -390,23 +357,6 @@ function PartyDetail({ 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 = party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0 @@ -422,13 +372,8 @@ function PartyDetail({

{toFa(party.phone)}

- - {syncMsg && {syncMsg}} -
void + reload: () => Promise upsertAccount: (item: Account) => void removeAccount: (id: string) => void upsertVoucher: (item: Voucher) => void @@ -163,7 +163,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) { [disconnect], ) - const load = useCallback(async () => { + const load = useCallback(async (): Promise => { setLoading(true) setError(null) try { @@ -181,11 +181,14 @@ export function AppStoreProvider({ children }: { children: ReactNode }) { acct.get(ENDPOINT.payslips).then(asList), acct.get(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) + return loaded } catch (err) { setReady(false) handleApiError(err) + return null } finally { setLoading(false) } @@ -279,9 +282,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) { loading, error, ready, - reload: () => { - void load() - }, + reload: load, upsertAccount: (item) => void upsert('accounts', item), removeAccount: (id) => void remove('accounts', id), upsertVoucher: (item) => void upsert('vouchers', item), diff --git a/src/utils/eventName.ts b/src/utils/eventName.ts index 144425c..2bdee53 100644 --- a/src/utils/eventName.ts +++ b/src/utils/eventName.ts @@ -20,3 +20,16 @@ export function eventNameFromTreasuryDescription(description: string): string | if (!raw || raw === '—') return undefined 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})` +} diff --git a/src/utils/voucherFilters.ts b/src/utils/voucherFilters.ts index 6b55972..6d8ed1e 100644 --- a/src/utils/voucherFilters.ts +++ b/src/utils/voucherFilters.ts @@ -1,5 +1,6 @@ import type { AppData, Voucher } from '../types' import { resolveVoucherRegarding } from './voucherRegarding' +import { visibleVouchers } from '../accounting/voucherDedupe' export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual' export type VoucherStatusFilter = 'all' | 'draft' | 'posted' @@ -96,8 +97,9 @@ export function filterVouchers( filters: VoucherFilters, ): Voucher[] { 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.status !== 'all' && voucher.status !== filters.status) return false @@ -125,7 +127,7 @@ export function voucherFilterSummary( data: AppData, ): Record, number> { 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 } return summary