Improve FunZone sales/treasury sync and keep IBAN out of treasury reference.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
511
docs/جریان-سیستم-و-شمارهگذاری.md
Normal file
511
docs/جریان-سیستم-و-شمارهگذاری.md
Normal file
@@ -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[تنظیمات<br/>نگاشت حسابها / سال مالی نمایشی]
|
||||
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`.*
|
||||
@@ -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<string | null>(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<AppData> => {
|
||||
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<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||
])
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -37,12 +37,14 @@ const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'پیشنویس',
|
||||
confirmed: 'قطعی',
|
||||
paid: 'تسویهشده',
|
||||
cancelled: 'لغو شده',
|
||||
}
|
||||
|
||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green' | 'rose'> = {
|
||||
draft: 'amber',
|
||||
confirmed: 'blue',
|
||||
paid: 'green',
|
||||
cancelled: 'rose',
|
||||
}
|
||||
|
||||
export function TradeModule({ config }: { config: TradeConfig }) {
|
||||
|
||||
@@ -18,7 +18,12 @@ interface MonthBucket {
|
||||
}
|
||||
|
||||
function isSaleInvoice(invoice: ReturnType<typeof useStore>['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(
|
||||
|
||||
@@ -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<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
||||
|
||||
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
|
||||
|
||||
|
||||
|
||||
export function Treasury() {
|
||||
|
||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||
|
||||
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
||||
|
||||
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(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<string>()
|
||||
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<Column<TreasuryTxn>> = [
|
||||
|
||||
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
|
||||
|
||||
{
|
||||
|
||||
key: 'kind',
|
||||
|
||||
header: 'نوع',
|
||||
|
||||
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>,
|
||||
|
||||
render: (t) => (
|
||||
<Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
key: 'source',
|
||||
|
||||
header: 'منبع',
|
||||
|
||||
render: (t) =>
|
||||
|
||||
isFunZoneTreasuryTxn(t) ? (
|
||||
|
||||
<Badge tone="indigo">فانزون</Badge>
|
||||
|
||||
) : (
|
||||
|
||||
<Badge tone="slate">سپیدار</Badge>
|
||||
|
||||
<Badge tone="slate">دستی</Badge>
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{ key: 'method', header: 'روش', render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge> },
|
||||
|
||||
{
|
||||
key: 'method',
|
||||
header: 'روش',
|
||||
render: (t) => <Badge tone="slate">{methodLabels[t.method]}</Badge>,
|
||||
},
|
||||
{ key: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) },
|
||||
|
||||
{
|
||||
|
||||
key: 'details',
|
||||
|
||||
header: 'شرح',
|
||||
|
||||
render: (t) => (
|
||||
|
||||
<span className="line-clamp-2 max-w-xs text-xs text-slate-500" title={t.description}>
|
||||
|
||||
{t.description.split('\n')[0] || '—'}
|
||||
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{ key: 'ref', header: 'مرجع', render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span> },
|
||||
|
||||
{
|
||||
|
||||
key: 'ref',
|
||||
header: 'مرجع',
|
||||
render: (t) => <span className="font-mono text-slate-500">{t.reference || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
|
||||
header: 'مبلغ',
|
||||
|
||||
align: 'end',
|
||||
|
||||
render: (t) => (
|
||||
|
||||
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
|
||||
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
||||
|
||||
</span>
|
||||
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
key: 'actions',
|
||||
|
||||
header: '',
|
||||
|
||||
align: 'end',
|
||||
|
||||
render: (t) => (
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
<Button
|
||||
|
||||
size="sm"
|
||||
|
||||
variant="ghost"
|
||||
|
||||
onClick={() => setDraft({ ...t })}
|
||||
|
||||
<span
|
||||
className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}
|
||||
>
|
||||
|
||||
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
|
||||
|
||||
</Button>
|
||||
|
||||
{!isFunZoneTreasuryTxn(t) && (
|
||||
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
||||
|
||||
حذف
|
||||
|
||||
</Button>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (t) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
|
||||
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
|
||||
</Button>
|
||||
{!isFunZoneTreasuryTxn(t) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-rose-600"
|
||||
onClick={() => setToDelete(t)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
<PageHeader
|
||||
|
||||
title="دریافت و پرداخت (خزانهداری)"
|
||||
|
||||
subtitle="دریافت از مشتریان (فانزون) و پرداخت به مالکان — همگام با سپیدار (سند + تسویه فاکتور)"
|
||||
|
||||
subtitle="شارژ کیف پول مشتری و پرداخت به مالکان — همگامسازی از اسناد حسابداری"
|
||||
actions={
|
||||
|
||||
<>
|
||||
|
||||
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
||||
|
||||
دریافت دستی
|
||||
|
||||
</Button>
|
||||
|
||||
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
|
||||
|
||||
پرداخت دستی
|
||||
|
||||
</Button>
|
||||
|
||||
</>
|
||||
|
||||
}
|
||||
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<Card className="border-brand-200 bg-brand-50/40 p-4">
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
||||
<div>
|
||||
|
||||
<h3 className="font-bold text-slate-800">اتصال فانزون → خزانه</h3>
|
||||
|
||||
<ul className="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li>↓ <strong>دریافت:</strong> پرداخت مشتری — مبلغ کل رویداد (رزرو/بلیت)</li>
|
||||
<li>↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)</li>
|
||||
<li>↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
|
||||
<li>↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
|
||||
<li>
|
||||
برای همگامسازی خودکار از فانزون به{' '}
|
||||
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||
اسناد حسابداری
|
||||
↓ <strong>دریافت:</strong> شارژ کیف پول مشتری (بدون مالیات و ۱۴٪) — بدهی به مشتری تا
|
||||
زمان برداشت
|
||||
</li>
|
||||
<li>
|
||||
↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (وضعیت انجامشده)
|
||||
</li>
|
||||
<li>
|
||||
↳ فروش بلیت →{' '}
|
||||
<Link
|
||||
to="/sales/invoice"
|
||||
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
|
||||
>
|
||||
فاکتور فروش
|
||||
</Link>{' '}
|
||||
بروید.
|
||||
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)
|
||||
</li>
|
||||
<li>
|
||||
↳ برداشت کیف پول مشتری به شبا →{' '}
|
||||
<Link
|
||||
to="/sales"
|
||||
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
|
||||
>
|
||||
فروش / لغو و برداشت
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
همگامسازی خودکار در{' '}
|
||||
<Link
|
||||
to="/vouchers"
|
||||
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
|
||||
>
|
||||
اسناد حسابداری
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="جمع دریافتها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
|
||||
|
||||
<StatCard label="جمع پرداختها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
|
||||
|
||||
<StatCard
|
||||
|
||||
label="مانده خالص"
|
||||
|
||||
value={formatMoney(summary.net)}
|
||||
|
||||
icon="💰"
|
||||
|
||||
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
||||
|
||||
/>
|
||||
|
||||
<StatCard label="دریافت فانزون" value={toFa(funZoneCounts.receipts)} icon="👤" tone="brand" />
|
||||
|
||||
<StatCard label="سود پلتفرم (۱۴٪)" value={formatMoney(summary.platformProfit)} icon="📈" tone="green" />
|
||||
|
||||
<StatCard
|
||||
label="فانزون"
|
||||
value={`${toFa(funZoneCounts.receipts)} ↓ / ${toFa(funZoneCounts.payments)} ↑`}
|
||||
icon="👤"
|
||||
tone="brand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<Card className="p-4">
|
||||
|
||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است — همگامسازی فانزون را بزنید" />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={sorted}
|
||||
rowKey={(t) => t.id}
|
||||
emptyTitle="تراکنشی ثبت نشده — از اسناد حسابداری همگامسازی کنید"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<Modal
|
||||
|
||||
open={draft !== null}
|
||||
|
||||
title={
|
||||
|
||||
draft
|
||||
|
||||
? isFunZoneTreasuryTxn(draft)
|
||||
|
||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)} (فانزون)`
|
||||
|
||||
: draft.id
|
||||
|
||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)}`
|
||||
|
||||
: `${kindLabels[draft.kind]} جدید`
|
||||
|
||||
: ''
|
||||
|
||||
}
|
||||
|
||||
onClose={() => setDraft(null)}
|
||||
|
||||
footer={
|
||||
|
||||
<>
|
||||
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
|
||||
{draft && isFunZoneTreasuryTxn(draft) ? 'بستن' : 'انصراف'}
|
||||
|
||||
</Button>
|
||||
|
||||
{draft && !isFunZoneTreasuryTxn(draft) && <Button onClick={handleSave}>ذخیره</Button>}
|
||||
|
||||
{draft && !isFunZoneTreasuryTxn(draft) && (
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
}
|
||||
|
||||
>
|
||||
|
||||
{draft && (
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
|
||||
{isFunZoneTreasuryTxn(draft) ? (
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
|
||||
|
||||
{draft.description}
|
||||
|
||||
</pre>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">طرف حساب</dt>
|
||||
|
||||
<dd className="font-medium">{partyName(draft.partyId)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">مبلغ</dt>
|
||||
|
||||
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">تاریخ</dt>
|
||||
|
||||
<dd>{formatDate(draft.date)}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<dt className="text-slate-400">مرجع</dt>
|
||||
|
||||
<dd className="font-mono">{draft.reference || '—'}</dd>
|
||||
|
||||
</div>
|
||||
|
||||
</dl>
|
||||
|
||||
</div>
|
||||
|
||||
) : (
|
||||
|
||||
<>
|
||||
|
||||
<Field label="تاریخ">
|
||||
|
||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||||
|
||||
<JalaliDateInput
|
||||
value={draft.date}
|
||||
onChange={(date) => setDraft({ ...draft, date })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="روش">
|
||||
|
||||
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
|
||||
|
||||
{Object.entries(methodLabels).map(([value, label]) => (
|
||||
|
||||
<option key={value} value={value}>
|
||||
|
||||
{label}
|
||||
|
||||
</option>
|
||||
|
||||
))}
|
||||
|
||||
</Select>
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="طرف حساب">
|
||||
|
||||
<Select
|
||||
|
||||
value={draft.partyId ?? ''}
|
||||
|
||||
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
||||
|
||||
value={draft.method}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, method: e.target.value as TreasuryMethod })
|
||||
}
|
||||
>
|
||||
|
||||
<option value="">— بدون طرف حساب —</option>
|
||||
|
||||
{treasuryParties.map((party) => (
|
||||
|
||||
<option key={party.id} value={party.id}>
|
||||
|
||||
{party.name}
|
||||
|
||||
{Object.entries(methodLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="طرف حساب">
|
||||
<Select
|
||||
value={draft.partyId ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
||||
>
|
||||
<option value="">— بدون طرف حساب —</option>
|
||||
{treasuryParties.map((party) => (
|
||||
<option key={party.id} value={party.id}>
|
||||
{party.name}
|
||||
{party.kind === 'supplier' ? ' (تامینکننده)' : ' (مشتری)'}
|
||||
</option>
|
||||
|
||||
))}
|
||||
|
||||
</Select>
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="مبلغ (تومان)">
|
||||
|
||||
<Input type="number" value={draft.amount} onChange={(e) => setDraft({ ...draft, amount: Number(e.target.value) || 0 })} />
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.amount}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, amount: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="شماره مرجع / چک">
|
||||
|
||||
<Input value={draft.reference} onChange={(e) => setDraft({ ...draft, reference: e.target.value })} />
|
||||
|
||||
<Input
|
||||
value={draft.reference}
|
||||
onChange={(e) => setDraft({ ...draft, reference: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
<Field label="توضیحات">
|
||||
|
||||
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
||||
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
open={toDelete !== null}
|
||||
|
||||
title="حذف تراکنش"
|
||||
|
||||
message="این تراکنش خزانه حذف شود؟"
|
||||
|
||||
onCancel={() => setToDelete(null)}
|
||||
|
||||
onConfirm={() => {
|
||||
|
||||
if (toDelete) {
|
||||
|
||||
const voucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(toDelete.id))
|
||||
|
||||
const voucher = data.vouchers.find(
|
||||
(v) => v.source === voucherSourceForTreasury(toDelete) || v.source === voucherSourceForTreasury(toDelete.id),
|
||||
)
|
||||
if (voucher) removeVoucher(voucher.id)
|
||||
|
||||
removeTreasury(toDelete.id)
|
||||
|
||||
|
||||
|
||||
if (toDelete.partyId) {
|
||||
|
||||
const dataAfterDelete = {
|
||||
|
||||
...data,
|
||||
|
||||
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
|
||||
|
||||
}
|
||||
|
||||
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
||||
|
||||
for (const inv of reconcileInvoicePaymentStatus(
|
||||
toDelete.partyId,
|
||||
dataAfterDelete,
|
||||
)) {
|
||||
upsertInvoice(inv)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
setToDelete(null)
|
||||
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export function Vouchers() {
|
||||
className: 'w-[7rem] whitespace-nowrap',
|
||||
render: (v) => {
|
||||
const cat = classifyVoucher(v, data)
|
||||
const tone = cat === 'customer' ? 'blue' : cat === 'supplier' ? 'amber' : 'slate'
|
||||
const tone = cat === 'manager' ? 'blue' : 'slate'
|
||||
return <Badge tone={tone}>{voucherCategoryLabel(cat)}</Badge>
|
||||
},
|
||||
},
|
||||
@@ -250,8 +250,8 @@ export function Vouchers() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||
یکبار همگامسازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری — بدون تکرار.
|
||||
برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته میشود.
|
||||
یکبار همگامسازی: شارژ/برداشت کیف پول در خزانه، فروش بلیت بهصورت فاکتور فروش
|
||||
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)، و سند حسابداری — بدون تکرار.
|
||||
</p>
|
||||
{lastSyncedAt ? (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
@@ -272,9 +272,8 @@ export function Vouchers() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="همه اسناد" value={toFa(displayedVouchers.length)} />
|
||||
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
|
||||
<StatCard label="تأمینکنندگان" value={toFa(summary.supplier)} tone="amber" />
|
||||
<StatCard label="دستی" value={toFa(summary.manual)} />
|
||||
<StatCard label="مدیر" value={toFa(summary.manager)} tone="brand" />
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
@@ -282,9 +281,8 @@ export function Vouchers() {
|
||||
<Field label="دسته">
|
||||
<Select value={category} onChange={(e) => setCategory(e.target.value as VoucherCategoryFilter)}>
|
||||
<option value="all">همه</option>
|
||||
<option value="customer">مشتریان</option>
|
||||
<option value="supplier">تأمینکنندگان</option>
|
||||
<option value="manual">دستی</option>
|
||||
<option value="manager">مدیر</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="از تاریخ">
|
||||
@@ -378,7 +376,8 @@ export function Vouchers() {
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.number}
|
||||
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
<Field label="تاریخ">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
/**
|
||||
* Smoke tests for FunZone category inventory logic.
|
||||
* Run: npx tsx src/pages/inventory/funzoneInventory.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
buildBuyerRows,
|
||||
buildCategoryInventoryRows,
|
||||
eventLifecycle,
|
||||
type InventoryEvent,
|
||||
type InventoryReservation,
|
||||
} from './funzoneInventory'
|
||||
} from './funzoneInventory.ts'
|
||||
|
||||
const categories = [{ id: 'cat-1', name: 'بازی' }]
|
||||
|
||||
@@ -17,6 +21,7 @@ const events: InventoryEvent[] = [
|
||||
price: 100,
|
||||
category: { id: 'cat-1', name: 'بازی' },
|
||||
start_time: '2099-01-01T10:00:00Z',
|
||||
event_status: 'upcoming',
|
||||
},
|
||||
{
|
||||
id: 'evt-done',
|
||||
@@ -25,6 +30,7 @@ const events: InventoryEvent[] = [
|
||||
price: 100,
|
||||
category: { id: 'cat-1', name: 'بازی' },
|
||||
start_time: '2020-01-01T10:00:00Z',
|
||||
event_status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'evt-failed',
|
||||
@@ -33,6 +39,7 @@ const events: InventoryEvent[] = [
|
||||
price: 100,
|
||||
category: { id: 'cat-1', name: 'بازی' },
|
||||
start_time: '2020-01-01T10:00:00Z',
|
||||
event_status: 'cancelled',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -69,37 +76,46 @@ const reservations: InventoryReservation[] = [
|
||||
},
|
||||
]
|
||||
|
||||
describe('funzoneInventory', () => {
|
||||
it('classifies lifecycle by start time and minimum seats', () => {
|
||||
const now = Date.parse('2025-01-01T00:00:00Z')
|
||||
expect(eventLifecycle(events[0], 3, now)).toBe('inProcess')
|
||||
expect(eventLifecycle(events[1], 2, now)).toBe('completed')
|
||||
expect(eventLifecycle(events[2], 4, now)).toBe('canceled')
|
||||
})
|
||||
assert.equal(eventLifecycle(events[0], 3), 'inProcess')
|
||||
assert.equal(eventLifecycle(events[1], 2), 'completed')
|
||||
assert.equal(eventLifecycle(events[2], 4), '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)
|
||||
})
|
||||
assert.equal(
|
||||
eventLifecycle({ id: 'a', start_time: '2099-01-01T10:00:00Z', minimum: 5 }, 3, now),
|
||||
'inProcess',
|
||||
)
|
||||
assert.equal(
|
||||
eventLifecycle(
|
||||
{
|
||||
id: 'closed-success',
|
||||
start_time: '2099-06-01T10:00:00Z',
|
||||
minimum: 10,
|
||||
event_status: 'completed',
|
||||
},
|
||||
2,
|
||||
now,
|
||||
),
|
||||
'completed',
|
||||
)
|
||||
|
||||
const rows = buildCategoryInventoryRows(categories, events, reservations)
|
||||
assert.equal(rows.length, 1)
|
||||
assert.equal(rows[0].inProcessCount, 1)
|
||||
assert.equal(rows[0].completedCount, 1)
|
||||
assert.equal(rows[0].canceledCount, 1)
|
||||
assert.equal(rows[0].boughtTickets, 5)
|
||||
assert.equal(rows[0].canceledTickets, 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)
|
||||
})
|
||||
})
|
||||
assert.equal(buyers.length, 1)
|
||||
assert.equal(buyers[0].customerName, 'رضا کریمی')
|
||||
assert.equal(buyers[0].tickets, 4)
|
||||
|
||||
console.log('funzoneInventory tests passed')
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface InventoryEvent {
|
||||
price?: number
|
||||
category?: { id?: string; name?: string } | null
|
||||
start_time?: string | null
|
||||
/** Backend event status: upcoming | ongoing | completed | cancelled */
|
||||
event_status?: string | null
|
||||
}
|
||||
|
||||
export interface InventoryReservation {
|
||||
@@ -58,6 +60,12 @@ export function eventLifecycle(
|
||||
seatsForMinimum: number,
|
||||
nowMs: number = Date.now(),
|
||||
): EventLifecycle {
|
||||
const status = (event.event_status ?? '').toLowerCase()
|
||||
if (status === 'completed') return 'completed'
|
||||
if (status === 'cancelled' || status === 'canceled') return 'canceled'
|
||||
if (status === 'upcoming' || status === 'ongoing') return 'inProcess'
|
||||
|
||||
// Fallback when event_status is missing: start_time + minimum seats
|
||||
const startMs = event.start_time ? Date.parse(event.start_time) : NaN
|
||||
const started = Number.isFinite(startMs) && startMs <= nowMs
|
||||
if (!started) return 'inProcess'
|
||||
|
||||
@@ -6,7 +6,7 @@ export function FunZoneSalesSyncBanner() {
|
||||
<Card className="border-slate-200 bg-slate-50/80 p-4">
|
||||
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
|
||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||
برای دریافت پرداختهای رویداد، فاکتور فروش و سند حسابداری از فانزون، از صفحه{' '}
|
||||
برای همگامسازی فروش بلیت (فاکتور با مالیات و ۱۴٪)، لغو بلیت (فاکتور لغو شده)، و اسناد حسابداری از فانزون، از صفحه{' '}
|
||||
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||
اسناد حسابداری
|
||||
</Link>{' '}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import type { Invoice, Party } from '../../types'
|
||||
import type { Party } from '../../types'
|
||||
import { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
||||
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
|
||||
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
||||
import type { WalletTxnStatus } from '../../api/types'
|
||||
|
||||
interface PendingWithdrawalsPanelProps {
|
||||
withdrawals: ApiWithdrawal[]
|
||||
loading: boolean
|
||||
updatingId: string | null
|
||||
error: string | null
|
||||
returnInvoices: Invoice[]
|
||||
customerList: Party[]
|
||||
bookedWithdrawalIds: Set<string>
|
||||
onReload: () => void
|
||||
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
||||
onToggleStatus: (withdrawal: ApiWithdrawal) => void
|
||||
onBookPayment: (withdrawal: ApiWithdrawal) => void
|
||||
}
|
||||
|
||||
function CustomerCell({ withdrawal }: { withdrawal: ApiWithdrawal }) {
|
||||
@@ -58,24 +60,74 @@ function RequestDateCell({ iso }: { iso: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function PendingWithdrawalsPanel({
|
||||
withdrawals,
|
||||
loading,
|
||||
error,
|
||||
returnInvoices,
|
||||
customerList,
|
||||
onReload,
|
||||
onCreateReturn,
|
||||
}: PendingWithdrawalsPanelProps) {
|
||||
const openWithdrawals = useMemo(
|
||||
() => withdrawals.filter((w) => !invoiceForWithdrawal(returnInvoices, w.id)),
|
||||
[withdrawals, returnInvoices],
|
||||
)
|
||||
function statusLabel(status: WalletTxnStatus): string {
|
||||
if (status === 'completed') return 'تکمیل شده'
|
||||
if (status === 'pending') return 'در انتظار'
|
||||
return 'ناموفق'
|
||||
}
|
||||
|
||||
function StatusToggleButton({
|
||||
withdrawal,
|
||||
busy,
|
||||
onToggle,
|
||||
}: {
|
||||
withdrawal: ApiWithdrawal
|
||||
busy: boolean
|
||||
onToggle: (withdrawal: ApiWithdrawal) => void
|
||||
}) {
|
||||
const tone =
|
||||
withdrawal.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200'
|
||||
: withdrawal.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800 hover:bg-amber-200'
|
||||
: 'bg-rose-100 text-rose-800 hover:bg-rose-200'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || withdrawal.status === 'failed'}
|
||||
onClick={() => onToggle(withdrawal)}
|
||||
title={
|
||||
withdrawal.status === 'failed'
|
||||
? undefined
|
||||
: withdrawal.status === 'pending'
|
||||
? 'تغییر به تکمیل شده و ثبت پرداخت خزانه'
|
||||
: 'بازگشت به در انتظار'
|
||||
}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${tone}`}
|
||||
>
|
||||
{busy ? '…' : statusLabel(withdrawal.status)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function WithdrawalsTable({
|
||||
title,
|
||||
subtitle,
|
||||
rows,
|
||||
loading,
|
||||
updatingId,
|
||||
customerList,
|
||||
bookedWithdrawalIds,
|
||||
showBookPayment,
|
||||
onToggleStatus,
|
||||
onBookPayment,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
rows: ApiWithdrawal[]
|
||||
loading: boolean
|
||||
updatingId: string | null
|
||||
customerList: Party[]
|
||||
bookedWithdrawalIds: Set<string>
|
||||
showBookPayment: boolean
|
||||
onToggleStatus: (withdrawal: ApiWithdrawal) => void
|
||||
onBookPayment: (withdrawal: ApiWithdrawal) => void
|
||||
}) {
|
||||
const columns: Array<Column<ApiWithdrawal>> = [
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'مشتری',
|
||||
key: 'user',
|
||||
header: 'نام',
|
||||
headerClassName: 'w-[11rem]',
|
||||
className: 'w-[11rem]',
|
||||
render: (w) => <CustomerCell withdrawal={w} />,
|
||||
@@ -90,6 +142,19 @@ export function PendingWithdrawalsPanel({
|
||||
<span className="tabular-nums text-base font-bold text-brand-700">{formatMoney(w.amount)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
headerClassName: 'w-[7rem]',
|
||||
className: 'w-[7rem] whitespace-nowrap',
|
||||
render: (w) => (
|
||||
<StatusToggleButton
|
||||
withdrawal={w}
|
||||
busy={updatingId === w.id}
|
||||
onToggle={onToggleStatus}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'iban',
|
||||
header: 'شماره شبا',
|
||||
@@ -97,6 +162,17 @@ export function PendingWithdrawalsPanel({
|
||||
className: 'min-w-[14rem]',
|
||||
render: (w) => <IbanCell iban={w.iban} />,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'توضیحات',
|
||||
headerClassName: 'min-w-[10rem]',
|
||||
className: 'min-w-[10rem]',
|
||||
render: (w) => (
|
||||
<span className="text-sm text-slate-600" title={w.description || undefined}>
|
||||
{w.description?.trim() || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requestedAt',
|
||||
header: 'تاریخ درخواست',
|
||||
@@ -104,14 +180,10 @@ export function PendingWithdrawalsPanel({
|
||||
className: 'w-[6.5rem]',
|
||||
render: (w) => <RequestDateCell iso={w.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
headerClassName: 'w-[5.5rem]',
|
||||
className: 'w-[5.5rem] whitespace-nowrap',
|
||||
render: () => <Badge tone="amber">در انتظار</Badge>,
|
||||
},
|
||||
{
|
||||
]
|
||||
|
||||
if (showBookPayment) {
|
||||
columns.push({
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
@@ -119,66 +191,70 @@ export function PendingWithdrawalsPanel({
|
||||
headerClassName: 'w-[9.5rem]',
|
||||
className: 'w-[9.5rem] whitespace-nowrap',
|
||||
render: (w) => {
|
||||
const booked = bookedWithdrawalIds.has(w.id)
|
||||
if (booked) {
|
||||
return <Badge tone="green">پرداخت ثبت شد</Badge>
|
||||
}
|
||||
if (w.status === 'completed') {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!customerList.find((p) => p.externalId === w.user_id) || updatingId === w.id}
|
||||
onClick={() => onBookPayment(w)}
|
||||
title="وضعیت تکمیل است ولی سند خزانه هنوز ثبت نشده"
|
||||
>
|
||||
ثبت پرداخت
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const party = customerList.find((p) => p.externalId === w.user_id)
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!party}
|
||||
onClick={() => onCreateReturn(w)}
|
||||
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
||||
disabled={!party || updatingId === w.id}
|
||||
onClick={() => onBookPayment(w)}
|
||||
title={party ? 'ثبت پرداخت خزانه بدون فاکتور برگشتی' : 'ابتدا مشتری را همگام کنید'}
|
||||
>
|
||||
ثبت فاکتور برگشتی
|
||||
ثبت پرداخت
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const pendingCount = rows.filter((w) => w.status === 'pending').length
|
||||
const pendingSum = rows.filter((w) => w.status === 'pending').reduce((s, w) => s + w.amount, 0)
|
||||
|
||||
return (
|
||||
<Card className="border-amber-200 bg-gradient-to-b from-amber-50/60 to-white p-4 sm:p-5">
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-amber-100 pb-4">
|
||||
<Card className="border-slate-200 bg-white p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">
|
||||
درخواستهای برداشت کیف پول مشتریان — شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-slate-800">{title}</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">{subtitle}</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<StatCard
|
||||
label="برداشت در انتظار"
|
||||
value={toFa(openWithdrawals.length)}
|
||||
label="در انتظار"
|
||||
value={toFa(pendingCount)}
|
||||
icon="⏳"
|
||||
tone={openWithdrawals.length > 0 ? 'amber' : 'green'}
|
||||
/>
|
||||
<StatCard
|
||||
label="جمع مبالغ"
|
||||
value={formatMoney(openWithdrawals.reduce((s, w) => s + w.amount, 0))}
|
||||
icon="💳"
|
||||
tone="brand"
|
||||
tone={pendingCount > 0 ? 'amber' : 'green'}
|
||||
/>
|
||||
<StatCard label="جمع در انتظار" value={formatMoney(pendingSum)} icon="💳" tone="brand" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={openWithdrawals}
|
||||
rows={rows}
|
||||
rowKey={(w) => w.id}
|
||||
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
||||
emptyTitle="برداشتی یافت نشد"
|
||||
minWidth="52rem"
|
||||
size="comfortable"
|
||||
className="rounded-xl"
|
||||
@@ -188,3 +264,73 @@ export function PendingWithdrawalsPanel({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function PendingWithdrawalsPanel({
|
||||
withdrawals,
|
||||
loading,
|
||||
updatingId,
|
||||
error,
|
||||
customerList,
|
||||
bookedWithdrawalIds,
|
||||
onReload,
|
||||
onToggleStatus,
|
||||
onBookPayment,
|
||||
}: PendingWithdrawalsPanelProps) {
|
||||
const customerWithdrawals = useMemo(
|
||||
() => withdrawals.filter((w) => w.user_type === 'customer'),
|
||||
[withdrawals],
|
||||
)
|
||||
const ownerWithdrawals = useMemo(
|
||||
() => withdrawals.filter((w) => w.user_type === 'owner'),
|
||||
[withdrawals],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-800">برداشتها</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
وضعیت را بین «در انتظار» و «تکمیل شده» عوض کنید. تکمیلشده برای مشتری و مالک،
|
||||
پرداخت خزانه ثبت میکند.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
||||
بروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<WithdrawalsTable
|
||||
title="برداشتها (مشتریان)"
|
||||
subtitle="برداشت کیف پول — با تکمیل، پرداخت از خزانه ثبت میشود (بدون فاکتور برگشتی)."
|
||||
rows={customerWithdrawals}
|
||||
loading={loading}
|
||||
updatingId={updatingId}
|
||||
customerList={customerList}
|
||||
bookedWithdrawalIds={bookedWithdrawalIds}
|
||||
showBookPayment
|
||||
onToggleStatus={onToggleStatus}
|
||||
onBookPayment={onBookPayment}
|
||||
/>
|
||||
|
||||
<WithdrawalsTable
|
||||
title="برداشتها (مالکان)"
|
||||
subtitle="پس از تکمیل، پرداخت خزانه (پرداخت به مالک) همان لحظه ثبت میشود."
|
||||
rows={ownerWithdrawals}
|
||||
loading={loading}
|
||||
updatingId={updatingId}
|
||||
customerList={customerList}
|
||||
bookedWithdrawalIds={bookedWithdrawalIds}
|
||||
showBookPayment={false}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onBookPayment={onBookPayment}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,24 +24,18 @@ import {
|
||||
customers,
|
||||
documentTypeLabels,
|
||||
invoiceRegarding,
|
||||
isWithdrawalReturn,
|
||||
reconcileInvoicePaymentStatus,
|
||||
statusLabels,
|
||||
} from './salesUtils'
|
||||
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
||||
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
|
||||
import { formatEventName } from '../../utils/eventName'
|
||||
import {
|
||||
usePendingCustomerWithdrawals,
|
||||
withdrawalNoteTag,
|
||||
} from './usePendingCustomerWithdrawals'
|
||||
import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
|
||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green' | 'rose'> = {
|
||||
draft: 'amber',
|
||||
confirmed: 'blue',
|
||||
paid: 'green',
|
||||
cancelled: 'rose',
|
||||
}
|
||||
|
||||
const newLine = (): InvoiceLine => ({
|
||||
@@ -59,8 +53,6 @@ interface SalesDocumentSectionProps {
|
||||
subtitle: string
|
||||
/** Allow converting from proforma when creating sales invoice */
|
||||
allowProformaConversion?: boolean
|
||||
/** Allow linking to original invoice for returns */
|
||||
allowRelatedInvoice?: boolean
|
||||
}
|
||||
|
||||
export function SalesDocumentSection({
|
||||
@@ -68,7 +60,6 @@ export function SalesDocumentSection({
|
||||
title,
|
||||
subtitle,
|
||||
allowProformaConversion = false,
|
||||
allowRelatedInvoice = false,
|
||||
}: SalesDocumentSectionProps) {
|
||||
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
||||
const location = useLocation()
|
||||
@@ -81,22 +72,15 @@ export function SalesDocumentSection({
|
||||
() => customers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
const documents = useMemo(() => byDocumentType(data.invoices, documentType), [data.invoices, documentType])
|
||||
const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
|
||||
const saleInvoices = useMemo(() => byDocumentType(data.invoices, 'invoice'), [data.invoices])
|
||||
const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
|
||||
const isReturnSection = documentType === 'return'
|
||||
const {
|
||||
withdrawals: pendingWithdrawals,
|
||||
loading: pendingLoading,
|
||||
error: pendingError,
|
||||
reload: reloadPending,
|
||||
} = usePendingCustomerWithdrawals(isReturnSection)
|
||||
|
||||
const returnDocuments = useMemo(
|
||||
() => (isReturnSection ? documents : []),
|
||||
[documents, isReturnSection],
|
||||
const documents = useMemo(
|
||||
() =>
|
||||
byDocumentType(data.invoices, documentType).filter((i) =>
|
||||
documentType === 'invoice' ? i.status !== 'cancelled' : true,
|
||||
),
|
||||
[data.invoices, documentType],
|
||||
)
|
||||
const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
|
||||
const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
|
||||
|
||||
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||
const salesTypeName = (id?: string) =>
|
||||
@@ -162,51 +146,11 @@ export function SalesDocumentSection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const loadFromInvoice = (invoiceId: string) => {
|
||||
const source = saleInvoices.find((i) => i.id === invoiceId)
|
||||
if (!source) return
|
||||
setPreviousDraft(null)
|
||||
setDraft({
|
||||
...createDraft(),
|
||||
partyId: source.partyId,
|
||||
salesTypeId: source.salesTypeId,
|
||||
relatedInvoiceId: source.id,
|
||||
note: `برگشت از فاکتور #${source.number}`,
|
||||
lines: source.lines.map((l) => ({ ...l, id: createId('il-'), eventName: l.eventName })),
|
||||
})
|
||||
}
|
||||
|
||||
const loadFromWithdrawal = (withdrawal: ApiWithdrawal) => {
|
||||
const party = customerList.find((p) => p.externalId === withdrawal.user_id)
|
||||
if (!party) return
|
||||
setPreviousDraft(null)
|
||||
setDraft({
|
||||
...createDraft(),
|
||||
partyId: party.id,
|
||||
date: withdrawal.created_at.slice(0, 10),
|
||||
status: 'draft',
|
||||
note: `${withdrawalNoteTag(withdrawal.id)}\nبرداشت کیف پول مشتری — ${withdrawal.user_name}${withdrawal.iban ? `\nشبا: ${withdrawal.iban}` : ''}`,
|
||||
lines: [
|
||||
{
|
||||
id: createId('il-'),
|
||||
productId: '',
|
||||
quantity: 1,
|
||||
unitPrice: withdrawal.amount,
|
||||
discount: 0,
|
||||
taxRate: 0,
|
||||
eventName: 'wallet',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft || !draft.partyId) return
|
||||
const fromWithdrawal = isWithdrawalReturn(draft)
|
||||
const cleanLines = draft.lines.filter((line) => {
|
||||
if (fromWithdrawal && line.unitPrice > 0) return true
|
||||
return Boolean(line.productId || line.eventName) && line.quantity > 0
|
||||
})
|
||||
const cleanLines = draft.lines.filter(
|
||||
(line) => Boolean(line.productId || line.eventName) && line.quantity > 0,
|
||||
)
|
||||
if (cleanLines.length === 0) return
|
||||
|
||||
const saved: Invoice = {
|
||||
@@ -228,7 +172,7 @@ export function SalesDocumentSection({
|
||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||
|
||||
if (saved.partyId && saved.status !== 'draft') {
|
||||
if (saved.partyId && saved.status !== 'draft' && saved.status !== 'cancelled') {
|
||||
const dataAfterSave = {
|
||||
...data,
|
||||
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
||||
@@ -311,18 +255,6 @@ export function SalesDocumentSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isReturnSection && (
|
||||
<PendingWithdrawalsPanel
|
||||
withdrawals={pendingWithdrawals}
|
||||
loading={pendingLoading}
|
||||
error={pendingError}
|
||||
returnInvoices={returnDocuments}
|
||||
customerList={customerList}
|
||||
onReload={() => void reloadPending()}
|
||||
onCreateReturn={loadFromWithdrawal}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -417,21 +349,6 @@ export function SalesDocumentSection({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{allowRelatedInvoice && !draft.id && saleInvoices.length > 0 && (
|
||||
<Field label="برگشت از فاکتور">
|
||||
<Select value="" onChange={(e) => e.target.value && loadFromInvoice(e.target.value)}>
|
||||
<option value="">— انتخاب فاکتور فروش —</option>
|
||||
{saleInvoices
|
||||
.filter((i) => i.status !== 'draft')
|
||||
.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
#{toFa(i.number)} · {partyName(i.partyId)} · {formatMoney(invoiceTotals(i).net)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field label="شماره">
|
||||
<Input
|
||||
@@ -457,7 +374,9 @@ export function SalesDocumentSection({
|
||||
value={draft.status}
|
||||
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
{Object.entries(statusLabels)
|
||||
.filter(([value]) => value !== 'cancelled')
|
||||
.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
@@ -502,7 +421,7 @@ export function SalesDocumentSection({
|
||||
line={line}
|
||||
products={data.products}
|
||||
showEventName
|
||||
withdrawalReturn={isWithdrawalReturn(draft)}
|
||||
withdrawalReturn={false}
|
||||
onChange={(patch) => updateLine(line.id, patch)}
|
||||
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
|
||||
/>
|
||||
@@ -579,9 +498,7 @@ export function SalesDocumentSection({
|
||||
|
||||
{documentType !== 'proforma' && draft.status !== 'draft' && (
|
||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
{isWithdrawalReturn(draft)
|
||||
? 'برداشت کیف پول — موجودی انبار تغییر نمیکند؛ سند حسابداری برداشت ثبت میشود.'
|
||||
: 'با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.'}
|
||||
با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SalesDocumentSection } from './SalesDocumentSection'
|
||||
import { SalesReturn as SalesReturnPage } from './SalesReturnPage'
|
||||
|
||||
export function SalesProforma() {
|
||||
return (
|
||||
@@ -21,13 +22,7 @@ export function SalesInvoice() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Withdrawals + cancelled sales invoices (no فاکتور برگشتی). */
|
||||
export function SalesReturn() {
|
||||
return (
|
||||
<SalesDocumentSection
|
||||
documentType="return"
|
||||
title="فاکتور برگشتی جدید"
|
||||
subtitle="برگشت کالا یا ثبت فاکتور برگشتی برای برداشتهای در انتظار مشتریان (از پنل مدیریت)"
|
||||
allowRelatedInvoice
|
||||
/>
|
||||
)
|
||||
return <SalesReturnPage />
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const processSteps = [
|
||||
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
||||
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
|
||||
{ step: 3, label: 'دریافت', desc: 'تسویه از مشتری در ماژول دریافت و پرداخت', to: '/treasury', icon: '💰' },
|
||||
{ step: 4, label: 'فاکتور برگشتی', desc: 'برداشت در انتظار مشتریان و برگشت کالا', to: '/sales/return', icon: '↩️' },
|
||||
{ step: 4, label: 'برداشت و لغو', desc: 'برداشت کیف پول و فاکتورهای فروش لغو شده', to: '/sales/return', icon: '🚫' },
|
||||
]
|
||||
|
||||
export function SalesProcess() {
|
||||
@@ -31,7 +31,7 @@ export function SalesProcess() {
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
||||
<p className="mb-6 text-sm text-slate-500">
|
||||
جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
|
||||
جریان استاندارد فروش: از پیشفاکتور تا فاکتور، دریافت، و لغو/برداشت.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -61,7 +61,7 @@ export function SalesProcess() {
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<StatCard label="پیشفاکتور" value={toFa(summary.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
|
||||
<StatCard label="فاکتور فروش" value={toFa(summary.invoiceCount)} hint={formatMoney(summary.totalSales)} />
|
||||
<StatCard label="فاکتور برگشتی" value={toFa(summary.returnCount)} hint={formatMoney(summary.totalReturns)} />
|
||||
<StatCard label="فاکتور لغو شده" value={toFa(summary.cancelledCount)} hint={formatMoney(summary.totalCancelled)} />
|
||||
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
||||
<StatCard label="همگام فانزون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ export function SalesProcess() {
|
||||
<li className="flex items-start gap-2">
|
||||
<span>📦</span>
|
||||
<span>
|
||||
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
|
||||
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش میدهد؛ فاکتور لغو شده اثر موجودی را برنمیگرداند اگر قبلاً لغو نشده باشد.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
|
||||
306
src/pages/sales/SalesReturnPage.tsx
Normal file
306
src/pages/sales/SalesReturnPage.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useStore } from '../../store/AppStore'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import type { Invoice } from '../../types'
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
DataTable,
|
||||
StatCard,
|
||||
type Column,
|
||||
} from '../../components/ui'
|
||||
import { formatDate, formatMoney, toFa } from '../../utils/format'
|
||||
import { invoiceTotals } from '../../utils/accounting'
|
||||
import { formatEventName } from '../../utils/eventName'
|
||||
import { createId } from '../../utils/id'
|
||||
import {
|
||||
buildTreasuryVoucher,
|
||||
customers,
|
||||
invoiceRegarding,
|
||||
statusLabels,
|
||||
suppliers,
|
||||
voucherSourceForTreasury,
|
||||
} from './salesUtils'
|
||||
import { usePendingCustomerWithdrawals } from './usePendingCustomerWithdrawals'
|
||||
import { PendingWithdrawalsPanel } from './PendingWithdrawalsPanel'
|
||||
import {
|
||||
applyWithdrawalCompletedEffects,
|
||||
applyWithdrawalReopenedEffects,
|
||||
treasurySourceForWithdrawal,
|
||||
withdrawalAlreadyBooked,
|
||||
} from './withdrawalTreasury'
|
||||
import { FunZoneSalesSyncBanner } from './FunZoneSalesSyncBanner'
|
||||
import { isCancelledSalesInvoice } from './funzoneSalesSync'
|
||||
|
||||
/**
|
||||
* Withdrawals + cancelled ticket sales.
|
||||
* Replaces فاکتور برگشتی — cancellations void the original sales invoice.
|
||||
*/
|
||||
export function SalesReturn() {
|
||||
const {
|
||||
data,
|
||||
upsertTreasuryAsync,
|
||||
removeTreasuryAsync,
|
||||
upsertVoucherAsync,
|
||||
removeVoucherAsync,
|
||||
removeInvoiceAsync,
|
||||
reload,
|
||||
} = useStore()
|
||||
|
||||
const customerList = useMemo(
|
||||
() => customers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
const ownerList = useMemo(
|
||||
() => suppliers(data.parties).filter((p) => p.id),
|
||||
[data.parties],
|
||||
)
|
||||
|
||||
const {
|
||||
withdrawals,
|
||||
loading,
|
||||
updatingId,
|
||||
error,
|
||||
reload: reloadWithdrawals,
|
||||
toggleStatus,
|
||||
} = usePendingCustomerWithdrawals(true)
|
||||
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [bookingId, setBookingId] = useState<string | null>(null)
|
||||
|
||||
const cancelledInvoices = useMemo(
|
||||
() =>
|
||||
data.invoices
|
||||
.filter(isCancelledSalesInvoice)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
|
||||
[data.invoices],
|
||||
)
|
||||
|
||||
const bookedWithdrawalIds = useMemo(
|
||||
() => new Set(withdrawals.filter((w) => withdrawalAlreadyBooked(data, w)).map((w) => w.id)),
|
||||
[withdrawals, data],
|
||||
)
|
||||
|
||||
const partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||
|
||||
const partyForWithdrawal = (withdrawal: ApiWithdrawal) =>
|
||||
withdrawal.user_type === 'owner'
|
||||
? ownerList.find((p) => p.externalId === withdrawal.user_id)
|
||||
: customerList.find((p) => p.externalId === withdrawal.user_id)
|
||||
|
||||
/** Persist treasury پرداخت + voucher for customer or owner payout. */
|
||||
const persistWithdrawalPayment = async (withdrawal: ApiWithdrawal, partyId: string) => {
|
||||
if (withdrawalAlreadyBooked(data, withdrawal)) return
|
||||
|
||||
const effects = applyWithdrawalCompletedEffects(withdrawal, partyId, data)
|
||||
for (const voucherId of effects.removeVoucherIds) {
|
||||
await removeVoucherAsync(voucherId)
|
||||
}
|
||||
for (const invoiceId of effects.removeInvoiceIds) {
|
||||
await removeInvoiceAsync(invoiceId)
|
||||
}
|
||||
|
||||
const persisted = await upsertTreasuryAsync({
|
||||
...effects.treasury,
|
||||
eventName: withdrawal.user_type === 'customer' ? 'wallet' : effects.treasury.eventName || '',
|
||||
})
|
||||
|
||||
const voucher = buildTreasuryVoucher(
|
||||
{
|
||||
...persisted,
|
||||
eventName:
|
||||
persisted.eventName || (withdrawal.user_type === 'customer' ? 'wallet' : ''),
|
||||
},
|
||||
data,
|
||||
)
|
||||
if (voucher) {
|
||||
const existing = data.vouchers.find(
|
||||
(v) =>
|
||||
v.source === voucherSourceForTreasury(persisted) ||
|
||||
v.source === treasurySourceForWithdrawal(withdrawal),
|
||||
)
|
||||
await upsertVoucherAsync({
|
||||
...voucher,
|
||||
id: existing?.id || createId('v-'),
|
||||
source: voucherSourceForTreasury(persisted),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const bookWithdrawalPayment = async (withdrawal: ApiWithdrawal) => {
|
||||
const party = partyForWithdrawal(withdrawal)
|
||||
if (!party) {
|
||||
setActionError(
|
||||
withdrawal.user_type === 'owner'
|
||||
? 'ابتدا مالک را از بخش طرفحسابها همگام کنید'
|
||||
: 'ابتدا مشتری را از بخش مشتریان همگام کنید',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setBookingId(withdrawal.id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await persistWithdrawalPayment(withdrawal, party.id)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'خطا در ثبت پرداخت برداشت')
|
||||
} finally {
|
||||
setBookingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (withdrawal: ApiWithdrawal) => {
|
||||
setActionError(null)
|
||||
const nextIsCompleted = withdrawal.status === 'pending'
|
||||
const party = partyForWithdrawal(withdrawal)
|
||||
|
||||
if (nextIsCompleted && !party) {
|
||||
setActionError(
|
||||
withdrawal.user_type === 'owner'
|
||||
? 'ابتدا مالک را همگام کنید؛ بدون طرفحساب نمیتوان برداشت را تکمیل کرد'
|
||||
: 'ابتدا مشتری را همگام کنید؛ بدون طرفحساب نمیتوان برداشت را تکمیل کرد',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setBookingId(withdrawal.id)
|
||||
try {
|
||||
if (nextIsCompleted && party) {
|
||||
await persistWithdrawalPayment(withdrawal, party.id)
|
||||
} else if (!nextIsCompleted) {
|
||||
const effects = applyWithdrawalReopenedEffects(withdrawal, data)
|
||||
for (const voucherId of effects.removeVoucherIds) {
|
||||
await removeVoucherAsync(voucherId)
|
||||
}
|
||||
if (effects.removeTreasuryId) await removeTreasuryAsync(effects.removeTreasuryId)
|
||||
}
|
||||
|
||||
await toggleStatus(withdrawal)
|
||||
await reloadWithdrawals()
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت')
|
||||
await reloadWithdrawals()
|
||||
} finally {
|
||||
setBookingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelledColumns: Array<Column<Invoice>> = [
|
||||
{
|
||||
key: 'number',
|
||||
header: 'شماره',
|
||||
render: (i) => <span className="font-mono">{toFa(i.number)}</span>,
|
||||
},
|
||||
{ key: 'date', header: 'تاریخ', render: (i) => formatDate(i.date) },
|
||||
{ key: 'party', header: 'مشتری', render: (i) => partyName(i.partyId) },
|
||||
{
|
||||
key: 'regarding',
|
||||
header: 'بابت',
|
||||
render: (i) => {
|
||||
const regarding = invoiceRegarding(i)
|
||||
return regarding ? formatEventName(regarding) : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'مبلغ',
|
||||
align: 'end',
|
||||
render: (i) => <span className="tabular-nums">{formatMoney(invoiceTotals(i).net)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: () => <Badge tone="rose">{statusLabels.cancelled}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'cancelledAt',
|
||||
header: 'جزئیات',
|
||||
render: (i) => {
|
||||
const cancelLine = i.note.split('\n').find((l) => l.startsWith('تاریخ لغو:'))
|
||||
return (
|
||||
<span className="text-xs text-slate-500">
|
||||
{cancelLine?.replace('تاریخ لغو: ', '') ?? '—'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FunZoneSalesSyncBanner />
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-800">برداشتها و فاکتورهای لغو شده</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-slate-500">
|
||||
با لغو بلیت، همان فاکتور فروش «لغو شده» میشود (بدون مالیات/۱۴٪). برداشت مشتری فقط بهصورت
|
||||
پرداخت خزانه ثبت میشود.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label="فاکتور لغو شده"
|
||||
value={toFa(cancelledInvoices.length)}
|
||||
icon="🚫"
|
||||
tone={cancelledInvoices.length > 0 ? 'rose' : 'green'}
|
||||
/>
|
||||
<StatCard
|
||||
label="جمع لغو شده"
|
||||
value={formatMoney(cancelledInvoices.reduce((s, i) => s + invoiceTotals(i).net, 0))}
|
||||
icon="↩️"
|
||||
tone="amber"
|
||||
/>
|
||||
<StatCard label="برداشتها" value={toFa(withdrawals.length)} icon="💳" tone="brand" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{(actionError || error) && (
|
||||
<p className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{actionError || error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PendingWithdrawalsPanel
|
||||
withdrawals={withdrawals}
|
||||
loading={loading}
|
||||
updatingId={updatingId ?? bookingId}
|
||||
error={null}
|
||||
customerList={customerList}
|
||||
bookedWithdrawalIds={bookedWithdrawalIds}
|
||||
onReload={() => void reloadWithdrawals()}
|
||||
onToggleStatus={(w) => void handleToggleStatus(w)}
|
||||
onBookPayment={(w) => void bookWithdrawalPayment(w)}
|
||||
/>
|
||||
|
||||
<Card className="p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">فاکتورهای فروش لغو شده</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
بلیتهایی که کاربر یا رویداد لغو کرده — همان فاکتور فروش با وضعیت لغو شده.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/sales/invoice" className="text-sm text-brand-600 hover:underline">
|
||||
مشاهده فاکتورهای فروش
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<DataTable
|
||||
columns={cancelledColumns}
|
||||
rows={cancelledInvoices}
|
||||
rowKey={(i) => i.id}
|
||||
emptyTitle="فاکتور لغوشدهای ثبت نشده است"
|
||||
minWidth="48rem"
|
||||
size="comfortable"
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function SalesReview() {
|
||||
const summary = useMemo(() => salesSummary(data), [data])
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
let docs = salesDocuments(data.invoices)
|
||||
let docs = salesDocuments(data.invoices).filter((i) => (i.documentType ?? 'invoice') !== 'return')
|
||||
if (filterType !== 'all') {
|
||||
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export function SalesReview() {
|
||||
header: 'وضعیت',
|
||||
render: (i) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : 'amber'}>
|
||||
<Badge tone={i.status === 'paid' ? 'green' : i.status === 'confirmed' ? 'blue' : i.status === 'cancelled' ? 'rose' : 'amber'}>
|
||||
{statusLabels[i.status]}
|
||||
</Badge>
|
||||
{isFunZoneSalesInvoice(i) ? <Badge tone="slate">فانزون</Badge> : null}
|
||||
@@ -96,7 +96,7 @@ export function SalesReview() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
||||
<StatCard label="برگشت" value={formatMoney(summary.totalReturns)} />
|
||||
<StatCard label="لغو شده" value={formatMoney(summary.totalCancelled)} />
|
||||
<StatCard label="فروش خالص" value={formatMoney(summary.netSales)} />
|
||||
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
|
||||
</div>
|
||||
@@ -106,7 +106,9 @@ export function SalesReview() {
|
||||
<Field label="نوع سند">
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
|
||||
<option value="all">همه</option>
|
||||
{Object.entries(documentTypeLabels).map(([value, label]) => (
|
||||
{Object.entries(documentTypeLabels)
|
||||
.filter(([value]) => value !== 'return')
|
||||
.map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
|
||||
123
src/pages/sales/funzoneSalesSync.cancel.test.ts
Normal file
123
src/pages/sales/funzoneSalesSync.cancel.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Run: npx tsx src/pages/sales/funzoneSalesSync.cancel.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AppData, Invoice, Party } from '../../types'
|
||||
import type { ApiPaymentOrRefund } from '../../api/types'
|
||||
import {
|
||||
buildFunZoneSalesSync,
|
||||
findOriginalTicketInvoiceForCancellation,
|
||||
} from './funzoneSalesSync'
|
||||
|
||||
const customer: Party = {
|
||||
id: 'c1',
|
||||
kind: 'customer',
|
||||
name: 'Ali',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'cust-1',
|
||||
externalSource: 'funzone_customer',
|
||||
}
|
||||
|
||||
const baseData = (invoices: Invoice[]): Pick<AppData, 'parties' | 'invoices' | 'vouchers'> => ({
|
||||
parties: [customer],
|
||||
invoices,
|
||||
vouchers: [],
|
||||
})
|
||||
|
||||
const paidInvoice: Invoice = {
|
||||
id: 'inv-1',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 1,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status: 'paid',
|
||||
note: 'funzone:pay:pay-1\nفاکتور فروش بلیت',
|
||||
lines: [
|
||||
{
|
||||
id: 'l1',
|
||||
productId: '',
|
||||
quantity: 1,
|
||||
unitPrice: 100_000,
|
||||
discount: 0,
|
||||
taxRate: 0,
|
||||
eventName: 'Jazz Night',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const payment: ApiPaymentOrRefund = {
|
||||
id: 'pay-1',
|
||||
type: 'payment',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: '',
|
||||
ref_num: '',
|
||||
amount: 100_000,
|
||||
event_name: 'Jazz Night',
|
||||
category_name: null,
|
||||
payment_method: 'gateway',
|
||||
event_date: null,
|
||||
event_time: null,
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
reservation_id: 'res-1',
|
||||
refund_status: null,
|
||||
}
|
||||
|
||||
const cancellation: ApiPaymentOrRefund = {
|
||||
id: 'ref-1',
|
||||
type: 'cancellation',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: '',
|
||||
ref_num: '',
|
||||
amount: 100_000,
|
||||
event_name: 'Jazz Night',
|
||||
category_name: null,
|
||||
event_date: null,
|
||||
event_time: null,
|
||||
created_at: '2026-01-02T12:00:00Z',
|
||||
reservation_id: 'res-1',
|
||||
refund_status: 'completed',
|
||||
}
|
||||
|
||||
{
|
||||
const found = findOriginalTicketInvoiceForCancellation(
|
||||
cancellation,
|
||||
[payment],
|
||||
[paidInvoice],
|
||||
'c1',
|
||||
)
|
||||
assert.equal(found?.id, 'inv-1')
|
||||
}
|
||||
|
||||
{
|
||||
const result = buildFunZoneSalesSync(baseData([paidInvoice]), [payment, cancellation])
|
||||
assert.equal(result.toUpsert.length, 1)
|
||||
assert.equal(result.toUpsert[0].status, 'cancelled')
|
||||
assert.equal(result.toUpsert[0].documentType, 'invoice')
|
||||
assert.ok(result.toUpsert[0].note.includes('funzone:cancel:ref-1'))
|
||||
assert.equal(result.cancelledInvoices, 1)
|
||||
assert.ok(!result.toUpsert.some((i) => i.documentType === 'return'))
|
||||
}
|
||||
|
||||
{
|
||||
const legacyReturn: Invoice = {
|
||||
id: 'inv-ret',
|
||||
kind: 'sale',
|
||||
documentType: 'return',
|
||||
number: 9,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-02',
|
||||
status: 'confirmed',
|
||||
note: 'funzone:cancel:old-1\nبرگشت خودکار',
|
||||
lines: [{ id: 'lr', productId: '', quantity: 1, unitPrice: 50_000, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
const result = buildFunZoneSalesSync(baseData([paidInvoice, legacyReturn]), [payment, cancellation])
|
||||
assert.ok(result.toRemoveIds.includes('inv-ret'))
|
||||
}
|
||||
|
||||
console.log('funzoneSalesSync.cancel.test.ts: ok')
|
||||
@@ -2,10 +2,10 @@ import type { ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { AppData, Invoice } from '../../types'
|
||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { isEventCustomerPayment } from '../../utils/funzoneFees'
|
||||
import { isEventCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
|
||||
import { invoiceTotals } from '../../utils/accounting'
|
||||
import { eventNameWithCategory } from '../../utils/eventName'
|
||||
import { byDocumentType, isWithdrawalReturn } from './salesUtils'
|
||||
import { byDocumentType, findVoucherForInvoice, isWithdrawalReturn } from './salesUtils'
|
||||
|
||||
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
|
||||
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
|
||||
@@ -32,6 +32,10 @@ export function isFunZoneSalesInvoice(invoice: Invoice): boolean {
|
||||
return isFunZoneTicketSalesInvoice(invoice) || invoice.note.includes(FUNZONE_CANCEL_NOTE_PREFIX)
|
||||
}
|
||||
|
||||
export function isCancelledSalesInvoice(invoice: Invoice): boolean {
|
||||
return invoice.kind === 'sale' && invoice.status === 'cancelled'
|
||||
}
|
||||
|
||||
function isoDateFromApi(iso: string): string {
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
@@ -40,37 +44,102 @@ function findInvoiceByNoteTag(invoices: Invoice[], tag: string): Invoice | undef
|
||||
return invoices.find((inv) => inv.note.includes(tag))
|
||||
}
|
||||
|
||||
function cancellationAlreadyBooked(
|
||||
invoices: Invoice[],
|
||||
customerName: string,
|
||||
amount: number,
|
||||
): boolean {
|
||||
for (const inv of invoices) {
|
||||
if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue
|
||||
const net = invoiceTotals(inv).net
|
||||
if (Math.abs(net - amount) > 0.01) continue
|
||||
if (inv.note.includes(customerName)) return true
|
||||
function eventNamesMatch(invoiceEvent: string | undefined, cancelEvent: string): boolean {
|
||||
const a = (invoiceEvent || '').toLowerCase().trim()
|
||||
const b = (cancelEvent || '').toLowerCase().trim()
|
||||
if (!a || !b) return false
|
||||
const aBase = a.split('(')[0].trim()
|
||||
const bBase = b.split('(')[0].trim()
|
||||
return a.includes(bBase) || b.includes(aBase) || a === b
|
||||
}
|
||||
|
||||
/** Resolve the original ticket sales invoice for a FunZone cancellation. */
|
||||
export function findOriginalTicketInvoiceForCancellation(
|
||||
cancellation: ApiPaymentOrRefund,
|
||||
payments: ApiPaymentOrRefund[],
|
||||
invoices: Invoice[],
|
||||
partyId: string,
|
||||
): Invoice | undefined {
|
||||
if (cancellation.reservation_id) {
|
||||
const payment = payments.find(
|
||||
(p) =>
|
||||
p.type === 'payment' &&
|
||||
p.reservation_id &&
|
||||
p.reservation_id === cancellation.reservation_id,
|
||||
)
|
||||
if (payment) {
|
||||
const byTag =
|
||||
findInvoiceByNoteTag(invoices, funzonePaymentNoteTag(payment.id, true)) ??
|
||||
findInvoiceByNoteTag(invoices, funzonePaymentNoteTag(payment.id, false))
|
||||
if (byTag) return byTag
|
||||
}
|
||||
}
|
||||
|
||||
const amount = Math.abs(cancellation.amount)
|
||||
const candidates = invoices
|
||||
.filter((inv) => {
|
||||
if (inv.partyId !== partyId) return false
|
||||
if (inv.status === 'draft') return false
|
||||
if (!isFunZoneTicketSalesInvoice(inv)) return false
|
||||
// Already cancelled by a different refund — do not reassign.
|
||||
if (inv.status === 'cancelled') {
|
||||
const existingCancel = inv.note
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith(FUNZONE_CANCEL_NOTE_PREFIX))
|
||||
if (existingCancel && !existingCancel.includes(cancellation.id)) return false
|
||||
}
|
||||
if (Math.abs(invoiceTotals(inv).net - amount) > 0.01) return false
|
||||
return eventNamesMatch(inv.lines[0]?.eventName, cancellation.event_name)
|
||||
})
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
||||
|
||||
// Heuristic only when unique (avoids cancelling the wrong same-price ticket).
|
||||
const open = candidates.filter((c) => c.status !== 'cancelled')
|
||||
if (open.length === 1) return open[0]
|
||||
if (open.length === 0 && candidates.length === 1) return candidates[0]
|
||||
return undefined
|
||||
}
|
||||
|
||||
function markInvoiceCancelled(
|
||||
invoice: Invoice,
|
||||
cancellation: ApiPaymentOrRefund,
|
||||
): Invoice {
|
||||
const tag = funzoneCancellationNoteTag(cancellation.id)
|
||||
const noteLines = invoice.note
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith(FUNZONE_CANCEL_NOTE_PREFIX) && !line.startsWith('لغو شده'))
|
||||
noteLines.push(tag)
|
||||
noteLines.push(`لغو شده — ${cancellation.customer_name}`)
|
||||
noteLines.push(`تاریخ لغو: ${isoDateFromApi(cancellation.created_at)}`)
|
||||
|
||||
return {
|
||||
...invoice,
|
||||
status: 'cancelled',
|
||||
documentType: 'invoice',
|
||||
note: noteLines.join('\n'),
|
||||
lines: invoice.lines.map((l) => ({ ...l })),
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function invoiceUnchanged(existing: Invoice, built: Invoice): boolean {
|
||||
if (existing.partyId !== built.partyId || existing.date !== built.date) return false
|
||||
if (existing.documentType !== built.documentType) return false
|
||||
if (existing.status !== built.status) return false
|
||||
if (existing.note !== built.note) return false
|
||||
if (Math.abs(invoiceTotals(existing).net - invoiceTotals(built).net) > 0.01) return false
|
||||
const existingEvent = existing.lines[0]?.eventName ?? ''
|
||||
const builtEvent = built.lines[0]?.eventName ?? ''
|
||||
return existingEvent === builtEvent
|
||||
}
|
||||
|
||||
/** Event ticket payment → فاکتور فروش; booking cancellation → فاکتور برگشتی. */
|
||||
/** Event ticket payment → فاکتور فروش; booking cancellation → mark original as لغو شده. */
|
||||
export function buildSalesInvoiceFromCustomerPayment(
|
||||
item: ApiPaymentOrRefund,
|
||||
partyId: string,
|
||||
existingInvoices: Invoice[],
|
||||
number: number,
|
||||
existing?: Invoice,
|
||||
allPayments: ApiPaymentOrRefund[] = [],
|
||||
): Invoice | null {
|
||||
const amount = Math.abs(item.amount)
|
||||
if (amount <= 0) return null
|
||||
@@ -80,6 +149,12 @@ export function buildSalesInvoiceFromCustomerPayment(
|
||||
|
||||
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
||||
// Keep cancelled sales invoices cancelled; do not recreate as paid.
|
||||
if (prior?.status === 'cancelled') return null
|
||||
|
||||
const split = splitEventPayment(amount)
|
||||
const payLabel =
|
||||
item.payment_method === 'wallet' ? 'پرداخت از کیف پول' : 'پرداخت درگاه'
|
||||
|
||||
return {
|
||||
id: prior?.id ?? '',
|
||||
@@ -89,7 +164,15 @@ export function buildSalesInvoiceFromCustomerPayment(
|
||||
partyId,
|
||||
date: isoDateFromApi(item.created_at),
|
||||
status: prior?.status === 'draft' ? 'draft' : 'paid',
|
||||
note: `${tag}\nفاکتور خودکار از فانزون — ${item.customer_name}`,
|
||||
note: [
|
||||
tag,
|
||||
`فاکتور فروش بلیت از فانزون — ${item.customer_name}`,
|
||||
`روش پرداخت: ${payLabel}`,
|
||||
`مبلغ کل: ${split.gross}`,
|
||||
`مالیات (۱۰٪): ${split.tax}`,
|
||||
`سود پلتفرم (۱۴٪): ${split.platformProfit}`,
|
||||
`سهم تامینکننده (مالک): ${split.ownerNet}`,
|
||||
].join('\n'),
|
||||
lines: [
|
||||
{
|
||||
id: prior?.lines[0]?.id ?? createId('il-'),
|
||||
@@ -104,81 +187,74 @@ export function buildSalesInvoiceFromCustomerPayment(
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet withdrawals / wallet refunds are handled via برداشتها → treasury payment.
|
||||
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
|
||||
if (isWallet) {
|
||||
if (cancellationAlreadyBooked(existingInvoices, item.customer_name, amount)) return null
|
||||
return null
|
||||
}
|
||||
if (isWallet) return null
|
||||
|
||||
const tag = funzoneCancellationNoteTag(item.id)
|
||||
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
||||
if (!isEventCustomerPayment(item.event_name)) return null
|
||||
|
||||
return {
|
||||
id: prior?.id ?? '',
|
||||
kind: 'sale',
|
||||
documentType: 'return',
|
||||
number: prior?.number ?? number,
|
||||
const original = findOriginalTicketInvoiceForCancellation(
|
||||
item,
|
||||
allPayments,
|
||||
existingInvoices,
|
||||
partyId,
|
||||
date: isoDateFromApi(item.created_at),
|
||||
status: prior?.status ?? 'confirmed',
|
||||
note: `${tag}\nبرگشت خودکار از فانزون — ${item.customer_name}`,
|
||||
lines: [
|
||||
{
|
||||
id: prior?.lines[0]?.id ?? createId('il-'),
|
||||
productId: '',
|
||||
quantity: 1,
|
||||
unitPrice: amount,
|
||||
discount: 0,
|
||||
taxRate: 0,
|
||||
eventName: eventNameWithCategory(item.event_name, item.category_name),
|
||||
},
|
||||
],
|
||||
relatedInvoiceId: prior?.relatedInvoiceId,
|
||||
}
|
||||
)
|
||||
if (!original) return null
|
||||
|
||||
const cancelled = markInvoiceCancelled(original, item)
|
||||
if (existing && invoiceUnchanged(existing, cancelled)) return null
|
||||
return cancelled
|
||||
}
|
||||
|
||||
export interface FunZoneSalesSyncResult {
|
||||
toUpsert: Invoice[]
|
||||
toRemoveIds: string[]
|
||||
toRemoveVoucherIds: string[]
|
||||
skipped: number
|
||||
unmatchedCustomers: number
|
||||
paymentCount: number
|
||||
cancellationCount: number
|
||||
cancelledInvoices: number
|
||||
}
|
||||
|
||||
export function buildFunZoneSalesSync(
|
||||
data: Pick<AppData, 'parties' | 'invoices'>,
|
||||
data: Pick<AppData, 'parties' | 'invoices' | 'vouchers'>,
|
||||
customerPayments: ApiPaymentOrRefund[],
|
||||
): FunZoneSalesSyncResult {
|
||||
const toUpsert: Invoice[] = []
|
||||
const toRemoveIds: string[] = []
|
||||
const toRemoveVoucherIds: string[] = []
|
||||
let skipped = 0
|
||||
let unmatchedCustomers = 0
|
||||
let paymentCount = 0
|
||||
let cancellationCount = 0
|
||||
let cancelledInvoices = 0
|
||||
|
||||
let invoiceNumber = nextNumber(byDocumentType(data.invoices, 'invoice'))
|
||||
let returnNumber = nextNumber(byDocumentType(data.invoices, 'return'))
|
||||
const working = [...data.invoices]
|
||||
|
||||
for (const item of customerPayments) {
|
||||
const payments = customerPayments.filter((i) => i.type === 'payment')
|
||||
const cancellations = customerPayments.filter((i) => i.type === 'cancellation')
|
||||
|
||||
// Pass 1: ticket sales → فاکتور فروش
|
||||
for (const item of payments) {
|
||||
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||
if (!party?.id) {
|
||||
unmatchedCustomers += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === 'payment' && isEventCustomerPayment(item.event_name)) paymentCount += 1
|
||||
if (item.type === 'cancellation' && isEventCustomerPayment(item.event_name)) cancellationCount += 1
|
||||
if (isEventCustomerPayment(item.event_name)) paymentCount += 1
|
||||
|
||||
const tag =
|
||||
item.type === 'payment'
|
||||
? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||
: funzoneCancellationNoteTag(item.id)
|
||||
const existing = findInvoiceByNoteTag(data.invoices, tag)
|
||||
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||
const existing = findInvoiceByNoteTag(working, tag)
|
||||
const built = buildSalesInvoiceFromCustomerPayment(
|
||||
item,
|
||||
party.id,
|
||||
data.invoices,
|
||||
item.type === 'payment' ? invoiceNumber : returnNumber,
|
||||
working,
|
||||
invoiceNumber,
|
||||
existing,
|
||||
customerPayments,
|
||||
)
|
||||
if (!built) continue
|
||||
|
||||
@@ -187,14 +263,77 @@ export function buildFunZoneSalesSync(
|
||||
continue
|
||||
}
|
||||
|
||||
toUpsert.push(built)
|
||||
if (!existing) {
|
||||
if (built.documentType === 'invoice') invoiceNumber += 1
|
||||
else returnNumber += 1
|
||||
}
|
||||
const withId = { ...built, id: built.id || createId('inv-') }
|
||||
const upsertIdx = toUpsert.findIndex((i) => i.id === withId.id)
|
||||
if (upsertIdx >= 0) toUpsert[upsertIdx] = withId
|
||||
else toUpsert.push(withId)
|
||||
|
||||
const idx = working.findIndex((i) => i.id === withId.id)
|
||||
if (idx >= 0) working[idx] = withId
|
||||
else working.push(withId)
|
||||
|
||||
if (!existing) invoiceNumber += 1
|
||||
}
|
||||
|
||||
return { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount }
|
||||
// Pass 2: cancellations → mark original فاکتور فروش as لغو شده (no فاکتور برگشتی)
|
||||
for (const item of cancellations) {
|
||||
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||
if (!party?.id) {
|
||||
unmatchedCustomers += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (isEventCustomerPayment(item.event_name)) cancellationCount += 1
|
||||
|
||||
const built = buildSalesInvoiceFromCustomerPayment(
|
||||
item,
|
||||
party.id,
|
||||
working,
|
||||
0,
|
||||
undefined,
|
||||
customerPayments,
|
||||
)
|
||||
if (!built) continue
|
||||
|
||||
const existing = working.find((i) => i.id === built.id)
|
||||
if (existing && invoiceUnchanged(existing, built)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const upsertIdx = toUpsert.findIndex((i) => i.id === built.id)
|
||||
if (upsertIdx >= 0) toUpsert[upsertIdx] = built
|
||||
else toUpsert.push(built)
|
||||
|
||||
cancelledInvoices += 1
|
||||
const idx = working.findIndex((i) => i.id === built.id)
|
||||
if (idx >= 0) working[idx] = built
|
||||
}
|
||||
|
||||
// Remove legacy FunZone cancel return invoices (and their vouchers).
|
||||
for (const inv of data.invoices) {
|
||||
const isLegacyCancelReturn =
|
||||
inv.kind === 'sale' &&
|
||||
(inv.documentType ?? 'invoice') === 'return' &&
|
||||
inv.note.includes(FUNZONE_CANCEL_NOTE_PREFIX) &&
|
||||
!isWithdrawalReturn(inv)
|
||||
|
||||
if (!isLegacyCancelReturn) continue
|
||||
toRemoveIds.push(inv.id)
|
||||
const voucher = findVoucherForInvoice(data.vouchers, inv)
|
||||
if (voucher) toRemoveVoucherIds.push(voucher.id)
|
||||
}
|
||||
|
||||
return {
|
||||
toUpsert,
|
||||
toRemoveIds,
|
||||
toRemoveVoucherIds,
|
||||
skipped,
|
||||
unmatchedCustomers,
|
||||
paymentCount,
|
||||
cancellationCount,
|
||||
cancelledInvoices,
|
||||
}
|
||||
}
|
||||
|
||||
export function assignInvoiceIds(items: Invoice[]): Invoice[] {
|
||||
|
||||
94
src/pages/sales/funzoneSalesSync.wallet.test.ts
Normal file
94
src/pages/sales/funzoneSalesSync.wallet.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Run: npx tsx src/pages/sales/funzoneSalesSync.wallet.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import type { ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { Party } from '../../types'
|
||||
import { buildSalesInvoiceFromCustomerPayment } from './funzoneSalesSync'
|
||||
import { buildTreasuryFromCustomerPayment } from '../treasury/funzoneTreasurySync'
|
||||
import { buildSalesVoucher } from './salesUtils'
|
||||
|
||||
const party: Party = {
|
||||
id: 'c1',
|
||||
kind: 'customer',
|
||||
name: 'Ali',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'cust-1',
|
||||
externalSource: 'funzone_customer',
|
||||
}
|
||||
|
||||
const walletTicket: ApiPaymentOrRefund = {
|
||||
id: 'bp-1',
|
||||
type: 'payment',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: '',
|
||||
ref_num: '',
|
||||
amount: 100_000,
|
||||
event_name: 'Football Night',
|
||||
category_name: 'Sport',
|
||||
payment_method: 'wallet',
|
||||
event_date: '2026-01-01',
|
||||
event_time: '18:00',
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
reservation_id: 'res-1',
|
||||
refund_status: null,
|
||||
}
|
||||
|
||||
// Must create فاکتور فروش
|
||||
const invoice = buildSalesInvoiceFromCustomerPayment(walletTicket, party.id, [], 1)
|
||||
assert.ok(invoice)
|
||||
assert.equal(invoice!.documentType, 'invoice')
|
||||
assert.equal(invoice!.kind, 'sale')
|
||||
assert.ok(invoice!.note.includes('funzone:pay:wallet:bp-1'))
|
||||
assert.ok(invoice!.note.includes('پرداخت از کیف پول'))
|
||||
assert.ok(invoice!.note.includes('سود پلتفرم (۱۴٪)'))
|
||||
assert.equal(invoice!.lines[0].unitPrice, 100_000)
|
||||
|
||||
// Must NOT create دریافت in treasury
|
||||
assert.equal(buildTreasuryFromCustomerPayment(walletTicket, party.id, 1), null)
|
||||
|
||||
// Voucher from invoice debits customer wallet liability
|
||||
const accounts = [
|
||||
{ id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1004', code: '1004', name: 'AR', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2002', code: '2002', name: 'Tax', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2005', code: '2005', name: 'Owner', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2006', code: '2006', name: 'Wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a4001', code: '4001', name: 'Income', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
]
|
||||
const voucher = buildSalesVoucher(
|
||||
{ ...invoice!, id: 'inv-1', status: 'paid' },
|
||||
{
|
||||
accounts,
|
||||
vouchers: [],
|
||||
parties: [party],
|
||||
products: [],
|
||||
settings: {
|
||||
name: 'T',
|
||||
economicCode: '',
|
||||
fiscalYear: '1404',
|
||||
baseCurrency: 'تومان',
|
||||
taxRate: 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
voucherAccounts: {
|
||||
bankAccountId: 'a1002',
|
||||
ownerPayableAccountId: 'a2005',
|
||||
customerPayableAccountId: 'a2006',
|
||||
ownerShareAccountId: 'a4001',
|
||||
salesIncomeAccountId: 'a4001',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert.ok(voucher)
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 100_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a4001' && l.credit === 14_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2002' && l.credit === 10_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2005' && l.credit === 76_000))
|
||||
|
||||
console.log('wallet ticket → فاکتور فروش checks passed')
|
||||
@@ -28,31 +28,40 @@ export function treasurySourceForFunzoneInvoice(invoice: Invoice): string | null
|
||||
: treasurySourceCustomerPayment(ref.id)
|
||||
}
|
||||
|
||||
/** Event ticket sales are booked once via treasury (fee split); skip duplicate invoice vouchers. */
|
||||
export function shouldSkipInvoiceVoucherForFunzoneTicket(
|
||||
invoice: Invoice,
|
||||
data: Pick<AppData, 'treasury'>,
|
||||
): boolean {
|
||||
const source = treasurySourceForFunzoneInvoice(invoice)
|
||||
if (!source) return false
|
||||
return data.treasury.some((txn) => txn.source === source)
|
||||
function treasurySourcesForFunzonePaymentId(paymentId: string): string[] {
|
||||
return [treasurySourceCustomerPayment(paymentId), treasurySourceWalletPayment(paymentId)]
|
||||
}
|
||||
|
||||
/** Removes invoice vouchers when a treasury voucher already exists for the same FunZone payment. */
|
||||
/**
|
||||
* Ticket fee split is booked on the فاکتور فروش voucher — never skip it.
|
||||
* (Kept for call-site compatibility.)
|
||||
*/
|
||||
export function shouldSkipInvoiceVoucherForFunzoneTicket(
|
||||
_invoice: Invoice,
|
||||
_data: Pick<AppData, 'treasury'>,
|
||||
): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes leftover treasury vouchers for ticket payments once the sales invoice
|
||||
* voucher exists (invoice is the source of truth for tax / 14% / owner).
|
||||
*/
|
||||
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)
|
||||
const ref = funzonePaymentRefFromInvoice(invoice)
|
||||
if (!ref) continue
|
||||
const sources = treasurySourcesForFunzonePaymentId(ref.id)
|
||||
const treasuryTxn = data.treasury.find((txn) => sources.includes(txn.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)
|
||||
toRemove.push(treasuryVoucher.id)
|
||||
}
|
||||
}
|
||||
return toRemove
|
||||
|
||||
@@ -10,7 +10,7 @@ export const salesNavItems: SalesNavItem[] = [
|
||||
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
|
||||
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
|
||||
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
|
||||
{ to: '/sales/return', label: 'فاکتور برگشتی جدید', icon: '↩️' },
|
||||
{ to: '/sales/return', label: 'برداشتها و لغو شده', icon: '🚫' },
|
||||
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
|
||||
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
|
||||
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildInvoiceVoucher,
|
||||
buildPurchaseVoucher,
|
||||
buildSalesVoucher,
|
||||
buildTreasuryVoucher,
|
||||
invoicesToSettle,
|
||||
isWithdrawalReturn,
|
||||
partyAccountBalance,
|
||||
@@ -58,6 +59,9 @@ const accounts = [
|
||||
{ id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1005', code: '1005', name: 'Inventory', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2001', code: '2001', name: 'Payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2002', code: '2002', name: 'Tax', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2005', code: '2005', name: 'Owner payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2006', code: '2006', name: 'Customer wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
]
|
||||
|
||||
@@ -82,8 +86,8 @@ const baseData = (): AppData => ({
|
||||
phone: '',
|
||||
voucherAccounts: {
|
||||
bankAccountId: 'a1002',
|
||||
ownerPayableAccountId: 'a2001',
|
||||
customerPayableAccountId: 'a2001',
|
||||
ownerPayableAccountId: 'a2005',
|
||||
customerPayableAccountId: 'a2006',
|
||||
ownerShareAccountId: 'a4001',
|
||||
salesIncomeAccountId: 'a4001',
|
||||
},
|
||||
@@ -190,6 +194,50 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||
assert.ok(buildInvoiceVoucher(purchaseInvoice('confirmed'), data))
|
||||
}
|
||||
|
||||
// FunZone ticket sale voucher: tax 10% + platform 14% + owner 76%
|
||||
{
|
||||
const data = baseData()
|
||||
const ticketInvoice: Invoice = {
|
||||
id: 'inv-fz',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 9,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status: 'paid',
|
||||
note: 'funzone:pay:pay-1\nفاکتور فروش بلیت',
|
||||
lines: [{ id: 'l-fz', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
const voucher = buildSalesVoucher(ticketInvoice, data)
|
||||
assert.ok(voucher)
|
||||
assert.equal(voucher!.lines.length, 4)
|
||||
assert.equal(voucher!.lines.reduce((s, l) => s + l.debit, 0), 100_000)
|
||||
assert.equal(voucher!.lines.reduce((s, l) => s + l.credit, 0), 100_000)
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2002' && l.credit === 10_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a4001' && l.credit === 14_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2005' && l.credit === 76_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 100_000))
|
||||
}
|
||||
|
||||
// FunZone ticket paid from wallet → debit customer wallet liability
|
||||
{
|
||||
const data = baseData()
|
||||
const ticketInvoice: Invoice = {
|
||||
id: 'inv-fz-w',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 10,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status: 'paid',
|
||||
note: 'funzone:pay:wallet:pay-2\nفاکتور فروش بلیت',
|
||||
lines: [{ id: 'l-fz-w', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
const voucher = buildSalesVoucher(ticketInvoice, data)
|
||||
assert.ok(voucher)
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 100_000))
|
||||
}
|
||||
|
||||
// Withdrawal return: no stock impact
|
||||
{
|
||||
const withdrawalReturn: Invoice = {
|
||||
@@ -237,7 +285,72 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||
assert.equal(settled[0].id, 'inv-a')
|
||||
}
|
||||
|
||||
// Return + receipt: FIFO marks both invoices paid when fully covered
|
||||
// Cancelled FunZone gateway ticket: bank stays, liability to customer wallet (no tax/14%)
|
||||
{
|
||||
const data = baseData()
|
||||
const cancelled: Invoice = {
|
||||
id: 'inv-fz-cancel',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 1,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status: 'cancelled',
|
||||
note: 'funzone:pay:pay-9\nfunzone:cancel:ref-9\nلغو شده\nتاریخ لغو: 2026-01-02',
|
||||
lines: [{ id: 'l-fz-c', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
const voucher = buildSalesVoucher(cancelled, data)
|
||||
assert.ok(voucher)
|
||||
assert.equal(voucher!.date, '2026-01-02')
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 100_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.credit === 100_000))
|
||||
assert.equal(voucher!.lines.length, 2)
|
||||
assert.equal(partyReceivable('c1', { ...data, invoices: [cancelled] }), 0)
|
||||
assert.equal(stockDeltas(cancelled).size, 0)
|
||||
}
|
||||
|
||||
// Cancelled FunZone wallet ticket: remove voucher (sale was wallet debit)
|
||||
{
|
||||
const data = baseData()
|
||||
const cancelled: Invoice = {
|
||||
id: 'inv-fz-w-cancel',
|
||||
kind: 'sale',
|
||||
documentType: 'invoice',
|
||||
number: 2,
|
||||
partyId: 'c1',
|
||||
date: '2026-01-01',
|
||||
status: 'cancelled',
|
||||
note: 'funzone:pay:wallet:pay-8\nfunzone:cancel:ref-8\nلغو شده',
|
||||
lines: [{ id: 'l-fz-wc', productId: '', quantity: 1, unitPrice: 100_000, discount: 0, taxRate: 0 }],
|
||||
}
|
||||
assert.equal(buildSalesVoucher(cancelled, data), null)
|
||||
}
|
||||
|
||||
// Withdrawal treasury payment without eventName still hits wallet payable (source-based)
|
||||
{
|
||||
const data = baseData()
|
||||
const voucher = buildTreasuryVoucher(
|
||||
{
|
||||
id: 'tr-wd',
|
||||
number: 1,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-03',
|
||||
partyId: 'c1',
|
||||
amount: 50_000,
|
||||
reference: '',
|
||||
description: 'برداشت کیف پول مشتری',
|
||||
source: 'withdrawal:wd-99',
|
||||
},
|
||||
data,
|
||||
)
|
||||
assert.ok(voucher)
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 50_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 50_000))
|
||||
}
|
||||
|
||||
|
||||
// Cancelled invoice is excluded from sales receivable; legacy return still subtracts
|
||||
{
|
||||
const data = baseData()
|
||||
data.invoices = [
|
||||
@@ -271,6 +384,7 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
||||
assert.equal(settled.length, 2)
|
||||
}
|
||||
|
||||
|
||||
// Treasury delete reverts paid status
|
||||
{
|
||||
const data = baseData()
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { invoiceTotals } from '../../utils/accounting'
|
||||
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { isWalletCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
|
||||
|
||||
/** Resolves a chart account by code, with optional settings fallback. */
|
||||
export function accountByCode(
|
||||
@@ -27,13 +28,24 @@ export function accountByCode(
|
||||
export const documentTypeLabels: Record<SalesDocumentType, string> = {
|
||||
proforma: 'پیشفاکتور',
|
||||
invoice: 'فاکتور فروش',
|
||||
return: 'فاکتور برگشتی',
|
||||
/** Legacy only — app no longer creates return documents. */
|
||||
return: 'فاکتور برگشتی (قدیمی)',
|
||||
}
|
||||
|
||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||
draft: 'پیشنویس',
|
||||
confirmed: 'قطعی',
|
||||
paid: 'تسویهشده',
|
||||
cancelled: 'لغو شده',
|
||||
}
|
||||
|
||||
/** Active sales invoice (not draft/cancelled/proforma/legacy return). */
|
||||
export function isActiveSalesInvoice(invoice: Invoice): boolean {
|
||||
if (invoice.kind !== 'sale' || invoice.status === 'draft' || invoice.status === 'cancelled') {
|
||||
return false
|
||||
}
|
||||
const docType = invoice.documentType ?? 'invoice'
|
||||
return docType === 'invoice'
|
||||
}
|
||||
|
||||
/** Sales documents only (kind=sale). */
|
||||
@@ -63,10 +75,13 @@ export function partyReceivable(
|
||||
let balance = party?.openingBalance ?? 0
|
||||
|
||||
for (const inv of salesDocuments(data.invoices)) {
|
||||
if (inv.partyId !== partyId || inv.status === 'draft') continue
|
||||
if (inv.partyId !== partyId || inv.status === 'draft' || inv.status === 'cancelled') continue
|
||||
const net = invoiceTotals(inv).net
|
||||
const docType = inv.documentType ?? 'invoice'
|
||||
if (docType === 'proforma') continue
|
||||
// FunZone tickets are prepaid (bank/wallet voucher), not customer AR.
|
||||
if (inv.note.includes('funzone:pay:')) continue
|
||||
// Legacy return docs still reverse receivable until sync removes them.
|
||||
balance += docType === 'return' ? -net : net
|
||||
}
|
||||
|
||||
@@ -142,7 +157,7 @@ export function invoiceRegarding(invoice: Invoice): string | undefined {
|
||||
|
||||
/** Whether stock/inventory should be affected for this document. */
|
||||
export function affectsStock(invoice: Invoice): boolean {
|
||||
if (invoice.status === 'draft') return false
|
||||
if (invoice.status === 'draft' || invoice.status === 'cancelled') return false
|
||||
if (isWithdrawalReturn(invoice)) return false
|
||||
if (invoice.kind === 'purchase') return true
|
||||
const docType = invoice.documentType ?? 'invoice'
|
||||
@@ -221,6 +236,7 @@ export function buildSalesVoucher(
|
||||
credit,
|
||||
})
|
||||
|
||||
// Legacy withdrawal return invoices (no longer created by the app).
|
||||
if (isWithdrawalReturn(invoice)) {
|
||||
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
|
||||
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||
@@ -235,7 +251,7 @@ export function buildSalesVoucher(
|
||||
description: `برداشت کیف پول - ${label}`,
|
||||
regarding: regarding ?? 'wallet',
|
||||
status: 'posted',
|
||||
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
||||
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice),
|
||||
lines: [
|
||||
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
|
||||
makeLine(bankId, 0, net, `پرداخت از بانک - ${label}`),
|
||||
@@ -243,8 +259,89 @@ export function buildSalesVoucher(
|
||||
}
|
||||
}
|
||||
|
||||
const isFunZoneTicket =
|
||||
docType === 'invoice' && invoice.note.includes('funzone:pay:')
|
||||
|
||||
// Cancelled FunZone ticket — void fee split; keep cash correct.
|
||||
if (invoice.status === 'cancelled') {
|
||||
if (!isFunZoneTicket) return null
|
||||
|
||||
const {
|
||||
bankAccountId: bankSettingId,
|
||||
customerPayableAccountId,
|
||||
} = data.settings.voucherAccounts
|
||||
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||
const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
|
||||
if (!customerPayableId) return null
|
||||
|
||||
const fromWallet = /funzone:pay:wallet:/.test(invoice.note)
|
||||
const cancelLabel = `لغو فاکتور فروش #${invoice.number} - ${party?.name ?? ''}`
|
||||
const cancelDate =
|
||||
invoice.note
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('تاریخ لغو:'))
|
||||
?.replace('تاریخ لغو:', '')
|
||||
.trim() || invoice.date
|
||||
|
||||
// Wallet sale was Dr wallet / Cr fees — removing the voucher restores both (net zero).
|
||||
if (fromWallet) return null
|
||||
|
||||
// Gateway sale was Dr bank / Cr fees — after cancel, cash stays and is owed to customer wallet.
|
||||
if (!bankId) return null
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: cancelDate,
|
||||
description: invoice.note || cancelLabel,
|
||||
regarding,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice),
|
||||
lines: [
|
||||
makeLine(bankId, net, 0, `مانده بانکی پس از لغو بلیت - ${cancelLabel}`),
|
||||
makeLine(customerPayableId, 0, net, `بدهی کیف پول مشتری پس از لغو - ${cancelLabel}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// FunZone ticket sale: cash/wallet sale with tax 10% + platform 14% + owner 76%.
|
||||
if (isFunZoneTicket) {
|
||||
const {
|
||||
bankAccountId: bankSettingId,
|
||||
salesIncomeAccountId: incomeSettingId,
|
||||
ownerPayableAccountId,
|
||||
customerPayableAccountId,
|
||||
} = data.settings.voucherAccounts
|
||||
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||
const taxAccountId = accountByCode(data.accounts, '2002')
|
||||
const ownerPayableId = accountByCode(data.accounts, '2005', ownerPayableAccountId)
|
||||
const platformIncomeId = accountByCode(data.accounts, '4001', incomeSettingId || salesIncomeAccountId)
|
||||
const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
|
||||
const fromWallet = /funzone:pay:wallet:/.test(invoice.note)
|
||||
const debitAccountId = fromWallet ? customerPayableId : bankId
|
||||
if (!debitAccountId || !taxAccountId || !ownerPayableId || !platformIncomeId) return null
|
||||
|
||||
const split = splitEventPayment(net)
|
||||
const debitLabel = fromWallet ? 'کاهش کیف پول مشتری' : 'واریز بابت فروش بلیت'
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: invoice.date,
|
||||
description: invoice.note || label,
|
||||
regarding,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice),
|
||||
lines: [
|
||||
makeLine(debitAccountId, split.gross, 0, `${debitLabel} - ${label}`),
|
||||
makeLine(ownerPayableId, 0, split.ownerNet, `سهم تامینکننده (مالک) - ${label}`),
|
||||
makeLine(platformIncomeId, 0, split.platformProfit, `سود پلتفرم (۱۴٪) - ${label}`),
|
||||
makeLine(taxAccountId, 0, split.tax, `مالیات (۱۰٪) - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!salesIncomeAccountId || !receivableAccountId) return null
|
||||
|
||||
// Legacy return path (app no longer creates these).
|
||||
const isReturn = docType === 'return'
|
||||
const lines: VoucherLine[] = isReturn
|
||||
? [
|
||||
@@ -263,7 +360,7 @@ export function buildSalesVoucher(
|
||||
description: label,
|
||||
regarding,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice.id),
|
||||
source: voucherSourceForInvoice(invoice),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
@@ -299,7 +396,7 @@ export function buildPurchaseVoucher(
|
||||
date: invoice.date,
|
||||
description: label,
|
||||
status: 'posted',
|
||||
source: voucherSourceForInvoice(invoice.id),
|
||||
source: voucherSourceForInvoice(invoice),
|
||||
lines: [
|
||||
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
|
||||
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
|
||||
@@ -379,6 +476,7 @@ export function reconcileInvoicePaymentStatus(
|
||||
const updates: Invoice[] = []
|
||||
for (const inv of data.invoices) {
|
||||
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||
if (inv.status === 'cancelled') continue
|
||||
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
||||
|
||||
const shouldPaid = shouldBePaidIds.has(inv.id)
|
||||
@@ -395,8 +493,12 @@ export function findVoucherForInvoice(
|
||||
vouchers: Voucher[],
|
||||
invoice: Invoice,
|
||||
): Voucher | undefined {
|
||||
const byInvoice = vouchers.find((v) => v.source === voucherSourceForInvoice(invoice.id))
|
||||
if (byInvoice) return byInvoice
|
||||
const preferred = voucherSourceForInvoice(invoice)
|
||||
const byPreferred = vouchers.find((v) => v.source === preferred)
|
||||
if (byPreferred) return byPreferred
|
||||
|
||||
const byInvoiceId = vouchers.find((v) => v.source === `invoice:${invoice.id}`)
|
||||
if (byInvoiceId) return byInvoiceId
|
||||
|
||||
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||
if (withdrawalId) {
|
||||
@@ -489,6 +591,51 @@ export function buildTreasuryVoucher(
|
||||
const tax = txn.taxAmount ?? 0
|
||||
const profit = txn.platformProfitAmount ?? 0
|
||||
const ownerNet = txn.ownerNetAmount ?? 0
|
||||
|
||||
const source = txn.source ?? ''
|
||||
const isWalletBySource =
|
||||
source.startsWith('withdrawal:') ||
|
||||
source.startsWith('funzone:wallet-pay:') ||
|
||||
isWalletCustomerPayment(txn.eventName) ||
|
||||
/کیف\s*پول|wallet/i.test(txn.description || '')
|
||||
|
||||
// Wallet top-up: bank in, liability to customer (no tax / 14%).
|
||||
const isWalletTopUp =
|
||||
txn.kind === 'receipt' && isWalletBySource && tax <= 0 && profit <= 0
|
||||
if (isWalletTopUp && customerPayableId) {
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: txn.date,
|
||||
description: txn.description || label,
|
||||
regarding: 'wallet',
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn),
|
||||
lines: [
|
||||
makeLine(bankId, amount, 0, `واریز شارژ کیف پول - ${label}`),
|
||||
makeLine(customerPayableId, 0, amount, `بدهی به مشتری (کیف پول) - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet withdraw: pay customer back (clear liability).
|
||||
const isWalletWithdraw = txn.kind === 'payment' && isWalletBySource && customerPayableId
|
||||
if (isWalletWithdraw) {
|
||||
return {
|
||||
id: createId('v-'),
|
||||
number: nextNumber(data.vouchers),
|
||||
date: txn.date,
|
||||
description: txn.description || label,
|
||||
regarding: 'wallet',
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn),
|
||||
lines: [
|
||||
makeLine(customerPayableId, amount, 0, `برداشت کیف پول مشتری - ${label}`),
|
||||
makeLine(bankId, 0, amount, `پرداخت از بانک - ${label}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const hasEventSplit =
|
||||
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
||||
|
||||
@@ -505,7 +652,7 @@ export function buildTreasuryVoucher(
|
||||
description: txn.description || label,
|
||||
regarding,
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn.id),
|
||||
source: voucherSourceForTreasury(txn),
|
||||
lines: [
|
||||
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
|
||||
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
||||
@@ -535,45 +682,61 @@ export function buildTreasuryVoucher(
|
||||
description: txn.description || label,
|
||||
regarding: txn.eventName,
|
||||
status: 'posted',
|
||||
source: voucherSourceForTreasury(txn.id),
|
||||
source: voucherSourceForTreasury(txn),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
export function voucherSourceForInvoice(invoiceId: string): string {
|
||||
return `invoice:${invoiceId}`
|
||||
export function voucherSourceForInvoice(invoiceOrId: Invoice | string): string {
|
||||
if (typeof invoiceOrId !== 'string') {
|
||||
const wallet = invoiceOrId.note.match(/funzone:pay:wallet:([^\s\n]+)/)
|
||||
if (wallet?.[1]) return `funzone:invoice:wallet:${wallet[1]}`
|
||||
const pay = invoiceOrId.note.match(/funzone:pay:([^\s\n]+)/)
|
||||
if (pay?.[1] && !pay[1].startsWith('wallet:')) return `funzone:invoice:${pay[1]}`
|
||||
return `invoice:${invoiceOrId.id}`
|
||||
}
|
||||
return `invoice:${invoiceOrId}`
|
||||
}
|
||||
|
||||
export function voucherSourceForAdjustment(adjustmentId: string): string {
|
||||
return `adjustment:${adjustmentId}`
|
||||
}
|
||||
|
||||
export function voucherSourceForTreasury(txnId: string): string {
|
||||
return `treasury:${txnId}`
|
||||
/** Prefer stable FunZone/withdrawal source when present so vouchers survive server UUID remap. */
|
||||
export function voucherSourceForTreasury(txnOrId: TreasuryTxn | string): string {
|
||||
if (typeof txnOrId !== 'string') {
|
||||
if (txnOrId.source?.trim()) return txnOrId.source.trim()
|
||||
return `treasury:${txnOrId.id}`
|
||||
}
|
||||
return `treasury:${txnOrId}`
|
||||
}
|
||||
|
||||
/** Summary stats for the sales process dashboard. */
|
||||
export function salesSummary(data: AppData) {
|
||||
const proformas = byDocumentType(data.invoices, 'proforma')
|
||||
const invoices = byDocumentType(data.invoices, 'invoice')
|
||||
const returns = byDocumentType(data.invoices, 'return')
|
||||
const activeInvoices = invoices.filter((i) => i.status !== 'cancelled' && i.status !== 'draft')
|
||||
const cancelled = invoices.filter((i) => i.status === 'cancelled')
|
||||
|
||||
const sumNet = (list: Invoice[]) =>
|
||||
list.filter((i) => i.status !== 'draft').reduce((s, i) => s + invoiceTotals(i).net, 0)
|
||||
list.filter((i) => i.status !== 'draft' && i.status !== 'cancelled').reduce((s, i) => s + invoiceTotals(i).net, 0)
|
||||
|
||||
const unpaid = invoices.filter((i) => i.status === 'confirmed')
|
||||
const customersList = customers(data.parties)
|
||||
|
||||
return {
|
||||
proformaCount: proformas.length,
|
||||
invoiceCount: invoices.length,
|
||||
returnCount: returns.length,
|
||||
invoiceCount: activeInvoices.length,
|
||||
cancelledCount: cancelled.length,
|
||||
/** @deprecated kept for older UI — always 0 conceptually; use cancelledCount */
|
||||
returnCount: cancelled.length,
|
||||
adjustmentCount: data.partyAdjustments.length,
|
||||
salesTypeCount: data.salesTypes.length,
|
||||
customerCount: customersList.length,
|
||||
totalSales: sumNet(invoices),
|
||||
totalReturns: sumNet(returns),
|
||||
netSales: sumNet(invoices) - sumNet(returns),
|
||||
totalCancelled: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
|
||||
totalReturns: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
|
||||
netSales: sumNet(invoices),
|
||||
unpaidCount: unpaid.length,
|
||||
unpaidAmount: sumNet(unpaid),
|
||||
openProformas: proformas.filter((p) => p.status === 'draft').length,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiGet } from '../../api/client'
|
||||
import { apiGet, apiPatch } from '../../api/client'
|
||||
import { ENDPOINTS } from '../../api/config'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import type { ApiWithdrawal, WalletTxnStatus } from '../../api/types'
|
||||
|
||||
/** Pending customer wallet withdrawals from admin panel accounting API. */
|
||||
/** All owner/customer withdrawals from the FunZone admin withdrawals API. */
|
||||
export function usePendingCustomerWithdrawals(enabled = true) {
|
||||
const [withdrawals, setWithdrawals] = useState<ApiWithdrawal[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -14,10 +15,10 @@ export function usePendingCustomerWithdrawals(enabled = true) {
|
||||
setError(null)
|
||||
try {
|
||||
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||
const pending = (res.withdrawals ?? []).filter(
|
||||
(w) => w.user_type === 'customer' && w.status === 'pending',
|
||||
const list = [...(res.withdrawals ?? [])].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
)
|
||||
setWithdrawals(pending)
|
||||
setWithdrawals(list)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
|
||||
setWithdrawals([])
|
||||
@@ -30,7 +31,31 @@ export function usePendingCustomerWithdrawals(enabled = true) {
|
||||
if (enabled) void load()
|
||||
}, [enabled, load])
|
||||
|
||||
return { withdrawals, loading, error, reload: load }
|
||||
const toggleStatus = useCallback(async (withdrawal: ApiWithdrawal) => {
|
||||
const nextStatus: WalletTxnStatus =
|
||||
withdrawal.status === 'pending' ? 'completed' : 'pending'
|
||||
|
||||
setUpdatingId(withdrawal.id)
|
||||
setError(null)
|
||||
try {
|
||||
await apiPatch(ENDPOINTS.UPDATE_WITHDRAWAL_STATUS(withdrawal.id), {
|
||||
status: nextStatus,
|
||||
user_type: withdrawal.user_type,
|
||||
})
|
||||
setWithdrawals((prev) =>
|
||||
prev.map((w) => (w.id === withdrawal.id ? { ...w, status: nextStatus } : w)),
|
||||
)
|
||||
return nextStatus
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'خطا در بروزرسانی وضعیت برداشت'
|
||||
setError(message)
|
||||
throw err instanceof Error ? err : new Error(message)
|
||||
} finally {
|
||||
setUpdatingId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { withdrawals, loading, updatingId, error, reload: load, toggleStatus }
|
||||
}
|
||||
|
||||
export const withdrawalNoteTag = (withdrawalId: string): string => `withdrawal:${withdrawalId}`
|
||||
|
||||
122
src/pages/sales/withdrawalTreasury.test.ts
Normal file
122
src/pages/sales/withdrawalTreasury.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Run: npx tsx src/pages/sales/withdrawalTreasury.test.ts
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AppData, Party } from '../../types'
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import {
|
||||
applyWithdrawalCompletedEffects,
|
||||
treasurySourceForWithdrawal,
|
||||
withdrawalAlreadyBooked,
|
||||
} from './withdrawalTreasury'
|
||||
import { treasurySourceOwnerTxn } from '../treasury/funzoneTreasurySync'
|
||||
|
||||
const customer: Party = {
|
||||
id: 'c1',
|
||||
kind: 'customer',
|
||||
name: 'Customer',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'cust-1',
|
||||
externalSource: 'funzone_customer',
|
||||
}
|
||||
|
||||
const owner: Party = {
|
||||
id: 'o1',
|
||||
kind: 'supplier',
|
||||
name: 'Owner',
|
||||
phone: '',
|
||||
economicCode: '',
|
||||
address: '',
|
||||
openingBalance: 0,
|
||||
externalId: 'own-1',
|
||||
externalSource: 'funzone_owner',
|
||||
}
|
||||
|
||||
const accounts = [
|
||||
{ id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1004', code: '1004', name: 'Receivable', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2005', code: '2005', name: 'Owner payable', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2006', code: '2006', name: 'Customer wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
]
|
||||
|
||||
const baseData = (): AppData => ({
|
||||
accounts,
|
||||
vouchers: [],
|
||||
parties: [customer, owner],
|
||||
products: [],
|
||||
invoices: [],
|
||||
salesTypes: [],
|
||||
partyAdjustments: [],
|
||||
treasury: [],
|
||||
employees: [],
|
||||
payslips: [],
|
||||
settings: {
|
||||
name: 'Test',
|
||||
economicCode: '',
|
||||
fiscalYear: '1404',
|
||||
baseCurrency: 'تومان',
|
||||
taxRate: 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
voucherAccounts: {
|
||||
bankAccountId: 'a1002',
|
||||
ownerPayableAccountId: 'a2005',
|
||||
customerPayableAccountId: 'a2006',
|
||||
ownerShareAccountId: 'a4001',
|
||||
salesIncomeAccountId: 'a4001',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
{
|
||||
const w: ApiWithdrawal = {
|
||||
id: 'wd-c',
|
||||
user_type: 'customer',
|
||||
user_id: 'cust-1',
|
||||
user_name: 'Ali',
|
||||
username: 'ali',
|
||||
amount: 50_000,
|
||||
status: 'pending',
|
||||
description: '',
|
||||
iban: 'IR00',
|
||||
created_at: '2026-01-03T10:00:00Z',
|
||||
}
|
||||
assert.equal(treasurySourceForWithdrawal(w), 'withdrawal:wd-c')
|
||||
const effects = applyWithdrawalCompletedEffects(w, 'c1', baseData())
|
||||
assert.equal(effects.treasury.kind, 'payment')
|
||||
assert.equal(effects.treasury.source, 'withdrawal:wd-c')
|
||||
assert.equal(effects.treasury.eventName, 'wallet')
|
||||
assert.ok(effects.voucher)
|
||||
assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a2006' && l.debit === 50_000))
|
||||
assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 50_000))
|
||||
assert.equal(withdrawalAlreadyBooked({ invoices: [], treasury: [effects.treasury] }, w), true)
|
||||
}
|
||||
|
||||
{
|
||||
const w: ApiWithdrawal = {
|
||||
id: 'wd-o',
|
||||
user_type: 'owner',
|
||||
user_id: 'own-1',
|
||||
user_name: 'Owner',
|
||||
username: 'own',
|
||||
amount: 80_000,
|
||||
status: 'pending',
|
||||
description: '',
|
||||
iban: 'IR11',
|
||||
created_at: '2026-01-04T10:00:00Z',
|
||||
}
|
||||
assert.equal(treasurySourceForWithdrawal(w), treasurySourceOwnerTxn('wd-o'))
|
||||
const effects = applyWithdrawalCompletedEffects(w, 'o1', baseData())
|
||||
assert.equal(effects.treasury.kind, 'payment')
|
||||
assert.equal(effects.treasury.source, 'funzone:owner-txn:wd-o')
|
||||
assert.ok(effects.voucher)
|
||||
// Owner پرداخت: Dr owner payable / Cr bank
|
||||
assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a2005' && l.debit === 80_000))
|
||||
assert.ok(effects.voucher!.lines.some((l) => l.accountId === 'a1002' && l.credit === 80_000))
|
||||
}
|
||||
|
||||
console.log('withdrawalTreasury.test.ts: ok')
|
||||
168
src/pages/sales/withdrawalTreasury.ts
Normal file
168
src/pages/sales/withdrawalTreasury.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ApiWithdrawal } from '../../api/types'
|
||||
import type { AppData, Invoice, TreasuryTxn, Voucher } from '../../types'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { treasurySourceOwnerTxn } from '../treasury/funzoneTreasurySync'
|
||||
import {
|
||||
buildTreasuryVoucher,
|
||||
findVoucherForInvoice,
|
||||
isWithdrawalReturn,
|
||||
voucherSourceForTreasury,
|
||||
withdrawalIdFromNote,
|
||||
} from './salesUtils'
|
||||
import { withdrawalNoteTag } from './usePendingCustomerWithdrawals'
|
||||
|
||||
/** Customer IBAN payouts use withdrawal:{id}; owner payouts share FunZone owner-txn source. */
|
||||
export function treasurySourceForWithdrawal(withdrawal: ApiWithdrawal | string): string {
|
||||
if (typeof withdrawal === 'string') return `withdrawal:${withdrawal}`
|
||||
if (withdrawal.user_type === 'owner') return treasurySourceOwnerTxn(withdrawal.id)
|
||||
return `withdrawal:${withdrawal.id}`
|
||||
}
|
||||
|
||||
export function findTreasuryForWithdrawal(
|
||||
treasury: TreasuryTxn[],
|
||||
withdrawal: ApiWithdrawal | string,
|
||||
): TreasuryTxn | undefined {
|
||||
const source = treasurySourceForWithdrawal(withdrawal)
|
||||
const byPrimary = treasury.find((t) => t.source === source)
|
||||
if (byPrimary) return byPrimary
|
||||
// Legacy customer rows / reopen fallback
|
||||
if (typeof withdrawal !== 'string') {
|
||||
return treasury.find((t) => t.source === `withdrawal:${withdrawal.id}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function withdrawalAlreadyBooked(
|
||||
data: Pick<AppData, 'invoices' | 'treasury'>,
|
||||
withdrawal: ApiWithdrawal | string,
|
||||
): boolean {
|
||||
if (findTreasuryForWithdrawal(data.treasury, withdrawal)) return true
|
||||
const id = typeof withdrawal === 'string' ? withdrawal : withdrawal.id
|
||||
const tag = withdrawalNoteTag(id)
|
||||
return data.invoices.some(
|
||||
(inv) =>
|
||||
inv.note.includes(tag) &&
|
||||
inv.status !== 'draft' &&
|
||||
(isWithdrawalReturn(inv) || inv.documentType === 'return'),
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a treasury پرداخت for a completed customer or owner withdrawal. */
|
||||
export function buildWithdrawalTreasuryPayment(
|
||||
withdrawal: ApiWithdrawal,
|
||||
partyId: string,
|
||||
data: Pick<AppData, 'treasury'>,
|
||||
existing?: TreasuryTxn,
|
||||
): TreasuryTxn {
|
||||
const isOwner = withdrawal.user_type === 'owner'
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
number: existing?.number ?? nextNumber(data.treasury),
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: withdrawal.created_at.slice(0, 10),
|
||||
partyId,
|
||||
amount: withdrawal.amount,
|
||||
taxAmount: 0,
|
||||
platformProfitAmount: 0,
|
||||
ownerNetAmount: isOwner ? withdrawal.amount : 0,
|
||||
reference: '',
|
||||
description: [
|
||||
isOwner
|
||||
? `برداشت مالک به بانک — ${withdrawal.user_name}`
|
||||
: `برداشت کیف پول مشتری — ${withdrawal.user_name}`,
|
||||
withdrawal.username ? `@${withdrawal.username}` : '',
|
||||
withdrawal.iban ? `شبا: ${withdrawal.iban}` : '',
|
||||
'منبع: فانزون',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
eventName: isOwner ? '' : 'wallet',
|
||||
source: treasurySourceForWithdrawal(withdrawal),
|
||||
}
|
||||
}
|
||||
|
||||
export function legacyWithdrawalReturnInvoices(
|
||||
invoices: Invoice[],
|
||||
withdrawalId: string,
|
||||
): Invoice[] {
|
||||
const tag = withdrawalNoteTag(withdrawalId)
|
||||
return invoices.filter(
|
||||
(inv) => isWithdrawalReturn(inv) && inv.note.includes(tag) && inv.status !== 'draft',
|
||||
)
|
||||
}
|
||||
|
||||
export function applyWithdrawalCompletedEffects(
|
||||
withdrawal: ApiWithdrawal,
|
||||
partyId: string,
|
||||
data: AppData,
|
||||
): {
|
||||
treasury: TreasuryTxn
|
||||
voucher: Voucher | null
|
||||
removeTreasuryId?: string
|
||||
removeVoucherIds: string[]
|
||||
removeInvoiceIds: string[]
|
||||
} {
|
||||
const existingTreasury = findTreasuryForWithdrawal(data.treasury, withdrawal)
|
||||
const treasury = {
|
||||
...buildWithdrawalTreasuryPayment(withdrawal, partyId, data, existingTreasury),
|
||||
id: existingTreasury?.id || createId('tr-'),
|
||||
}
|
||||
|
||||
const existingVoucher =
|
||||
data.vouchers.find((v) => v.source === voucherSourceForTreasury(treasury)) ||
|
||||
data.vouchers.find((v) => v.source === voucherSourceForTreasury(treasury.id))
|
||||
const voucher = buildTreasuryVoucher(treasury, data)
|
||||
const voucherToSave =
|
||||
voucher && existingVoucher ? { ...voucher, id: existingVoucher.id } : voucher
|
||||
|
||||
const removeVoucherIds: string[] = []
|
||||
const removeInvoiceIds: string[] = []
|
||||
|
||||
for (const inv of legacyWithdrawalReturnInvoices(data.invoices, withdrawal.id)) {
|
||||
removeInvoiceIds.push(inv.id)
|
||||
const legacyVoucher = findVoucherForInvoice(data.vouchers, inv)
|
||||
if (legacyVoucher) removeVoucherIds.push(legacyVoucher.id)
|
||||
const bySource = data.vouchers.find((v) => v.source === `withdrawal:${withdrawal.id}`)
|
||||
if (bySource && !removeVoucherIds.includes(bySource.id)) removeVoucherIds.push(bySource.id)
|
||||
}
|
||||
|
||||
return {
|
||||
treasury,
|
||||
voucher: voucherToSave,
|
||||
removeTreasuryId: undefined,
|
||||
removeVoucherIds,
|
||||
removeInvoiceIds,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyWithdrawalReopenedEffects(
|
||||
withdrawal: ApiWithdrawal | string,
|
||||
data: AppData,
|
||||
): {
|
||||
removeTreasuryId?: string
|
||||
removeVoucherIds: string[]
|
||||
} {
|
||||
const treasury = findTreasuryForWithdrawal(data.treasury, withdrawal)
|
||||
const removeVoucherIds: string[] = []
|
||||
if (treasury) {
|
||||
const v =
|
||||
data.vouchers.find((x) => x.source === voucherSourceForTreasury(treasury)) ||
|
||||
data.vouchers.find((x) => x.source === voucherSourceForTreasury(treasury.id))
|
||||
if (v) removeVoucherIds.push(v.id)
|
||||
}
|
||||
const id = typeof withdrawal === 'string' ? withdrawal : withdrawal.id
|
||||
const byWithdrawal = data.vouchers.find((v) => v.source === `withdrawal:${id}`)
|
||||
if (byWithdrawal && !removeVoucherIds.includes(byWithdrawal.id)) {
|
||||
removeVoucherIds.push(byWithdrawal.id)
|
||||
}
|
||||
const byOwner = data.vouchers.find((v) => v.source === treasurySourceOwnerTxn(id))
|
||||
if (byOwner && !removeVoucherIds.includes(byOwner.id)) {
|
||||
removeVoucherIds.push(byOwner.id)
|
||||
}
|
||||
return { removeTreasuryId: treasury?.id, removeVoucherIds }
|
||||
}
|
||||
|
||||
export function withdrawalIdFromInvoiceNote(note: string): string | null {
|
||||
return withdrawalIdFromNote(note)
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
buildFunZoneTreasurySync,
|
||||
buildTreasuryFromCustomerPayment,
|
||||
buildTreasuryFromOwnerTransaction,
|
||||
isMisplacedTicketTreasuryTxn,
|
||||
} from './funzoneTreasurySync'
|
||||
import { buildTreasuryVoucher } from '../sales/salesUtils'
|
||||
|
||||
// Fee split matches admin panel (10% tax + 14% profit)
|
||||
{
|
||||
const split = splitEventPayment(100_000)
|
||||
assert.equal(split.tax, 10_000)
|
||||
@@ -47,7 +48,37 @@ const owner: Party = {
|
||||
externalSource: 'funzone_owner',
|
||||
}
|
||||
|
||||
// Event payment → receipt with fee split
|
||||
const accounts = [
|
||||
{ id: 'a1002', code: '1002', name: 'Bank', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a1004', code: '1004', name: 'AR', type: 'asset' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2005', code: '2005', name: 'Owner', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a2006', code: '2006', name: 'Wallet', type: 'liability' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
{ id: 'a4001', code: '4001', name: 'Income', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||
]
|
||||
|
||||
const voucherData = {
|
||||
accounts,
|
||||
vouchers: [],
|
||||
parties: [customer, owner],
|
||||
settings: {
|
||||
name: 'T',
|
||||
economicCode: '',
|
||||
fiscalYear: '1404',
|
||||
baseCurrency: 'تومان',
|
||||
taxRate: 0,
|
||||
address: '',
|
||||
phone: '',
|
||||
voucherAccounts: {
|
||||
bankAccountId: 'a1002',
|
||||
ownerPayableAccountId: 'a2005',
|
||||
customerPayableAccountId: 'a2006',
|
||||
ownerShareAccountId: 'a4001',
|
||||
salesIncomeAccountId: 'a4001',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Ticket sale → NOT treasury
|
||||
{
|
||||
const item: ApiPaymentOrRefund = {
|
||||
id: 'pay-1',
|
||||
@@ -64,14 +95,119 @@ const owner: Party = {
|
||||
reservation_id: 'res-1',
|
||||
refund_status: null,
|
||||
}
|
||||
const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
|
||||
assert.equal(txn.kind, 'receipt')
|
||||
assert.equal(txn.platformProfitAmount, 14_000)
|
||||
assert.equal(txn.taxAmount, 10_000)
|
||||
assert.equal(txn.ownerNetAmount, 76_000)
|
||||
assert.equal(buildTreasuryFromCustomerPayment(item, 'c1', 1), null)
|
||||
}
|
||||
|
||||
// Owner deposit & booking_payment excluded from sync
|
||||
// Booking refund → NOT treasury (wallet credit, not bank out)
|
||||
{
|
||||
const item: ApiPaymentOrRefund = {
|
||||
id: 'ref-1',
|
||||
type: 'cancellation',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: '',
|
||||
ref_num: '',
|
||||
amount: 100_000,
|
||||
event_name: 'Football Night',
|
||||
event_date: null,
|
||||
event_time: null,
|
||||
created_at: '2026-01-02T10:00:00Z',
|
||||
reservation_id: 'res-1',
|
||||
refund_status: 'completed',
|
||||
}
|
||||
assert.equal(buildTreasuryFromCustomerPayment(item, 'c1', 1), null)
|
||||
}
|
||||
|
||||
// Wallet top-up → دریافت without tax/14% + correct voucher
|
||||
{
|
||||
const item: ApiPaymentOrRefund = {
|
||||
id: 'pay-w',
|
||||
type: 'payment',
|
||||
customer_id: 'cust-1',
|
||||
customer_name: 'Ali',
|
||||
trace_no: 'TW',
|
||||
ref_num: 'RW',
|
||||
amount: 50_000,
|
||||
event_name: 'wallet',
|
||||
event_date: null,
|
||||
event_time: null,
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
reservation_id: null,
|
||||
refund_status: null,
|
||||
}
|
||||
const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
|
||||
assert.equal(txn.kind, 'receipt')
|
||||
assert.equal(txn.taxAmount, 0)
|
||||
assert.equal(txn.platformProfitAmount, 0)
|
||||
assert.equal(txn.ownerNetAmount, 0)
|
||||
assert.equal(txn.source, 'funzone:wallet-pay:pay-w')
|
||||
assert.ok(txn.description.includes('شارژ کیف پول'))
|
||||
|
||||
const voucher = buildTreasuryVoucher({ ...txn, id: 'tr-w' }, voucherData)
|
||||
assert.ok(voucher)
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a1002' && l.debit === 50_000))
|
||||
assert.ok(voucher!.lines.some((l) => l.accountId === 'a2006' && l.credit === 50_000))
|
||||
}
|
||||
|
||||
// Misplaced detection
|
||||
{
|
||||
assert.equal(
|
||||
isMisplacedTicketTreasuryTxn({
|
||||
id: 'tr1',
|
||||
number: 1,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-01',
|
||||
partyId: 'c1',
|
||||
amount: 100_000,
|
||||
taxAmount: 10_000,
|
||||
platformProfitAmount: 14_000,
|
||||
ownerNetAmount: 76_000,
|
||||
reference: '',
|
||||
description: '',
|
||||
eventName: 'Football Night',
|
||||
source: 'funzone:cust-pay:pay-1',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
isMisplacedTicketTreasuryTxn({
|
||||
id: 'tr2',
|
||||
number: 2,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-01',
|
||||
partyId: 'c1',
|
||||
amount: 50_000,
|
||||
taxAmount: 0,
|
||||
platformProfitAmount: 0,
|
||||
ownerNetAmount: 0,
|
||||
reference: '',
|
||||
description: '',
|
||||
eventName: 'wallet',
|
||||
source: 'funzone:wallet-pay:pay-w',
|
||||
}),
|
||||
false,
|
||||
)
|
||||
assert.equal(
|
||||
isMisplacedTicketTreasuryTxn({
|
||||
id: 'tr3',
|
||||
number: 3,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-01',
|
||||
partyId: 'c1',
|
||||
amount: 100_000,
|
||||
reference: '',
|
||||
description: 'old refund',
|
||||
eventName: 'Football Night',
|
||||
source: 'funzone:cust-pay:ref-1',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// Owner sync + cleanup
|
||||
{
|
||||
const ownerTxns: ApiOwnerTransaction[] = [
|
||||
{
|
||||
@@ -106,9 +242,26 @@ const owner: Party = {
|
||||
},
|
||||
]
|
||||
|
||||
const stale: TreasuryTxn = {
|
||||
id: 'tr-stale',
|
||||
const staleTicket: TreasuryTxn = {
|
||||
id: 'tr-ticket',
|
||||
number: 1,
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
partyId: 'c1',
|
||||
amount: 100_000,
|
||||
taxAmount: 10_000,
|
||||
platformProfitAmount: 14_000,
|
||||
ownerNetAmount: 76_000,
|
||||
reference: '',
|
||||
description: 'old ticket',
|
||||
eventName: 'Football Night',
|
||||
source: 'funzone:cust-pay:old-ticket',
|
||||
}
|
||||
|
||||
const staleBooking: TreasuryTxn = {
|
||||
id: 'tr-stale',
|
||||
number: 2,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-02',
|
||||
@@ -120,19 +273,55 @@ const owner: Party = {
|
||||
}
|
||||
|
||||
const result = buildFunZoneTreasurySync(
|
||||
{ parties: [customer, owner], treasury: [stale] },
|
||||
{ parties: [customer, owner], treasury: [staleTicket, staleBooking] },
|
||||
[],
|
||||
ownerTxns,
|
||||
)
|
||||
|
||||
assert.equal(result.excludedOwnerTxns, 2)
|
||||
assert.equal(result.toRemove.length, 1)
|
||||
assert.equal(result.toRemove[0].id, 'tr-stale')
|
||||
assert.equal(result.toRemove.length, 2)
|
||||
assert.ok(result.toRemove.some((t) => t.id === 'tr-ticket'))
|
||||
assert.ok(result.toRemove.some((t) => t.id === 'tr-stale'))
|
||||
assert.equal(result.toUpsert.length, 1)
|
||||
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
|
||||
}
|
||||
|
||||
assert.equal(buildTreasuryFromOwnerTransaction(
|
||||
// Pending owner withdraw must NOT enter خزانه
|
||||
{
|
||||
const pendingWithdraw: ApiOwnerTransaction = {
|
||||
id: 'ot-pending',
|
||||
owner_id: 'own-1',
|
||||
type: 'withdraw',
|
||||
amount: 10_000,
|
||||
status: 'pending',
|
||||
description: 'pending',
|
||||
created_at: '2026-01-04T10:00:00Z',
|
||||
updated_at: '2026-01-04T10:00:00Z',
|
||||
}
|
||||
const premature: TreasuryTxn = {
|
||||
id: 'tr-pending',
|
||||
number: 9,
|
||||
kind: 'payment',
|
||||
method: 'bank',
|
||||
date: '2026-01-04',
|
||||
partyId: 'o1',
|
||||
amount: 10_000,
|
||||
reference: '',
|
||||
description: 'premature',
|
||||
source: 'funzone:owner-txn:ot-pending',
|
||||
}
|
||||
const result = buildFunZoneTreasurySync(
|
||||
{ parties: [customer, owner], treasury: [premature] },
|
||||
[],
|
||||
[pendingWithdraw],
|
||||
)
|
||||
assert.equal(result.toUpsert.length, 0)
|
||||
assert.equal(result.toRemove.length, 1)
|
||||
assert.equal(result.toRemove[0].id, 'tr-pending')
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
buildTreasuryFromOwnerTransaction(
|
||||
{
|
||||
id: 'x',
|
||||
owner_id: 'own-1',
|
||||
@@ -146,6 +335,8 @@ assert.equal(buildTreasuryFromOwnerTransaction(
|
||||
'o1',
|
||||
'Owner',
|
||||
1,
|
||||
), null)
|
||||
),
|
||||
null,
|
||||
)
|
||||
|
||||
console.log('funzoneTreasurySync tests passed.')
|
||||
|
||||
@@ -2,12 +2,11 @@ import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
||||
import type { AppData, TreasuryTxn } from '../../types'
|
||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||
import { createId, nextNumber } from '../../utils/id'
|
||||
import { formatEventName } from '../../utils/eventName'
|
||||
import {
|
||||
isEventCustomerPayment,
|
||||
isWalletCustomerPayment,
|
||||
ownerTxnExcludedFromTreasury,
|
||||
ownerTxnIncludedInTreasury,
|
||||
splitEventPayment,
|
||||
} from '../../utils/funzoneFees'
|
||||
|
||||
const ownerTypeLabels: Record<string, string> = {
|
||||
@@ -39,10 +38,6 @@ function isoDateFromApi(iso: string): string {
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
|
||||
function eventLabel(eventName: string | null | undefined): string {
|
||||
return formatEventName(eventName)
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'completed') return 'انجامشده'
|
||||
if (status === 'pending') return 'در انتظار'
|
||||
@@ -50,7 +45,11 @@ function statusLabel(status: string): string {
|
||||
return status
|
||||
}
|
||||
|
||||
/** Customer platform payment → دریافت; cancellation/refund → پرداخت. */
|
||||
/**
|
||||
* Wallet top-up → دریافت (no tax/14%).
|
||||
* Ticket sales → NOT treasury (فاکتور فروش).
|
||||
* Booking refunds → NOT treasury (credit customer wallet; IBAN withdraw is in فروش).
|
||||
*/
|
||||
export function buildTreasuryFromCustomerPayment(
|
||||
item: ApiPaymentOrRefund,
|
||||
partyId: string,
|
||||
@@ -61,56 +60,58 @@ 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
|
||||
// Event ticket payments belong on sales invoices (tax + 14% + owner), not دریافت.
|
||||
if (isPayment && isEventCustomerPayment(item.event_name)) return null
|
||||
// Cancellation/refunds credit the customer wallet — not a bank payout.
|
||||
if (!isPayment) return null
|
||||
|
||||
const isWallet = isWalletCustomerPayment(item.event_name)
|
||||
// Only wallet top-ups become دریافت. Unknown non-wallet payments are ignored.
|
||||
if (!isWallet) return null
|
||||
|
||||
const lines = [
|
||||
isPayment
|
||||
? fromWallet
|
||||
? `پرداخت از کیف پول — ${item.customer_name}`
|
||||
: `دریافت از مشتری — ${item.customer_name}`
|
||||
: `بازپرداخت به مشتری — ${item.customer_name}`,
|
||||
`نوع: ${isPayment ? (fromWallet ? 'پرداخت کیف پول' : 'پرداخت مشتری') : 'لغو/بازپرداخت'}`,
|
||||
`رویداد: ${eventLabel(item.event_name)}`,
|
||||
split
|
||||
? [
|
||||
`مبلغ کل: ${split.gross}`,
|
||||
`مالیات (۱۰٪): ${split.tax}`,
|
||||
`سود پلتفرم (۱۴٪): ${split.platformProfit}`,
|
||||
`سهم مالک (خالص): ${split.ownerNet}`,
|
||||
].join('\n')
|
||||
: null,
|
||||
item.event_date ? `تاریخ رویداد: ${item.event_date}${item.event_time ? ` ${item.event_time}` : ''}` : null,
|
||||
item.reservation_id ? `شناسه رزرو: ${item.reservation_id}` : null,
|
||||
`دریافت از مشتری (شارژ کیف پول) — ${item.customer_name}`,
|
||||
'نوع: شارژ کیف پول',
|
||||
`مبلغ: ${amount}`,
|
||||
item.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
|
||||
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
|
||||
item.refund_status ? `وضعیت بازپرداخت: ${statusLabel(item.refund_status)}` : null,
|
||||
'منبع: فانزون',
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
id: existing?.id ?? '',
|
||||
number: existing?.number ?? number,
|
||||
kind,
|
||||
method: fromWallet ? 'cash' : 'bank',
|
||||
kind: 'receipt',
|
||||
method: 'bank',
|
||||
date: isoDateFromApi(item.created_at),
|
||||
partyId,
|
||||
amount,
|
||||
taxAmount: split?.tax ?? 0,
|
||||
platformProfitAmount: split?.platformProfit ?? 0,
|
||||
ownerNetAmount: split?.ownerNet ?? 0,
|
||||
taxAmount: 0,
|
||||
platformProfitAmount: 0,
|
||||
ownerNetAmount: 0,
|
||||
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||
description: lines.join('\n'),
|
||||
eventName: item.event_name || undefined,
|
||||
source:
|
||||
isPayment && fromWallet
|
||||
? treasurySourceWalletPayment(item.id)
|
||||
: treasurySourceCustomerPayment(item.id),
|
||||
eventName: 'wallet',
|
||||
source: treasurySourceWalletPayment(item.id),
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy rows that no longer belong in خزانه after ticket/wallet accounting split. */
|
||||
export function isMisplacedTicketTreasuryTxn(txn: TreasuryTxn): boolean {
|
||||
const source = txn.source ?? ''
|
||||
if (!source.startsWith('funzone:cust-pay:') && !source.startsWith('funzone:wallet-pay:')) {
|
||||
return false
|
||||
}
|
||||
// Wallet top-ups stay in treasury (eventName may be missing after reload — trust source).
|
||||
if (source.startsWith('funzone:wallet-pay:') && txn.kind === 'receipt') return false
|
||||
if (isWalletCustomerPayment(txn.eventName) && txn.kind === 'receipt') return false
|
||||
// Old ticket receipts (with or without fee split)
|
||||
if (txn.kind === 'receipt') return true
|
||||
// Old booking-refund "پرداخت" rows (wallet credit, not bank out)
|
||||
if (txn.kind === 'payment' && source.startsWith('funzone:cust-pay:')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Owner bank payout or refund only (excludes deposit & booking_payment). */
|
||||
export function buildTreasuryFromOwnerTransaction(
|
||||
txn: ApiOwnerTransaction,
|
||||
@@ -174,23 +175,29 @@ export function buildFunZoneTreasurySync(
|
||||
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
|
||||
.map((t) => treasurySourceOwnerTxn(t.id)),
|
||||
)
|
||||
const toRemove = data.treasury.filter((t) => t.source && staleSources.has(t.source))
|
||||
const toRemoveMap = new Map<string, TreasuryTxn>()
|
||||
for (const txn of data.treasury) {
|
||||
if (txn.source && staleSources.has(txn.source)) toRemoveMap.set(txn.id, txn)
|
||||
if (isMisplacedTicketTreasuryTxn(txn)) toRemoveMap.set(txn.id, txn)
|
||||
}
|
||||
|
||||
let receiptNumber = nextNumber(data.treasury.filter((t) => t.kind === 'receipt'))
|
||||
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
|
||||
|
||||
for (const item of customerPayments) {
|
||||
// Only wallet top-ups — skip tickets/refunds before party lookup.
|
||||
if (item.type !== 'payment' || !isWalletCustomerPayment(item.event_name)) continue
|
||||
|
||||
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||
if (!party?.id) {
|
||||
unmatchedCustomers += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const source =
|
||||
item.type === 'payment' && item.payment_method === 'wallet'
|
||||
? treasurySourceWalletPayment(item.id)
|
||||
: treasurySourceCustomerPayment(item.id)
|
||||
const existing = findTreasuryBySource(data.treasury, source)
|
||||
const source = treasurySourceWalletPayment(item.id)
|
||||
const existing =
|
||||
findTreasuryBySource(data.treasury, source) ??
|
||||
findTreasuryBySource(data.treasury, treasurySourceCustomerPayment(item.id))
|
||||
const built = buildTreasuryFromCustomerPayment(
|
||||
item,
|
||||
party.id,
|
||||
@@ -205,12 +212,24 @@ export function buildFunZoneTreasurySync(
|
||||
}
|
||||
|
||||
toUpsert.push(built)
|
||||
if (!existing && built.kind === 'receipt') receiptNumber += 1
|
||||
if (!existing && built.kind === 'payment') paymentNumber += 1
|
||||
if (!existing) receiptNumber += 1
|
||||
}
|
||||
|
||||
// Pending/failed owner payouts previously imported — remove until completed.
|
||||
for (const txn of data.treasury) {
|
||||
if (!txn.source?.startsWith('funzone:owner-txn:')) continue
|
||||
const ownerTxnId = txn.source.slice('funzone:owner-txn:'.length)
|
||||
const ownerTxn = ownerTransactions.find((t) => t.id === ownerTxnId)
|
||||
if (ownerTxn && ownerTxn.status !== 'completed') {
|
||||
toRemoveMap.set(txn.id, txn)
|
||||
}
|
||||
}
|
||||
|
||||
const toRemove = Array.from(toRemoveMap.values())
|
||||
|
||||
for (const txn of ownerTransactions) {
|
||||
if (txn.status === 'failed') continue
|
||||
// Only completed bank payouts — pending must not hit خزانه yet.
|
||||
if (txn.status !== 'completed') continue
|
||||
|
||||
if (ownerTxnExcludedFromTreasury(txn.type)) {
|
||||
excludedOwnerTxns += 1
|
||||
|
||||
@@ -22,8 +22,20 @@ export interface FunZoneTreasurySyncStats {
|
||||
ownerTxnCount: number
|
||||
}
|
||||
|
||||
export interface FunZoneTreasurySyncInput {
|
||||
customerPayments?: ApiPaymentOrRefund[]
|
||||
ownerTransactions?: ApiOwnerTransaction[]
|
||||
}
|
||||
|
||||
export function useFunZoneTreasurySync() {
|
||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||
const {
|
||||
data,
|
||||
upsertTreasuryAsync,
|
||||
removeTreasuryAsync,
|
||||
upsertVoucherAsync,
|
||||
removeVoucherAsync,
|
||||
upsertInvoiceAsync,
|
||||
} = useStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
||||
@@ -32,7 +44,7 @@ export function useFunZoneTreasurySync() {
|
||||
dataRef.current = data
|
||||
|
||||
const persistTreasuryBatch = useCallback(
|
||||
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
||||
async (baseData: AppData, items: TreasuryTxn[]): Promise<FunZoneTreasurySyncStats> => {
|
||||
const saved = assignTreasuryIds(items)
|
||||
let created = 0
|
||||
let updated = 0
|
||||
@@ -43,27 +55,38 @@ export function useFunZoneTreasurySync() {
|
||||
if (isNew) created += 1
|
||||
else updated += 1
|
||||
|
||||
upsertTreasury(txn)
|
||||
const persisted = await upsertTreasuryAsync({
|
||||
...txn,
|
||||
eventName: txn.eventName || (txn.source?.includes('wallet') ? 'wallet' : ''),
|
||||
})
|
||||
|
||||
const existingVoucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
|
||||
const voucher = buildTreasuryVoucher(txn, working)
|
||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||
if (voucher) upsertVoucher(voucher)
|
||||
const voucherKey = voucherSourceForTreasury(persisted)
|
||||
const existingVoucher = working.vouchers.find((v) => v.source === voucherKey)
|
||||
const builtVoucher = buildTreasuryVoucher(persisted, working)
|
||||
const voucher =
|
||||
builtVoucher && existingVoucher
|
||||
? { ...builtVoucher, id: existingVoucher.id }
|
||||
: builtVoucher
|
||||
if (!voucher && existingVoucher) await removeVoucherAsync(existingVoucher.id)
|
||||
if (voucher) await upsertVoucherAsync(voucher)
|
||||
|
||||
working = {
|
||||
...working,
|
||||
treasury: [...working.treasury.filter((t) => t.id !== txn.id && t.source !== txn.source), txn],
|
||||
treasury: [
|
||||
...working.treasury.filter((t) => t.id !== persisted.id && t.source !== persisted.source),
|
||||
persisted,
|
||||
],
|
||||
vouchers: [
|
||||
...working.vouchers.filter((v) => v.id !== existingVoucher?.id),
|
||||
...working.vouchers.filter((v) => v.id !== existingVoucher?.id && v.source !== voucherKey),
|
||||
...(voucher ? [voucher] : []),
|
||||
],
|
||||
}
|
||||
|
||||
if (txn.partyId) {
|
||||
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
||||
if (persisted.partyId) {
|
||||
for (const inv of reconcileInvoicePaymentStatus(persisted.partyId, working)) {
|
||||
const current = working.invoices.find((i) => i.id === inv.id)
|
||||
if (current?.status === inv.status) continue
|
||||
upsertInvoice(inv)
|
||||
await upsertInvoiceAsync(inv)
|
||||
working = {
|
||||
...working,
|
||||
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
|
||||
@@ -84,10 +107,10 @@ export function useFunZoneTreasurySync() {
|
||||
ownerTxnCount: 0,
|
||||
}
|
||||
},
|
||||
[upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice],
|
||||
[upsertTreasuryAsync, removeTreasuryAsync, upsertVoucherAsync, removeVoucherAsync, upsertInvoiceAsync],
|
||||
)
|
||||
|
||||
const sync = useCallback(async (): Promise<FunZoneTreasurySyncStats | null> => {
|
||||
const sync = useCallback(async (input?: FunZoneTreasurySyncInput): Promise<FunZoneTreasurySyncStats | null> => {
|
||||
if (syncingRef.current) return null
|
||||
syncingRef.current = true
|
||||
setLoading(true)
|
||||
@@ -95,10 +118,14 @@ export function useFunZoneTreasurySync() {
|
||||
try {
|
||||
const current = dataRef.current
|
||||
const [customerPayments, ownerTransactions] = await Promise.all([
|
||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||
input?.customerPayments
|
||||
? Promise.resolve(input.customerPayments)
|
||||
: apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||
(res) => res.payments_and_refunds ?? [],
|
||||
),
|
||||
apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||
input?.ownerTransactions
|
||||
? Promise.resolve(input.ownerTransactions)
|
||||
: apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||
])
|
||||
|
||||
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
||||
@@ -106,9 +133,10 @@ export function useFunZoneTreasurySync() {
|
||||
|
||||
let working = { ...current }
|
||||
for (const stale of toRemove) {
|
||||
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
||||
if (voucher) removeVoucher(voucher.id)
|
||||
removeTreasury(stale.id)
|
||||
const voucherKey = voucherSourceForTreasury(stale)
|
||||
const voucher = working.vouchers.find((v) => v.source === voucherKey)
|
||||
if (voucher) await removeVoucherAsync(voucher.id)
|
||||
await removeTreasuryAsync(stale.id)
|
||||
working = {
|
||||
...working,
|
||||
treasury: working.treasury.filter((t) => t.id !== stale.id),
|
||||
@@ -116,7 +144,7 @@ export function useFunZoneTreasurySync() {
|
||||
}
|
||||
}
|
||||
|
||||
const batchStats = persistTreasuryBatch(working, toUpsert)
|
||||
const batchStats = await persistTreasuryBatch(working, toUpsert)
|
||||
|
||||
setStats({
|
||||
...batchStats,
|
||||
@@ -126,7 +154,7 @@ export function useFunZoneTreasurySync() {
|
||||
unmatchedOwners,
|
||||
excludedOwnerTxns,
|
||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
||||
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
||||
ownerTxnCount: ownerTransactions.filter((t) => t.status === 'completed' && t.type === 'withdraw').length,
|
||||
})
|
||||
return {
|
||||
...batchStats,
|
||||
@@ -136,7 +164,7 @@ export function useFunZoneTreasurySync() {
|
||||
unmatchedOwners,
|
||||
excludedOwnerTxns,
|
||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
||||
ownerTxnCount: ownerTransactions.filter((t) => t.status !== 'failed' && t.type === 'withdraw').length,
|
||||
ownerTxnCount: ownerTransactions.filter((t) => t.status === 'completed' && t.type === 'withdraw').length,
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
||||
|
||||
@@ -94,22 +94,28 @@ interface AppStoreValue {
|
||||
upsertAccount: (item: Account) => void
|
||||
removeAccount: (id: string) => void
|
||||
upsertVoucher: (item: Voucher) => void
|
||||
upsertVoucherAsync: (item: Voucher) => Promise<Voucher>
|
||||
removeVoucher: (id: string) => void
|
||||
removeVoucherAsync: (id: string) => Promise<void>
|
||||
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
|
||||
createVouchers: (items: Voucher[]) => Promise<number>
|
||||
upsertParty: (item: Party) => void
|
||||
upsertPartyAsync: (item: Party) => Promise<void>
|
||||
upsertPartyAsync: (item: Party) => Promise<Party>
|
||||
removeParty: (id: string) => void
|
||||
upsertProduct: (item: Product) => void
|
||||
removeProduct: (id: string) => void
|
||||
upsertInvoice: (item: Invoice) => void
|
||||
upsertInvoiceAsync: (item: Invoice) => Promise<Invoice>
|
||||
removeInvoice: (id: string) => void
|
||||
removeInvoiceAsync: (id: string) => Promise<void>
|
||||
upsertSalesType: (item: SalesType) => void
|
||||
removeSalesType: (id: string) => void
|
||||
upsertPartyAdjustment: (item: PartyAdjustment) => void
|
||||
removePartyAdjustment: (id: string) => void
|
||||
upsertTreasury: (item: TreasuryTxn) => void
|
||||
upsertTreasuryAsync: (item: TreasuryTxn) => Promise<TreasuryTxn>
|
||||
removeTreasury: (id: string) => void
|
||||
removeTreasuryAsync: (id: string) => Promise<void>
|
||||
upsertEmployee: (item: Employee) => void
|
||||
removeEmployee: (id: string) => void
|
||||
upsertPayslip: (item: Payslip) => void
|
||||
@@ -147,6 +153,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const dataRef = useRef(data)
|
||||
const inFlightLoadRef = useRef<Promise<AppData | null> | null>(null)
|
||||
useEffect(() => {
|
||||
dataRef.current = data
|
||||
}, [data])
|
||||
@@ -164,24 +171,45 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const load = useCallback(async (): Promise<AppData | null> => {
|
||||
if (inFlightLoadRef.current) return inFlightLoadRef.current
|
||||
|
||||
const request = (async (): Promise<AppData | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings] =
|
||||
await Promise.all([
|
||||
// Keep load stable under production traffic by avoiding one large request burst.
|
||||
const [accounts, vouchers, parties] = await Promise.all([
|
||||
acct.get(ENDPOINT.accounts).then(asList<Account>),
|
||||
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
|
||||
acct.get(ENDPOINT.parties).then(asList<Party>),
|
||||
])
|
||||
const [products, invoices, salesTypes] = await Promise.all([
|
||||
acct.get(ENDPOINT.products).then(asList<Product>),
|
||||
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
|
||||
acct.get(ENDPOINT.salesTypes).then(asList<SalesType>),
|
||||
])
|
||||
const [partyAdjustments, treasury, employees] = await Promise.all([
|
||||
acct.get(ENDPOINT.partyAdjustments).then(asList<PartyAdjustment>),
|
||||
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
|
||||
acct.get(ENDPOINT.employees).then(asList<Employee>),
|
||||
])
|
||||
const [payslips, settings] = await Promise.all([
|
||||
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
||||
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
||||
])
|
||||
const loaded = { 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
|
||||
@@ -192,6 +220,14 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightLoadRef.current = request
|
||||
try {
|
||||
return await request
|
||||
} finally {
|
||||
inFlightLoadRef.current = null
|
||||
}
|
||||
}, [handleApiError])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,7 +241,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
}, [connected, load])
|
||||
|
||||
const upsert = useCallback(
|
||||
async <K extends CollectionKey>(key: K, item: AppData[K][number]) => {
|
||||
async <K extends CollectionKey>(key: K, item: AppData[K][number]): Promise<AppData[K][number]> => {
|
||||
const entity = item as Identifiable
|
||||
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
|
||||
const payload = serializePayload(key, entity)
|
||||
@@ -218,10 +254,11 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
x.id === entity.id ? (saved as Identifiable) : x,
|
||||
),
|
||||
}))
|
||||
} else {
|
||||
return saved
|
||||
}
|
||||
const saved = await acct.post<AppData[K][number]>(ENDPOINT[key], payload)
|
||||
setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
|
||||
}
|
||||
return saved
|
||||
} catch (err) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
@@ -286,7 +323,9 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
upsertAccount: (item) => void upsert('accounts', item),
|
||||
removeAccount: (id) => void remove('accounts', id),
|
||||
upsertVoucher: (item) => void upsert('vouchers', item),
|
||||
upsertVoucherAsync: (item) => upsert('vouchers', item),
|
||||
removeVoucher: (id) => void remove('vouchers', id),
|
||||
removeVoucherAsync: (id) => remove('vouchers', id),
|
||||
createVouchers,
|
||||
upsertParty: (item) => void upsert('parties', item),
|
||||
upsertPartyAsync: (item) => upsert('parties', item),
|
||||
@@ -294,13 +333,17 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
||||
upsertProduct: (item) => void upsert('products', item),
|
||||
removeProduct: (id) => void remove('products', id),
|
||||
upsertInvoice: (item) => void upsert('invoices', item),
|
||||
upsertInvoiceAsync: (item) => upsert('invoices', item),
|
||||
removeInvoice: (id) => void remove('invoices', id),
|
||||
removeInvoiceAsync: (id) => remove('invoices', id),
|
||||
upsertSalesType: (item) => void upsert('salesTypes', item),
|
||||
removeSalesType: (id) => void remove('salesTypes', id),
|
||||
upsertPartyAdjustment: (item) => void upsert('partyAdjustments', item),
|
||||
removePartyAdjustment: (id) => void remove('partyAdjustments', id),
|
||||
upsertTreasury: (item) => void upsert('treasury', item),
|
||||
upsertTreasuryAsync: (item) => upsert('treasury', item),
|
||||
removeTreasury: (id) => void remove('treasury', id),
|
||||
removeTreasuryAsync: (id) => remove('treasury', id),
|
||||
upsertEmployee: (item) => void upsert('employees', item),
|
||||
removeEmployee: (id) => void remove('employees', id),
|
||||
upsertPayslip: (item) => void upsert('payslips', item),
|
||||
|
||||
@@ -110,7 +110,8 @@ export interface InvoiceLine {
|
||||
}
|
||||
|
||||
export type InvoiceKind = 'sale' | 'purchase'
|
||||
export type InvoiceStatus = 'draft' | 'confirmed' | 'paid'
|
||||
/** `cancelled` = voided sales invoice (ticket/event cancel). Legacy `return` docs may still exist in DB. */
|
||||
export type InvoiceStatus = 'draft' | 'confirmed' | 'paid' | 'cancelled'
|
||||
|
||||
export interface Invoice {
|
||||
id: ID
|
||||
|
||||
@@ -25,6 +25,11 @@ export function isEventCustomerPayment(eventName: string | null | undefined): bo
|
||||
return Boolean(eventName && eventName.toLowerCase() !== 'wallet')
|
||||
}
|
||||
|
||||
/** Customer wallet top-up / wallet withdrawal (not a ticket sale). */
|
||||
export function isWalletCustomerPayment(eventName: string | null | undefined): boolean {
|
||||
return (eventName || '').toLowerCase() === 'wallet'
|
||||
}
|
||||
|
||||
/** Owner wallet credits already reflected in customer receipts — exclude from treasury. */
|
||||
export function ownerTxnExcludedFromTreasury(type: string): boolean {
|
||||
return type === 'deposit' || type === 'booking_payment'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AppData, Voucher } from '../types'
|
||||
import { resolveVoucherRegarding } from './voucherRegarding'
|
||||
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||||
|
||||
export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
|
||||
export type VoucherCategoryFilter = 'all' | 'manual' | 'manager'
|
||||
export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
|
||||
export type VoucherOriginFilter = 'all' | 'funzone' | 'manual'
|
||||
|
||||
@@ -25,51 +25,20 @@ export const defaultVoucherFilters: VoucherFilters = {
|
||||
}
|
||||
|
||||
const categoryLabels: Record<Exclude<VoucherCategoryFilter, 'all'>, string> = {
|
||||
customer: 'مشتریان',
|
||||
supplier: 'تأمینکنندگان',
|
||||
manual: 'دستی',
|
||||
manager: 'مدیر',
|
||||
}
|
||||
|
||||
export function voucherCategoryLabel(category: Exclude<VoucherCategoryFilter, 'all'>): string {
|
||||
return categoryLabels[category]
|
||||
}
|
||||
|
||||
/** Classifies a voucher as customer-related, supplier-related, or manually entered. */
|
||||
/** Classifies vouchers to only two categories: imported (دستی) or manager-created (مدیر). */
|
||||
export function classifyVoucher(
|
||||
voucher: Voucher,
|
||||
data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
|
||||
_data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
|
||||
): Exclude<VoucherCategoryFilter, 'all'> {
|
||||
const src = voucher.source ?? ''
|
||||
|
||||
if (src.startsWith('funzone:cust') || src.startsWith('withdrawal:')) return 'customer'
|
||||
if (src.startsWith('funzone:owner-txn')) return 'supplier'
|
||||
|
||||
if (src.startsWith('invoice:')) {
|
||||
const inv = data.invoices.find((i) => i.id === src.slice('invoice:'.length))
|
||||
if (inv?.kind === 'purchase') return 'supplier'
|
||||
if (inv?.kind === 'sale') return 'customer'
|
||||
}
|
||||
|
||||
if (src.startsWith('adjustment:')) {
|
||||
const adj = data.partyAdjustments.find((a) => a.id === src.slice('adjustment:'.length))
|
||||
const party = adj ? data.parties.find((p) => p.id === adj.partyId) : undefined
|
||||
return party?.kind === 'supplier' ? 'supplier' : 'customer'
|
||||
}
|
||||
|
||||
if (src.startsWith('treasury:')) {
|
||||
const txn = data.treasury.find((t) => t.id === src.slice('treasury:'.length))
|
||||
if (txn) {
|
||||
if (txn.source?.startsWith('funzone:owner-txn')) return 'supplier'
|
||||
if (txn.source?.startsWith('funzone:cust')) return 'customer'
|
||||
if (txn.partyId) {
|
||||
const party = data.parties.find((p) => p.id === txn.partyId)
|
||||
if (party?.kind === 'supplier') return 'supplier'
|
||||
if (party?.kind === 'customer') return 'customer'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'manual'
|
||||
return isAutomatedVoucher(voucher) ? 'manual' : 'manager'
|
||||
}
|
||||
|
||||
export function isAutomatedVoucher(voucher: Voucher): boolean {
|
||||
@@ -126,7 +95,7 @@ export function voucherFilterSummary(
|
||||
vouchers: Voucher[],
|
||||
data: AppData,
|
||||
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
|
||||
const summary = { customer: 0, supplier: 0, manual: 0 }
|
||||
const summary = { manual: 0, manager: 0 }
|
||||
for (const voucher of visibleVouchers(vouchers, data)) {
|
||||
summary[classifyVoucher(voucher, data)] += 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user