diff --git a/docs/جریان-سیستم-و-شماره‌گذاری.md b/docs/جریان-سیستم-و-شماره‌گذاری.md new file mode 100644 index 0000000..5d38c40 --- /dev/null +++ b/docs/جریان-سیستم-و-شماره‌گذاری.md @@ -0,0 +1,511 @@ +# مستند کامل سامانه حسابداری FunZone + +این سند توضیح می‌دهد **اعداد و شماره‌ها چطور ساخته می‌شوند**، و **هر بخش منو چطور به بقیه وصل است** — از داشبورد تا تنظیمات. + +--- + +## ۱. نمای کلی معماری + +| لایه | تکنولوژی | آدرس تقریبی | +|------|----------|-------------| +| فرانت‌اند حسابداری | React + TypeScript + Vite | اپ `funzone-accounting` | +| API حسابداری | Django REST | معمولاً `localhost:8010/api` | +| API اصلی FunZone | Django (مشتری، پرداخت، مالک، رویداد) | معمولاً `localhost:8000/api` | + +**منبع حقیقت داده‌ها:** `AppStore` همه موجودیت‌ها را از API حسابداری بارگذاری می‌کند و CRUD را از همان‌جا انجام می‌دهد. همگام‌سازی با FunZone (بلیت، کیف پول، برداشت) از API اصلی می‌خواند و در دفتر حسابداری ثبت می‌کند. + +``` +┌─────────────────┐ همگام‌سازی ┌──────────────────────┐ +│ FunZone API │ ──────────────────► │ Accounting Frontend │ +│ پرداخت/کیف‌پول │ │ AppStore + صفحات │ +└─────────────────┘ └──────────┬───────────┘ + │ REST + ▼ + ┌──────────────────────┐ + │ Accounting Backend │ + │ جداول دفتر / شماره سند│ + └──────────────────────┘ +``` + +--- + +## ۲. ساختار منو و مسیرها + +| گروه | برچسب منو | مسیر | صفحه اصلی | +|------|-----------|------|-----------| +| اصلی | داشبورد | `/` | `Dashboard.tsx` | +| حسابداری | حساب‌ها (کدینگ) | `/accounts` | `Accounts.tsx` | +| حسابداری | اسناد حسابداری | `/vouchers` | `Vouchers.tsx` | +| عملیات | مشتریان و فروش | `/sales/*` | `Sales.tsx` + زیرصفحات | +| عملیات | تأمین‌کنندگان و خرید | `/purchases/*` | `Purchases.tsx` | +| عملیات | انبارداری | `/inventory` | `Inventory.tsx` | +| عملیات | دریافت و پرداخت | `/treasury` | `Treasury.tsx` | +| عملیات | حقوق و دستمزد | `/payroll` | `Payroll.tsx` | +| تحلیل | گزارش‌ها | `/reports` | `Reports.tsx` | +| سیستم | تنظیمات | `/settings` | `Settings.tsx` | + +### زیرمنوی فروش + +| مسیر | نقش | +|------|-----| +| `/sales` | فرایند فروش | +| `/sales/proforma` | پیش‌فاکتور | +| `/sales/invoice` | فاکتور فروش | +| `/sales/return` | برداشت‌ها و لغو شده | +| `/sales/adjustments` | اعلامیه بدهکار/بستانکار | +| `/sales/sales-types` | نوع فروش | +| `/sales/customers` | مشتریان | +| `/sales/review` | مرور فروش | + +تعریف منو: `src/components/layout/navigation.ts` + +--- + +## ۳. تولید اعداد و شماره‌ها (قلب سیستم) + +### ۳.۱ الگوریتم مشترک فرانت‌اند + +فایل: `src/utils/id.ts` + +```ts +// شناسه موقت کلاینت (قبل از UUID سرور) +createId(prefix) → مثلاً "v-" + زمان + تصادفی + +// شماره ترتیبی سند/فاکتور/… +nextNumber(items) → max(item.number) + 1 +``` + +**نکات مهم:** + +- هیچ جدول Sequence / سری شماره‌گذاری جدا در دیتابیس نیست. +- **سال مالی (`fiscalYear`) فقط نمایشی است** و شماره‌ها را ریست یا جدا نمی‌کند. +- برای بیشتر اسناد، شماره را **کلاینت** با `nextNumber` روی لیست فیلترشده می‌سازد و سرور همان عدد را ذخیره می‌کند. +- **استثنا: شماره سند حسابداری (`Voucher.number`)** — سرور شماره کلاینت را نادیده می‌گیرد و خودش اختصاص می‌دهد. + +--- + +### ۳.۲ ماتریس شماره‌گذاری + +| نوع سند | فیلد | محدوده سری | کی می‌سازد؟ | فایل / منطق | +|---------|------|------------|-------------|-------------| +| **سند حسابداری** | `number` | همه اسناد | **سرور** (`_next_number` + قفل ردیف) | `serializers.py` | +| **پیش‌فاکتور فروش** | `number` | فقط `documentType=proforma` و `kind=sale` | کلاینت | `SalesDocumentSection` | +| **فاکتور فروش** | `number` | فقط `documentType=invoice` و `kind=sale` | کلاینت | `SalesDocumentSection` + سینک FunZone | +| **فاکتور خرید** | `number` | همه `kind=purchase` | کلاینت | `TradeModule` | +| **دریافت** | `number` | فقط `kind=receipt` | کلاینت | `Treasury.tsx` / سینک خزانه | +| **پرداخت** | `number` | فقط `kind=payment` | کلاینت | `Treasury.tsx` / سینک | +| **برداشت کیف پول** | `number` | **کل** لیست `treasury` (نه فقط payment) | کلاینت | `withdrawalTreasury.ts` | +| **اعلامیه بدهکار/بستانکار** | `number` | همه اعلامیه‌ها | کلاینت | `PartyAdjustmentsPage` | +| **کد حساب** | `code` | دستی / یکتا در DB | کاربر | `Accounts.tsx` + seed | +| **کد کالا / نوع فروش / پرسنل** | `code` | دستی | کاربر | بدون auto | +| **فیش حقوقی** | — | ندارد | — | با `id` و `period` | + +--- + +### ۳.۳ شماره سند حسابداری (تنها شماره قطعی سمت سرور) + +در `funzone-accounting-backend/accounting/serializers.py`: + +1. فیلد `number` در `VoucherSerializer` **read-only** است. +2. هنگام `create`، سرور با `select_for_update` آخرین شماره را می‌گیرد و `+1` می‌کند. +3. بعد از ایجاد، شماره تغییر نمی‌کند (`update` فیلد `number` را حذف می‌کند). +4. اگر سندی با `number <= 0` باشد، در update می‌تواند شماره بگیرد. + +فرانت قبل از POST ممکن است شماره موقت با `nextNumber(data.vouchers)` نشان دهد؛ بعد از ذخیره، شماره واقعی همانی است که سرور برمی‌گرداند. + +--- + +### ۳.۴ شناسه‌های داخلی (`createId`) + +| پیشوند | موجودیت | +|--------|---------| +| `v-` | سند | +| `vl-` | سطر سند | +| `inv-` | فاکتور | +| `il-` | سطر فاکتور | +| `tr-` | دریافت/پرداخت | +| `acc-` | حساب | +| `prod-` | کالا | +| `party-` | طرف‌حساب | +| `st-` | نوع فروش | +| `adj-` | اعلامیه | +| `emp-` | کارمند | +| `ps-` | فیش حقوقی | + +این‌ها شناسه موقت UI هستند؛ بعد از ایجاد در بک‌اند معمولاً با UUID سرور جایگزین می‌شوند. + +--- + +### ۳.۵ تگ‌های پایدار `source` (برای جلوگیری از دوباره‌ثبتی) + +| الگو | معنی | +|------|------| +| `invoice:{uuid}` | فاکتور دستی | +| `funzone:invoice:{paymentId}` | سند بلیت درگاه | +| `funzone:invoice:wallet:{paymentId}` | سند بلیت از کیف پول | +| `adjustment:{id}` | اعلامیه | +| `treasury:{id}` | خزانه دستی | +| `funzone:cust-pay:{id}` | پرداخت مشتری (قدیمی/خزانه) | +| `funzone:wallet-pay:{id}` | تراکنش کیف پول | +| `funzone:owner-txn:{id}` | تراکنش مالک | +| `withdrawal:{id}` | برداشت IBAN | + +فایل کلیدی لینک بلیت↔خزانه: `src/pages/sales/funzoneVoucherLink.ts` +منبع حقیقت تقسیم کارمزد بلیت = **سند فاکتور فروش**؛ اگر سند خزانه تکراری برای همان پرداخت بماند، حذف می‌شود. + +--- + +## ۴. کدینگ حساب‌ها و نقش هر کد + +حساب‌های پیش‌فرض از `seeding.py` (نمونه مهم‌ها): + +| کد | نام | نقش در سیستم | +|----|-----|--------------| +| 1001 | صندوق | نقدینگی داشبورد | +| 1002 | بانک ملت | بدهکار/بستانکار نقد در اسناد خودکار | +| 1003 | بانک سامان | نقدینگی | +| 1004 | دریافتنی مشتریان | فروش عادی، اعلامیه، تسویه خزانه | +| 1005 | موجودی کالا | فاکتور خرید | +| 2001 | پرداختنی تأمین‌کنندگان | خرید | +| 2002 | مالیات پرداختنی | ۱۰٪ بلیت FunZone | +| 2003 / 2004 | حقوق / بیمه | در کدینگ هست؛ حقوق هنوز سند نمی‌زند | +| 2005 | بدهی به مالکان (کیف پول) | ۷۶٪ سهم مالک | +| 2006 | بدهی به مشتریان (کیف پول) | شارژ/برداشت/بلیت از کیف | +| 4001 | درآمد فروش | فروش عادی + ۱۴٪ سود پلتفرم | +| 5002 | هزینه حقوق | رزرو شده؛ Payroll وصل نیست | +| 5007 | سهم مالکان | در تنظیمات مپ شده؛ مسیر فعلی بلیت بیشتر از 2005/4001/2002 استفاده می‌کند | + +**تنظیمات نگاشت حساب‌ها** (`Settings` → `voucherAccounts`): بانک، بدهی مالک، بدهی مشتری، سهم مالک، درآمد فروش. + +بدون کدینگ درست، `buildSalesVoucher` / `buildPurchaseVoucher` / `buildTreasuryVoucher` سند نمی‌سازند (`null`). + +--- + +## ۵. فلوچارت اتصال ماژول‌ها + +### ۵.۱ نمای کلی ارتباطات + +```mermaid +flowchart TB + subgraph سیستم["سیستم"] + SET[تنظیمات
نگاشت حساب‌ها / سال مالی نمایشی] + end + + subgraph حسابداری["حسابداری"] + ACC[حساب‌ها / کدینگ] + VOC[اسناد حسابداری] + end + + subgraph عملیات["عملیات"] + SAL[مشتریان و فروش] + PUR[تأمین‌کنندگان و خرید] + INV[انبارداری] + TRE[دریافت و پرداخت] + PAY[حقوق و دستمزد] + end + + subgraph تحلیل["تحلیل"] + REP[گزارش‌ها] + DAS[داشبورد] + end + + subgraph خارجی["خارج از اپ"] + FZ[FunZone API] + end + + SET --> ACC + SET --> SAL + SET --> TRE + ACC --> VOC + ACC --> SAL + ACC --> PUR + ACC --> TRE + + FZ -->|سینک طرف‌حساب| SAL + FZ -->|سینک بلیت / لغو| SAL + FZ -->|شارژ کیف / پرداخت مالک| TRE + FZ -->|رویداد / رزرو| INV + + SAL -->|buildSalesVoucher + موجودی| VOC + SAL -->|اعلامیه| VOC + PUR -->|buildPurchaseVoucher + موجودی| VOC + PUR -->|افزایش stock| INV + SAL -->|کاهش stock| INV + TRE -->|buildTreasuryVoucher + تسویه فاکتور| VOC + TRE -->|reconcile paid/confirmed| SAL + + PAY -.->|فعلاً بدون سند| VOC + + VOC --> REP + VOC --> DAS + SAL --> DAS + PUR --> DAS + INV --> DAS + TRE --> DAS +``` + +### ۵.۲ جریان تولید سند از عملیات + +```mermaid +flowchart LR + A[عملیات کاربر یا سینک FunZone] --> B{نوع؟} + B -->|فاکتور فروش/خرید| C[ذخیره Invoice] + B -->|دریافت/پرداخت| D[ذخیره TreasuryTxn] + B -->|اعلامیه| E[ذخیره PartyAdjustment] + B -->|سند دستی| F[Vouchers UI] + + C --> G[invoiceSideEffects] + G --> H[تغییر موجودی کالا] + G --> I[buildInvoiceVoucher] + D --> J[buildTreasuryVoucher] + D --> K[reconcileInvoicePaymentStatus] + E --> L[buildAdjustmentVoucher] + F --> M[createDraft + nextNumber کلاینت] + + I --> N[POST سند] + J --> N + L --> N + M --> N + N --> O[سرور: شماره قطعی Voucher] + O --> P[دفتر کل / تراز / داشبورد / گزارش] +``` + +### ۵.۳ جریان بلیت FunZone (تقسیم مبالغ) + +برای مبلغ ناخالص `G`: + +| جزء | نرخ | حساب بستانکار معمول | +|-----|-----|---------------------| +| مالیات | ۱۰٪ | 2002 | +| سود پلتفرم | ۱۴٪ | 4001 | +| سهم مالک | ۷۶٪ (باقیمانده) | 2005 | + +**بدهکار:** + +- درگاه: 1002 بانک +- کیف پول مشتری: 2006 + +تابع: `splitEventPayment` در `src/utils/funzoneFees.ts` +سند: `buildSalesVoucher` وقتی note شامل `funzone:pay:` است. + +```mermaid +flowchart TB + P[پرداخت بلیت در FunZone] --> S[funzoneSalesSync] + S --> I[فاکتور فروش status=paid] + I --> V[سند با source=funzone:invoice:…] + V --> L1[بدهکار بانک یا کیف 2006] + V --> L2[بستانکار مالک 2005] + V --> L3[بستانکار درآمد 4001] + V --> L4[بستانکار مالیات 2002] + T[سند خزانه تکراری همان پرداخت] -.->|حذف توسط funzoneVoucherLink| X[حذف] +``` + +### ۵.۴ ترتیب همگام‌سازی یکپارچه + +`unifiedVoucherSync.ts`: + +1. سینک خزانه از FunZone (شارژ کیف، تراکنش مالک، …) +2. سینک فاکتورهای فروش بلیت + ثبت سند فاکتور +3. حذف اسناد تکراری / منسوخ (`supersededVoucherIds` + لینک بلیت) + +--- + +## ۶. جزئیات هر بخش منو + +### ۶.۱ داشبورد + +**می‌خواند از:** اسناد posted، فاکتورها، موجودی کالا، حساب‌های نقدی. + +**نمایش تقریبی:** + +- نقدینگی (کدهای 1001–1003) +- جمع فروش / خرید از فاکتورها +- سود و زیان و تراز از `trialBalance` روی اسناد +- موجودی کم / فاکتورهای تسویه‌نشده +- نمودار چندماهه + +**تولید عدد:** خودش شماره سند نمی‌سازد؛ فقط تجمیع می‌کند. + +--- + +### ۶.۲ حساب‌ها (کدینگ) + +**نقش:** درخت حساب‌ها (`parentId`)، مانده افتتاحیه، نوع (دارایی/بدهی/درآمد/هزینه). + +**اتصال:** پایه همه سطرهای سند (`VoucherLine.accountId`). بدون حساب‌های اجباری، اسناد خودکار ساخته نمی‌شوند. + +**شماره:** کد حساب دستی و یکتا — بدون `nextNumber`. + +--- + +### ۶.۳ اسناد حسابداری + +**ورودی‌ها:** + +| منبع | چگونه | +|------|--------| +| دستی در همین صفحه | پیش‌نویس + ثبت | +| فروش | `buildSalesVoucher` | +| خرید | `buildPurchaseVoucher` | +| خزانه | `buildTreasuryVoucher` | +| اعلامیه | `buildAdjustmentVoucher` | +| دکمه همگام FunZone | `unifiedVoucherSync` | + +**شماره:** سرور قطعی می‌کند. + +**وضعیت:** `draft` | `posted` — گزارش‌ها معمولاً روی posted (منهای اسناد حذف‌شده/منسوخ). + +--- + +### ۶.۴ مشتریان و فروش + +**موجودیت‌ها:** Party (مشتری)، Invoice فروش، نوع فروش، اعلامیه، سینک FunZone. + +| عمل | اثر حسابداری | اثر انبار | +|-----|--------------|-----------| +| پیش‌فاکتور | بدون سند | معمولاً بدون تغییر قطعی موجودی مسیر اصلی سند | +| فاکتور فروش عادی confirmed | بدهکار 1004 / بستانکار درآمد | کاهش stock | +| بلیت FunZone | تقسیم ۱۰/۱۴/۷۶ | — | +| لغو بلیت درگاه | بانک بدهکار / 2006 بستانکار | — | +| لغو بلیت کیف پول | حذف/بازگردانی سند قبلی | — | +| اعلامیه بدهکار | ↑ مطالبات / ↑ درآمد | — | +| اعلامیه بستانکار | عکس بالا | — | + +**شماره فاکتور:** سری جدا برای پیش‌فاکتور و فاکتور (`nextNumber` روی همان `documentType`). + +**تسویه:** خزانه با `reconcileInvoicePaymentStatus` به صورت FIFO وضعیت `confirmed` ↔ `paid` را عوض می‌کند (بلیت‌های FunZone prepaid می‌مانند). + +--- + +### ۶.۵ تأمین‌کنندگان و خرید + +**موجودیت‌ها:** Party (supplier)، Invoice خرید. + +**سند خودکار (confirmed):** + +- بدهکار 1005 موجودی +- بستانکار 2001 پرداختنی + +**شماره:** یک سری برای همه فاکتورهای خرید. + +**اتصال به خزانه:** پرداخت به تأمین‌کننده از ماژول دریافت/پرداخت؛ تسویه فاکتور خرید از همان منطق reconcile. + +--- + +### ۶.۶ انبارداری + +**دو لایه:** + +1. **موجودی کالا در حسابداری** — فیلد `Product.stock`؛ با تأیید فاکتور خرید/فروش عوض می‌شود (`applyStockChanges` / `stockDeltas`). +2. **نمای عملیاتی FunZone** — رویداد/رزرو زنده (`funzoneInventory.ts`)؛ **سند حسابداری نمی‌زند**. + +یعنی انبار «دفتر انبار جدا» ندارد؛ موجودی از فاکتورها مشتق می‌شود + نمای FunZone برای عملیات. + +--- + +### ۶.۷ دریافت و پرداخت (خزانه) + +| نوع تراکنش | سند تقریبی | +|------------|------------| +| شارژ کیف پول مشتری | بدهکار بانک / بستانکار 2006 | +| برداشت کیف پول | بدهکار 2006 / بستانکار بانک | +| دریافت عادی از مشتری | بدهکار بانک / بستانکار 1004 | +| پرداخت به تأمین‌کننده/مالک | بدهکار 2005 یا 2001 / بستانکار بانک | +| مسیر قدیمی split روی خزانه | همان ۱۰/۱۴/۷۶ (امروز ترجیح با سند فاکتور بلیت است) | + +**شماره:** دریافت و پرداخت سری جدا در UI؛ مسیر برداشت ممکن است از `nextNumber` روی **کل** خزانه استفاده کند. + +--- + +### ۶.۸ حقوق و دستمزد + +CRUD کارمند و فیش حقوقی. + +**اتصال به اسناد: فعلاً وجود ندارد.** حساب‌های 2003/2004/5002 در کدینگ آماده هستند ولی Payroll سند ثبت نمی‌کند. + +--- + +### ۶.۹ گزارش‌ها + +از توابع `src/utils/accounting.ts`: + +- تراز آزمایشی (`trialBalance`) +- دفتر حساب (`accountLedger`) +- سود و زیان (`incomeStatement`) +- ترازنامه (`balanceSheet`) + +ورودی: حساب‌ها (مانده افتتاحیه) + اسناد posted (فیلتر اسناد منسوخ). + +--- + +### ۶.۱۰ تنظیمات + +- اطلاعات شرکت و **سال مالی نمایشی** +- نگاشت حساب‌های پیش‌فرض اسناد خودکار +- ریست/seed داده‌ها (در صورت فعال بودن در UI) + +بدون نگاشت درست، زنجیره فروش/خزانه/بلیت می‌شکند. + +--- + +## ۷. موجودیت‌ها و روابط + +``` +Account (درخت) + └── VoucherLine.accountId → Voucher + +Party (customer | supplier ، گاهی externalId از FunZone) + ├── Invoice.partyId + ├── PartyAdjustment.partyId + └── TreasuryTxn.partyId + +Invoice (sale | purchase) + └── Voucher با source پایدار + +TreasuryTxn + └── Voucher با source خزانه / FunZone / withdrawal + +Employee → Payslip (بدون Voucher) + +CompanySettings (singleton) → FKs به Account +``` + +**وضعیت فاکتور:** `draft` | `confirmed` | `paid` | `cancelled` +**وضعیت سند:** `draft` | `posted` + +--- + +## ۸. فایل‌های کلیدی (مرجع سریع) + +| فایل | نقش | +|------|-----| +| `src/utils/id.ts` | `createId` / `nextNumber` | +| `src/pages/sales/salesUtils.ts` | همه سازنده‌های سند + تسویه + source | +| `src/pages/sales/invoiceEffects.ts` | اثر جانبی ذخیره فاکتور | +| `src/utils/funzoneFees.ts` | تقسیم ۱۰٪ / ۱۴٪ / ۷۶٪ | +| `src/pages/sales/funzoneVoucherLink.ts` | لینک بلیت و حذف سند تکراری خزانه | +| `src/accounting/unifiedVoucherSync.ts` | ارکستراسیون سینک | +| `src/accounting/voucherDedupe.ts` | حذف/مخفی اسناد تکراری | +| `src/pages/sales/funzoneSalesSync.ts` | پرداخت FunZone → فاکتور | +| `src/pages/treasury/funzoneTreasurySync.ts` | کیف پول / مالک → خزانه | +| `src/utils/accounting.ts` | محاسبات گزارش و تراز | +| `src/store/AppStore.tsx` | بارگذاری و CRUD | +| `backend/.../serializers.py` | شماره قطعی سند | +| `backend/.../seeding.py` | کدینگ اولیه | + +> ⚠️ `voucherSync.ts` مسیر قدیمی است؛ برای جلوگیری از دوباره‌ثبتی از مسیر یکپارچه (`unifiedVoucherSync`) استفاده شود. + +--- + +## ۹. جمع‌بندی قوانین شماره‌گذاری (برای تیم) + +1. **فقط شماره سند حسابداری روی سرور قطعی است**؛ بقیه `max+1` سمت کلاینت روی لیست فیلترشده. +2. **سال مالی شماره‌ها را جدا یا ریست نمی‌کند.** +3. **پیش‌فاکتور و فاکتور فروش سری جدا** دارند؛ **خرید یک سری** دارد. +4. **دریافت و پرداخت در UI سری جدا** دارند؛ مسیر برداشت ممکن است با سری دیگر هم‌پوشانی عددی داشته باشد. +5. **بلیت FunZone:** فاکتور + سند با تگ `funzone:invoice:…`؛ تقسیم کارمزد روی همان سند؛ خزانه برای همان بلیت نباید دوبار ثبت شود. +6. **حقوق هنوز به دفتر کل وصل نیست.** + +--- + +*آخرین به‌روزرسانی بر اساس کدبیس `funzone-accounting` و `funzone-accounting-backend`.* diff --git a/src/accounting/unifiedVoucherSync.ts b/src/accounting/unifiedVoucherSync.ts index 7353bdb..3d7de88 100644 --- a/src/accounting/unifiedVoucherSync.ts +++ b/src/accounting/unifiedVoucherSync.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react' -import { apiGet } from '../api/client' +import { apiGet, asList } from '../api/client' import { ENDPOINTS } from '../api/config' -import type { ApiPaymentOrRefund } from '../api/types' +import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' import type { AppData, Invoice } from '../types' import { useStore } from '../store/AppStore' import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync' @@ -26,7 +26,14 @@ export interface UnifiedFunZoneSyncStats { } export function useUnifiedFunZoneVoucherSync() { - const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore() + const { + data, + upsertInvoiceAsync, + upsertVoucherAsync, + removeVoucherAsync, + removeInvoiceAsync, + reload, + } = useStore() const treasury = useFunZoneTreasurySync() const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -37,33 +44,50 @@ export function useUnifiedFunZoneVoucherSync() { dataRef.current = data const applyInvoiceIfChanged = useCallback( - (working: AppData, invoice: Invoice): AppData => { + async (working: AppData, invoice: Invoice): Promise => { const previous = working.invoices.find((i) => i.id === invoice.id) if (previous && invoiceUnchanged(previous, invoice) && previous.status === invoice.status) { return working } + // Build effects against the invoice we intend to save (stable FunZone voucher source). const effects = invoiceSideEffects(invoice, previous ?? null, working) - upsertInvoice(invoice) - if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId) - if (effects.voucher) upsertVoucher(effects.voucher) + const saved = await upsertInvoiceAsync(invoice) + const savedInvoice = { ...invoice, id: saved.id } + + // Rebuild voucher with saved id so legacy invoice:{uuid} fallback also works. + const reboundEffects = invoiceSideEffects(savedInvoice, previous ?? null, { + ...working, + invoices: [...working.invoices.filter((i) => i.id !== previous?.id && i.id !== saved.id), savedInvoice], + }) + const voucherBuilt = reboundEffects.voucher ?? effects.voucher + const removeVoucherId = reboundEffects.removeVoucherId ?? effects.removeVoucherId + + const voucherToSave = + voucherBuilt && removeVoucherId + ? { ...voucherBuilt, id: removeVoucherId } + : voucherBuilt + if (!voucherToSave && removeVoucherId) { + await removeVoucherAsync(removeVoucherId) + } + if (voucherToSave) await upsertVoucherAsync(voucherToSave) let next: AppData = { ...working, - invoices: [...working.invoices.filter((i) => i.id !== invoice.id), invoice], + invoices: [...working.invoices.filter((i) => i.id !== previous?.id && i.id !== saved.id), saved], vouchers: [ ...working.vouchers.filter( - (v) => v.id !== effects.removeVoucherId && v.id !== effects.voucher?.id, + (v) => v.id !== removeVoucherId && v.id !== voucherToSave?.id && v.source !== voucherToSave?.source, ), - ...(effects.voucher ? [effects.voucher] : []), + ...(voucherToSave ? [voucherToSave] : []), ], } - if (invoice.partyId && invoice.status !== 'draft') { - for (const settled of reconcileInvoicePaymentStatus(invoice.partyId, next)) { + if (savedInvoice.partyId && savedInvoice.status !== 'draft' && savedInvoice.status !== 'cancelled') { + for (const settled of reconcileInvoicePaymentStatus(savedInvoice.partyId, next)) { const current = next.invoices.find((i) => i.id === settled.id) if (current?.status === settled.status) continue - upsertInvoice(settled) + await upsertInvoiceAsync(settled) next = { ...next, invoices: next.invoices.map((i) => (i.id === settled.id ? settled : i)), @@ -73,28 +97,43 @@ export function useUnifiedFunZoneVoucherSync() { return next }, - [upsertInvoice, upsertVoucher, removeVoucher], + [upsertInvoiceAsync, upsertVoucherAsync, removeVoucherAsync], ) - const syncSalesInvoices = useCallback(async () => { + const syncSalesInvoices = useCallback(async (customerPayments: ApiPaymentOrRefund[]) => { const current = dataRef.current - const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>( - ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(), - ).then((res) => res.payments_and_refunds ?? []) - const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } = - buildFunZoneSalesSync(current, customerPayments) + const { + toUpsert, + toRemoveIds, + toRemoveVoucherIds, + skipped, + unmatchedCustomers, + paymentCount, + cancellationCount, + } = buildFunZoneSalesSync(current, customerPayments) + + for (const voucherId of toRemoveVoucherIds) { + await removeVoucherAsync(voucherId) + } + for (const invoiceId of toRemoveIds) { + await removeInvoiceAsync(invoiceId) + } + + const afterCleanup = await reload() + if (afterCleanup) dataRef.current = afterCleanup + const base = dataRef.current const saved = assignInvoiceIds(toUpsert) let created = 0 let updated = 0 - let working = { ...current } + let working = { ...base } for (const invoice of saved) { - const isNew = !current.invoices.some((i) => i.id === invoice.id) + const isNew = !base.invoices.some((i) => i.id === invoice.id) if (isNew) created += 1 else updated += 1 - working = applyInvoiceIfChanged(working, invoice) + working = await applyInvoiceIfChanged(working, invoice) } return { @@ -105,7 +144,13 @@ export function useUnifiedFunZoneVoucherSync() { funzonePayments: paymentCount, funzoneCancellations: cancellationCount, } - }, [applyInvoiceIfChanged]) + }, [applyInvoiceIfChanged, removeInvoiceAsync, removeVoucherAsync, reload]) + + const legacyFunZoneVoucherIds = useCallback((working: AppData): string[] => { + return working.vouchers + .filter((voucher) => Boolean(voucher.source?.match(/^funzone:(cust|owner-txn):/))) + .map((voucher) => voucher.id) + }, []) const sync = useCallback(async () => { if (syncingRef.current) return @@ -113,19 +158,27 @@ export function useUnifiedFunZoneVoucherSync() { setLoading(true) setError(null) try { - const treasuryStats = await treasury.sync() + const [customerPayments, ownerTransactions] = await Promise.all([ + apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then( + (res) => res.payments_and_refunds ?? [], + ), + apiGet(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList), + ]) + + const treasuryStats = await treasury.sync({ customerPayments, ownerTransactions }) const freshAfterTreasury = await reload() if (freshAfterTreasury) dataRef.current = freshAfterTreasury - const salesResult = await syncSalesInvoices() + const salesResult = await syncSalesInvoices(customerPayments) const freshAfterSales = await reload() const working = freshAfterSales ?? dataRef.current const duplicateIds = supersededVoucherIds(working) - for (const voucherId of duplicateIds) { - removeVoucher(voucherId) + const staleLegacyIds = legacyFunZoneVoucherIds(working) + const toDeleteIds = new Set([...duplicateIds, ...staleLegacyIds]) + for (const voucherId of toDeleteIds) { + await removeVoucherAsync(voucherId) } - await reload() setStats({ treasuryCreated: treasuryStats?.created ?? 0, @@ -133,7 +186,7 @@ export function useUnifiedFunZoneVoucherSync() { treasurySkipped: treasuryStats?.skipped ?? 0, treasuryRemoved: treasuryStats?.removed ?? 0, ...salesResult, - duplicatesRemoved: duplicateIds.length, + duplicatesRemoved: toDeleteIds.size, unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0, }) setLastSyncedAt(new Date().toISOString()) @@ -143,7 +196,7 @@ export function useUnifiedFunZoneVoucherSync() { syncingRef.current = false setLoading(false) } - }, [treasury, syncSalesInvoices, reload, removeVoucher]) + }, [treasury, syncSalesInvoices, reload, removeVoucherAsync, legacyFunZoneVoucherIds]) return { sync, loading, error, stats, lastSyncedAt } } diff --git a/src/accounting/voucherDedupe.ts b/src/accounting/voucherDedupe.ts index 0cb7f27..9e9c437 100644 --- a/src/accounting/voucherDedupe.ts +++ b/src/accounting/voucherDedupe.ts @@ -13,7 +13,7 @@ function treasuryVoucherForPaymentSource( ): 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)) + return data.vouchers.find((v) => v.source === voucherSourceForTreasury(txn)) } /** Voucher ids superseded by a more complete treasury or invoice-linked record. */ diff --git a/src/accounting/voucherSync.ts b/src/accounting/voucherSync.ts index ea4ac33..763e223 100644 --- a/src/accounting/voucherSync.ts +++ b/src/accounting/voucherSync.ts @@ -1,3 +1,7 @@ +/** + * LEGACY — superseded by `useUnifiedFunZoneVoucherSync` (sales invoices + treasury). + * Do not wire this hook into the UI; it can double-book ticket/cancel/withdraw flows. + */ import { useCallback } from 'react' import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types' import { diff --git a/src/api/config.ts b/src/api/config.ts index f89b41a..c5f4afb 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -44,6 +44,8 @@ export const ENDPOINTS = { ? `/payments/admin/customer-payments-refunds/?customer_id=${customerId}` : '/payments/admin/customer-payments-refunds/', WITHDRAWALS: '/payments/admin/withdrawals/', + UPDATE_WITHDRAWAL_STATUS: (withdrawalId: string) => + `/payments/admin/withdrawal-status/${withdrawalId}/`, } as const /** Endpoints served by the standalone accounting backend (relative to its base URL). */ diff --git a/src/components/business/TradeModule.tsx b/src/components/business/TradeModule.tsx index c3d635d..82fb254 100644 --- a/src/components/business/TradeModule.tsx +++ b/src/components/business/TradeModule.tsx @@ -37,12 +37,14 @@ const statusLabels: Record = { draft: 'پیش‌نویس', confirmed: 'قطعی', paid: 'تسویه‌شده', + cancelled: 'لغو شده', } -const statusTones: Record = { +const statusTones: Record = { draft: 'amber', confirmed: 'blue', paid: 'green', + cancelled: 'rose', } export function TradeModule({ config }: { config: TradeConfig }) { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 5379af5..c302755 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -18,7 +18,12 @@ interface MonthBucket { } function isSaleInvoice(invoice: ReturnType['data']['invoices'][number]): boolean { - return invoice.kind === 'sale' && (invoice.documentType ?? 'invoice') === 'invoice' && invoice.status !== 'draft' + return ( + invoice.kind === 'sale' && + (invoice.documentType ?? 'invoice') === 'invoice' && + invoice.status !== 'draft' && + invoice.status !== 'cancelled' + ) } function monthlyBuckets( diff --git a/src/pages/Treasury.tsx b/src/pages/Treasury.tsx index 26a4cda..bf35ae4 100644 --- a/src/pages/Treasury.tsx +++ b/src/pages/Treasury.tsx @@ -1,664 +1,432 @@ import { useMemo, useState } from 'react' import { Link } from 'react-router-dom' - import { useStore } from '../store/AppStore' - import { - Badge, - Button, - Card, - ConfirmDialog, - DataTable, - Field, - Input, - JalaliDateInput, - Modal, - PageHeader, - Select, - StatCard, - Textarea, - type Column, - } from '../components/ui' - import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types' - import { createId, nextNumber } from '../utils/id' - import { formatDate, formatMoney, todayISO, toFa } from '../utils/format' - -import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, suppliers, voucherSourceForTreasury } from './sales/salesUtils' - -import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync' - - +import { + buildTreasuryVoucher, + customers, + reconcileInvoicePaymentStatus, + suppliers, + voucherSourceForTreasury, +} from './sales/salesUtils' +import { isFunZoneTreasuryTxn, isMisplacedTicketTreasuryTxn } from './treasury/funzoneTreasurySync' const kindLabels: Record = { receipt: 'دریافت', payment: 'پرداخت' } - const methodLabels: Record = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' } - - export function Treasury() { - const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore() - const [draft, setDraft] = useState(null) - const [toDelete, setToDelete] = useState(null) - - const partyName = (id: string | null) => - id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—' - - - const funZoneCounts = useMemo(() => { - - const fz = data.treasury.filter(isFunZoneTreasuryTxn) - - return { - - receipts: fz.filter((t) => t.kind === 'receipt').length, - - payments: fz.filter((t) => t.kind === 'payment').length, - - } - - }, [data.treasury]) - - - - const summary = useMemo(() => { - - const receipts = data.treasury.filter((t) => t.kind === 'receipt').reduce((s, t) => s + t.amount, 0) - - const payments = data.treasury.filter((t) => t.kind === 'payment').reduce((s, t) => s + t.amount, 0) - - const platformProfit = data.treasury.reduce((s, t) => s + (t.platformProfitAmount ?? 0), 0) - - return { receipts, payments, net: receipts - payments, platformProfit } - - }, [data.treasury]) - - - - const sorted = useMemo( - - () => [...data.treasury].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number), - + /** Hide legacy ticket/refund rows until همگام‌سازی removes them. */ + const visibleTreasury = useMemo( + () => data.treasury.filter((t) => !isMisplacedTicketTreasuryTxn(t)), [data.treasury], - ) + const funZoneCounts = useMemo(() => { + const fz = visibleTreasury.filter(isFunZoneTreasuryTxn) + return { + receipts: fz.filter((t) => t.kind === 'receipt').length, + payments: fz.filter((t) => t.kind === 'payment').length, + } + }, [visibleTreasury]) + const summary = useMemo(() => { + const receipts = visibleTreasury + .filter((t) => t.kind === 'receipt') + .reduce((s, t) => s + t.amount, 0) + const payments = visibleTreasury + .filter((t) => t.kind === 'payment') + .reduce((s, t) => s + t.amount, 0) + return { receipts, payments, net: receipts - payments } + }, [visibleTreasury]) + + const sorted = useMemo( + () => [...visibleTreasury].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number), + [visibleTreasury], + ) const treasuryParties = useMemo(() => { - if (!draft) return [] - - const list = draft.kind === 'receipt' ? customers(data.parties) : suppliers(data.parties) - - return list.filter((p) => p.id) - + const list = + draft.kind === 'receipt' + ? customers(data.parties) + : [...customers(data.parties), ...suppliers(data.parties)] + const seen = new Set() + return list.filter((p) => { + if (!p.id || seen.has(p.id)) return false + seen.add(p.id) + return true + }) }, [data.parties, draft]) - - const createDraft = (kind: TreasuryKind): TreasuryTxn => ({ - id: '', - - number: nextNumber(data.treasury.filter((t) => t.kind === kind)), - + number: nextNumber(visibleTreasury.filter((t) => t.kind === kind)), kind, - method: 'bank', - date: todayISO(), - partyId: null, - amount: 0, - reference: '', - description: '', - source: '', - }) - - const handleSave = () => { - if (!draft || draft.amount <= 0) return - const saved: TreasuryTxn = { - ...draft, - id: draft.id || createId('tr-'), - source: draft.source ?? '', - } - - upsertTreasury(saved) - - - const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved.id)) - + const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved)) const newVoucher = buildTreasuryVoucher(saved, data) - if (existingVoucher) removeVoucher(existingVoucher.id) - if (newVoucher) upsertVoucher(newVoucher) - - if (saved.partyId) { - const dataAfterSave = { - ...data, - treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved], - } - for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) { - upsertInvoice(inv) - } - } - - setDraft(null) - } - - const columns: Array> = [ - { key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) }, - { - key: 'kind', - header: 'نوع', - - render: (t) => {kindLabels[t.kind]}, - + render: (t) => ( + {kindLabels[t.kind]} + ), }, - { - key: 'source', - header: 'منبع', - render: (t) => - isFunZoneTreasuryTxn(t) ? ( - فان‌زون - ) : ( - - سپیدار - + دستی ), - }, - - { key: 'method', header: 'روش', render: (t) => {methodLabels[t.method]} }, - + { + key: 'method', + header: 'روش', + render: (t) => {methodLabels[t.method]}, + }, { key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) }, - { - key: 'details', - header: 'شرح', - render: (t) => ( - - {t.description.split('\n')[0] || '—'} - - ), - }, - - { key: 'ref', header: 'مرجع', render: (t) => {t.reference || '—'} }, - { - + key: 'ref', + header: 'مرجع', + render: (t) => {t.reference || '—'}, + }, + { key: 'amount', - header: 'مبلغ', - align: 'end', - render: (t) => ( - - - + {t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)} - - ), - }, - { - key: 'actions', - header: '', - align: 'end', - render: (t) => ( -
- - - {!isFunZoneTreasuryTxn(t) && ( - - - )} -
- ), - }, - ] - - return ( -
- - - - - } - /> - - - -
- -
- -

اتصال فان‌زون → خزانه

- -
    -
  • دریافت: پرداخت مشتری — مبلغ کل رویداد (رزرو/بلیت)
  • -
  • ↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)
  • -
  • پرداخت: فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)
  • -
  • ↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یک‌بار هنگام خرید بلیت در «دریافت» لحاظ شده
  • -
  • - برای همگام‌سازی خودکار از فان‌زون به{' '} - - اسناد حسابداری - {' '} - بروید. -
  • -
- -
- -
- +

اتصال فان‌زون → خزانه

+
    +
  • + ↓ دریافت: شارژ کیف پول مشتری (بدون مالیات و ۱۴٪) — بدهی به مشتری تا + زمان برداشت +
  • +
  • + ↑ پرداخت: فقط برداشت بانکی و بازپرداخت مالک (وضعیت انجام‌شده) +
  • +
  • + ↳ فروش بلیت →{' '} + + فاکتور فروش + {' '} + (مالیات ۱۰٪ + سود ۱۴٪ + سهم تامین‌کننده) +
  • +
  • + ↳ برداشت کیف پول مشتری به شبا →{' '} + + فروش / لغو و برداشت + +
  • +
  • + همگام‌سازی خودکار در{' '} + + اسناد حسابداری + +
  • +
- - -
- +
- - = 0 ? 'brand' : 'rose'} - /> - - - - - +
- - - - t.id} emptyTitle="تراکنشی ثبت نشده است — همگام‌سازی فان‌زون را بزنید" /> - + t.id} + emptyTitle="تراکنشی ثبت نشده — از اسناد حسابداری همگام‌سازی کنید" + /> - - setDraft(null)} - footer={ - <> - - - {draft && !isFunZoneTreasuryTxn(draft) && } - - - - } - - > - - {draft && ( - -
- - {isFunZoneTreasuryTxn(draft) ? ( - -
- -
-
-                  {draft.description}
-
-                
- -
- -
- -
طرف حساب
- -
{partyName(draft.partyId)}
- -
- -
- -
مبلغ
- -
{formatMoney(draft.amount)}
- -
- -
- -
تاریخ
- -
{formatDate(draft.date)}
- -
- -
- -
مرجع
- -
{draft.reference || '—'}
- -
- -
- -
- - ) : ( - - <> - - - - setDraft({ ...draft, date })} /> - - - - - - - - - - - - - - - - - - setDraft({ ...draft, amount: Number(e.target.value) || 0 })} /> - - - - - - setDraft({ ...draft, reference: e.target.value })} /> - - - -
- - - -