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 { useCallback, useRef, useState } from 'react'
|
||||||
import { apiGet } from '../api/client'
|
import { apiGet, asList } from '../api/client'
|
||||||
import { ENDPOINTS } from '../api/config'
|
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 type { AppData, Invoice } from '../types'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync'
|
import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync'
|
||||||
@@ -26,7 +26,14 @@ export interface UnifiedFunZoneSyncStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useUnifiedFunZoneVoucherSync() {
|
export function useUnifiedFunZoneVoucherSync() {
|
||||||
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
|
const {
|
||||||
|
data,
|
||||||
|
upsertInvoiceAsync,
|
||||||
|
upsertVoucherAsync,
|
||||||
|
removeVoucherAsync,
|
||||||
|
removeInvoiceAsync,
|
||||||
|
reload,
|
||||||
|
} = useStore()
|
||||||
const treasury = useFunZoneTreasurySync()
|
const treasury = useFunZoneTreasurySync()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -37,33 +44,50 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
dataRef.current = data
|
dataRef.current = data
|
||||||
|
|
||||||
const applyInvoiceIfChanged = useCallback(
|
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)
|
const previous = working.invoices.find((i) => i.id === invoice.id)
|
||||||
if (previous && invoiceUnchanged(previous, invoice) && previous.status === invoice.status) {
|
if (previous && invoiceUnchanged(previous, invoice) && previous.status === invoice.status) {
|
||||||
return working
|
return working
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build effects against the invoice we intend to save (stable FunZone voucher source).
|
||||||
const effects = invoiceSideEffects(invoice, previous ?? null, working)
|
const effects = invoiceSideEffects(invoice, previous ?? null, working)
|
||||||
upsertInvoice(invoice)
|
const saved = await upsertInvoiceAsync(invoice)
|
||||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
const savedInvoice = { ...invoice, id: saved.id }
|
||||||
if (effects.voucher) upsertVoucher(effects.voucher)
|
|
||||||
|
// 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 = {
|
let next: AppData = {
|
||||||
...working,
|
...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: [
|
vouchers: [
|
||||||
...working.vouchers.filter(
|
...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') {
|
if (savedInvoice.partyId && savedInvoice.status !== 'draft' && savedInvoice.status !== 'cancelled') {
|
||||||
for (const settled of reconcileInvoicePaymentStatus(invoice.partyId, next)) {
|
for (const settled of reconcileInvoicePaymentStatus(savedInvoice.partyId, next)) {
|
||||||
const current = next.invoices.find((i) => i.id === settled.id)
|
const current = next.invoices.find((i) => i.id === settled.id)
|
||||||
if (current?.status === settled.status) continue
|
if (current?.status === settled.status) continue
|
||||||
upsertInvoice(settled)
|
await upsertInvoiceAsync(settled)
|
||||||
next = {
|
next = {
|
||||||
...next,
|
...next,
|
||||||
invoices: next.invoices.map((i) => (i.id === settled.id ? settled : i)),
|
invoices: next.invoices.map((i) => (i.id === settled.id ? settled : i)),
|
||||||
@@ -73,28 +97,43 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
|
|
||||||
return next
|
return next
|
||||||
},
|
},
|
||||||
[upsertInvoice, upsertVoucher, removeVoucher],
|
[upsertInvoiceAsync, upsertVoucherAsync, removeVoucherAsync],
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncSalesInvoices = useCallback(async () => {
|
const syncSalesInvoices = useCallback(async (customerPayments: ApiPaymentOrRefund[]) => {
|
||||||
const current = dataRef.current
|
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 } =
|
const {
|
||||||
buildFunZoneSalesSync(current, customerPayments)
|
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)
|
const saved = assignInvoiceIds(toUpsert)
|
||||||
let created = 0
|
let created = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
let working = { ...current }
|
let working = { ...base }
|
||||||
|
|
||||||
for (const invoice of saved) {
|
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
|
if (isNew) created += 1
|
||||||
else updated += 1
|
else updated += 1
|
||||||
working = applyInvoiceIfChanged(working, invoice)
|
working = await applyInvoiceIfChanged(working, invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -105,7 +144,13 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
funzonePayments: paymentCount,
|
funzonePayments: paymentCount,
|
||||||
funzoneCancellations: cancellationCount,
|
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 () => {
|
const sync = useCallback(async () => {
|
||||||
if (syncingRef.current) return
|
if (syncingRef.current) return
|
||||||
@@ -113,19 +158,27 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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()
|
const freshAfterTreasury = await reload()
|
||||||
if (freshAfterTreasury) dataRef.current = freshAfterTreasury
|
if (freshAfterTreasury) dataRef.current = freshAfterTreasury
|
||||||
|
|
||||||
const salesResult = await syncSalesInvoices()
|
const salesResult = await syncSalesInvoices(customerPayments)
|
||||||
|
|
||||||
const freshAfterSales = await reload()
|
const freshAfterSales = await reload()
|
||||||
const working = freshAfterSales ?? dataRef.current
|
const working = freshAfterSales ?? dataRef.current
|
||||||
const duplicateIds = supersededVoucherIds(working)
|
const duplicateIds = supersededVoucherIds(working)
|
||||||
for (const voucherId of duplicateIds) {
|
const staleLegacyIds = legacyFunZoneVoucherIds(working)
|
||||||
removeVoucher(voucherId)
|
const toDeleteIds = new Set([...duplicateIds, ...staleLegacyIds])
|
||||||
|
for (const voucherId of toDeleteIds) {
|
||||||
|
await removeVoucherAsync(voucherId)
|
||||||
}
|
}
|
||||||
await reload()
|
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
treasuryCreated: treasuryStats?.created ?? 0,
|
treasuryCreated: treasuryStats?.created ?? 0,
|
||||||
@@ -133,7 +186,7 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
treasurySkipped: treasuryStats?.skipped ?? 0,
|
treasurySkipped: treasuryStats?.skipped ?? 0,
|
||||||
treasuryRemoved: treasuryStats?.removed ?? 0,
|
treasuryRemoved: treasuryStats?.removed ?? 0,
|
||||||
...salesResult,
|
...salesResult,
|
||||||
duplicatesRemoved: duplicateIds.length,
|
duplicatesRemoved: toDeleteIds.size,
|
||||||
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
|
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
|
||||||
})
|
})
|
||||||
setLastSyncedAt(new Date().toISOString())
|
setLastSyncedAt(new Date().toISOString())
|
||||||
@@ -143,7 +196,7 @@ export function useUnifiedFunZoneVoucherSync() {
|
|||||||
syncingRef.current = false
|
syncingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [treasury, syncSalesInvoices, reload, removeVoucher])
|
}, [treasury, syncSalesInvoices, reload, removeVoucherAsync, legacyFunZoneVoucherIds])
|
||||||
|
|
||||||
return { sync, loading, error, stats, lastSyncedAt }
|
return { sync, loading, error, stats, lastSyncedAt }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function treasuryVoucherForPaymentSource(
|
|||||||
): Voucher | undefined {
|
): Voucher | undefined {
|
||||||
const txn = data.treasury.find((t) => t.source === paymentSource)
|
const txn = data.treasury.find((t) => t.source === paymentSource)
|
||||||
if (!txn) return undefined
|
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. */
|
/** 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 { useCallback } from 'react'
|
||||||
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const ENDPOINTS = {
|
|||||||
? `/payments/admin/customer-payments-refunds/?customer_id=${customerId}`
|
? `/payments/admin/customer-payments-refunds/?customer_id=${customerId}`
|
||||||
: '/payments/admin/customer-payments-refunds/',
|
: '/payments/admin/customer-payments-refunds/',
|
||||||
WITHDRAWALS: '/payments/admin/withdrawals/',
|
WITHDRAWALS: '/payments/admin/withdrawals/',
|
||||||
|
UPDATE_WITHDRAWAL_STATUS: (withdrawalId: string) =>
|
||||||
|
`/payments/admin/withdrawal-status/${withdrawalId}/`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Endpoints served by the standalone accounting backend (relative to its base URL). */
|
/** Endpoints served by the standalone accounting backend (relative to its base URL). */
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ const statusLabels: Record<InvoiceStatus, string> = {
|
|||||||
draft: 'پیشنویس',
|
draft: 'پیشنویس',
|
||||||
confirmed: 'قطعی',
|
confirmed: 'قطعی',
|
||||||
paid: 'تسویهشده',
|
paid: 'تسویهشده',
|
||||||
|
cancelled: 'لغو شده',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green'> = {
|
const statusTones: Record<InvoiceStatus, 'amber' | 'blue' | 'green' | 'rose'> = {
|
||||||
draft: 'amber',
|
draft: 'amber',
|
||||||
confirmed: 'blue',
|
confirmed: 'blue',
|
||||||
paid: 'green',
|
paid: 'green',
|
||||||
|
cancelled: 'rose',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TradeModule({ config }: { config: TradeConfig }) {
|
export function TradeModule({ config }: { config: TradeConfig }) {
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ interface MonthBucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSaleInvoice(invoice: ReturnType<typeof useStore>['data']['invoices'][number]): boolean {
|
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(
|
function monthlyBuckets(
|
||||||
|
|||||||
@@ -1,664 +1,432 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
Badge,
|
Badge,
|
||||||
|
|
||||||
Button,
|
Button,
|
||||||
|
|
||||||
Card,
|
Card,
|
||||||
|
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|
||||||
Field,
|
Field,
|
||||||
|
|
||||||
Input,
|
Input,
|
||||||
|
|
||||||
JalaliDateInput,
|
JalaliDateInput,
|
||||||
|
|
||||||
Modal,
|
Modal,
|
||||||
|
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
|
||||||
Select,
|
Select,
|
||||||
|
|
||||||
StatCard,
|
StatCard,
|
||||||
|
|
||||||
Textarea,
|
Textarea,
|
||||||
|
|
||||||
type Column,
|
type Column,
|
||||||
|
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
|
|
||||||
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
|
import type { TreasuryKind, TreasuryMethod, TreasuryTxn } from '../types'
|
||||||
|
|
||||||
import { createId, nextNumber } from '../utils/id'
|
import { createId, nextNumber } from '../utils/id'
|
||||||
|
|
||||||
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
||||||
|
import {
|
||||||
import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, suppliers, voucherSourceForTreasury } from './sales/salesUtils'
|
buildTreasuryVoucher,
|
||||||
|
customers,
|
||||||
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
|
reconcileInvoicePaymentStatus,
|
||||||
|
suppliers,
|
||||||
|
voucherSourceForTreasury,
|
||||||
|
} from './sales/salesUtils'
|
||||||
|
import { isFunZoneTreasuryTxn, isMisplacedTicketTreasuryTxn } from './treasury/funzoneTreasurySync'
|
||||||
|
|
||||||
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
||||||
|
|
||||||
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
|
const methodLabels: Record<TreasuryMethod, string> = { cash: 'نقدی', bank: 'بانکی', cheque: 'چک' }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function Treasury() {
|
export function Treasury() {
|
||||||
|
|
||||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||||
|
|
||||||
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
||||||
|
|
||||||
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
|
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const partyName = (id: string | null) =>
|
const partyName = (id: string | null) =>
|
||||||
|
|
||||||
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—'
|
id ? data.parties.find((p) => p.id === id)?.name ?? '—' : '—'
|
||||||
|
|
||||||
|
/** Hide legacy ticket/refund rows until همگامسازی removes them. */
|
||||||
|
const visibleTreasury = useMemo(
|
||||||
const funZoneCounts = useMemo(() => {
|
() => data.treasury.filter((t) => !isMisplacedTicketTreasuryTxn(t)),
|
||||||
|
|
||||||
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),
|
|
||||||
|
|
||||||
[data.treasury],
|
[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(() => {
|
const treasuryParties = useMemo(() => {
|
||||||
|
|
||||||
if (!draft) return []
|
if (!draft) return []
|
||||||
|
const list =
|
||||||
const list = draft.kind === 'receipt' ? customers(data.parties) : suppliers(data.parties)
|
draft.kind === 'receipt'
|
||||||
|
? customers(data.parties)
|
||||||
return list.filter((p) => p.id)
|
: [...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])
|
}, [data.parties, draft])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
|
const createDraft = (kind: TreasuryKind): TreasuryTxn => ({
|
||||||
|
|
||||||
id: '',
|
id: '',
|
||||||
|
number: nextNumber(visibleTreasury.filter((t) => t.kind === kind)),
|
||||||
number: nextNumber(data.treasury.filter((t) => t.kind === kind)),
|
|
||||||
|
|
||||||
kind,
|
kind,
|
||||||
|
|
||||||
method: 'bank',
|
method: 'bank',
|
||||||
|
|
||||||
date: todayISO(),
|
date: todayISO(),
|
||||||
|
|
||||||
partyId: null,
|
partyId: null,
|
||||||
|
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
|
||||||
reference: '',
|
reference: '',
|
||||||
|
|
||||||
description: '',
|
description: '',
|
||||||
|
|
||||||
source: '',
|
source: '',
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
|
||||||
if (!draft || draft.amount <= 0) return
|
if (!draft || draft.amount <= 0) return
|
||||||
|
|
||||||
const saved: TreasuryTxn = {
|
const saved: TreasuryTxn = {
|
||||||
|
|
||||||
...draft,
|
...draft,
|
||||||
|
|
||||||
id: draft.id || createId('tr-'),
|
id: draft.id || createId('tr-'),
|
||||||
|
|
||||||
source: draft.source ?? '',
|
source: draft.source ?? '',
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
upsertTreasury(saved)
|
upsertTreasury(saved)
|
||||||
|
|
||||||
|
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved))
|
||||||
|
|
||||||
const existingVoucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(saved.id))
|
|
||||||
|
|
||||||
const newVoucher = buildTreasuryVoucher(saved, data)
|
const newVoucher = buildTreasuryVoucher(saved, data)
|
||||||
|
|
||||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
if (existingVoucher) removeVoucher(existingVoucher.id)
|
||||||
|
|
||||||
if (newVoucher) upsertVoucher(newVoucher)
|
if (newVoucher) upsertVoucher(newVoucher)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (saved.partyId) {
|
if (saved.partyId) {
|
||||||
|
|
||||||
const dataAfterSave = {
|
const dataAfterSave = {
|
||||||
|
|
||||||
...data,
|
...data,
|
||||||
|
|
||||||
treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved],
|
treasury: [...data.treasury.filter((t) => t.id !== saved.id), saved],
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) {
|
||||||
|
|
||||||
upsertInvoice(inv)
|
upsertInvoice(inv)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setDraft(null)
|
setDraft(null)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const columns: Array<Column<TreasuryTxn>> = [
|
const columns: Array<Column<TreasuryTxn>> = [
|
||||||
|
|
||||||
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
|
{ key: 'date', header: 'تاریخ', render: (t) => formatDate(t.date) },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
key: 'kind',
|
key: 'kind',
|
||||||
|
|
||||||
header: 'نوع',
|
header: 'نوع',
|
||||||
|
render: (t) => (
|
||||||
render: (t) => <Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>,
|
<Badge tone={t.kind === 'receipt' ? 'green' : 'rose'}>{kindLabels[t.kind]}</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
key: 'source',
|
key: 'source',
|
||||||
|
|
||||||
header: 'منبع',
|
header: 'منبع',
|
||||||
|
|
||||||
render: (t) =>
|
render: (t) =>
|
||||||
|
|
||||||
isFunZoneTreasuryTxn(t) ? (
|
isFunZoneTreasuryTxn(t) ? (
|
||||||
|
|
||||||
<Badge tone="indigo">فانزون</Badge>
|
<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: 'party', header: 'طرف حساب', render: (t) => partyName(t.partyId) },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
key: 'details',
|
key: 'details',
|
||||||
|
|
||||||
header: 'شرح',
|
header: 'شرح',
|
||||||
|
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
|
|
||||||
<span className="line-clamp-2 max-w-xs text-xs text-slate-500" title={t.description}>
|
<span className="line-clamp-2 max-w-xs text-xs text-slate-500" title={t.description}>
|
||||||
|
|
||||||
{t.description.split('\n')[0] || '—'}
|
{t.description.split('\n')[0] || '—'}
|
||||||
|
|
||||||
</span>
|
</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',
|
key: 'amount',
|
||||||
|
|
||||||
header: 'مبلغ',
|
header: 'مبلغ',
|
||||||
|
|
||||||
align: 'end',
|
align: 'end',
|
||||||
|
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
|
<span
|
||||||
<span className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}>
|
className={`tabular-nums font-semibold ${t.kind === 'receipt' ? 'text-emerald-600' : 'text-rose-600'}`}
|
||||||
|
>
|
||||||
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
{t.kind === 'receipt' ? '+' : '−'} {formatMoney(t.amount)}
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
),
|
),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|
||||||
header: '',
|
header: '',
|
||||||
|
|
||||||
align: 'end',
|
align: 'end',
|
||||||
|
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
|
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...t })}>
|
||||||
<Button
|
|
||||||
|
|
||||||
size="sm"
|
|
||||||
|
|
||||||
variant="ghost"
|
|
||||||
|
|
||||||
onClick={() => setDraft({ ...t })}
|
|
||||||
|
|
||||||
>
|
|
||||||
|
|
||||||
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
|
{isFunZoneTreasuryTxn(t) ? 'جزئیات' : 'ویرایش'}
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isFunZoneTreasuryTxn(t) && (
|
{!isFunZoneTreasuryTxn(t) && (
|
||||||
|
<Button
|
||||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(t)}>
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-rose-600"
|
||||||
|
onClick={() => setToDelete(t)}
|
||||||
|
>
|
||||||
حذف
|
حذف
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
),
|
),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|
||||||
title="دریافت و پرداخت (خزانهداری)"
|
title="دریافت و پرداخت (خزانهداری)"
|
||||||
|
subtitle="شارژ کیف پول مشتری و پرداخت به مالکان — همگامسازی از اسناد حسابداری"
|
||||||
subtitle="دریافت از مشتریان (فانزون) و پرداخت به مالکان — همگام با سپیدار (سند + تسویه فاکتور)"
|
|
||||||
|
|
||||||
actions={
|
actions={
|
||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
||||||
|
|
||||||
دریافت دستی
|
دریافت دستی
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
|
<Button variant="danger" icon="↑" onClick={() => setDraft(createDraft('payment'))}>
|
||||||
|
|
||||||
پرداخت دستی
|
پرداخت دستی
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Card className="border-brand-200 bg-brand-50/40 p-4">
|
<Card className="border-brand-200 bg-brand-50/40 p-4">
|
||||||
|
<h3 className="font-bold text-slate-800">اتصال فانزون → خزانه</h3>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<ul className="mt-2 space-y-1 text-sm text-slate-600">
|
||||||
|
<li>
|
||||||
<div>
|
↓ <strong>دریافت:</strong> شارژ کیف پول مشتری (بدون مالیات و ۱۴٪) — بدهی به مشتری تا
|
||||||
|
زمان برداشت
|
||||||
<h3 className="font-bold text-slate-800">اتصال فانزون → خزانه</h3>
|
</li>
|
||||||
|
<li>
|
||||||
<ul className="mt-2 space-y-1 text-sm text-slate-600">
|
↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (وضعیت انجامشده)
|
||||||
<li>↓ <strong>دریافت:</strong> پرداخت مشتری — مبلغ کل رویداد (رزرو/بلیت)</li>
|
</li>
|
||||||
<li>↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)</li>
|
<li>
|
||||||
<li>↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
|
↳ فروش بلیت →{' '}
|
||||||
<li>↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
|
<Link
|
||||||
<li>
|
to="/sales/invoice"
|
||||||
برای همگامسازی خودکار از فانزون به{' '}
|
className="font-medium text-brand-700 underline decoration-dotted underline-offset-4"
|
||||||
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
>
|
||||||
اسناد حسابداری
|
فاکتور فروش
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
بروید.
|
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li>
|
||||||
|
↳ برداشت کیف پول مشتری به شبا →{' '}
|
||||||
</div>
|
<Link
|
||||||
|
to="/sales"
|
||||||
</div>
|
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>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
||||||
|
|
||||||
<StatCard label="جمع دریافتها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
|
<StatCard label="جمع دریافتها" value={formatMoney(summary.receipts)} icon="↓" tone="green" />
|
||||||
|
|
||||||
<StatCard label="جمع پرداختها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
|
<StatCard label="جمع پرداختها" value={formatMoney(summary.payments)} icon="↑" tone="rose" />
|
||||||
|
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|
||||||
label="مانده خالص"
|
label="مانده خالص"
|
||||||
|
|
||||||
value={formatMoney(summary.net)}
|
value={formatMoney(summary.net)}
|
||||||
|
|
||||||
icon="💰"
|
icon="💰"
|
||||||
|
|
||||||
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
tone={summary.net >= 0 ? 'brand' : 'rose'}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
<StatCard label="دریافت فانزون" value={toFa(funZoneCounts.receipts)} icon="👤" tone="brand" />
|
label="فانزون"
|
||||||
|
value={`${toFa(funZoneCounts.receipts)} ↓ / ${toFa(funZoneCounts.payments)} ↑`}
|
||||||
<StatCard label="سود پلتفرم (۱۴٪)" value={formatMoney(summary.platformProfit)} icon="📈" tone="green" />
|
icon="👤"
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
<DataTable
|
||||||
<DataTable columns={columns} rows={sorted} rowKey={(t) => t.id} emptyTitle="تراکنشی ثبت نشده است — همگامسازی فانزون را بزنید" />
|
columns={columns}
|
||||||
|
rows={sorted}
|
||||||
|
rowKey={(t) => t.id}
|
||||||
|
emptyTitle="تراکنشی ثبت نشده — از اسناد حسابداری همگامسازی کنید"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|
||||||
open={draft !== null}
|
open={draft !== null}
|
||||||
|
|
||||||
title={
|
title={
|
||||||
|
|
||||||
draft
|
draft
|
||||||
|
|
||||||
? isFunZoneTreasuryTxn(draft)
|
? isFunZoneTreasuryTxn(draft)
|
||||||
|
|
||||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)} (فانزون)`
|
? `${kindLabels[draft.kind]} #${toFa(draft.number)} (فانزون)`
|
||||||
|
|
||||||
: draft.id
|
: draft.id
|
||||||
|
|
||||||
? `${kindLabels[draft.kind]} #${toFa(draft.number)}`
|
? `${kindLabels[draft.kind]} #${toFa(draft.number)}`
|
||||||
|
|
||||||
: `${kindLabels[draft.kind]} جدید`
|
: `${kindLabels[draft.kind]} جدید`
|
||||||
|
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose={() => setDraft(null)}
|
onClose={() => setDraft(null)}
|
||||||
|
|
||||||
footer={
|
footer={
|
||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||||
|
|
||||||
{draft && isFunZoneTreasuryTxn(draft) ? 'بستن' : 'انصراف'}
|
{draft && isFunZoneTreasuryTxn(draft) ? 'بستن' : 'انصراف'}
|
||||||
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{draft && !isFunZoneTreasuryTxn(draft) && (
|
||||||
{draft && !isFunZoneTreasuryTxn(draft) && <Button onClick={handleSave}>ذخیره</Button>}
|
<Button onClick={handleSave}>ذخیره</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
|
}
|
||||||
}
|
>
|
||||||
|
{draft && (
|
||||||
>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{isFunZoneTreasuryTxn(draft) ? (
|
||||||
{draft && (
|
<div className="sm:col-span-2">
|
||||||
|
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
{draft.description}
|
||||||
|
</pre>
|
||||||
{isFunZoneTreasuryTxn(draft) ? (
|
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
<div className="sm:col-span-2">
|
<dt className="text-slate-400">طرف حساب</dt>
|
||||||
|
<dd className="font-medium">{partyName(draft.partyId)}</dd>
|
||||||
<pre className="whitespace-pre-wrap rounded-xl bg-slate-50 p-4 text-sm text-slate-700">
|
</div>
|
||||||
|
<div>
|
||||||
{draft.description}
|
<dt className="text-slate-400">مبلغ</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
|
||||||
</pre>
|
</div>
|
||||||
|
<div>
|
||||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
<dt className="text-slate-400">تاریخ</dt>
|
||||||
|
<dd>{formatDate(draft.date)}</dd>
|
||||||
<div>
|
</div>
|
||||||
|
<div>
|
||||||
<dt className="text-slate-400">طرف حساب</dt>
|
<dt className="text-slate-400">مرجع</dt>
|
||||||
|
<dd className="font-mono">{draft.reference || '—'}</dd>
|
||||||
<dd className="font-medium">{partyName(draft.partyId)}</dd>
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div>
|
<>
|
||||||
|
<Field label="تاریخ">
|
||||||
<dt className="text-slate-400">مبلغ</dt>
|
<JalaliDateInput
|
||||||
|
value={draft.date}
|
||||||
<dd className="font-semibold tabular-nums">{formatMoney(draft.amount)}</dd>
|
onChange={(date) => setDraft({ ...draft, date })}
|
||||||
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
<Field label="روش">
|
||||||
<div>
|
<Select
|
||||||
|
value={draft.method}
|
||||||
<dt className="text-slate-400">تاریخ</dt>
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, method: e.target.value as TreasuryMethod })
|
||||||
<dd>{formatDate(draft.date)}</dd>
|
}
|
||||||
|
>
|
||||||
</div>
|
{Object.entries(methodLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
<div>
|
{label}
|
||||||
|
</option>
|
||||||
<dt className="text-slate-400">مرجع</dt>
|
))}
|
||||||
|
</Select>
|
||||||
<dd className="font-mono">{draft.reference || '—'}</dd>
|
</Field>
|
||||||
|
<Field label="طرف حساب">
|
||||||
</div>
|
<Select
|
||||||
|
value={draft.partyId ?? ''}
|
||||||
</dl>
|
onChange={(e) => setDraft({ ...draft, partyId: e.target.value || null })}
|
||||||
|
>
|
||||||
</div>
|
<option value="">— بدون طرف حساب —</option>
|
||||||
|
{treasuryParties.map((party) => (
|
||||||
) : (
|
<option key={party.id} value={party.id}>
|
||||||
|
{party.name}
|
||||||
<>
|
{party.kind === 'supplier' ? ' (تامینکننده)' : ' (مشتری)'}
|
||||||
|
</option>
|
||||||
<Field label="تاریخ">
|
))}
|
||||||
|
</Select>
|
||||||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
</Field>
|
||||||
|
<Field label="مبلغ (تومان)">
|
||||||
</Field>
|
<Input
|
||||||
|
type="number"
|
||||||
<Field label="روش">
|
value={draft.amount}
|
||||||
|
onChange={(e) =>
|
||||||
<Select value={draft.method} onChange={(e) => setDraft({ ...draft, method: e.target.value as TreasuryMethod })}>
|
setDraft({ ...draft, amount: Number(e.target.value) || 0 })
|
||||||
|
}
|
||||||
{Object.entries(methodLabels).map(([value, label]) => (
|
/>
|
||||||
|
</Field>
|
||||||
<option key={value} value={value}>
|
<Field label="شماره مرجع / چک">
|
||||||
|
<Input
|
||||||
{label}
|
value={draft.reference}
|
||||||
|
onChange={(e) => setDraft({ ...draft, reference: e.target.value })}
|
||||||
</option>
|
/>
|
||||||
|
</Field>
|
||||||
))}
|
<div className="sm:col-span-2">
|
||||||
|
<Field label="توضیحات">
|
||||||
</Select>
|
<Textarea
|
||||||
|
rows={2}
|
||||||
</Field>
|
value={draft.description}
|
||||||
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
<Field label="طرف حساب">
|
/>
|
||||||
|
</Field>
|
||||||
<Select
|
</div>
|
||||||
|
</>
|
||||||
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}
|
|
||||||
|
|
||||||
</option>
|
|
||||||
|
|
||||||
))}
|
|
||||||
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="مبلغ (تومان)">
|
|
||||||
|
|
||||||
<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 })} />
|
|
||||||
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
|
|
||||||
<Field label="توضیحات">
|
|
||||||
|
|
||||||
<Textarea rows={2} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
|
|
||||||
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>
|
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|
||||||
open={toDelete !== null}
|
open={toDelete !== null}
|
||||||
|
|
||||||
title="حذف تراکنش"
|
title="حذف تراکنش"
|
||||||
|
|
||||||
message="این تراکنش خزانه حذف شود؟"
|
message="این تراکنش خزانه حذف شود؟"
|
||||||
|
|
||||||
onCancel={() => setToDelete(null)}
|
onCancel={() => setToDelete(null)}
|
||||||
|
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
|
|
||||||
if (toDelete) {
|
if (toDelete) {
|
||||||
|
const voucher = data.vouchers.find(
|
||||||
const voucher = data.vouchers.find((v) => v.source === voucherSourceForTreasury(toDelete.id))
|
(v) => v.source === voucherSourceForTreasury(toDelete) || v.source === voucherSourceForTreasury(toDelete.id),
|
||||||
|
)
|
||||||
if (voucher) removeVoucher(voucher.id)
|
if (voucher) removeVoucher(voucher.id)
|
||||||
|
|
||||||
removeTreasury(toDelete.id)
|
removeTreasury(toDelete.id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (toDelete.partyId) {
|
if (toDelete.partyId) {
|
||||||
|
|
||||||
const dataAfterDelete = {
|
const dataAfterDelete = {
|
||||||
|
|
||||||
...data,
|
...data,
|
||||||
|
|
||||||
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
|
treasury: data.treasury.filter((t) => t.id !== toDelete.id),
|
||||||
|
|
||||||
}
|
}
|
||||||
|
for (const inv of reconcileInvoicePaymentStatus(
|
||||||
for (const inv of reconcileInvoicePaymentStatus(toDelete.partyId, dataAfterDelete)) {
|
toDelete.partyId,
|
||||||
|
dataAfterDelete,
|
||||||
|
)) {
|
||||||
upsertInvoice(inv)
|
upsertInvoice(inv)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setToDelete(null)
|
setToDelete(null)
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function Vouchers() {
|
|||||||
className: 'w-[7rem] whitespace-nowrap',
|
className: 'w-[7rem] whitespace-nowrap',
|
||||||
render: (v) => {
|
render: (v) => {
|
||||||
const cat = classifyVoucher(v, data)
|
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>
|
return <Badge tone={tone}>{voucherCategoryLabel(cat)}</Badge>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -250,8 +250,8 @@ export function Vouchers() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
|
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
یکبار همگامسازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری — بدون تکرار.
|
یکبار همگامسازی: شارژ/برداشت کیف پول در خزانه، فروش بلیت بهصورت فاکتور فروش
|
||||||
برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته میشود.
|
(مالیات ۱۰٪ + سود ۱۴٪ + سهم تامینکننده)، و سند حسابداری — بدون تکرار.
|
||||||
</p>
|
</p>
|
||||||
{lastSyncedAt ? (
|
{lastSyncedAt ? (
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
<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">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard label="همه اسناد" value={toFa(displayedVouchers.length)} />
|
<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.manual)} />
|
||||||
|
<StatCard label="مدیر" value={toFa(summary.manager)} tone="brand" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
@@ -282,9 +281,8 @@ export function Vouchers() {
|
|||||||
<Field label="دسته">
|
<Field label="دسته">
|
||||||
<Select value={category} onChange={(e) => setCategory(e.target.value as VoucherCategoryFilter)}>
|
<Select value={category} onChange={(e) => setCategory(e.target.value as VoucherCategoryFilter)}>
|
||||||
<option value="all">همه</option>
|
<option value="all">همه</option>
|
||||||
<option value="customer">مشتریان</option>
|
|
||||||
<option value="supplier">تأمینکنندگان</option>
|
|
||||||
<option value="manual">دستی</option>
|
<option value="manual">دستی</option>
|
||||||
|
<option value="manager">مدیر</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="از تاریخ">
|
<Field label="از تاریخ">
|
||||||
@@ -378,7 +376,8 @@ export function Vouchers() {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={draft.number}
|
value={draft.number}
|
||||||
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
|
readOnly
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="تاریخ">
|
<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 {
|
import {
|
||||||
buildBuyerRows,
|
buildBuyerRows,
|
||||||
buildCategoryInventoryRows,
|
buildCategoryInventoryRows,
|
||||||
eventLifecycle,
|
eventLifecycle,
|
||||||
type InventoryEvent,
|
type InventoryEvent,
|
||||||
type InventoryReservation,
|
type InventoryReservation,
|
||||||
} from './funzoneInventory'
|
} from './funzoneInventory.ts'
|
||||||
|
|
||||||
const categories = [{ id: 'cat-1', name: 'بازی' }]
|
const categories = [{ id: 'cat-1', name: 'بازی' }]
|
||||||
|
|
||||||
@@ -17,6 +21,7 @@ const events: InventoryEvent[] = [
|
|||||||
price: 100,
|
price: 100,
|
||||||
category: { id: 'cat-1', name: 'بازی' },
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
start_time: '2099-01-01T10:00:00Z',
|
start_time: '2099-01-01T10:00:00Z',
|
||||||
|
event_status: 'upcoming',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'evt-done',
|
id: 'evt-done',
|
||||||
@@ -25,6 +30,7 @@ const events: InventoryEvent[] = [
|
|||||||
price: 100,
|
price: 100,
|
||||||
category: { id: 'cat-1', name: 'بازی' },
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
start_time: '2020-01-01T10:00:00Z',
|
start_time: '2020-01-01T10:00:00Z',
|
||||||
|
event_status: 'completed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'evt-failed',
|
id: 'evt-failed',
|
||||||
@@ -33,6 +39,7 @@ const events: InventoryEvent[] = [
|
|||||||
price: 100,
|
price: 100,
|
||||||
category: { id: 'cat-1', name: 'بازی' },
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
start_time: '2020-01-01T10:00:00Z',
|
start_time: '2020-01-01T10:00:00Z',
|
||||||
|
event_status: 'cancelled',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,37 +76,46 @@ const reservations: InventoryReservation[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('funzoneInventory', () => {
|
assert.equal(eventLifecycle(events[0], 3), 'inProcess')
|
||||||
it('classifies lifecycle by start time and minimum seats', () => {
|
assert.equal(eventLifecycle(events[1], 2), 'completed')
|
||||||
const now = Date.parse('2025-01-01T00:00:00Z')
|
assert.equal(eventLifecycle(events[2], 4), 'canceled')
|
||||||
expect(eventLifecycle(events[0], 3, now)).toBe('inProcess')
|
|
||||||
expect(eventLifecycle(events[1], 2, now)).toBe('completed')
|
|
||||||
expect(eventLifecycle(events[2], 4, now)).toBe('canceled')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aggregates category counts and bought/canceled tickets', () => {
|
const now = Date.parse('2025-01-01T00:00:00Z')
|
||||||
const now = Date.parse('2025-01-01T00:00:00Z')
|
assert.equal(
|
||||||
const rows = buildCategoryInventoryRows(categories, events, reservations, now)
|
eventLifecycle({ id: 'a', start_time: '2099-01-01T10:00:00Z', minimum: 5 }, 3, now),
|
||||||
expect(rows).toHaveLength(1)
|
'inProcess',
|
||||||
expect(rows[0].inProcessCount).toBe(1)
|
)
|
||||||
expect(rows[0].completedCount).toBe(1)
|
assert.equal(
|
||||||
expect(rows[0].canceledCount).toBe(1)
|
eventLifecycle(
|
||||||
expect(rows[0].boughtTickets).toBe(5)
|
{
|
||||||
expect(rows[0].canceledTickets).toBe(4)
|
id: 'closed-success',
|
||||||
})
|
start_time: '2099-06-01T10:00:00Z',
|
||||||
|
minimum: 10,
|
||||||
|
event_status: 'completed',
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
'completed',
|
||||||
|
)
|
||||||
|
|
||||||
it('shows confirmed buyers for failed events in canceled lifecycle', () => {
|
const rows = buildCategoryInventoryRows(categories, events, reservations)
|
||||||
const now = Date.parse('2025-01-01T00:00:00Z')
|
assert.equal(rows.length, 1)
|
||||||
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
|
assert.equal(rows[0].inProcessCount, 1)
|
||||||
lifecycle: 'canceled',
|
assert.equal(rows[0].completedCount, 1)
|
||||||
dateFrom: '',
|
assert.equal(rows[0].canceledCount, 1)
|
||||||
dateTo: '',
|
assert.equal(rows[0].boughtTickets, 5)
|
||||||
search: '',
|
assert.equal(rows[0].canceledTickets, 4)
|
||||||
sort: 'mostTickets',
|
|
||||||
nowMs: now,
|
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
|
||||||
})
|
lifecycle: 'canceled',
|
||||||
expect(buyers).toHaveLength(1)
|
dateFrom: '',
|
||||||
expect(buyers[0].customerName).toBe('رضا کریمی')
|
dateTo: '',
|
||||||
expect(buyers[0].tickets).toBe(4)
|
search: '',
|
||||||
})
|
sort: 'mostTickets',
|
||||||
})
|
})
|
||||||
|
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
|
price?: number
|
||||||
category?: { id?: string; name?: string } | null
|
category?: { id?: string; name?: string } | null
|
||||||
start_time?: string | null
|
start_time?: string | null
|
||||||
|
/** Backend event status: upcoming | ongoing | completed | cancelled */
|
||||||
|
event_status?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryReservation {
|
export interface InventoryReservation {
|
||||||
@@ -58,6 +60,12 @@ export function eventLifecycle(
|
|||||||
seatsForMinimum: number,
|
seatsForMinimum: number,
|
||||||
nowMs: number = Date.now(),
|
nowMs: number = Date.now(),
|
||||||
): EventLifecycle {
|
): 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 startMs = event.start_time ? Date.parse(event.start_time) : NaN
|
||||||
const started = Number.isFinite(startMs) && startMs <= nowMs
|
const started = Number.isFinite(startMs) && startMs <= nowMs
|
||||||
if (!started) return 'inProcess'
|
if (!started) return 'inProcess'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function FunZoneSalesSyncBanner() {
|
|||||||
<Card className="border-slate-200 bg-slate-50/80 p-4">
|
<Card className="border-slate-200 bg-slate-50/80 p-4">
|
||||||
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
|
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
<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 to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||||
اسناد حسابداری
|
اسناد حسابداری
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { ApiWithdrawal } from '../../api/types'
|
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 { Badge, Button, Card, DataTable, StatCard, type Column } from '../../components/ui'
|
||||||
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
|
import { formatDate, formatIban, formatMoney, formatTime, toFa } from '../../utils/format'
|
||||||
import { invoiceForWithdrawal } from './usePendingCustomerWithdrawals'
|
import type { WalletTxnStatus } from '../../api/types'
|
||||||
|
|
||||||
interface PendingWithdrawalsPanelProps {
|
interface PendingWithdrawalsPanelProps {
|
||||||
withdrawals: ApiWithdrawal[]
|
withdrawals: ApiWithdrawal[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
updatingId: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
returnInvoices: Invoice[]
|
|
||||||
customerList: Party[]
|
customerList: Party[]
|
||||||
|
bookedWithdrawalIds: Set<string>
|
||||||
onReload: () => void
|
onReload: () => void
|
||||||
onCreateReturn: (withdrawal: ApiWithdrawal) => void
|
onToggleStatus: (withdrawal: ApiWithdrawal) => void
|
||||||
|
onBookPayment: (withdrawal: ApiWithdrawal) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomerCell({ withdrawal }: { withdrawal: ApiWithdrawal }) {
|
function CustomerCell({ withdrawal }: { withdrawal: ApiWithdrawal }) {
|
||||||
@@ -58,24 +60,74 @@ function RequestDateCell({ iso }: { iso: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PendingWithdrawalsPanel({
|
function statusLabel(status: WalletTxnStatus): string {
|
||||||
withdrawals,
|
if (status === 'completed') return 'تکمیل شده'
|
||||||
loading,
|
if (status === 'pending') return 'در انتظار'
|
||||||
error,
|
return 'ناموفق'
|
||||||
returnInvoices,
|
}
|
||||||
customerList,
|
|
||||||
onReload,
|
|
||||||
onCreateReturn,
|
|
||||||
}: PendingWithdrawalsPanelProps) {
|
|
||||||
const openWithdrawals = useMemo(
|
|
||||||
() => withdrawals.filter((w) => !invoiceForWithdrawal(returnInvoices, w.id)),
|
|
||||||
[withdrawals, returnInvoices],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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>> = [
|
const columns: Array<Column<ApiWithdrawal>> = [
|
||||||
{
|
{
|
||||||
key: 'customer',
|
key: 'user',
|
||||||
header: 'مشتری',
|
header: 'نام',
|
||||||
headerClassName: 'w-[11rem]',
|
headerClassName: 'w-[11rem]',
|
||||||
className: 'w-[11rem]',
|
className: 'w-[11rem]',
|
||||||
render: (w) => <CustomerCell withdrawal={w} />,
|
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>
|
<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',
|
key: 'iban',
|
||||||
header: 'شماره شبا',
|
header: 'شماره شبا',
|
||||||
@@ -97,6 +162,17 @@ export function PendingWithdrawalsPanel({
|
|||||||
className: 'min-w-[14rem]',
|
className: 'min-w-[14rem]',
|
||||||
render: (w) => <IbanCell iban={w.iban} />,
|
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',
|
key: 'requestedAt',
|
||||||
header: 'تاریخ درخواست',
|
header: 'تاریخ درخواست',
|
||||||
@@ -104,14 +180,10 @@ export function PendingWithdrawalsPanel({
|
|||||||
className: 'w-[6.5rem]',
|
className: 'w-[6.5rem]',
|
||||||
render: (w) => <RequestDateCell iso={w.created_at} />,
|
render: (w) => <RequestDateCell iso={w.created_at} />,
|
||||||
},
|
},
|
||||||
{
|
]
|
||||||
key: 'status',
|
|
||||||
header: 'وضعیت',
|
if (showBookPayment) {
|
||||||
headerClassName: 'w-[5.5rem]',
|
columns.push({
|
||||||
className: 'w-[5.5rem] whitespace-nowrap',
|
|
||||||
render: () => <Badge tone="amber">در انتظار</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'end',
|
align: 'end',
|
||||||
@@ -119,66 +191,70 @@ export function PendingWithdrawalsPanel({
|
|||||||
headerClassName: 'w-[9.5rem]',
|
headerClassName: 'w-[9.5rem]',
|
||||||
className: 'w-[9.5rem] whitespace-nowrap',
|
className: 'w-[9.5rem] whitespace-nowrap',
|
||||||
render: (w) => {
|
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)
|
const party = customerList.find((p) => p.externalId === w.user_id)
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={!party}
|
disabled={!party || updatingId === w.id}
|
||||||
onClick={() => onCreateReturn(w)}
|
onClick={() => onBookPayment(w)}
|
||||||
title={party ? undefined : 'ابتدا مشتری را همگام کنید'}
|
title={party ? 'ثبت پرداخت خزانه بدون فاکتور برگشتی' : 'ابتدا مشتری را همگام کنید'}
|
||||||
>
|
>
|
||||||
ثبت فاکتور برگشتی
|
ثبت پرداخت
|
||||||
</Button>
|
</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 (
|
return (
|
||||||
<Card className="border-amber-200 bg-gradient-to-b from-amber-50/60 to-white p-4 sm:p-5">
|
<Card className="border-slate-200 bg-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">
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3 border-b border-slate-100 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-slate-800">برداشت در انتظار (پنل مدیریت)</h3>
|
<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">
|
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-slate-500">{subtitle}</p>
|
||||||
درخواستهای برداشت کیف پول مشتریان — شبا، مبلغ و تاریخ هر درخواست جدا نمایش داده میشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" icon="↻" size="sm" onClick={onReload} disabled={loading}>
|
|
||||||
بروزرسانی
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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
|
<StatCard
|
||||||
label="برداشت در انتظار"
|
label="در انتظار"
|
||||||
value={toFa(openWithdrawals.length)}
|
value={toFa(pendingCount)}
|
||||||
icon="⏳"
|
icon="⏳"
|
||||||
tone={openWithdrawals.length > 0 ? 'amber' : 'green'}
|
tone={pendingCount > 0 ? 'amber' : 'green'}
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="جمع مبالغ"
|
|
||||||
value={formatMoney(openWithdrawals.reduce((s, w) => s + w.amount, 0))}
|
|
||||||
icon="💳"
|
|
||||||
tone="brand"
|
|
||||||
/>
|
/>
|
||||||
|
<StatCard label="جمع در انتظار" value={formatMoney(pendingSum)} icon="💳" tone="brand" />
|
||||||
</div>
|
</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 ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-slate-400">در حال بارگذاری برداشتها…</p>
|
<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">
|
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={openWithdrawals}
|
rows={rows}
|
||||||
rowKey={(w) => w.id}
|
rowKey={(w) => w.id}
|
||||||
emptyTitle="برداشت در انتظاری ثبت نشده است"
|
emptyTitle="برداشتی یافت نشد"
|
||||||
minWidth="52rem"
|
minWidth="52rem"
|
||||||
size="comfortable"
|
size="comfortable"
|
||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
@@ -188,3 +264,73 @@ export function PendingWithdrawalsPanel({
|
|||||||
</Card>
|
</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,
|
customers,
|
||||||
documentTypeLabels,
|
documentTypeLabels,
|
||||||
invoiceRegarding,
|
invoiceRegarding,
|
||||||
isWithdrawalReturn,
|
|
||||||
reconcileInvoicePaymentStatus,
|
reconcileInvoicePaymentStatus,
|
||||||
statusLabels,
|
statusLabels,
|
||||||
} from './salesUtils'
|
} from './salesUtils'
|
||||||
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
import { invoiceSideEffects, invoiceDeleteEffects } from './invoiceEffects'
|
||||||
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
|
import { InvoiceLineGoodsCell } from '../../components/business/InvoiceLineGoodsCell'
|
||||||
import { formatEventName } from '../../utils/eventName'
|
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',
|
draft: 'amber',
|
||||||
confirmed: 'blue',
|
confirmed: 'blue',
|
||||||
paid: 'green',
|
paid: 'green',
|
||||||
|
cancelled: 'rose',
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLine = (): InvoiceLine => ({
|
const newLine = (): InvoiceLine => ({
|
||||||
@@ -59,8 +53,6 @@ interface SalesDocumentSectionProps {
|
|||||||
subtitle: string
|
subtitle: string
|
||||||
/** Allow converting from proforma when creating sales invoice */
|
/** Allow converting from proforma when creating sales invoice */
|
||||||
allowProformaConversion?: boolean
|
allowProformaConversion?: boolean
|
||||||
/** Allow linking to original invoice for returns */
|
|
||||||
allowRelatedInvoice?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SalesDocumentSection({
|
export function SalesDocumentSection({
|
||||||
@@ -68,7 +60,6 @@ export function SalesDocumentSection({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
allowProformaConversion = false,
|
allowProformaConversion = false,
|
||||||
allowRelatedInvoice = false,
|
|
||||||
}: SalesDocumentSectionProps) {
|
}: SalesDocumentSectionProps) {
|
||||||
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -81,22 +72,15 @@ export function SalesDocumentSection({
|
|||||||
() => customers(data.parties).filter((p) => p.id),
|
() => customers(data.parties).filter((p) => p.id),
|
||||||
[data.parties],
|
[data.parties],
|
||||||
)
|
)
|
||||||
const documents = useMemo(() => byDocumentType(data.invoices, documentType), [data.invoices, documentType])
|
const documents = useMemo(
|
||||||
const proformas = useMemo(() => byDocumentType(data.invoices, 'proforma'), [data.invoices])
|
() =>
|
||||||
const saleInvoices = useMemo(() => byDocumentType(data.invoices, 'invoice'), [data.invoices])
|
byDocumentType(data.invoices, documentType).filter((i) =>
|
||||||
const activeSalesTypes = useMemo(() => data.salesTypes.filter((t) => t.active), [data.salesTypes])
|
documentType === 'invoice' ? i.status !== 'cancelled' : true,
|
||||||
const isReturnSection = documentType === 'return'
|
),
|
||||||
const {
|
[data.invoices, documentType],
|
||||||
withdrawals: pendingWithdrawals,
|
|
||||||
loading: pendingLoading,
|
|
||||||
error: pendingError,
|
|
||||||
reload: reloadPending,
|
|
||||||
} = usePendingCustomerWithdrawals(isReturnSection)
|
|
||||||
|
|
||||||
const returnDocuments = useMemo(
|
|
||||||
() => (isReturnSection ? documents : []),
|
|
||||||
[documents, isReturnSection],
|
|
||||||
)
|
)
|
||||||
|
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 partyName = (id: string) => data.parties.find((p) => p.id === id)?.name ?? '—'
|
||||||
const salesTypeName = (id?: string) =>
|
const salesTypeName = (id?: string) =>
|
||||||
@@ -162,51 +146,11 @@ export function SalesDocumentSection({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 = () => {
|
const handleSave = () => {
|
||||||
if (!draft || !draft.partyId) return
|
if (!draft || !draft.partyId) return
|
||||||
const fromWithdrawal = isWithdrawalReturn(draft)
|
const cleanLines = draft.lines.filter(
|
||||||
const cleanLines = draft.lines.filter((line) => {
|
(line) => Boolean(line.productId || line.eventName) && line.quantity > 0,
|
||||||
if (fromWithdrawal && line.unitPrice > 0) return true
|
)
|
||||||
return Boolean(line.productId || line.eventName) && line.quantity > 0
|
|
||||||
})
|
|
||||||
if (cleanLines.length === 0) return
|
if (cleanLines.length === 0) return
|
||||||
|
|
||||||
const saved: Invoice = {
|
const saved: Invoice = {
|
||||||
@@ -228,7 +172,7 @@ export function SalesDocumentSection({
|
|||||||
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId)
|
||||||
if (effects.voucher) upsertVoucher(effects.voucher)
|
if (effects.voucher) upsertVoucher(effects.voucher)
|
||||||
|
|
||||||
if (saved.partyId && saved.status !== 'draft') {
|
if (saved.partyId && saved.status !== 'draft' && saved.status !== 'cancelled') {
|
||||||
const dataAfterSave = {
|
const dataAfterSave = {
|
||||||
...data,
|
...data,
|
||||||
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved],
|
||||||
@@ -311,18 +255,6 @@ export function SalesDocumentSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isReturnSection && (
|
|
||||||
<PendingWithdrawalsPanel
|
|
||||||
withdrawals={pendingWithdrawals}
|
|
||||||
loading={pendingLoading}
|
|
||||||
error={pendingError}
|
|
||||||
returnInvoices={returnDocuments}
|
|
||||||
customerList={customerList}
|
|
||||||
onReload={() => void reloadPending()}
|
|
||||||
onCreateReturn={loadFromWithdrawal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -417,21 +349,6 @@ export function SalesDocumentSection({
|
|||||||
</Field>
|
</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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Field label="شماره">
|
<Field label="شماره">
|
||||||
<Input
|
<Input
|
||||||
@@ -457,7 +374,9 @@ export function SalesDocumentSection({
|
|||||||
value={draft.status}
|
value={draft.status}
|
||||||
onChange={(e) => setDraft({ ...draft, status: e.target.value as InvoiceStatus })}
|
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}>
|
<option key={value} value={value}>
|
||||||
{label}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
@@ -502,7 +421,7 @@ export function SalesDocumentSection({
|
|||||||
line={line}
|
line={line}
|
||||||
products={data.products}
|
products={data.products}
|
||||||
showEventName
|
showEventName
|
||||||
withdrawalReturn={isWithdrawalReturn(draft)}
|
withdrawalReturn={false}
|
||||||
onChange={(patch) => updateLine(line.id, patch)}
|
onChange={(patch) => updateLine(line.id, patch)}
|
||||||
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
|
onSelectProduct={(productId) => onSelectProduct(line.id, productId)}
|
||||||
/>
|
/>
|
||||||
@@ -579,9 +498,7 @@ export function SalesDocumentSection({
|
|||||||
|
|
||||||
{documentType !== 'proforma' && draft.status !== 'draft' && (
|
{documentType !== 'proforma' && draft.status !== 'draft' && (
|
||||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
{isWithdrawalReturn(draft)
|
با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.
|
||||||
? 'برداشت کیف پول — موجودی انبار تغییر نمیکند؛ سند حسابداری برداشت ثبت میشود.'
|
|
||||||
: 'با وضعیت «قطعی» یا «تسویهشده»، موجودی انبار و سند حسابداری بهروزرسانی میشود.'}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SalesDocumentSection } from './SalesDocumentSection'
|
import { SalesDocumentSection } from './SalesDocumentSection'
|
||||||
|
import { SalesReturn as SalesReturnPage } from './SalesReturnPage'
|
||||||
|
|
||||||
export function SalesProforma() {
|
export function SalesProforma() {
|
||||||
return (
|
return (
|
||||||
@@ -21,13 +22,7 @@ export function SalesInvoice() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Withdrawals + cancelled sales invoices (no فاکتور برگشتی). */
|
||||||
export function SalesReturn() {
|
export function SalesReturn() {
|
||||||
return (
|
return <SalesReturnPage />
|
||||||
<SalesDocumentSection
|
|
||||||
documentType="return"
|
|
||||||
title="فاکتور برگشتی جدید"
|
|
||||||
subtitle="برگشت کالا یا ثبت فاکتور برگشتی برای برداشتهای در انتظار مشتریان (از پنل مدیریت)"
|
|
||||||
allowRelatedInvoice
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const processSteps = [
|
|||||||
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
{ step: 1, label: 'پیشفاکتور', desc: 'صدور پیشنهاد قیمت برای مشتری', to: '/sales/proforma', icon: '📋' },
|
||||||
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
|
{ step: 2, label: 'فاکتور فروش', desc: 'ثبت فروش قطعی و کاهش موجودی', to: '/sales/invoice', icon: '🧾' },
|
||||||
{ step: 3, label: 'دریافت', desc: 'تسویه از مشتری در ماژول دریافت و پرداخت', to: '/treasury', 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() {
|
export function SalesProcess() {
|
||||||
@@ -31,7 +31,7 @@ export function SalesProcess() {
|
|||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
<h2 className="mb-1 text-lg font-bold text-slate-800">فرایند فروش</h2>
|
||||||
<p className="mb-6 text-sm text-slate-500">
|
<p className="mb-6 text-sm text-slate-500">
|
||||||
جریان استاندارد فروش مشابه سپیدار: از پیشفاکتور تا فاکتور، دریافت و برگشت.
|
جریان استاندارد فروش: از پیشفاکتور تا فاکتور، دریافت، و لغو/برداشت.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<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">
|
<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.proformaCount)} hint={`${toFa(summary.openProformas)} باز`} />
|
||||||
<StatCard label="فاکتور فروش" value={toFa(summary.invoiceCount)} hint={formatMoney(summary.totalSales)} />
|
<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={formatMoney(summary.netSales)} hint={`${toFa(summary.unpaidCount)} تسویهنشده`} />
|
||||||
<StatCard label="همگام فانزون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
|
<StatCard label="همگام فانزون" value={toFa(funzoneSyncedCount)} hint="فاکتور از پرداخت رویداد" tone="brand" />
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@ export function SalesProcess() {
|
|||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span>📦</span>
|
<span>📦</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش و فاکتور برگشتی آن را افزایش میدهد.
|
<strong>انبارداری:</strong> فاکتور فروش قطعی موجودی را کاهش میدهد؛ فاکتور لغو شده اثر موجودی را برنمیگرداند اگر قبلاً لغو نشده باشد.
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<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 summary = useMemo(() => salesSummary(data), [data])
|
||||||
|
|
||||||
const filteredDocs = useMemo(() => {
|
const filteredDocs = useMemo(() => {
|
||||||
let docs = salesDocuments(data.invoices)
|
let docs = salesDocuments(data.invoices).filter((i) => (i.documentType ?? 'invoice') !== 'return')
|
||||||
if (filterType !== 'all') {
|
if (filterType !== 'all') {
|
||||||
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
|
docs = docs.filter((i) => (i.documentType ?? 'invoice') === filterType)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export function SalesReview() {
|
|||||||
header: 'وضعیت',
|
header: 'وضعیت',
|
||||||
render: (i) => (
|
render: (i) => (
|
||||||
<div className="flex flex-col items-start gap-1">
|
<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]}
|
{statusLabels[i.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isFunZoneSalesInvoice(i) ? <Badge tone="slate">فانزون</Badge> : null}
|
{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">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard label="کل فروش" value={formatMoney(summary.totalSales)} />
|
<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={formatMoney(summary.netSales)} />
|
||||||
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
|
<StatCard label="مشتریان" value={toFa(summary.customerCount)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +106,9 @@ export function SalesReview() {
|
|||||||
<Field label="نوع سند">
|
<Field label="نوع سند">
|
||||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
|
<Select value={filterType} onChange={(e) => setFilterType(e.target.value as SalesDocumentType | 'all')}>
|
||||||
<option value="all">همه</option>
|
<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}>
|
<option key={value} value={value}>
|
||||||
{label}
|
{label}
|
||||||
</option>
|
</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 type { AppData, Invoice } from '../../types'
|
||||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
import { isEventCustomerPayment } from '../../utils/funzoneFees'
|
import { isEventCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
|
||||||
import { invoiceTotals } from '../../utils/accounting'
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
import { eventNameWithCategory } from '../../utils/eventName'
|
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_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
|
||||||
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
|
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)
|
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 {
|
function isoDateFromApi(iso: string): string {
|
||||||
return iso.slice(0, 10)
|
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))
|
return invoices.find((inv) => inv.note.includes(tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancellationAlreadyBooked(
|
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[],
|
invoices: Invoice[],
|
||||||
customerName: string,
|
partyId: string,
|
||||||
amount: number,
|
): Invoice | undefined {
|
||||||
): boolean {
|
if (cancellation.reservation_id) {
|
||||||
for (const inv of invoices) {
|
const payment = payments.find(
|
||||||
if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue
|
(p) =>
|
||||||
const net = invoiceTotals(inv).net
|
p.type === 'payment' &&
|
||||||
if (Math.abs(net - amount) > 0.01) continue
|
p.reservation_id &&
|
||||||
if (inv.note.includes(customerName)) return true
|
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 {
|
export function invoiceUnchanged(existing: Invoice, built: Invoice): boolean {
|
||||||
if (existing.partyId !== built.partyId || existing.date !== built.date) return false
|
if (existing.partyId !== built.partyId || existing.date !== built.date) return false
|
||||||
if (existing.documentType !== built.documentType) return false
|
if (existing.documentType !== built.documentType) return false
|
||||||
if (existing.status !== built.status) 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
|
if (Math.abs(invoiceTotals(existing).net - invoiceTotals(built).net) > 0.01) return false
|
||||||
const existingEvent = existing.lines[0]?.eventName ?? ''
|
const existingEvent = existing.lines[0]?.eventName ?? ''
|
||||||
const builtEvent = built.lines[0]?.eventName ?? ''
|
const builtEvent = built.lines[0]?.eventName ?? ''
|
||||||
return existingEvent === builtEvent
|
return existingEvent === builtEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Event ticket payment → فاکتور فروش; booking cancellation → فاکتور برگشتی. */
|
/** Event ticket payment → فاکتور فروش; booking cancellation → mark original as لغو شده. */
|
||||||
export function buildSalesInvoiceFromCustomerPayment(
|
export function buildSalesInvoiceFromCustomerPayment(
|
||||||
item: ApiPaymentOrRefund,
|
item: ApiPaymentOrRefund,
|
||||||
partyId: string,
|
partyId: string,
|
||||||
existingInvoices: Invoice[],
|
existingInvoices: Invoice[],
|
||||||
number: number,
|
number: number,
|
||||||
existing?: Invoice,
|
existing?: Invoice,
|
||||||
|
allPayments: ApiPaymentOrRefund[] = [],
|
||||||
): Invoice | null {
|
): Invoice | null {
|
||||||
const amount = Math.abs(item.amount)
|
const amount = Math.abs(item.amount)
|
||||||
if (amount <= 0) return null
|
if (amount <= 0) return null
|
||||||
@@ -80,6 +149,12 @@ export function buildSalesInvoiceFromCustomerPayment(
|
|||||||
|
|
||||||
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||||
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
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 {
|
return {
|
||||||
id: prior?.id ?? '',
|
id: prior?.id ?? '',
|
||||||
@@ -89,7 +164,15 @@ export function buildSalesInvoiceFromCustomerPayment(
|
|||||||
partyId,
|
partyId,
|
||||||
date: isoDateFromApi(item.created_at),
|
date: isoDateFromApi(item.created_at),
|
||||||
status: prior?.status === 'draft' ? 'draft' : 'paid',
|
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: [
|
lines: [
|
||||||
{
|
{
|
||||||
id: prior?.lines[0]?.id ?? createId('il-'),
|
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'
|
const isWallet = (item.event_name || '').toLowerCase() === 'wallet'
|
||||||
if (isWallet) {
|
if (isWallet) return null
|
||||||
if (cancellationAlreadyBooked(existingInvoices, item.customer_name, amount)) return null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag = funzoneCancellationNoteTag(item.id)
|
if (!isEventCustomerPayment(item.event_name)) return null
|
||||||
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
|
||||||
|
|
||||||
return {
|
const original = findOriginalTicketInvoiceForCancellation(
|
||||||
id: prior?.id ?? '',
|
item,
|
||||||
kind: 'sale',
|
allPayments,
|
||||||
documentType: 'return',
|
existingInvoices,
|
||||||
number: prior?.number ?? number,
|
|
||||||
partyId,
|
partyId,
|
||||||
date: isoDateFromApi(item.created_at),
|
)
|
||||||
status: prior?.status ?? 'confirmed',
|
if (!original) return null
|
||||||
note: `${tag}\nبرگشت خودکار از فانزون — ${item.customer_name}`,
|
|
||||||
lines: [
|
const cancelled = markInvoiceCancelled(original, item)
|
||||||
{
|
if (existing && invoiceUnchanged(existing, cancelled)) return null
|
||||||
id: prior?.lines[0]?.id ?? createId('il-'),
|
return cancelled
|
||||||
productId: '',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: amount,
|
|
||||||
discount: 0,
|
|
||||||
taxRate: 0,
|
|
||||||
eventName: eventNameWithCategory(item.event_name, item.category_name),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relatedInvoiceId: prior?.relatedInvoiceId,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FunZoneSalesSyncResult {
|
export interface FunZoneSalesSyncResult {
|
||||||
toUpsert: Invoice[]
|
toUpsert: Invoice[]
|
||||||
|
toRemoveIds: string[]
|
||||||
|
toRemoveVoucherIds: string[]
|
||||||
skipped: number
|
skipped: number
|
||||||
unmatchedCustomers: number
|
unmatchedCustomers: number
|
||||||
paymentCount: number
|
paymentCount: number
|
||||||
cancellationCount: number
|
cancellationCount: number
|
||||||
|
cancelledInvoices: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFunZoneSalesSync(
|
export function buildFunZoneSalesSync(
|
||||||
data: Pick<AppData, 'parties' | 'invoices'>,
|
data: Pick<AppData, 'parties' | 'invoices' | 'vouchers'>,
|
||||||
customerPayments: ApiPaymentOrRefund[],
|
customerPayments: ApiPaymentOrRefund[],
|
||||||
): FunZoneSalesSyncResult {
|
): FunZoneSalesSyncResult {
|
||||||
const toUpsert: Invoice[] = []
|
const toUpsert: Invoice[] = []
|
||||||
|
const toRemoveIds: string[] = []
|
||||||
|
const toRemoveVoucherIds: string[] = []
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let unmatchedCustomers = 0
|
let unmatchedCustomers = 0
|
||||||
let paymentCount = 0
|
let paymentCount = 0
|
||||||
let cancellationCount = 0
|
let cancellationCount = 0
|
||||||
|
let cancelledInvoices = 0
|
||||||
|
|
||||||
let invoiceNumber = nextNumber(byDocumentType(data.invoices, 'invoice'))
|
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')
|
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||||
if (!party?.id) {
|
if (!party?.id) {
|
||||||
unmatchedCustomers += 1
|
unmatchedCustomers += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'payment' && isEventCustomerPayment(item.event_name)) paymentCount += 1
|
if (isEventCustomerPayment(item.event_name)) paymentCount += 1
|
||||||
if (item.type === 'cancellation' && isEventCustomerPayment(item.event_name)) cancellationCount += 1
|
|
||||||
|
|
||||||
const tag =
|
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||||
item.type === 'payment'
|
const existing = findInvoiceByNoteTag(working, tag)
|
||||||
? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
|
||||||
: funzoneCancellationNoteTag(item.id)
|
|
||||||
const existing = findInvoiceByNoteTag(data.invoices, tag)
|
|
||||||
const built = buildSalesInvoiceFromCustomerPayment(
|
const built = buildSalesInvoiceFromCustomerPayment(
|
||||||
item,
|
item,
|
||||||
party.id,
|
party.id,
|
||||||
data.invoices,
|
working,
|
||||||
item.type === 'payment' ? invoiceNumber : returnNumber,
|
invoiceNumber,
|
||||||
existing,
|
existing,
|
||||||
|
customerPayments,
|
||||||
)
|
)
|
||||||
if (!built) continue
|
if (!built) continue
|
||||||
|
|
||||||
@@ -187,14 +263,77 @@ export function buildFunZoneSalesSync(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
toUpsert.push(built)
|
const withId = { ...built, id: built.id || createId('inv-') }
|
||||||
if (!existing) {
|
const upsertIdx = toUpsert.findIndex((i) => i.id === withId.id)
|
||||||
if (built.documentType === 'invoice') invoiceNumber += 1
|
if (upsertIdx >= 0) toUpsert[upsertIdx] = withId
|
||||||
else returnNumber += 1
|
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[] {
|
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)
|
: treasurySourceCustomerPayment(ref.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Event ticket sales are booked once via treasury (fee split); skip duplicate invoice vouchers. */
|
function treasurySourcesForFunzonePaymentId(paymentId: string): string[] {
|
||||||
export function shouldSkipInvoiceVoucherForFunzoneTicket(
|
return [treasurySourceCustomerPayment(paymentId), treasurySourceWalletPayment(paymentId)]
|
||||||
invoice: Invoice,
|
|
||||||
data: Pick<AppData, 'treasury'>,
|
|
||||||
): boolean {
|
|
||||||
const source = treasurySourceForFunzoneInvoice(invoice)
|
|
||||||
if (!source) return false
|
|
||||||
return data.treasury.some((txn) => txn.source === source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes invoice vouchers when a treasury voucher already exists for the same FunZone payment. */
|
/**
|
||||||
|
* 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[] {
|
export function duplicateFunzoneInvoiceVoucherIds(data: AppData): string[] {
|
||||||
const toRemove: string[] = []
|
const toRemove: string[] = []
|
||||||
for (const invoice of data.invoices) {
|
for (const invoice of data.invoices) {
|
||||||
if (!isFunZoneTicketSalesInvoice(invoice)) continue
|
if (!isFunZoneTicketSalesInvoice(invoice)) continue
|
||||||
const source = treasurySourceForFunzoneInvoice(invoice)
|
const ref = funzonePaymentRefFromInvoice(invoice)
|
||||||
if (!source) continue
|
if (!ref) continue
|
||||||
const treasuryTxn = data.treasury.find((txn) => txn.source === source)
|
const sources = treasurySourcesForFunzonePaymentId(ref.id)
|
||||||
|
const treasuryTxn = data.treasury.find((txn) => sources.includes(txn.source ?? ''))
|
||||||
if (!treasuryTxn) continue
|
if (!treasuryTxn) continue
|
||||||
const treasuryVoucher = data.vouchers.find(
|
const treasuryVoucher = data.vouchers.find(
|
||||||
(v) => v.source === voucherSourceForTreasury(treasuryTxn.id),
|
(v) => v.source === voucherSourceForTreasury(treasuryTxn.id),
|
||||||
)
|
)
|
||||||
const invoiceVoucher = findVoucherForInvoice(data.vouchers, invoice)
|
const invoiceVoucher = findVoucherForInvoice(data.vouchers, invoice)
|
||||||
if (treasuryVoucher && invoiceVoucher && treasuryVoucher.id !== invoiceVoucher.id) {
|
if (treasuryVoucher && invoiceVoucher && treasuryVoucher.id !== invoiceVoucher.id) {
|
||||||
toRemove.push(invoiceVoucher.id)
|
toRemove.push(treasuryVoucher.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return toRemove
|
return toRemove
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const salesNavItems: SalesNavItem[] = [
|
|||||||
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
|
{ to: '/sales', label: 'فرایند فروش', icon: '🔀', end: true },
|
||||||
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
|
{ to: '/sales/proforma', label: 'پیشفاکتور جدید', icon: '📋' },
|
||||||
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
|
{ to: '/sales/invoice', label: 'فاکتور فروش جدید', icon: '🧾' },
|
||||||
{ to: '/sales/return', label: 'فاکتور برگشتی جدید', icon: '↩️' },
|
{ to: '/sales/return', label: 'برداشتها و لغو شده', icon: '🚫' },
|
||||||
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
|
{ to: '/sales/adjustments', label: 'اعلامیه بدهکار/بستانکار', icon: '⚖️' },
|
||||||
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
|
{ to: '/sales/sales-types', label: 'نوع فروش جدید', icon: '🏷️' },
|
||||||
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
|
{ to: '/sales/customers', label: 'مشتریان', icon: '👤' },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildInvoiceVoucher,
|
buildInvoiceVoucher,
|
||||||
buildPurchaseVoucher,
|
buildPurchaseVoucher,
|
||||||
buildSalesVoucher,
|
buildSalesVoucher,
|
||||||
|
buildTreasuryVoucher,
|
||||||
invoicesToSettle,
|
invoicesToSettle,
|
||||||
isWithdrawalReturn,
|
isWithdrawalReturn,
|
||||||
partyAccountBalance,
|
partyAccountBalance,
|
||||||
@@ -58,6 +59,9 @@ const accounts = [
|
|||||||
{ id: 'a1004', code: '1004', name: 'Receivable', 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: 'a1005', code: '1005', name: 'Inventory', 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: '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 },
|
{ id: 'a4001', code: '4001', name: 'Sales', type: 'income' as const, parentId: null, isGroup: false, openingBalance: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,8 +86,8 @@ const baseData = (): AppData => ({
|
|||||||
phone: '',
|
phone: '',
|
||||||
voucherAccounts: {
|
voucherAccounts: {
|
||||||
bankAccountId: 'a1002',
|
bankAccountId: 'a1002',
|
||||||
ownerPayableAccountId: 'a2001',
|
ownerPayableAccountId: 'a2005',
|
||||||
customerPayableAccountId: 'a2001',
|
customerPayableAccountId: 'a2006',
|
||||||
ownerShareAccountId: 'a4001',
|
ownerShareAccountId: 'a4001',
|
||||||
salesIncomeAccountId: 'a4001',
|
salesIncomeAccountId: 'a4001',
|
||||||
},
|
},
|
||||||
@@ -190,6 +194,50 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
|||||||
assert.ok(buildInvoiceVoucher(purchaseInvoice('confirmed'), data))
|
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
|
// Withdrawal return: no stock impact
|
||||||
{
|
{
|
||||||
const withdrawalReturn: Invoice = {
|
const withdrawalReturn: Invoice = {
|
||||||
@@ -237,7 +285,72 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
|||||||
assert.equal(settled[0].id, 'inv-a')
|
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()
|
const data = baseData()
|
||||||
data.invoices = [
|
data.invoices = [
|
||||||
@@ -271,6 +384,7 @@ const purchaseInvoice = (status: Invoice['status'] = 'confirmed'): Invoice => ({
|
|||||||
assert.equal(settled.length, 2)
|
assert.equal(settled.length, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Treasury delete reverts paid status
|
// Treasury delete reverts paid status
|
||||||
{
|
{
|
||||||
const data = baseData()
|
const data = baseData()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
import { invoiceTotals } from '../../utils/accounting'
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
|
import { eventNameFromTreasuryDescription } from '../../utils/eventName'
|
||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
|
import { isWalletCustomerPayment, splitEventPayment } from '../../utils/funzoneFees'
|
||||||
|
|
||||||
/** Resolves a chart account by code, with optional settings fallback. */
|
/** Resolves a chart account by code, with optional settings fallback. */
|
||||||
export function accountByCode(
|
export function accountByCode(
|
||||||
@@ -27,13 +28,24 @@ export function accountByCode(
|
|||||||
export const documentTypeLabels: Record<SalesDocumentType, string> = {
|
export const documentTypeLabels: Record<SalesDocumentType, string> = {
|
||||||
proforma: 'پیشفاکتور',
|
proforma: 'پیشفاکتور',
|
||||||
invoice: 'فاکتور فروش',
|
invoice: 'فاکتور فروش',
|
||||||
return: 'فاکتور برگشتی',
|
/** Legacy only — app no longer creates return documents. */
|
||||||
|
return: 'فاکتور برگشتی (قدیمی)',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const statusLabels: Record<InvoiceStatus, string> = {
|
export const statusLabels: Record<InvoiceStatus, string> = {
|
||||||
draft: 'پیشنویس',
|
draft: 'پیشنویس',
|
||||||
confirmed: 'قطعی',
|
confirmed: 'قطعی',
|
||||||
paid: 'تسویهشده',
|
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). */
|
/** Sales documents only (kind=sale). */
|
||||||
@@ -63,10 +75,13 @@ export function partyReceivable(
|
|||||||
let balance = party?.openingBalance ?? 0
|
let balance = party?.openingBalance ?? 0
|
||||||
|
|
||||||
for (const inv of salesDocuments(data.invoices)) {
|
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 net = invoiceTotals(inv).net
|
||||||
const docType = inv.documentType ?? 'invoice'
|
const docType = inv.documentType ?? 'invoice'
|
||||||
if (docType === 'proforma') continue
|
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
|
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. */
|
/** Whether stock/inventory should be affected for this document. */
|
||||||
export function affectsStock(invoice: Invoice): boolean {
|
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 (isWithdrawalReturn(invoice)) return false
|
||||||
if (invoice.kind === 'purchase') return true
|
if (invoice.kind === 'purchase') return true
|
||||||
const docType = invoice.documentType ?? 'invoice'
|
const docType = invoice.documentType ?? 'invoice'
|
||||||
@@ -221,6 +236,7 @@ export function buildSalesVoucher(
|
|||||||
credit,
|
credit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Legacy withdrawal return invoices (no longer created by the app).
|
||||||
if (isWithdrawalReturn(invoice)) {
|
if (isWithdrawalReturn(invoice)) {
|
||||||
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
|
const { customerPayableAccountId, bankAccountId: bankSettingId } = data.settings.voucherAccounts
|
||||||
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
const bankId = accountByCode(data.accounts, '1002', bankSettingId)
|
||||||
@@ -235,7 +251,7 @@ export function buildSalesVoucher(
|
|||||||
description: `برداشت کیف پول - ${label}`,
|
description: `برداشت کیف پول - ${label}`,
|
||||||
regarding: regarding ?? 'wallet',
|
regarding: regarding ?? 'wallet',
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice.id),
|
source: withdrawalId ? `withdrawal:${withdrawalId}` : voucherSourceForInvoice(invoice),
|
||||||
lines: [
|
lines: [
|
||||||
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
|
makeLine(payableId, net, 0, `برداشت کیف پول - ${label}`),
|
||||||
makeLine(bankId, 0, net, `پرداخت از بانک - ${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
|
if (!salesIncomeAccountId || !receivableAccountId) return null
|
||||||
|
|
||||||
|
// Legacy return path (app no longer creates these).
|
||||||
const isReturn = docType === 'return'
|
const isReturn = docType === 'return'
|
||||||
const lines: VoucherLine[] = isReturn
|
const lines: VoucherLine[] = isReturn
|
||||||
? [
|
? [
|
||||||
@@ -263,7 +360,7 @@ export function buildSalesVoucher(
|
|||||||
description: label,
|
description: label,
|
||||||
regarding,
|
regarding,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForInvoice(invoice.id),
|
source: voucherSourceForInvoice(invoice),
|
||||||
lines,
|
lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +396,7 @@ export function buildPurchaseVoucher(
|
|||||||
date: invoice.date,
|
date: invoice.date,
|
||||||
description: label,
|
description: label,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForInvoice(invoice.id),
|
source: voucherSourceForInvoice(invoice),
|
||||||
lines: [
|
lines: [
|
||||||
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
|
makeLine(inventoryAccountId, net, 0, `افزایش موجودی - ${label}`),
|
||||||
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
|
makeLine(payableAccountId, 0, net, `بدهی به تأمینکننده - ${label}`),
|
||||||
@@ -379,6 +476,7 @@ export function reconcileInvoicePaymentStatus(
|
|||||||
const updates: Invoice[] = []
|
const updates: Invoice[] = []
|
||||||
for (const inv of data.invoices) {
|
for (const inv of data.invoices) {
|
||||||
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
|
if (inv.kind !== kind || inv.partyId !== partyId || inv.status === 'draft') continue
|
||||||
|
if (inv.status === 'cancelled') continue
|
||||||
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
if (kind === 'sale' && (inv.documentType ?? 'invoice') !== 'invoice') continue
|
||||||
|
|
||||||
const shouldPaid = shouldBePaidIds.has(inv.id)
|
const shouldPaid = shouldBePaidIds.has(inv.id)
|
||||||
@@ -395,8 +493,12 @@ export function findVoucherForInvoice(
|
|||||||
vouchers: Voucher[],
|
vouchers: Voucher[],
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
): Voucher | undefined {
|
): Voucher | undefined {
|
||||||
const byInvoice = vouchers.find((v) => v.source === voucherSourceForInvoice(invoice.id))
|
const preferred = voucherSourceForInvoice(invoice)
|
||||||
if (byInvoice) return byInvoice
|
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)
|
const withdrawalId = withdrawalIdFromNote(invoice.note)
|
||||||
if (withdrawalId) {
|
if (withdrawalId) {
|
||||||
@@ -489,6 +591,51 @@ export function buildTreasuryVoucher(
|
|||||||
const tax = txn.taxAmount ?? 0
|
const tax = txn.taxAmount ?? 0
|
||||||
const profit = txn.platformProfitAmount ?? 0
|
const profit = txn.platformProfitAmount ?? 0
|
||||||
const ownerNet = txn.ownerNetAmount ?? 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 =
|
const hasEventSplit =
|
||||||
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
txn.kind === 'receipt' && tax > 0 && profit > 0 && ownerNet > 0 && ownerPayableId && platformIncomeId && taxAccountId
|
||||||
|
|
||||||
@@ -505,7 +652,7 @@ export function buildTreasuryVoucher(
|
|||||||
description: txn.description || label,
|
description: txn.description || label,
|
||||||
regarding,
|
regarding,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForTreasury(txn.id),
|
source: voucherSourceForTreasury(txn),
|
||||||
lines: [
|
lines: [
|
||||||
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
|
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
|
||||||
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
||||||
@@ -535,45 +682,61 @@ export function buildTreasuryVoucher(
|
|||||||
description: txn.description || label,
|
description: txn.description || label,
|
||||||
regarding: txn.eventName,
|
regarding: txn.eventName,
|
||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForTreasury(txn.id),
|
source: voucherSourceForTreasury(txn),
|
||||||
lines,
|
lines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function voucherSourceForInvoice(invoiceId: string): string {
|
export function voucherSourceForInvoice(invoiceOrId: Invoice | string): string {
|
||||||
return `invoice:${invoiceId}`
|
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 {
|
export function voucherSourceForAdjustment(adjustmentId: string): string {
|
||||||
return `adjustment:${adjustmentId}`
|
return `adjustment:${adjustmentId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function voucherSourceForTreasury(txnId: string): string {
|
/** Prefer stable FunZone/withdrawal source when present so vouchers survive server UUID remap. */
|
||||||
return `treasury:${txnId}`
|
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. */
|
/** Summary stats for the sales process dashboard. */
|
||||||
export function salesSummary(data: AppData) {
|
export function salesSummary(data: AppData) {
|
||||||
const proformas = byDocumentType(data.invoices, 'proforma')
|
const proformas = byDocumentType(data.invoices, 'proforma')
|
||||||
const invoices = byDocumentType(data.invoices, 'invoice')
|
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[]) =>
|
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 unpaid = invoices.filter((i) => i.status === 'confirmed')
|
||||||
const customersList = customers(data.parties)
|
const customersList = customers(data.parties)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proformaCount: proformas.length,
|
proformaCount: proformas.length,
|
||||||
invoiceCount: invoices.length,
|
invoiceCount: activeInvoices.length,
|
||||||
returnCount: returns.length,
|
cancelledCount: cancelled.length,
|
||||||
|
/** @deprecated kept for older UI — always 0 conceptually; use cancelledCount */
|
||||||
|
returnCount: cancelled.length,
|
||||||
adjustmentCount: data.partyAdjustments.length,
|
adjustmentCount: data.partyAdjustments.length,
|
||||||
salesTypeCount: data.salesTypes.length,
|
salesTypeCount: data.salesTypes.length,
|
||||||
customerCount: customersList.length,
|
customerCount: customersList.length,
|
||||||
totalSales: sumNet(invoices),
|
totalSales: sumNet(invoices),
|
||||||
totalReturns: sumNet(returns),
|
totalCancelled: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
|
||||||
netSales: sumNet(invoices) - sumNet(returns),
|
totalReturns: cancelled.reduce((s, i) => s + invoiceTotals(i).net, 0),
|
||||||
|
netSales: sumNet(invoices),
|
||||||
unpaidCount: unpaid.length,
|
unpaidCount: unpaid.length,
|
||||||
unpaidAmount: sumNet(unpaid),
|
unpaidAmount: sumNet(unpaid),
|
||||||
openProformas: proformas.filter((p) => p.status === 'draft').length,
|
openProformas: proformas.filter((p) => p.status === 'draft').length,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { apiGet } from '../../api/client'
|
import { apiGet, apiPatch } from '../../api/client'
|
||||||
import { ENDPOINTS } from '../../api/config'
|
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) {
|
export function usePendingCustomerWithdrawals(enabled = true) {
|
||||||
const [withdrawals, setWithdrawals] = useState<ApiWithdrawal[]>([])
|
const [withdrawals, setWithdrawals] = useState<ApiWithdrawal[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -14,10 +15,10 @@ export function usePendingCustomerWithdrawals(enabled = true) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
const res = await apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS)
|
||||||
const pending = (res.withdrawals ?? []).filter(
|
const list = [...(res.withdrawals ?? [])].sort(
|
||||||
(w) => w.user_type === 'customer' && w.status === 'pending',
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
)
|
)
|
||||||
setWithdrawals(pending)
|
setWithdrawals(list)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
|
setError(err instanceof Error ? err.message : 'خطا در بارگذاری برداشتها')
|
||||||
setWithdrawals([])
|
setWithdrawals([])
|
||||||
@@ -30,7 +31,31 @@ export function usePendingCustomerWithdrawals(enabled = true) {
|
|||||||
if (enabled) void load()
|
if (enabled) void load()
|
||||||
}, [enabled, 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}`
|
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,
|
buildFunZoneTreasurySync,
|
||||||
buildTreasuryFromCustomerPayment,
|
buildTreasuryFromCustomerPayment,
|
||||||
buildTreasuryFromOwnerTransaction,
|
buildTreasuryFromOwnerTransaction,
|
||||||
|
isMisplacedTicketTreasuryTxn,
|
||||||
} from './funzoneTreasurySync'
|
} from './funzoneTreasurySync'
|
||||||
|
import { buildTreasuryVoucher } from '../sales/salesUtils'
|
||||||
|
|
||||||
// Fee split matches admin panel (10% tax + 14% profit)
|
|
||||||
{
|
{
|
||||||
const split = splitEventPayment(100_000)
|
const split = splitEventPayment(100_000)
|
||||||
assert.equal(split.tax, 10_000)
|
assert.equal(split.tax, 10_000)
|
||||||
@@ -47,7 +48,37 @@ const owner: Party = {
|
|||||||
externalSource: 'funzone_owner',
|
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 = {
|
const item: ApiPaymentOrRefund = {
|
||||||
id: 'pay-1',
|
id: 'pay-1',
|
||||||
@@ -64,14 +95,119 @@ const owner: Party = {
|
|||||||
reservation_id: 'res-1',
|
reservation_id: 'res-1',
|
||||||
refund_status: null,
|
refund_status: null,
|
||||||
}
|
}
|
||||||
const txn = buildTreasuryFromCustomerPayment(item, 'c1', 1)!
|
assert.equal(buildTreasuryFromCustomerPayment(item, 'c1', 1), null)
|
||||||
assert.equal(txn.kind, 'receipt')
|
|
||||||
assert.equal(txn.platformProfitAmount, 14_000)
|
|
||||||
assert.equal(txn.taxAmount, 10_000)
|
|
||||||
assert.equal(txn.ownerNetAmount, 76_000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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[] = [
|
const ownerTxns: ApiOwnerTransaction[] = [
|
||||||
{
|
{
|
||||||
@@ -106,9 +242,26 @@ const owner: Party = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const stale: TreasuryTxn = {
|
const staleTicket: TreasuryTxn = {
|
||||||
id: 'tr-stale',
|
id: 'tr-ticket',
|
||||||
number: 1,
|
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',
|
kind: 'payment',
|
||||||
method: 'bank',
|
method: 'bank',
|
||||||
date: '2026-01-02',
|
date: '2026-01-02',
|
||||||
@@ -120,32 +273,70 @@ const owner: Party = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = buildFunZoneTreasurySync(
|
const result = buildFunZoneTreasurySync(
|
||||||
{ parties: [customer, owner], treasury: [stale] },
|
{ parties: [customer, owner], treasury: [staleTicket, staleBooking] },
|
||||||
[],
|
[],
|
||||||
ownerTxns,
|
ownerTxns,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(result.excludedOwnerTxns, 2)
|
assert.equal(result.excludedOwnerTxns, 2)
|
||||||
assert.equal(result.toRemove.length, 1)
|
assert.equal(result.toRemove.length, 2)
|
||||||
assert.equal(result.toRemove[0].id, 'tr-stale')
|
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.length, 1)
|
||||||
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
|
assert.equal(result.toUpsert[0].source, 'funzone:owner-txn:ot-withdraw')
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(buildTreasuryFromOwnerTransaction(
|
// Pending owner withdraw must NOT enter خزانه
|
||||||
{
|
{
|
||||||
id: 'x',
|
const pendingWithdraw: ApiOwnerTransaction = {
|
||||||
|
id: 'ot-pending',
|
||||||
owner_id: 'own-1',
|
owner_id: 'own-1',
|
||||||
type: 'deposit',
|
type: 'withdraw',
|
||||||
amount: 1,
|
amount: 10_000,
|
||||||
status: 'completed',
|
status: 'pending',
|
||||||
description: '',
|
description: 'pending',
|
||||||
created_at: '2026-01-01',
|
created_at: '2026-01-04T10:00:00Z',
|
||||||
updated_at: '2026-01-01',
|
updated_at: '2026-01-04T10:00:00Z',
|
||||||
},
|
}
|
||||||
'o1',
|
const premature: TreasuryTxn = {
|
||||||
'Owner',
|
id: 'tr-pending',
|
||||||
1,
|
number: 9,
|
||||||
), null)
|
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',
|
||||||
|
type: 'deposit',
|
||||||
|
amount: 1,
|
||||||
|
status: 'completed',
|
||||||
|
description: '',
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
updated_at: '2026-01-01',
|
||||||
|
},
|
||||||
|
'o1',
|
||||||
|
'Owner',
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
console.log('funzoneTreasurySync tests passed.')
|
console.log('funzoneTreasurySync tests passed.')
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../../api/types'
|
|||||||
import type { AppData, TreasuryTxn } from '../../types'
|
import type { AppData, TreasuryTxn } from '../../types'
|
||||||
import { findExistingParty } from '../../parties/funzonePartySync'
|
import { findExistingParty } from '../../parties/funzonePartySync'
|
||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
import { formatEventName } from '../../utils/eventName'
|
|
||||||
import {
|
import {
|
||||||
isEventCustomerPayment,
|
isEventCustomerPayment,
|
||||||
|
isWalletCustomerPayment,
|
||||||
ownerTxnExcludedFromTreasury,
|
ownerTxnExcludedFromTreasury,
|
||||||
ownerTxnIncludedInTreasury,
|
ownerTxnIncludedInTreasury,
|
||||||
splitEventPayment,
|
|
||||||
} from '../../utils/funzoneFees'
|
} from '../../utils/funzoneFees'
|
||||||
|
|
||||||
const ownerTypeLabels: Record<string, string> = {
|
const ownerTypeLabels: Record<string, string> = {
|
||||||
@@ -39,10 +38,6 @@ function isoDateFromApi(iso: string): string {
|
|||||||
return iso.slice(0, 10)
|
return iso.slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventLabel(eventName: string | null | undefined): string {
|
|
||||||
return formatEventName(eventName)
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
if (status === 'completed') return 'انجامشده'
|
if (status === 'completed') return 'انجامشده'
|
||||||
if (status === 'pending') return 'در انتظار'
|
if (status === 'pending') return 'در انتظار'
|
||||||
@@ -50,7 +45,11 @@ function statusLabel(status: string): string {
|
|||||||
return status
|
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(
|
export function buildTreasuryFromCustomerPayment(
|
||||||
item: ApiPaymentOrRefund,
|
item: ApiPaymentOrRefund,
|
||||||
partyId: string,
|
partyId: string,
|
||||||
@@ -61,56 +60,58 @@ export function buildTreasuryFromCustomerPayment(
|
|||||||
if (amount <= 0) return null
|
if (amount <= 0) return null
|
||||||
|
|
||||||
const isPayment = item.type === 'payment'
|
const isPayment = item.type === 'payment'
|
||||||
const fromWallet = item.payment_method === 'wallet'
|
// Event ticket payments belong on sales invoices (tax + 14% + owner), not دریافت.
|
||||||
const kind = isPayment ? 'receipt' : 'payment'
|
if (isPayment && isEventCustomerPayment(item.event_name)) return null
|
||||||
const isEvent = isPayment && isEventCustomerPayment(item.event_name)
|
// Cancellation/refunds credit the customer wallet — not a bank payout.
|
||||||
const split = isEvent ? splitEventPayment(amount) : null
|
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 = [
|
const lines = [
|
||||||
isPayment
|
`دریافت از مشتری (شارژ کیف پول) — ${item.customer_name}`,
|
||||||
? fromWallet
|
'نوع: شارژ کیف پول',
|
||||||
? `پرداخت از کیف پول — ${item.customer_name}`
|
`مبلغ: ${amount}`,
|
||||||
: `دریافت از مشتری — ${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.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
|
item.trace_no ? `شماره پیگیری: ${item.trace_no}` : null,
|
||||||
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
|
item.ref_num ? `مرجع بانکی: ${item.ref_num}` : null,
|
||||||
item.refund_status ? `وضعیت بازپرداخت: ${statusLabel(item.refund_status)}` : null,
|
|
||||||
'منبع: فانزون',
|
'منبع: فانزون',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: existing?.id ?? '',
|
id: existing?.id ?? '',
|
||||||
number: existing?.number ?? number,
|
number: existing?.number ?? number,
|
||||||
kind,
|
kind: 'receipt',
|
||||||
method: fromWallet ? 'cash' : 'bank',
|
method: 'bank',
|
||||||
date: isoDateFromApi(item.created_at),
|
date: isoDateFromApi(item.created_at),
|
||||||
partyId,
|
partyId,
|
||||||
amount,
|
amount,
|
||||||
taxAmount: split?.tax ?? 0,
|
taxAmount: 0,
|
||||||
platformProfitAmount: split?.platformProfit ?? 0,
|
platformProfitAmount: 0,
|
||||||
ownerNetAmount: split?.ownerNet ?? 0,
|
ownerNetAmount: 0,
|
||||||
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||||
description: lines.join('\n'),
|
description: lines.join('\n'),
|
||||||
eventName: item.event_name || undefined,
|
eventName: 'wallet',
|
||||||
source:
|
source: treasurySourceWalletPayment(item.id),
|
||||||
isPayment && fromWallet
|
|
||||||
? treasurySourceWalletPayment(item.id)
|
|
||||||
: treasurySourceCustomerPayment(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). */
|
/** Owner bank payout or refund only (excludes deposit & booking_payment). */
|
||||||
export function buildTreasuryFromOwnerTransaction(
|
export function buildTreasuryFromOwnerTransaction(
|
||||||
txn: ApiOwnerTransaction,
|
txn: ApiOwnerTransaction,
|
||||||
@@ -174,23 +175,29 @@ export function buildFunZoneTreasurySync(
|
|||||||
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
|
.filter((t) => ownerTxnExcludedFromTreasury(t.type))
|
||||||
.map((t) => treasurySourceOwnerTxn(t.id)),
|
.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 receiptNumber = nextNumber(data.treasury.filter((t) => t.kind === 'receipt'))
|
||||||
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
|
let paymentNumber = nextNumber(data.treasury.filter((t) => t.kind === 'payment'))
|
||||||
|
|
||||||
for (const item of customerPayments) {
|
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')
|
const party = findExistingParty(data.parties, item.customer_id, 'funzone_customer')
|
||||||
if (!party?.id) {
|
if (!party?.id) {
|
||||||
unmatchedCustomers += 1
|
unmatchedCustomers += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const source =
|
const source = treasurySourceWalletPayment(item.id)
|
||||||
item.type === 'payment' && item.payment_method === 'wallet'
|
const existing =
|
||||||
? treasurySourceWalletPayment(item.id)
|
findTreasuryBySource(data.treasury, source) ??
|
||||||
: treasurySourceCustomerPayment(item.id)
|
findTreasuryBySource(data.treasury, treasurySourceCustomerPayment(item.id))
|
||||||
const existing = findTreasuryBySource(data.treasury, source)
|
|
||||||
const built = buildTreasuryFromCustomerPayment(
|
const built = buildTreasuryFromCustomerPayment(
|
||||||
item,
|
item,
|
||||||
party.id,
|
party.id,
|
||||||
@@ -205,12 +212,24 @@ export function buildFunZoneTreasurySync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
toUpsert.push(built)
|
toUpsert.push(built)
|
||||||
if (!existing && built.kind === 'receipt') receiptNumber += 1
|
if (!existing) receiptNumber += 1
|
||||||
if (!existing && built.kind === 'payment') paymentNumber += 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) {
|
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)) {
|
if (ownerTxnExcludedFromTreasury(txn.type)) {
|
||||||
excludedOwnerTxns += 1
|
excludedOwnerTxns += 1
|
||||||
|
|||||||
@@ -22,8 +22,20 @@ export interface FunZoneTreasurySyncStats {
|
|||||||
ownerTxnCount: number
|
ownerTxnCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FunZoneTreasurySyncInput {
|
||||||
|
customerPayments?: ApiPaymentOrRefund[]
|
||||||
|
ownerTransactions?: ApiOwnerTransaction[]
|
||||||
|
}
|
||||||
|
|
||||||
export function useFunZoneTreasurySync() {
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
const [stats, setStats] = useState<FunZoneTreasurySyncStats | null>(null)
|
||||||
@@ -32,7 +44,7 @@ export function useFunZoneTreasurySync() {
|
|||||||
dataRef.current = data
|
dataRef.current = data
|
||||||
|
|
||||||
const persistTreasuryBatch = useCallback(
|
const persistTreasuryBatch = useCallback(
|
||||||
(baseData: AppData, items: TreasuryTxn[]): FunZoneTreasurySyncStats => {
|
async (baseData: AppData, items: TreasuryTxn[]): Promise<FunZoneTreasurySyncStats> => {
|
||||||
const saved = assignTreasuryIds(items)
|
const saved = assignTreasuryIds(items)
|
||||||
let created = 0
|
let created = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
@@ -43,27 +55,38 @@ export function useFunZoneTreasurySync() {
|
|||||||
if (isNew) created += 1
|
if (isNew) created += 1
|
||||||
else updated += 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 voucherKey = voucherSourceForTreasury(persisted)
|
||||||
const voucher = buildTreasuryVoucher(txn, working)
|
const existingVoucher = working.vouchers.find((v) => v.source === voucherKey)
|
||||||
if (existingVoucher) removeVoucher(existingVoucher.id)
|
const builtVoucher = buildTreasuryVoucher(persisted, working)
|
||||||
if (voucher) upsertVoucher(voucher)
|
const voucher =
|
||||||
|
builtVoucher && existingVoucher
|
||||||
|
? { ...builtVoucher, id: existingVoucher.id }
|
||||||
|
: builtVoucher
|
||||||
|
if (!voucher && existingVoucher) await removeVoucherAsync(existingVoucher.id)
|
||||||
|
if (voucher) await upsertVoucherAsync(voucher)
|
||||||
|
|
||||||
working = {
|
working = {
|
||||||
...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: [
|
vouchers: [
|
||||||
...working.vouchers.filter((v) => v.id !== existingVoucher?.id),
|
...working.vouchers.filter((v) => v.id !== existingVoucher?.id && v.source !== voucherKey),
|
||||||
...(voucher ? [voucher] : []),
|
...(voucher ? [voucher] : []),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
if (txn.partyId) {
|
if (persisted.partyId) {
|
||||||
for (const inv of reconcileInvoicePaymentStatus(txn.partyId, working)) {
|
for (const inv of reconcileInvoicePaymentStatus(persisted.partyId, working)) {
|
||||||
const current = working.invoices.find((i) => i.id === inv.id)
|
const current = working.invoices.find((i) => i.id === inv.id)
|
||||||
if (current?.status === inv.status) continue
|
if (current?.status === inv.status) continue
|
||||||
upsertInvoice(inv)
|
await upsertInvoiceAsync(inv)
|
||||||
working = {
|
working = {
|
||||||
...working,
|
...working,
|
||||||
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
|
invoices: working.invoices.map((i) => (i.id === inv.id ? inv : i)),
|
||||||
@@ -84,10 +107,10 @@ export function useFunZoneTreasurySync() {
|
|||||||
ownerTxnCount: 0,
|
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
|
if (syncingRef.current) return null
|
||||||
syncingRef.current = true
|
syncingRef.current = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -95,10 +118,14 @@ export function useFunZoneTreasurySync() {
|
|||||||
try {
|
try {
|
||||||
const current = dataRef.current
|
const current = dataRef.current
|
||||||
const [customerPayments, ownerTransactions] = await Promise.all([
|
const [customerPayments, ownerTransactions] = await Promise.all([
|
||||||
apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
input?.customerPayments
|
||||||
(res) => res.payments_and_refunds ?? [],
|
? Promise.resolve(input.customerPayments)
|
||||||
),
|
: apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS()).then(
|
||||||
apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
(res) => res.payments_and_refunds ?? [],
|
||||||
|
),
|
||||||
|
input?.ownerTransactions
|
||||||
|
? Promise.resolve(input.ownerTransactions)
|
||||||
|
: apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>),
|
||||||
])
|
])
|
||||||
|
|
||||||
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
const { toUpsert, toRemove, skipped, unmatchedCustomers, unmatchedOwners, excludedOwnerTxns } =
|
||||||
@@ -106,9 +133,10 @@ export function useFunZoneTreasurySync() {
|
|||||||
|
|
||||||
let working = { ...current }
|
let working = { ...current }
|
||||||
for (const stale of toRemove) {
|
for (const stale of toRemove) {
|
||||||
const voucher = working.vouchers.find((v) => v.source === voucherSourceForTreasury(stale.id))
|
const voucherKey = voucherSourceForTreasury(stale)
|
||||||
if (voucher) removeVoucher(voucher.id)
|
const voucher = working.vouchers.find((v) => v.source === voucherKey)
|
||||||
removeTreasury(stale.id)
|
if (voucher) await removeVoucherAsync(voucher.id)
|
||||||
|
await removeTreasuryAsync(stale.id)
|
||||||
working = {
|
working = {
|
||||||
...working,
|
...working,
|
||||||
treasury: working.treasury.filter((t) => t.id !== stale.id),
|
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({
|
setStats({
|
||||||
...batchStats,
|
...batchStats,
|
||||||
@@ -126,7 +154,7 @@ export function useFunZoneTreasurySync() {
|
|||||||
unmatchedOwners,
|
unmatchedOwners,
|
||||||
excludedOwnerTxns,
|
excludedOwnerTxns,
|
||||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
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 {
|
return {
|
||||||
...batchStats,
|
...batchStats,
|
||||||
@@ -136,7 +164,7 @@ export function useFunZoneTreasurySync() {
|
|||||||
unmatchedOwners,
|
unmatchedOwners,
|
||||||
excludedOwnerTxns,
|
excludedOwnerTxns,
|
||||||
customerPaymentCount: customerPayments.filter((p) => p.type === 'payment').length,
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی خزانه')
|
||||||
|
|||||||
@@ -94,22 +94,28 @@ interface AppStoreValue {
|
|||||||
upsertAccount: (item: Account) => void
|
upsertAccount: (item: Account) => void
|
||||||
removeAccount: (id: string) => void
|
removeAccount: (id: string) => void
|
||||||
upsertVoucher: (item: Voucher) => void
|
upsertVoucher: (item: Voucher) => void
|
||||||
|
upsertVoucherAsync: (item: Voucher) => Promise<Voucher>
|
||||||
removeVoucher: (id: string) => void
|
removeVoucher: (id: string) => void
|
||||||
|
removeVoucherAsync: (id: string) => Promise<void>
|
||||||
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
|
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
|
||||||
createVouchers: (items: Voucher[]) => Promise<number>
|
createVouchers: (items: Voucher[]) => Promise<number>
|
||||||
upsertParty: (item: Party) => void
|
upsertParty: (item: Party) => void
|
||||||
upsertPartyAsync: (item: Party) => Promise<void>
|
upsertPartyAsync: (item: Party) => Promise<Party>
|
||||||
removeParty: (id: string) => void
|
removeParty: (id: string) => void
|
||||||
upsertProduct: (item: Product) => void
|
upsertProduct: (item: Product) => void
|
||||||
removeProduct: (id: string) => void
|
removeProduct: (id: string) => void
|
||||||
upsertInvoice: (item: Invoice) => void
|
upsertInvoice: (item: Invoice) => void
|
||||||
|
upsertInvoiceAsync: (item: Invoice) => Promise<Invoice>
|
||||||
removeInvoice: (id: string) => void
|
removeInvoice: (id: string) => void
|
||||||
|
removeInvoiceAsync: (id: string) => Promise<void>
|
||||||
upsertSalesType: (item: SalesType) => void
|
upsertSalesType: (item: SalesType) => void
|
||||||
removeSalesType: (id: string) => void
|
removeSalesType: (id: string) => void
|
||||||
upsertPartyAdjustment: (item: PartyAdjustment) => void
|
upsertPartyAdjustment: (item: PartyAdjustment) => void
|
||||||
removePartyAdjustment: (id: string) => void
|
removePartyAdjustment: (id: string) => void
|
||||||
upsertTreasury: (item: TreasuryTxn) => void
|
upsertTreasury: (item: TreasuryTxn) => void
|
||||||
|
upsertTreasuryAsync: (item: TreasuryTxn) => Promise<TreasuryTxn>
|
||||||
removeTreasury: (id: string) => void
|
removeTreasury: (id: string) => void
|
||||||
|
removeTreasuryAsync: (id: string) => Promise<void>
|
||||||
upsertEmployee: (item: Employee) => void
|
upsertEmployee: (item: Employee) => void
|
||||||
removeEmployee: (id: string) => void
|
removeEmployee: (id: string) => void
|
||||||
upsertPayslip: (item: Payslip) => void
|
upsertPayslip: (item: Payslip) => void
|
||||||
@@ -147,6 +153,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
const dataRef = useRef(data)
|
const dataRef = useRef(data)
|
||||||
|
const inFlightLoadRef = useRef<Promise<AppData | null> | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dataRef.current = data
|
dataRef.current = data
|
||||||
}, [data])
|
}, [data])
|
||||||
@@ -164,33 +171,62 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const load = useCallback(async (): Promise<AppData | null> => {
|
const load = useCallback(async (): Promise<AppData | null> => {
|
||||||
setLoading(true)
|
if (inFlightLoadRef.current) return inFlightLoadRef.current
|
||||||
setError(null)
|
|
||||||
try {
|
const request = (async (): Promise<AppData | null> => {
|
||||||
const [accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings] =
|
setLoading(true)
|
||||||
await Promise.all([
|
setError(null)
|
||||||
|
try {
|
||||||
|
// 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.accounts).then(asList<Account>),
|
||||||
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
|
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
|
||||||
acct.get(ENDPOINT.parties).then(asList<Party>),
|
acct.get(ENDPOINT.parties).then(asList<Party>),
|
||||||
|
])
|
||||||
|
const [products, invoices, salesTypes] = await Promise.all([
|
||||||
acct.get(ENDPOINT.products).then(asList<Product>),
|
acct.get(ENDPOINT.products).then(asList<Product>),
|
||||||
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
|
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
|
||||||
acct.get(ENDPOINT.salesTypes).then(asList<SalesType>),
|
acct.get(ENDPOINT.salesTypes).then(asList<SalesType>),
|
||||||
|
])
|
||||||
|
const [partyAdjustments, treasury, employees] = await Promise.all([
|
||||||
acct.get(ENDPOINT.partyAdjustments).then(asList<PartyAdjustment>),
|
acct.get(ENDPOINT.partyAdjustments).then(asList<PartyAdjustment>),
|
||||||
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
|
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
|
||||||
acct.get(ENDPOINT.employees).then(asList<Employee>),
|
acct.get(ENDPOINT.employees).then(asList<Employee>),
|
||||||
|
])
|
||||||
|
const [payslips, settings] = await Promise.all([
|
||||||
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
||||||
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
||||||
])
|
])
|
||||||
const loaded = { accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings }
|
const loaded = {
|
||||||
setData(loaded)
|
accounts,
|
||||||
setReady(true)
|
vouchers,
|
||||||
return loaded
|
parties,
|
||||||
} catch (err) {
|
products,
|
||||||
setReady(false)
|
invoices,
|
||||||
handleApiError(err)
|
salesTypes,
|
||||||
return null
|
partyAdjustments,
|
||||||
|
treasury,
|
||||||
|
employees,
|
||||||
|
payslips,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
setData(loaded)
|
||||||
|
setReady(true)
|
||||||
|
return loaded
|
||||||
|
} catch (err) {
|
||||||
|
setReady(false)
|
||||||
|
handleApiError(err)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
inFlightLoadRef.current = request
|
||||||
|
try {
|
||||||
|
return await request
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
inFlightLoadRef.current = null
|
||||||
}
|
}
|
||||||
}, [handleApiError])
|
}, [handleApiError])
|
||||||
|
|
||||||
@@ -205,7 +241,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [connected, load])
|
}, [connected, load])
|
||||||
|
|
||||||
const upsert = useCallback(
|
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 entity = item as Identifiable
|
||||||
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
|
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
|
||||||
const payload = serializePayload(key, entity)
|
const payload = serializePayload(key, entity)
|
||||||
@@ -218,10 +254,11 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
x.id === entity.id ? (saved as Identifiable) : x,
|
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] }))
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (err) {
|
||||||
handleApiError(err)
|
handleApiError(err)
|
||||||
throw err
|
throw err
|
||||||
@@ -286,7 +323,9 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
upsertAccount: (item) => void upsert('accounts', item),
|
upsertAccount: (item) => void upsert('accounts', item),
|
||||||
removeAccount: (id) => void remove('accounts', id),
|
removeAccount: (id) => void remove('accounts', id),
|
||||||
upsertVoucher: (item) => void upsert('vouchers', item),
|
upsertVoucher: (item) => void upsert('vouchers', item),
|
||||||
|
upsertVoucherAsync: (item) => upsert('vouchers', item),
|
||||||
removeVoucher: (id) => void remove('vouchers', id),
|
removeVoucher: (id) => void remove('vouchers', id),
|
||||||
|
removeVoucherAsync: (id) => remove('vouchers', id),
|
||||||
createVouchers,
|
createVouchers,
|
||||||
upsertParty: (item) => void upsert('parties', item),
|
upsertParty: (item) => void upsert('parties', item),
|
||||||
upsertPartyAsync: (item) => upsert('parties', item),
|
upsertPartyAsync: (item) => upsert('parties', item),
|
||||||
@@ -294,13 +333,17 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
upsertProduct: (item) => void upsert('products', item),
|
upsertProduct: (item) => void upsert('products', item),
|
||||||
removeProduct: (id) => void remove('products', id),
|
removeProduct: (id) => void remove('products', id),
|
||||||
upsertInvoice: (item) => void upsert('invoices', item),
|
upsertInvoice: (item) => void upsert('invoices', item),
|
||||||
|
upsertInvoiceAsync: (item) => upsert('invoices', item),
|
||||||
removeInvoice: (id) => void remove('invoices', id),
|
removeInvoice: (id) => void remove('invoices', id),
|
||||||
|
removeInvoiceAsync: (id) => remove('invoices', id),
|
||||||
upsertSalesType: (item) => void upsert('salesTypes', item),
|
upsertSalesType: (item) => void upsert('salesTypes', item),
|
||||||
removeSalesType: (id) => void remove('salesTypes', id),
|
removeSalesType: (id) => void remove('salesTypes', id),
|
||||||
upsertPartyAdjustment: (item) => void upsert('partyAdjustments', item),
|
upsertPartyAdjustment: (item) => void upsert('partyAdjustments', item),
|
||||||
removePartyAdjustment: (id) => void remove('partyAdjustments', id),
|
removePartyAdjustment: (id) => void remove('partyAdjustments', id),
|
||||||
upsertTreasury: (item) => void upsert('treasury', item),
|
upsertTreasury: (item) => void upsert('treasury', item),
|
||||||
|
upsertTreasuryAsync: (item) => upsert('treasury', item),
|
||||||
removeTreasury: (id) => void remove('treasury', id),
|
removeTreasury: (id) => void remove('treasury', id),
|
||||||
|
removeTreasuryAsync: (id) => remove('treasury', id),
|
||||||
upsertEmployee: (item) => void upsert('employees', item),
|
upsertEmployee: (item) => void upsert('employees', item),
|
||||||
removeEmployee: (id) => void remove('employees', id),
|
removeEmployee: (id) => void remove('employees', id),
|
||||||
upsertPayslip: (item) => void upsert('payslips', item),
|
upsertPayslip: (item) => void upsert('payslips', item),
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export interface InvoiceLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InvoiceKind = 'sale' | 'purchase'
|
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 {
|
export interface Invoice {
|
||||||
id: ID
|
id: ID
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export function isEventCustomerPayment(eventName: string | null | undefined): bo
|
|||||||
return Boolean(eventName && eventName.toLowerCase() !== 'wallet')
|
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. */
|
/** Owner wallet credits already reflected in customer receipts — exclude from treasury. */
|
||||||
export function ownerTxnExcludedFromTreasury(type: string): boolean {
|
export function ownerTxnExcludedFromTreasury(type: string): boolean {
|
||||||
return type === 'deposit' || type === 'booking_payment'
|
return type === 'deposit' || type === 'booking_payment'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AppData, Voucher } from '../types'
|
|||||||
import { resolveVoucherRegarding } from './voucherRegarding'
|
import { resolveVoucherRegarding } from './voucherRegarding'
|
||||||
import { visibleVouchers } from '../accounting/voucherDedupe'
|
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 VoucherStatusFilter = 'all' | 'draft' | 'posted'
|
||||||
export type VoucherOriginFilter = 'all' | 'funzone' | 'manual'
|
export type VoucherOriginFilter = 'all' | 'funzone' | 'manual'
|
||||||
|
|
||||||
@@ -25,51 +25,20 @@ export const defaultVoucherFilters: VoucherFilters = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<Exclude<VoucherCategoryFilter, 'all'>, string> = {
|
const categoryLabels: Record<Exclude<VoucherCategoryFilter, 'all'>, string> = {
|
||||||
customer: 'مشتریان',
|
|
||||||
supplier: 'تأمینکنندگان',
|
|
||||||
manual: 'دستی',
|
manual: 'دستی',
|
||||||
|
manager: 'مدیر',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function voucherCategoryLabel(category: Exclude<VoucherCategoryFilter, 'all'>): string {
|
export function voucherCategoryLabel(category: Exclude<VoucherCategoryFilter, 'all'>): string {
|
||||||
return categoryLabels[category]
|
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(
|
export function classifyVoucher(
|
||||||
voucher: Voucher,
|
voucher: Voucher,
|
||||||
data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
|
_data: Pick<AppData, 'invoices' | 'partyAdjustments' | 'parties' | 'treasury'>,
|
||||||
): Exclude<VoucherCategoryFilter, 'all'> {
|
): Exclude<VoucherCategoryFilter, 'all'> {
|
||||||
const src = voucher.source ?? ''
|
return isAutomatedVoucher(voucher) ? 'manual' : 'manager'
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAutomatedVoucher(voucher: Voucher): boolean {
|
export function isAutomatedVoucher(voucher: Voucher): boolean {
|
||||||
@@ -126,7 +95,7 @@ export function voucherFilterSummary(
|
|||||||
vouchers: Voucher[],
|
vouchers: Voucher[],
|
||||||
data: AppData,
|
data: AppData,
|
||||||
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
|
): 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)) {
|
for (const voucher of visibleVouchers(vouchers, data)) {
|
||||||
summary[classifyVoucher(voucher, data)] += 1
|
summary[classifyVoucher(voucher, data)] += 1
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user