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