progress in application
This commit is contained in:
parent
76ab27aa24
commit
37f4e0b6b4
545
docs/RECEIPT_PAYMENT_SYSTEM.md
Normal file
545
docs/RECEIPT_PAYMENT_SYSTEM.md
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
# 📝 سیستم دریافت و پرداخت (Receipt & Payment System)
|
||||||
|
|
||||||
|
## 📌 مقدمه
|
||||||
|
|
||||||
|
سیستم دریافت و پرداخت یک سیستم حسابداری است که برای ثبت تراکنشهای مالی بین کسبوکار و اشخاص (مشتریان و تامینکنندگان) استفاده میشود.
|
||||||
|
|
||||||
|
## 🎯 هدف
|
||||||
|
|
||||||
|
این سیستم برای ثبت دو نوع سند طراحی شده است:
|
||||||
|
|
||||||
|
1. **دریافت (Receipt)**: دریافت وجه از اشخاص (مشتریان)
|
||||||
|
2. **پرداخت (Payment)**: پرداخت به اشخاص (تامینکنندگان/فروشندگان)
|
||||||
|
|
||||||
|
## 📊 ساختار داده
|
||||||
|
|
||||||
|
### سند (Document)
|
||||||
|
|
||||||
|
هر سند دریافت یا پرداخت شامل موارد زیر است:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"code": "RC-20250115-0001",
|
||||||
|
"business_id": 1,
|
||||||
|
"document_type": "receipt", // یا "payment"
|
||||||
|
"document_date": "2025-01-15",
|
||||||
|
"currency_id": 1,
|
||||||
|
"created_by_user_id": 5,
|
||||||
|
"person_lines": [
|
||||||
|
{
|
||||||
|
"person_id": 10,
|
||||||
|
"person_name": "علی احمدی",
|
||||||
|
"amount": 1000000,
|
||||||
|
"description": "تسویه حساب"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"account_lines": [
|
||||||
|
{
|
||||||
|
"account_id": 456,
|
||||||
|
"account_name": "صندوق",
|
||||||
|
"amount": 1000000,
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### خطوط سند (Document Lines)
|
||||||
|
|
||||||
|
هر سند شامل دو نوع خط است:
|
||||||
|
|
||||||
|
1. **خطوط اشخاص (Person Lines)**: تراکنشهای مربوط به اشخاص
|
||||||
|
2. **خطوط حسابها (Account Lines)**: تراکنشهای مربوط به حسابها (صندوق، بانک، چک، ...)
|
||||||
|
|
||||||
|
## 🧮 منطق حسابداری
|
||||||
|
|
||||||
|
### 1️⃣ دریافت وجه از اشخاص (Receipt)
|
||||||
|
|
||||||
|
**سناریو**: دریافت ۱,۰۰۰,۰۰۰ تومان از مشتری "علی احمدی" به صندوق
|
||||||
|
|
||||||
|
#### ثبت در حسابها:
|
||||||
|
|
||||||
|
```
|
||||||
|
صندوق (10202) بدهکار: 1,000,000
|
||||||
|
حساب دریافتنی - علی احمدی (10401) بستانکار: 1,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### منطق:
|
||||||
|
- **صندوق**: بدهکار میشود (چون دارایی افزایش یافته)
|
||||||
|
- **حساب دریافتنی شخص**: بستانکار میشود (چون بدهی مشتری کم شده)
|
||||||
|
|
||||||
|
#### کد نمونه (Frontend):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createReceipt(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 10,
|
||||||
|
'person_name': 'علی احمدی',
|
||||||
|
'amount': 1000000,
|
||||||
|
'description': 'تسویه حساب',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 456, // شناسه حساب صندوق
|
||||||
|
'amount': 1000000,
|
||||||
|
'description': '',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ پرداخت به اشخاص (Payment)
|
||||||
|
|
||||||
|
**سناریو**: پرداخت ۵۰۰,۰۰۰ تومان به تامینکننده "رضا محمدی" از بانک
|
||||||
|
|
||||||
|
#### ثبت در حسابها:
|
||||||
|
|
||||||
|
```
|
||||||
|
حساب پرداختنی - رضا محمدی (20201) بدهکار: 500,000
|
||||||
|
بانک (10203) بستانکار: 500,000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### منطق:
|
||||||
|
- **حساب پرداختنی شخص**: بدهکار میشود (چون بدهی ما به تامینکننده کم شده)
|
||||||
|
- **بانک**: بستانکار میشود (چون دارایی کاهش یافته)
|
||||||
|
|
||||||
|
#### کد نمونه (Frontend):
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createPayment(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 20,
|
||||||
|
'person_name': 'رضا محمدی',
|
||||||
|
'amount': 500000,
|
||||||
|
'description': 'پرداخت بدهی',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 789, // شناسه حساب بانک
|
||||||
|
'amount': 500000,
|
||||||
|
'description': 'انتقال بانکی',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 نحوه استفاده از API
|
||||||
|
|
||||||
|
### 1. ایجاد سند دریافت/پرداخت
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments/create`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document_type": "receipt",
|
||||||
|
"document_date": "2025-01-15",
|
||||||
|
"currency_id": 1,
|
||||||
|
"person_lines": [
|
||||||
|
{
|
||||||
|
"person_id": 10,
|
||||||
|
"person_name": "علی احمدی",
|
||||||
|
"amount": 1000000,
|
||||||
|
"description": "تسویه حساب"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"account_lines": [
|
||||||
|
{
|
||||||
|
"account_id": 456,
|
||||||
|
"amount": 1000000,
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra_info": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "RECEIPT_PAYMENT_CREATED",
|
||||||
|
"data": {
|
||||||
|
"id": 123,
|
||||||
|
"code": "RC-20250115-0001",
|
||||||
|
"business_id": 1,
|
||||||
|
"document_type": "receipt",
|
||||||
|
"document_date": "2025-01-15",
|
||||||
|
"person_lines": [...],
|
||||||
|
"account_lines": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. دریافت لیست اسناد
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skip": 0,
|
||||||
|
"take": 20,
|
||||||
|
"sort_by": "document_date",
|
||||||
|
"sort_desc": true,
|
||||||
|
"document_type": "receipt",
|
||||||
|
"from_date": "2025-01-01",
|
||||||
|
"to_date": "2025-01-31",
|
||||||
|
"search": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. دریافت جزئیات یک سند
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/receipts-payments/{document_id}`
|
||||||
|
|
||||||
|
### 4. حذف سند
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/v1/receipts-payments/{document_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 نحوه استفاده در Flutter
|
||||||
|
|
||||||
|
### 1. Import کردن سرویس:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:hesabix_ui/services/receipt_payment_service.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ایجاد instance:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final service = ReceiptPaymentService(apiClient);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ایجاد سند دریافت:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
try {
|
||||||
|
final result = await service.createReceipt(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 10,
|
||||||
|
'person_name': 'علی احمدی',
|
||||||
|
'amount': 1000000,
|
||||||
|
'description': 'تسویه حساب',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 456,
|
||||||
|
'amount': 1000000,
|
||||||
|
'description': '',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
print('سند با موفقیت ثبت شد: ${result['code']}');
|
||||||
|
} catch (e) {
|
||||||
|
print('خطا در ثبت سند: $e');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ انواع حسابهای مورد استفاده
|
||||||
|
|
||||||
|
| کد حساب | نام حساب | نوع | توضیحات |
|
||||||
|
|---------|----------|-----|---------|
|
||||||
|
| `10401` | حساب دریافتنی | `4` | طلب از مشتریان |
|
||||||
|
| `20201` | حساب پرداختنی | `9` | بدهی به تامینکنندگان |
|
||||||
|
| `10202` | صندوق | `1` | صندوق |
|
||||||
|
| `10203` | بانک | `3` | حساب بانکی |
|
||||||
|
| `10403` | اسناد دریافتنی | `5` | چک دریافتی |
|
||||||
|
| `20202` | اسناد پرداختنی | `10` | چک پرداختی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ قوانین و محدودیتها
|
||||||
|
|
||||||
|
### 1. تعادل سند:
|
||||||
|
- مجموع مبالغ **person_lines** باید برابر مجموع مبالغ **account_lines** باشد
|
||||||
|
- در غیر این صورت خطای `UNBALANCED_AMOUNTS` برگردانده میشود
|
||||||
|
|
||||||
|
### 2. اعتبارسنجی:
|
||||||
|
- حداقل یک خط برای اشخاص الزامی است
|
||||||
|
- حداقل یک خط برای حسابها الزامی است
|
||||||
|
- تمام مبالغ باید مثبت باشند
|
||||||
|
- ارز باید معتبر باشد
|
||||||
|
|
||||||
|
### 3. ایجاد خودکار حساب شخص:
|
||||||
|
- اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد میشود
|
||||||
|
- کد حساب: `{parent_code}-{person_id}`
|
||||||
|
- برای دریافت: `10401-{person_id}`
|
||||||
|
- برای پرداخت: `20201-{person_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 جریان کار (Workflow)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[شروع] --> B[کاربر وارد صفحه دریافت/پرداخت میشود]
|
||||||
|
B --> C[انتخاب نوع: دریافت یا پرداخت]
|
||||||
|
C --> D[کلیک بر روی دکمه افزودن]
|
||||||
|
D --> E[باز شدن دیالوگ]
|
||||||
|
E --> F[وارد کردن اطلاعات اشخاص]
|
||||||
|
F --> G[وارد کردن اطلاعات حسابها]
|
||||||
|
G --> H{تعادل برقرار است؟}
|
||||||
|
H -->|خیر| I[نمایش اختلاف]
|
||||||
|
I --> F
|
||||||
|
H -->|بله| J[فعال شدن دکمه ذخیره]
|
||||||
|
J --> K[کلیک بر روی ذخیره]
|
||||||
|
K --> L[ارسال به سرور]
|
||||||
|
L --> M{موفق؟}
|
||||||
|
M -->|بله| N[نمایش پیام موفقیت]
|
||||||
|
M -->|خیر| O[نمایش پیام خطا]
|
||||||
|
N --> P[بستن دیالوگ]
|
||||||
|
O --> E
|
||||||
|
P --> Q[بهروزرسانی لیست]
|
||||||
|
Q --> R[پایان]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 مثالهای کاربردی
|
||||||
|
|
||||||
|
### مثال 1: دریافت نقدی از مشتری
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createReceipt(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 10,
|
||||||
|
'person_name': 'شرکت ABC',
|
||||||
|
'amount': 5000000,
|
||||||
|
'description': 'دریافت بابت فاکتور شماره 123',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 456, // صندوق
|
||||||
|
'amount': 5000000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نتیجه در حسابها:**
|
||||||
|
```
|
||||||
|
صندوق (10202) بدهکار: 5,000,000
|
||||||
|
حساب دریافتنی - شرکت ABC بستانکار: 5,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### مثال 2: دریافت با چک از مشتری
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createReceipt(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 15,
|
||||||
|
'person_name': 'علی رضایی',
|
||||||
|
'amount': 3000000,
|
||||||
|
'description': 'دریافت بابت فاکتور 456',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 789, // اسناد دریافتنی (چک)
|
||||||
|
'amount': 3000000,
|
||||||
|
'description': 'چک شماره 12345678',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نتیجه در حسابها:**
|
||||||
|
```
|
||||||
|
اسناد دریافتنی (10403) بدهکار: 3,000,000
|
||||||
|
حساب دریافتنی - علی رضایی بستانکار: 3,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### مثال 3: دریافت مختلط (نقد + چک)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createReceipt(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 20,
|
||||||
|
'person_name': 'محمد حسینی',
|
||||||
|
'amount': 10000000,
|
||||||
|
'description': 'تسویه کامل',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 456, // صندوق
|
||||||
|
'amount': 4000000,
|
||||||
|
'description': 'نقد',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'account_id': 789, // چک دریافتنی
|
||||||
|
'amount': 6000000,
|
||||||
|
'description': 'چک شماره 87654321',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نتیجه در حسابها:**
|
||||||
|
```
|
||||||
|
صندوق (10202) بدهکار: 4,000,000
|
||||||
|
اسناد دریافتنی (10403) بدهکار: 6,000,000
|
||||||
|
حساب دریافتنی - محمد حسینی بستانکار: 10,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### مثال 4: پرداخت نقدی به تامینکننده
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createPayment(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 30,
|
||||||
|
'person_name': 'شرکت XYZ',
|
||||||
|
'amount': 8000000,
|
||||||
|
'description': 'پرداخت بابت خرید کالا',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 456, // صندوق
|
||||||
|
'amount': 8000000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نتیجه در حسابها:**
|
||||||
|
```
|
||||||
|
حساب پرداختنی - شرکت XYZ بدهکار: 8,000,000
|
||||||
|
صندوق (10202) بستانکار: 8,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### مثال 5: پرداخت به چند تامینکننده
|
||||||
|
|
||||||
|
```dart
|
||||||
|
await service.createPayment(
|
||||||
|
businessId: 1,
|
||||||
|
documentDate: DateTime.now(),
|
||||||
|
currencyId: 1,
|
||||||
|
personLines: [
|
||||||
|
{
|
||||||
|
'person_id': 35,
|
||||||
|
'person_name': 'تامینکننده A',
|
||||||
|
'amount': 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'person_id': 40,
|
||||||
|
'person_name': 'تامینکننده B',
|
||||||
|
'amount': 3000000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
accountLines: [
|
||||||
|
{
|
||||||
|
'account_id': 890, // بانک
|
||||||
|
'amount': 5000000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**نتیجه در حسابها:**
|
||||||
|
```
|
||||||
|
حساب پرداختنی - تامینکننده A بدهکار: 2,000,000
|
||||||
|
حساب پرداختنی - تامینکننده B بدهکار: 3,000,000
|
||||||
|
بانک (10203) بستانکار: 5,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 خطاهای رایج و راهحل
|
||||||
|
|
||||||
|
| کد خطا | توضیحات | راهحل |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| `INVALID_DOCUMENT_TYPE` | نوع سند نامعتبر | از "receipt" یا "payment" استفاده کنید |
|
||||||
|
| `CURRENCY_REQUIRED` | ارز الزامی است | currency_id را ارسال کنید |
|
||||||
|
| `PERSON_LINES_REQUIRED` | حداقل یک خط شخص الزامی | person_lines را پر کنید |
|
||||||
|
| `ACCOUNT_LINES_REQUIRED` | حداقل یک خط حساب الزامی | account_lines را پر کنید |
|
||||||
|
| `UNBALANCED_AMOUNTS` | عدم تعادل مبالغ | مجموع person_lines و account_lines باید برابر باشد |
|
||||||
|
| `PERSON_NOT_FOUND` | شخص یافت نشد | شناسه شخص را بررسی کنید |
|
||||||
|
| `ACCOUNT_NOT_FOUND` | حساب یافت نشد | شناسه حساب را بررسی کنید |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 نکات مهم
|
||||||
|
|
||||||
|
1. **تعادل سند**: همیشه مطمئن شوید که مجموع مبالغ اشخاص با مجموع مبالغ حسابها برابر است.
|
||||||
|
|
||||||
|
2. **ایجاد خودکار حساب**: اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد میشود.
|
||||||
|
|
||||||
|
3. **کد سند**: کد سند به صورت خودکار با فرمت زیر تولید میشود:
|
||||||
|
- دریافت: `RC-YYYYMMDD-NNNN`
|
||||||
|
- پرداخت: `PY-YYYYMMDD-NNNN`
|
||||||
|
|
||||||
|
4. **منطق حسابداری**:
|
||||||
|
- **دریافت**: شخص بستانکار، حساب (صندوق/بانک) بدهکار
|
||||||
|
- **پرداخت**: شخص بدهکار، حساب (صندوق/بانک) بستانکار
|
||||||
|
|
||||||
|
5. **چند شخص/چند حساب**: میتوانید در یک سند چند شخص و چند حساب داشته باشید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 منابع مرتبط
|
||||||
|
|
||||||
|
- [مستندات API](/hesabixAPI/README.md)
|
||||||
|
- [راهنمای استفاده از Flutter](/hesabixUI/hesabix_ui/README.md)
|
||||||
|
- [ساختار حسابها](/docs/ACCOUNTS_STRUCTURE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**تاریخ ایجاد**: 2025-01-13
|
||||||
|
**نسخه**: 1.0.0
|
||||||
|
**توسعهدهنده**: تیم Hesabix
|
||||||
|
|
||||||
163
hesabixAPI/adapters/api/v1/checks.py
Normal file
163
hesabixAPI/adapters/api/v1/checks.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastapi import APIRouter, Depends, Request, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
||||||
|
from app.core.permissions import require_business_management_dep, require_business_access
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
from adapters.api.v1.schema_models.check import (
|
||||||
|
CheckCreateRequest,
|
||||||
|
CheckUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.check_service import (
|
||||||
|
create_check,
|
||||||
|
update_check,
|
||||||
|
delete_check,
|
||||||
|
get_check_by_id,
|
||||||
|
list_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/checks", tags=["checks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/checks",
|
||||||
|
summary="لیست چکهای کسبوکار",
|
||||||
|
description="دریافت لیست چکها با جستجو/فیلتر",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def list_checks_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
query_info: QueryInfo,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": query_info.take,
|
||||||
|
"skip": query_info.skip,
|
||||||
|
"sort_by": query_info.sort_by,
|
||||||
|
"sort_desc": query_info.sort_desc,
|
||||||
|
"search": query_info.search,
|
||||||
|
"search_fields": query_info.search_fields,
|
||||||
|
"filters": query_info.filters,
|
||||||
|
}
|
||||||
|
# additional params: person_id (accept from query params or body)
|
||||||
|
# from query params
|
||||||
|
if request.query_params.get("person_id"):
|
||||||
|
try:
|
||||||
|
query_dict["person_id"] = int(request.query_params.get("person_id"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# from request body (DataTable additionalParams)
|
||||||
|
try:
|
||||||
|
body_json = await request.json()
|
||||||
|
if isinstance(body_json, dict) and body_json.get("person_id") is not None:
|
||||||
|
try:
|
||||||
|
query_dict["person_id"] = int(body_json.get("person_id"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = list_checks(db, business_id, query_dict)
|
||||||
|
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
|
||||||
|
return success_response(data=result, request=request, message="CHECKS_LIST_FETCHED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/checks/create",
|
||||||
|
summary="ایجاد چک",
|
||||||
|
description="ایجاد چک جدید برای کسبوکار",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def create_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: CheckCreateRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
created = create_check(db, business_id, payload)
|
||||||
|
return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/checks/{check_id}",
|
||||||
|
summary="جزئیات چک",
|
||||||
|
description="دریافت جزئیات چک",
|
||||||
|
)
|
||||||
|
async def get_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = get_check_by_id(db, check_id)
|
||||||
|
if not result:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DETAILS")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/checks/{check_id}",
|
||||||
|
summary="ویرایش چک",
|
||||||
|
description="ویرایش اطلاعات چک",
|
||||||
|
)
|
||||||
|
async def update_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
body: CheckUpdateRequest = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
payload: Dict[str, Any] = body.model_dump(exclude_unset=True)
|
||||||
|
result = update_check(db, check_id, payload)
|
||||||
|
if result is None:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_UPDATED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/checks/{check_id}",
|
||||||
|
summary="حذف چک",
|
||||||
|
description="حذف یک چک",
|
||||||
|
)
|
||||||
|
async def delete_check_endpoint(
|
||||||
|
request: Request,
|
||||||
|
check_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
result = get_check_by_id(db, check_id)
|
||||||
|
if result:
|
||||||
|
try:
|
||||||
|
biz_id = int(result.get("business_id"))
|
||||||
|
except Exception:
|
||||||
|
biz_id = None
|
||||||
|
if biz_id is not None and not ctx.can_access_business(biz_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
ok = delete_check(db, check_id)
|
||||||
|
if not ok:
|
||||||
|
raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404)
|
||||||
|
return success_response(data=None, request=request, message="CHECK_DELETED")
|
||||||
|
|
||||||
|
|
||||||
203
hesabixAPI/adapters/api/v1/receipts_payments.py
Normal file
203
hesabixAPI/adapters/api/v1/receipts_payments.py
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"""
|
||||||
|
API endpoints برای دریافت و پرداخت (Receipt & Payment)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from fastapi import APIRouter, Depends, Request, Body
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from adapters.db.session import get_db
|
||||||
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
from app.core.responses import success_response, format_datetime_fields, ApiError
|
||||||
|
from app.core.permissions import require_business_management_dep, require_business_access
|
||||||
|
from adapters.api.v1.schemas import QueryInfo
|
||||||
|
from app.services.receipt_payment_service import (
|
||||||
|
create_receipt_payment,
|
||||||
|
get_receipt_payment,
|
||||||
|
list_receipts_payments,
|
||||||
|
delete_receipt_payment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["receipts-payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/receipts-payments",
|
||||||
|
summary="لیست اسناد دریافت و پرداخت",
|
||||||
|
description="دریافت لیست اسناد دریافت و پرداخت با فیلتر و جستجو",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def list_receipts_payments_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
query_info: QueryInfo = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
لیست اسناد دریافت و پرداخت
|
||||||
|
|
||||||
|
پارامترهای اضافی در body:
|
||||||
|
- document_type: "receipt" یا "payment" (اختیاری)
|
||||||
|
- from_date: تاریخ شروع (اختیاری)
|
||||||
|
- to_date: تاریخ پایان (اختیاری)
|
||||||
|
"""
|
||||||
|
query_dict: Dict[str, Any] = {
|
||||||
|
"take": query_info.take,
|
||||||
|
"skip": query_info.skip,
|
||||||
|
"sort_by": query_info.sort_by,
|
||||||
|
"sort_desc": query_info.sort_desc,
|
||||||
|
"search": query_info.search,
|
||||||
|
}
|
||||||
|
|
||||||
|
# دریافت پارامترهای اضافی از body
|
||||||
|
try:
|
||||||
|
body_json = await request.json()
|
||||||
|
if isinstance(body_json, dict):
|
||||||
|
for key in ["document_type", "from_date", "to_date"]:
|
||||||
|
if key in body_json:
|
||||||
|
query_dict[key] = body_json[key]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = list_receipts_payments(db, business_id, query_dict)
|
||||||
|
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data=result,
|
||||||
|
request=request,
|
||||||
|
message="RECEIPTS_PAYMENTS_LIST_FETCHED"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/receipts-payments/create",
|
||||||
|
summary="ایجاد سند دریافت یا پرداخت",
|
||||||
|
description="ایجاد سند دریافت یا پرداخت جدید",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def create_receipt_payment_endpoint(
|
||||||
|
request: Request,
|
||||||
|
business_id: int,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
ایجاد سند دریافت یا پرداخت
|
||||||
|
|
||||||
|
Body باید شامل موارد زیر باشد:
|
||||||
|
{
|
||||||
|
"document_type": "receipt" | "payment",
|
||||||
|
"document_date": "2025-01-15T10:30:00",
|
||||||
|
"currency_id": 1,
|
||||||
|
"person_lines": [
|
||||||
|
{
|
||||||
|
"person_id": 123,
|
||||||
|
"person_name": "علی احمدی",
|
||||||
|
"amount": 1000000,
|
||||||
|
"description": "توضیحات (اختیاری)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"account_lines": [
|
||||||
|
{
|
||||||
|
"account_id": 456,
|
||||||
|
"amount": 1000000,
|
||||||
|
"transaction_type": "bank" | "cash_register" | "petty_cash" | "check",
|
||||||
|
"transaction_date": "2025-01-15T10:30:00",
|
||||||
|
"commission": 5000, // اختیاری
|
||||||
|
"description": "توضیحات (اختیاری)",
|
||||||
|
// اطلاعات اضافی بر اساس نوع تراکنش:
|
||||||
|
"bank_id": "123", // برای نوع bank
|
||||||
|
"bank_name": "بانک ملی",
|
||||||
|
"cash_register_id": "456", // برای نوع cash_register
|
||||||
|
"cash_register_name": "صندوق اصلی",
|
||||||
|
"petty_cash_id": "789", // برای نوع petty_cash
|
||||||
|
"petty_cash_name": "تنخواهگردان فروش",
|
||||||
|
"check_id": "101", // برای نوع check
|
||||||
|
"check_number": "123456"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra_info": {} // اختیاری
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
created = create_receipt_payment(db, business_id, ctx.get_user_id(), body)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data=format_datetime_fields(created, request),
|
||||||
|
request=request,
|
||||||
|
message="RECEIPT_PAYMENT_CREATED"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/receipts-payments/{document_id}",
|
||||||
|
summary="جزئیات سند دریافت/پرداخت",
|
||||||
|
description="دریافت جزئیات یک سند دریافت یا پرداخت",
|
||||||
|
)
|
||||||
|
async def get_receipt_payment_endpoint(
|
||||||
|
request: Request,
|
||||||
|
document_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""دریافت جزئیات سند"""
|
||||||
|
result = get_receipt_payment(db, document_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
raise ApiError(
|
||||||
|
"DOCUMENT_NOT_FOUND",
|
||||||
|
"Receipt/Payment document not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# بررسی دسترسی
|
||||||
|
business_id = result.get("business_id")
|
||||||
|
if business_id and not ctx.can_access_business(business_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data=format_datetime_fields(result, request),
|
||||||
|
request=request,
|
||||||
|
message="RECEIPT_PAYMENT_DETAILS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/receipts-payments/{document_id}",
|
||||||
|
summary="حذف سند دریافت/پرداخت",
|
||||||
|
description="حذف یک سند دریافت یا پرداخت",
|
||||||
|
)
|
||||||
|
async def delete_receipt_payment_endpoint(
|
||||||
|
request: Request,
|
||||||
|
document_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
ctx: AuthContext = Depends(get_current_user),
|
||||||
|
_: None = Depends(require_business_management_dep),
|
||||||
|
):
|
||||||
|
"""حذف سند"""
|
||||||
|
# دریافت سند برای بررسی دسترسی
|
||||||
|
result = get_receipt_payment(db, document_id)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
business_id = result.get("business_id")
|
||||||
|
if business_id and not ctx.can_access_business(business_id):
|
||||||
|
raise ApiError("FORBIDDEN", "Access denied", http_status=403)
|
||||||
|
|
||||||
|
ok = delete_receipt_payment(db, document_id)
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
raise ApiError(
|
||||||
|
"DOCUMENT_NOT_FOUND",
|
||||||
|
"Receipt/Payment document not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data=None,
|
||||||
|
request=request,
|
||||||
|
message="RECEIPT_PAYMENT_DELETED"
|
||||||
|
)
|
||||||
|
|
||||||
72
hesabixAPI/adapters/api/v1/schema_models/check.py
Normal file
72
hesabixAPI/adapters/api/v1/schema_models/check.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class CheckCreateRequest(BaseModel):
|
||||||
|
type: Literal['received', 'transferred']
|
||||||
|
person_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
issue_date: str
|
||||||
|
due_date: str
|
||||||
|
check_number: str = Field(..., min_length=1, max_length=50)
|
||||||
|
sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16)
|
||||||
|
bank_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
branch_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
amount: float = Field(..., gt=0)
|
||||||
|
currency_id: int = Field(..., ge=1)
|
||||||
|
|
||||||
|
@field_validator('sayad_code')
|
||||||
|
@classmethod
|
||||||
|
def validate_sayad(cls, v: Optional[str]):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.isdigit():
|
||||||
|
raise ValueError('شناسه صیاد باید فقط عددی باشد')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CheckUpdateRequest(BaseModel):
|
||||||
|
type: Optional[Literal['received', 'transferred']] = None
|
||||||
|
person_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
issue_date: Optional[str] = None
|
||||||
|
due_date: Optional[str] = None
|
||||||
|
check_number: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
||||||
|
sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16)
|
||||||
|
bank_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
branch_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
amount: Optional[float] = Field(default=None, gt=0)
|
||||||
|
currency_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
@field_validator('sayad_code')
|
||||||
|
@classmethod
|
||||||
|
def validate_sayad(cls, v: Optional[str]):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.isdigit():
|
||||||
|
raise ValueError('شناسه صیاد باید فقط عددی باشد')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CheckResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
type: str
|
||||||
|
person_id: Optional[int]
|
||||||
|
person_name: Optional[str]
|
||||||
|
issue_date: str
|
||||||
|
due_date: str
|
||||||
|
check_number: str
|
||||||
|
sayad_code: Optional[str]
|
||||||
|
bank_name: Optional[str]
|
||||||
|
branch_name: Optional[str]
|
||||||
|
amount: float
|
||||||
|
currency_id: int
|
||||||
|
currency: Optional[str]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,3 +39,4 @@ from .tax_unit import TaxUnit # noqa: F401
|
||||||
from .tax_type import TaxType # noqa: F401
|
from .tax_type import TaxType # noqa: F401
|
||||||
from .bank_account import BankAccount # noqa: F401
|
from .bank_account import BankAccount # noqa: F401
|
||||||
from .petty_cash import PettyCash # noqa: F401
|
from .petty_cash import PettyCash # noqa: F401
|
||||||
|
from .check import Check # noqa: F401
|
||||||
|
|
|
||||||
65
hesabixAPI/adapters/db/models/check.py
Normal file
65
hesabixAPI/adapters/db/models/check.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
Numeric,
|
||||||
|
Enum as SQLEnum,
|
||||||
|
Index,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from adapters.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CheckType(str, Enum):
|
||||||
|
RECEIVED = "received"
|
||||||
|
TRANSFERRED = "transferred"
|
||||||
|
|
||||||
|
|
||||||
|
class Check(Base):
|
||||||
|
__tablename__ = "checks"
|
||||||
|
__table_args__ = (
|
||||||
|
# پیشنهاد: یکتا بودن شماره چک در سطح کسبوکار
|
||||||
|
UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'),
|
||||||
|
# پیشنهاد: یکتا بودن شناسه صیاد در سطح کسبوکار (چند NULL مجاز است)
|
||||||
|
UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'),
|
||||||
|
Index('ix_checks_business_type', 'business_id', 'type'),
|
||||||
|
Index('ix_checks_business_person', 'business_id', 'person_id'),
|
||||||
|
Index('ix_checks_business_issue_date', 'business_id', 'issue_date'),
|
||||||
|
Index('ix_checks_business_due_date', 'business_id', 'due_date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
type: Mapped[CheckType] = mapped_column(SQLEnum(CheckType, name="check_type"), nullable=False, index=True)
|
||||||
|
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
issue_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
due_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
|
||||||
|
check_number: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
sayad_code: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
|
|
||||||
|
bank_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||||
|
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
# روابط
|
||||||
|
business = relationship("Business", backref="checks")
|
||||||
|
person = relationship("Person", lazy="joined")
|
||||||
|
currency = relationship("Currency")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +15,13 @@ class DocumentLine(Base):
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
|
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
|
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||||
|
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
bank_account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
cash_register_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("cash_registers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
petty_cash_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("petty_cash.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
check_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("checks.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True, default=0)
|
||||||
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||||
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
@ -26,5 +33,11 @@ class DocumentLine(Base):
|
||||||
# Relationships
|
# Relationships
|
||||||
document = relationship("Document", back_populates="lines")
|
document = relationship("Document", back_populates="lines")
|
||||||
account = relationship("Account", back_populates="document_lines")
|
account = relationship("Account", back_populates="document_lines")
|
||||||
|
person = relationship("Person")
|
||||||
|
product = relationship("Product")
|
||||||
|
bank_account = relationship("BankAccount")
|
||||||
|
cash_register = relationship("CashRegister")
|
||||||
|
petty_cash = relationship("PettyCash")
|
||||||
|
check = relationship("Check")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from adapters.api.v1.support.priorities import router as support_priorities_rout
|
||||||
from adapters.api.v1.support.statuses import router as support_statuses_router
|
from adapters.api.v1.support.statuses import router as support_statuses_router
|
||||||
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
|
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
|
||||||
from adapters.api.v1.admin.email_config import router as admin_email_config_router
|
from adapters.api.v1.admin.email_config import router as admin_email_config_router
|
||||||
|
from adapters.api.v1.receipts_payments import router as receipts_payments_router
|
||||||
from app.core.i18n import negotiate_locale, Translator
|
from app.core.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||||
|
|
@ -299,10 +300,13 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
application.include_router(persons_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(customers_router, prefix=settings.api_v1_prefix)
|
application.include_router(customers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
||||||
|
from adapters.api.v1.checks import router as checks_router
|
||||||
|
application.include_router(checks_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
|
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
|
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
# Support endpoints
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
|
||||||
286
hesabixAPI/app/services/check_service.py
Normal file
286
hesabixAPI/app/services/check_service.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
|
from adapters.db.models.check import Check, CheckType
|
||||||
|
from adapters.db.models.person import Person
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(dt: str) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
ctype = str(data.get('type', '')).lower()
|
||||||
|
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||||
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
|
||||||
|
person_id = data.get('person_id')
|
||||||
|
if ctype == CheckType.RECEIVED.value and not person_id:
|
||||||
|
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
|
||||||
|
|
||||||
|
issue_date = _parse_iso(str(data.get('issue_date')))
|
||||||
|
due_date = _parse_iso(str(data.get('due_date')))
|
||||||
|
if due_date < issue_date:
|
||||||
|
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
|
||||||
|
|
||||||
|
sayad = data.get('sayad_code')
|
||||||
|
if sayad is not None:
|
||||||
|
s = str(sayad).strip()
|
||||||
|
if s and (len(s) != 16 or not s.isdigit()):
|
||||||
|
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
|
||||||
|
|
||||||
|
amount = data.get('amount')
|
||||||
|
try:
|
||||||
|
amount_val = float(amount)
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
|
||||||
|
if amount_val <= 0:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
|
||||||
|
|
||||||
|
check_number = str(data.get('check_number', '')).strip()
|
||||||
|
if not check_number:
|
||||||
|
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
|
||||||
|
|
||||||
|
# یونیک بودن در سطح کسبوکار
|
||||||
|
exists = db.query(Check).filter(and_(Check.business_id == business_id, Check.check_number == check_number)).first()
|
||||||
|
if exists is not None:
|
||||||
|
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
|
||||||
|
|
||||||
|
if sayad:
|
||||||
|
exists_sayad = db.query(Check).filter(and_(Check.business_id == business_id, Check.sayad_code == sayad)).first()
|
||||||
|
if exists_sayad is not None:
|
||||||
|
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
|
||||||
|
|
||||||
|
obj = Check(
|
||||||
|
business_id=business_id,
|
||||||
|
type=CheckType(ctype),
|
||||||
|
person_id=int(person_id) if person_id else None,
|
||||||
|
issue_date=issue_date,
|
||||||
|
due_date=due_date,
|
||||||
|
check_number=check_number,
|
||||||
|
sayad_code=str(sayad).strip() if sayad else None,
|
||||||
|
bank_name=(str(data.get('bank_name')).strip() if data.get('bank_name') else None),
|
||||||
|
branch_name=(str(data.get('branch_name')).strip() if data.get('branch_name') else None),
|
||||||
|
amount=amount_val,
|
||||||
|
currency_id=int(data.get('currency_id')),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return check_to_dict(db, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
return check_to_dict(db, obj) if obj else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if 'type' in data and data['type'] is not None:
|
||||||
|
ctype = str(data['type']).lower()
|
||||||
|
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||||
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
obj.type = CheckType(ctype)
|
||||||
|
|
||||||
|
if 'person_id' in data:
|
||||||
|
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
|
||||||
|
|
||||||
|
if 'issue_date' in data and data['issue_date'] is not None:
|
||||||
|
obj.issue_date = _parse_iso(str(data['issue_date']))
|
||||||
|
if 'due_date' in data and data['due_date'] is not None:
|
||||||
|
obj.due_date = _parse_iso(str(data['due_date']))
|
||||||
|
if obj.due_date < obj.issue_date:
|
||||||
|
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
|
||||||
|
|
||||||
|
if 'check_number' in data and data['check_number'] is not None:
|
||||||
|
new_num = str(data['check_number']).strip()
|
||||||
|
if not new_num:
|
||||||
|
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
|
||||||
|
exists = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.check_number == new_num, Check.id != obj.id)).first()
|
||||||
|
if exists is not None:
|
||||||
|
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
|
||||||
|
obj.check_number = new_num
|
||||||
|
|
||||||
|
if 'sayad_code' in data:
|
||||||
|
s = data['sayad_code']
|
||||||
|
if s is not None:
|
||||||
|
s = str(s).strip()
|
||||||
|
if s and (len(s) != 16 or not s.isdigit()):
|
||||||
|
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
|
||||||
|
if s:
|
||||||
|
exists_sayad = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.sayad_code == s, Check.id != obj.id)).first()
|
||||||
|
if exists_sayad is not None:
|
||||||
|
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
|
||||||
|
obj.sayad_code = s if s else None
|
||||||
|
|
||||||
|
for field in ["bank_name", "branch_name"]:
|
||||||
|
if field in data:
|
||||||
|
setattr(obj, field, (str(data[field]).strip() if data[field] is not None else None))
|
||||||
|
|
||||||
|
if 'amount' in data and data['amount'] is not None:
|
||||||
|
try:
|
||||||
|
amount_val = float(data['amount'])
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
|
||||||
|
if amount_val <= 0:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
|
||||||
|
obj.amount = amount_val
|
||||||
|
|
||||||
|
if 'currency_id' in data and data['currency_id'] is not None:
|
||||||
|
obj.currency_id = int(data['currency_id'])
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return check_to_dict(db, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_check(db: Session, check_id: int) -> bool:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
if obj is None:
|
||||||
|
return False
|
||||||
|
db.delete(obj)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
q = db.query(Check).filter(Check.business_id == business_id)
|
||||||
|
|
||||||
|
# جستجو
|
||||||
|
if query.get("search") and query.get("search_fields"):
|
||||||
|
term = f"%{query['search']}%"
|
||||||
|
conditions = []
|
||||||
|
for f in query["search_fields"]:
|
||||||
|
if f == "check_number":
|
||||||
|
conditions.append(Check.check_number.ilike(term))
|
||||||
|
elif f == "sayad_code":
|
||||||
|
conditions.append(Check.sayad_code.ilike(term))
|
||||||
|
elif f == "bank_name":
|
||||||
|
conditions.append(Check.bank_name.ilike(term))
|
||||||
|
elif f == "branch_name":
|
||||||
|
conditions.append(Check.branch_name.ilike(term))
|
||||||
|
elif f == "person_name":
|
||||||
|
# join به persons
|
||||||
|
q = q.join(Person, Check.person_id == Person.id, isouter=True)
|
||||||
|
conditions.append(Person.alias_name.ilike(term))
|
||||||
|
if conditions:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
q = q.filter(or_(*conditions))
|
||||||
|
|
||||||
|
# فیلترها
|
||||||
|
if query.get("filters"):
|
||||||
|
from app.core.calendar import CalendarConverter
|
||||||
|
for flt in query["filters"]:
|
||||||
|
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
|
||||||
|
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
|
||||||
|
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
|
||||||
|
if not prop or not op:
|
||||||
|
continue
|
||||||
|
if prop == 'type' and op == '=':
|
||||||
|
q = q.filter(Check.type == val)
|
||||||
|
elif prop == 'currency' and op == '=':
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.currency_id == int(val))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif prop == 'person_id' and op == '=':
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.person_id == int(val))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif prop in ('issue_date', 'due_date'):
|
||||||
|
# انتظار: فیلترهای بازه با اپراتورهای ">=" و "<=" از DataTable
|
||||||
|
try:
|
||||||
|
if isinstance(val, str) and val:
|
||||||
|
# ورودی تاریخ ممکن است بر اساس هدر تقویم باشد؛ در این لایه فرض بر ISO است (از فرانت ارسال میشود)
|
||||||
|
dt = _parse_iso(val)
|
||||||
|
col = getattr(Check, prop)
|
||||||
|
if op == ">=":
|
||||||
|
q = q.filter(col >= dt)
|
||||||
|
elif op == "<=":
|
||||||
|
q = q.filter(col <= dt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# additional params: person_id
|
||||||
|
person_param = query.get('person_id')
|
||||||
|
if person_param:
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.person_id == int(person_param))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# مرتبسازی
|
||||||
|
sort_by = query.get("sort_by") or "created_at"
|
||||||
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
col = getattr(Check, sort_by, Check.created_at)
|
||||||
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
|
|
||||||
|
# صفحهبندی
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [check_to_dict(db, i) for i in items],
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]:
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
person_name = None
|
||||||
|
if obj.person_id:
|
||||||
|
p = db.query(Person).filter(Person.id == obj.person_id).first()
|
||||||
|
person_name = getattr(p, 'alias_name', None)
|
||||||
|
currency_title = None
|
||||||
|
try:
|
||||||
|
c = db.query(Currency).filter(Currency.id == obj.currency_id).first()
|
||||||
|
currency_title = c.title or c.code if c else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"id": obj.id,
|
||||||
|
"business_id": obj.business_id,
|
||||||
|
"type": obj.type.value,
|
||||||
|
"person_id": obj.person_id,
|
||||||
|
"person_name": person_name,
|
||||||
|
"issue_date": obj.issue_date.isoformat(),
|
||||||
|
"due_date": obj.due_date.isoformat(),
|
||||||
|
"check_number": obj.check_number,
|
||||||
|
"sayad_code": obj.sayad_code,
|
||||||
|
"bank_name": obj.bank_name,
|
||||||
|
"branch_name": obj.branch_name,
|
||||||
|
"amount": float(obj.amount),
|
||||||
|
"currency_id": obj.currency_id,
|
||||||
|
"currency": currency_title,
|
||||||
|
"created_at": obj.created_at.isoformat(),
|
||||||
|
"updated_at": obj.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
534
hesabixAPI/app/services/receipt_payment_service.py
Normal file
534
hesabixAPI/app/services/receipt_payment_service.py
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
"""
|
||||||
|
سرویس دریافت و پرداخت (Receipt & Payment)
|
||||||
|
|
||||||
|
این سرویس برای ثبت اسناد دریافت و پرداخت استفاده میشود که شامل:
|
||||||
|
- دریافت وجه از اشخاص (مشتریان)
|
||||||
|
- پرداخت به اشخاص (تامینکنندگان)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
|
from adapters.db.models.document import Document
|
||||||
|
from adapters.db.models.document_line import DocumentLine
|
||||||
|
from adapters.db.models.account import Account
|
||||||
|
from adapters.db.models.person import Person
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
from adapters.db.models.user import User
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
# نوعهای سند
|
||||||
|
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
|
||||||
|
DOCUMENT_TYPE_PAYMENT = "payment" # پرداخت
|
||||||
|
|
||||||
|
# نوعهای حساب (از migration)
|
||||||
|
ACCOUNT_TYPE_RECEIVABLE = "person" # حساب دریافتنی
|
||||||
|
ACCOUNT_TYPE_PAYABLE = "person" # حساب پرداختنی
|
||||||
|
ACCOUNT_TYPE_CASH = "cash_register" # صندوق
|
||||||
|
ACCOUNT_TYPE_BANK = "bank" # بانک
|
||||||
|
ACCOUNT_TYPE_CHECK_RECEIVED = "check" # اسناد دریافتنی (چک دریافتی)
|
||||||
|
ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرداختی)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_date(dt: str | datetime | date) -> date:
|
||||||
|
"""تبدیل تاریخ به فرمت date"""
|
||||||
|
if isinstance(dt, date):
|
||||||
|
return dt
|
||||||
|
if isinstance(dt, datetime):
|
||||||
|
return dt.date()
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(str(dt).replace('Z', '+00:00'))
|
||||||
|
return parsed.date()
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_person_account(
|
||||||
|
db: Session,
|
||||||
|
business_id: int,
|
||||||
|
person_id: int,
|
||||||
|
is_receivable: bool
|
||||||
|
) -> Account:
|
||||||
|
"""
|
||||||
|
ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
business_id: شناسه کسبوکار
|
||||||
|
person_id: شناسه شخص
|
||||||
|
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Account: حساب شخص
|
||||||
|
"""
|
||||||
|
person = db.query(Person).filter(
|
||||||
|
and_(Person.id == person_id, Person.business_id == business_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
|
||||||
|
|
||||||
|
# کد حساب والد
|
||||||
|
parent_code = "10401" if is_receivable else "20201"
|
||||||
|
account_type = ACCOUNT_TYPE_RECEIVABLE if is_receivable else ACCOUNT_TYPE_PAYABLE
|
||||||
|
|
||||||
|
# پیدا کردن حساب والد
|
||||||
|
parent_account = db.query(Account).filter(
|
||||||
|
and_(
|
||||||
|
Account.business_id == None, # حسابهای عمومی
|
||||||
|
Account.code == parent_code
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not parent_account:
|
||||||
|
raise ApiError(
|
||||||
|
"PARENT_ACCOUNT_NOT_FOUND",
|
||||||
|
f"Parent account with code {parent_code} not found",
|
||||||
|
http_status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
# بررسی وجود حساب شخص
|
||||||
|
person_account_code = f"{parent_code}-{person_id}"
|
||||||
|
person_account = db.query(Account).filter(
|
||||||
|
and_(
|
||||||
|
Account.business_id == business_id,
|
||||||
|
Account.code == person_account_code
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not person_account:
|
||||||
|
# ایجاد حساب جدید برای شخص
|
||||||
|
account_name = f"{person.alias_name}"
|
||||||
|
if is_receivable:
|
||||||
|
account_name = f"طلب از {account_name}"
|
||||||
|
else:
|
||||||
|
account_name = f"بدهی به {account_name}"
|
||||||
|
|
||||||
|
person_account = Account(
|
||||||
|
business_id=business_id,
|
||||||
|
code=person_account_code,
|
||||||
|
name=account_name,
|
||||||
|
account_type=account_type,
|
||||||
|
parent_id=parent_account.id,
|
||||||
|
)
|
||||||
|
db.add(person_account)
|
||||||
|
db.flush() # برای دریافت ID
|
||||||
|
|
||||||
|
return person_account
|
||||||
|
|
||||||
|
|
||||||
|
def create_receipt_payment(
|
||||||
|
db: Session,
|
||||||
|
business_id: int,
|
||||||
|
user_id: int,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
ایجاد سند دریافت یا پرداخت
|
||||||
|
|
||||||
|
Args:
|
||||||
|
business_id: شناسه کسبوکار
|
||||||
|
user_id: شناسه کاربر ایجادکننده
|
||||||
|
data: اطلاعات سند شامل:
|
||||||
|
- document_type: "receipt" یا "payment"
|
||||||
|
- document_date: تاریخ سند
|
||||||
|
- currency_id: شناسه ارز
|
||||||
|
- person_lines: لیست تراکنشهای اشخاص [{"person_id": int, "amount": float, "description": str?}, ...]
|
||||||
|
- account_lines: لیست تراکنشهای حسابها [{"account_id": int, "amount": float, "description": str?}, ...]
|
||||||
|
- extra_info: اطلاعات اضافی (اختیاری)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: اطلاعات سند ایجاد شده
|
||||||
|
"""
|
||||||
|
# اعتبارسنجی نوع سند
|
||||||
|
document_type = str(data.get("document_type", "")).lower()
|
||||||
|
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||||
|
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
|
||||||
|
|
||||||
|
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
|
||||||
|
|
||||||
|
# اعتبارسنجی تاریخ
|
||||||
|
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
|
||||||
|
|
||||||
|
# اعتبارسنجی ارز
|
||||||
|
currency_id = data.get("currency_id")
|
||||||
|
if not currency_id:
|
||||||
|
raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400)
|
||||||
|
|
||||||
|
currency = db.query(Currency).filter(Currency.id == int(currency_id)).first()
|
||||||
|
if not currency:
|
||||||
|
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
|
||||||
|
|
||||||
|
# اعتبارسنجی خطوط اشخاص
|
||||||
|
person_lines = data.get("person_lines", [])
|
||||||
|
if not person_lines or not isinstance(person_lines, list):
|
||||||
|
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
|
||||||
|
|
||||||
|
# اعتبارسنجی خطوط حسابها
|
||||||
|
account_lines = data.get("account_lines", [])
|
||||||
|
if not account_lines or not isinstance(account_lines, list):
|
||||||
|
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
|
||||||
|
|
||||||
|
# محاسبه مجموع مبالغ
|
||||||
|
person_total = sum(float(line.get("amount", 0)) for line in person_lines)
|
||||||
|
account_total = sum(float(line.get("amount", 0)) for line in account_lines)
|
||||||
|
|
||||||
|
# بررسی تعادل مبالغ
|
||||||
|
if abs(person_total - account_total) > 0.01: # tolerance برای خطای ممیز شناور
|
||||||
|
raise ApiError(
|
||||||
|
"UNBALANCED_AMOUNTS",
|
||||||
|
f"Person total ({person_total}) must equal account total ({account_total})",
|
||||||
|
http_status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# تولید کد سند
|
||||||
|
# فرمت: RP-YYYYMMDD-NNNN (RP = Receipt/Payment)
|
||||||
|
today = datetime.now().date()
|
||||||
|
prefix = f"{'RC' if is_receipt else 'PY'}-{today.strftime('%Y%m%d')}"
|
||||||
|
|
||||||
|
last_doc = db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.business_id == business_id,
|
||||||
|
Document.code.like(f"{prefix}-%")
|
||||||
|
)
|
||||||
|
).order_by(Document.code.desc()).first()
|
||||||
|
|
||||||
|
if last_doc:
|
||||||
|
try:
|
||||||
|
last_num = int(last_doc.code.split("-")[-1])
|
||||||
|
next_num = last_num + 1
|
||||||
|
except Exception:
|
||||||
|
next_num = 1
|
||||||
|
else:
|
||||||
|
next_num = 1
|
||||||
|
|
||||||
|
doc_code = f"{prefix}-{next_num:04d}"
|
||||||
|
|
||||||
|
# ایجاد سند
|
||||||
|
document = Document(
|
||||||
|
business_id=business_id,
|
||||||
|
code=doc_code,
|
||||||
|
document_type=document_type,
|
||||||
|
document_date=document_date,
|
||||||
|
currency_id=int(currency_id),
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
registered_at=datetime.utcnow(),
|
||||||
|
is_proforma=False,
|
||||||
|
extra_info=data.get("extra_info"),
|
||||||
|
)
|
||||||
|
db.add(document)
|
||||||
|
db.flush() # برای دریافت document.id
|
||||||
|
|
||||||
|
# ایجاد خطوط سند برای اشخاص
|
||||||
|
for person_line in person_lines:
|
||||||
|
person_id = person_line.get("person_id")
|
||||||
|
if not person_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
amount = Decimal(str(person_line.get("amount", 0)))
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = person_line.get("description", "").strip() or None
|
||||||
|
|
||||||
|
# دریافت یا ایجاد حساب شخص
|
||||||
|
# در دریافت: حساب دریافتنی (receivable)
|
||||||
|
# در پرداخت: حساب پرداختنی (payable)
|
||||||
|
person_account = _get_or_create_person_account(
|
||||||
|
db,
|
||||||
|
business_id,
|
||||||
|
int(person_id),
|
||||||
|
is_receivable=is_receipt
|
||||||
|
)
|
||||||
|
|
||||||
|
# ایجاد خط سند برای شخص
|
||||||
|
# در دریافت: شخص بستانکار (credit)
|
||||||
|
# در پرداخت: شخص بدهکار (debit)
|
||||||
|
line = DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=person_account.id,
|
||||||
|
debit=amount if not is_receipt else Decimal(0),
|
||||||
|
credit=amount if is_receipt else Decimal(0),
|
||||||
|
description=description,
|
||||||
|
extra_info={
|
||||||
|
"person_id": int(person_id),
|
||||||
|
"person_name": person_line.get("person_name"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(line)
|
||||||
|
|
||||||
|
# ایجاد خطوط سند برای حسابها
|
||||||
|
for account_line in account_lines:
|
||||||
|
account_id = account_line.get("account_id")
|
||||||
|
if not account_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
amount = Decimal(str(account_line.get("amount", 0)))
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = account_line.get("description", "").strip() or None
|
||||||
|
transaction_type = account_line.get("transaction_type")
|
||||||
|
transaction_date = account_line.get("transaction_date")
|
||||||
|
commission = account_line.get("commission")
|
||||||
|
|
||||||
|
# بررسی وجود حساب
|
||||||
|
account = db.query(Account).filter(
|
||||||
|
and_(
|
||||||
|
Account.id == int(account_id),
|
||||||
|
or_(
|
||||||
|
Account.business_id == business_id,
|
||||||
|
Account.business_id == None # حسابهای عمومی
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
raise ApiError(
|
||||||
|
"ACCOUNT_NOT_FOUND",
|
||||||
|
f"Account with id {account_id} not found",
|
||||||
|
http_status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# ایجاد اطلاعات اضافی برای خط سند
|
||||||
|
extra_info = {}
|
||||||
|
if transaction_type:
|
||||||
|
extra_info["transaction_type"] = transaction_type
|
||||||
|
if transaction_date:
|
||||||
|
extra_info["transaction_date"] = transaction_date
|
||||||
|
if commission:
|
||||||
|
extra_info["commission"] = float(commission)
|
||||||
|
|
||||||
|
# اطلاعات اضافی بر اساس نوع تراکنش
|
||||||
|
if transaction_type == "bank":
|
||||||
|
if account_line.get("bank_id"):
|
||||||
|
extra_info["bank_id"] = account_line.get("bank_id")
|
||||||
|
if account_line.get("bank_name"):
|
||||||
|
extra_info["bank_name"] = account_line.get("bank_name")
|
||||||
|
elif transaction_type == "cash_register":
|
||||||
|
if account_line.get("cash_register_id"):
|
||||||
|
extra_info["cash_register_id"] = account_line.get("cash_register_id")
|
||||||
|
if account_line.get("cash_register_name"):
|
||||||
|
extra_info["cash_register_name"] = account_line.get("cash_register_name")
|
||||||
|
elif transaction_type == "petty_cash":
|
||||||
|
if account_line.get("petty_cash_id"):
|
||||||
|
extra_info["petty_cash_id"] = account_line.get("petty_cash_id")
|
||||||
|
if account_line.get("petty_cash_name"):
|
||||||
|
extra_info["petty_cash_name"] = account_line.get("petty_cash_name")
|
||||||
|
elif transaction_type == "check":
|
||||||
|
if account_line.get("check_id"):
|
||||||
|
extra_info["check_id"] = account_line.get("check_id")
|
||||||
|
if account_line.get("check_number"):
|
||||||
|
extra_info["check_number"] = account_line.get("check_number")
|
||||||
|
|
||||||
|
# ایجاد خط سند برای حساب
|
||||||
|
# در دریافت: حساب بدهکار (debit) - دارایی افزایش مییابد
|
||||||
|
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش مییابد
|
||||||
|
line = DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=account.id,
|
||||||
|
debit=amount if is_receipt else Decimal(0),
|
||||||
|
credit=amount if not is_receipt else Decimal(0),
|
||||||
|
description=description,
|
||||||
|
extra_info=extra_info if extra_info else None,
|
||||||
|
)
|
||||||
|
db.add(line)
|
||||||
|
|
||||||
|
# ذخیره تغییرات
|
||||||
|
db.commit()
|
||||||
|
db.refresh(document)
|
||||||
|
|
||||||
|
return document_to_dict(db, document)
|
||||||
|
|
||||||
|
|
||||||
|
def get_receipt_payment(db: Session, document_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""دریافت جزئیات یک سند دریافت/پرداخت"""
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return document_to_dict(db, document)
|
||||||
|
|
||||||
|
|
||||||
|
def list_receipts_payments(
|
||||||
|
db: Session,
|
||||||
|
business_id: int,
|
||||||
|
query: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""لیست اسناد دریافت و پرداخت"""
|
||||||
|
q = db.query(Document).filter(
|
||||||
|
and_(
|
||||||
|
Document.business_id == business_id,
|
||||||
|
Document.document_type.in_([DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# فیلتر بر اساس نوع
|
||||||
|
doc_type = query.get("document_type")
|
||||||
|
if doc_type:
|
||||||
|
q = q.filter(Document.document_type == doc_type)
|
||||||
|
|
||||||
|
# فیلتر بر اساس تاریخ
|
||||||
|
from_date = query.get("from_date")
|
||||||
|
to_date = query.get("to_date")
|
||||||
|
|
||||||
|
if from_date:
|
||||||
|
try:
|
||||||
|
from_dt = _parse_iso_date(from_date)
|
||||||
|
q = q.filter(Document.document_date >= from_dt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if to_date:
|
||||||
|
try:
|
||||||
|
to_dt = _parse_iso_date(to_date)
|
||||||
|
q = q.filter(Document.document_date <= to_dt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# جستجو
|
||||||
|
search = query.get("search")
|
||||||
|
if search:
|
||||||
|
q = q.filter(Document.code.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
# مرتبسازی
|
||||||
|
sort_by = query.get("sort_by", "document_date")
|
||||||
|
sort_desc = query.get("sort_desc", True)
|
||||||
|
|
||||||
|
if hasattr(Document, sort_by):
|
||||||
|
col = getattr(Document, sort_by)
|
||||||
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
|
else:
|
||||||
|
q = q.order_by(Document.document_date.desc())
|
||||||
|
|
||||||
|
# صفحهبندی
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [document_to_dict(db, doc) for doc in items],
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_receipt_payment(db: Session, document_id: int) -> bool:
|
||||||
|
"""حذف سند دریافت/پرداخت"""
|
||||||
|
document = db.query(Document).filter(Document.id == document_id).first()
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.delete(document)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
|
"""تبدیل سند به دیکشنری"""
|
||||||
|
# دریافت خطوط سند
|
||||||
|
lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all()
|
||||||
|
|
||||||
|
# جداسازی خطوط اشخاص و حسابها
|
||||||
|
person_lines = []
|
||||||
|
account_lines = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
account = db.query(Account).filter(Account.id == line.account_id).first()
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_dict = {
|
||||||
|
"id": line.id,
|
||||||
|
"account_id": line.account_id,
|
||||||
|
"account_name": account.name,
|
||||||
|
"account_code": account.code,
|
||||||
|
"account_type": account.account_type,
|
||||||
|
"debit": float(line.debit),
|
||||||
|
"credit": float(line.credit),
|
||||||
|
"amount": float(line.debit if line.debit > 0 else line.credit),
|
||||||
|
"description": line.description,
|
||||||
|
"extra_info": line.extra_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
# اضافه کردن اطلاعات اضافی از extra_info
|
||||||
|
if line.extra_info:
|
||||||
|
if "transaction_type" in line.extra_info:
|
||||||
|
line_dict["transaction_type"] = line.extra_info["transaction_type"]
|
||||||
|
if "transaction_date" in line.extra_info:
|
||||||
|
line_dict["transaction_date"] = line.extra_info["transaction_date"]
|
||||||
|
if "commission" in line.extra_info:
|
||||||
|
line_dict["commission"] = line.extra_info["commission"]
|
||||||
|
if "bank_id" in line.extra_info:
|
||||||
|
line_dict["bank_id"] = line.extra_info["bank_id"]
|
||||||
|
if "bank_name" in line.extra_info:
|
||||||
|
line_dict["bank_name"] = line.extra_info["bank_name"]
|
||||||
|
if "cash_register_id" in line.extra_info:
|
||||||
|
line_dict["cash_register_id"] = line.extra_info["cash_register_id"]
|
||||||
|
if "cash_register_name" in line.extra_info:
|
||||||
|
line_dict["cash_register_name"] = line.extra_info["cash_register_name"]
|
||||||
|
if "petty_cash_id" in line.extra_info:
|
||||||
|
line_dict["petty_cash_id"] = line.extra_info["petty_cash_id"]
|
||||||
|
if "petty_cash_name" in line.extra_info:
|
||||||
|
line_dict["petty_cash_name"] = line.extra_info["petty_cash_name"]
|
||||||
|
if "check_id" in line.extra_info:
|
||||||
|
line_dict["check_id"] = line.extra_info["check_id"]
|
||||||
|
if "check_number" in line.extra_info:
|
||||||
|
line_dict["check_number"] = line.extra_info["check_number"]
|
||||||
|
|
||||||
|
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
|
||||||
|
if line.extra_info and line.extra_info.get("person_id"):
|
||||||
|
person_lines.append(line_dict)
|
||||||
|
else:
|
||||||
|
account_lines.append(line_dict)
|
||||||
|
|
||||||
|
# دریافت اطلاعات کاربر ایجادکننده
|
||||||
|
created_by = db.query(User).filter(User.id == document.created_by_user_id).first()
|
||||||
|
created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None
|
||||||
|
|
||||||
|
# دریافت اطلاعات ارز
|
||||||
|
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
|
||||||
|
currency_code = currency.code if currency else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": document.id,
|
||||||
|
"code": document.code,
|
||||||
|
"business_id": document.business_id,
|
||||||
|
"document_type": document.document_type,
|
||||||
|
"document_date": document.document_date.isoformat(),
|
||||||
|
"registered_at": document.registered_at.isoformat(),
|
||||||
|
"currency_id": document.currency_id,
|
||||||
|
"currency_code": currency_code,
|
||||||
|
"created_by_user_id": document.created_by_user_id,
|
||||||
|
"created_by_name": created_by_name,
|
||||||
|
"is_proforma": document.is_proforma,
|
||||||
|
"extra_info": document.extra_info,
|
||||||
|
"person_lines": person_lines,
|
||||||
|
"account_lines": account_lines,
|
||||||
|
"created_at": document.created_at.isoformat(),
|
||||||
|
"updated_at": document.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -11,14 +11,17 @@ adapters/api/v1/business_users.py
|
||||||
adapters/api/v1/businesses.py
|
adapters/api/v1/businesses.py
|
||||||
adapters/api/v1/cash_registers.py
|
adapters/api/v1/cash_registers.py
|
||||||
adapters/api/v1/categories.py
|
adapters/api/v1/categories.py
|
||||||
|
adapters/api/v1/checks.py
|
||||||
adapters/api/v1/currencies.py
|
adapters/api/v1/currencies.py
|
||||||
adapters/api/v1/customers.py
|
adapters/api/v1/customers.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
|
adapters/api/v1/invoices.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
adapters/api/v1/petty_cash.py
|
adapters/api/v1/petty_cash.py
|
||||||
adapters/api/v1/price_lists.py
|
adapters/api/v1/price_lists.py
|
||||||
adapters/api/v1/product_attributes.py
|
adapters/api/v1/product_attributes.py
|
||||||
adapters/api/v1/products.py
|
adapters/api/v1/products.py
|
||||||
|
adapters/api/v1/receipts_payments.py
|
||||||
adapters/api/v1/schemas.py
|
adapters/api/v1/schemas.py
|
||||||
adapters/api/v1/tax_types.py
|
adapters/api/v1/tax_types.py
|
||||||
adapters/api/v1/tax_units.py
|
adapters/api/v1/tax_units.py
|
||||||
|
|
@ -28,6 +31,7 @@ adapters/api/v1/admin/file_storage.py
|
||||||
adapters/api/v1/schema_models/__init__.py
|
adapters/api/v1/schema_models/__init__.py
|
||||||
adapters/api/v1/schema_models/account.py
|
adapters/api/v1/schema_models/account.py
|
||||||
adapters/api/v1/schema_models/bank_account.py
|
adapters/api/v1/schema_models/bank_account.py
|
||||||
|
adapters/api/v1/schema_models/check.py
|
||||||
adapters/api/v1/schema_models/email.py
|
adapters/api/v1/schema_models/email.py
|
||||||
adapters/api/v1/schema_models/file_storage.py
|
adapters/api/v1/schema_models/file_storage.py
|
||||||
adapters/api/v1/schema_models/person.py
|
adapters/api/v1/schema_models/person.py
|
||||||
|
|
@ -52,6 +56,7 @@ adapters/db/models/business_permission.py
|
||||||
adapters/db/models/captcha.py
|
adapters/db/models/captcha.py
|
||||||
adapters/db/models/cash_register.py
|
adapters/db/models/cash_register.py
|
||||||
adapters/db/models/category.py
|
adapters/db/models/category.py
|
||||||
|
adapters/db/models/check.py
|
||||||
adapters/db/models/currency.py
|
adapters/db/models/currency.py
|
||||||
adapters/db/models/document.py
|
adapters/db/models/document.py
|
||||||
adapters/db/models/document_line.py
|
adapters/db/models/document_line.py
|
||||||
|
|
@ -118,6 +123,7 @@ app/services/business_dashboard_service.py
|
||||||
app/services/business_service.py
|
app/services/business_service.py
|
||||||
app/services/captcha_service.py
|
app/services/captcha_service.py
|
||||||
app/services/cash_register_service.py
|
app/services/cash_register_service.py
|
||||||
|
app/services/check_service.py
|
||||||
app/services/email_service.py
|
app/services/email_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
|
|
@ -126,6 +132,7 @@ app/services/price_list_service.py
|
||||||
app/services/product_attribute_service.py
|
app/services/product_attribute_service.py
|
||||||
app/services/product_service.py
|
app/services/product_service.py
|
||||||
app/services/query_service.py
|
app/services/query_service.py
|
||||||
|
app/services/receipt_payment_service.py
|
||||||
app/services/pdf/__init__.py
|
app/services/pdf/__init__.py
|
||||||
app/services/pdf/base_pdf_service.py
|
app/services/pdf/base_pdf_service.py
|
||||||
app/services/pdf/modules/__init__.py
|
app/services/pdf/modules/__init__.py
|
||||||
|
|
@ -180,6 +187,9 @@ migrations/versions/20251002_000101_add_bank_accounts_table.py
|
||||||
migrations/versions/20251003_000201_add_cash_registers_table.py
|
migrations/versions/20251003_000201_add_cash_registers_table.py
|
||||||
migrations/versions/20251003_010501_add_name_to_cash_registers.py
|
migrations/versions/20251003_010501_add_name_to_cash_registers.py
|
||||||
migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
|
migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
|
||||||
|
migrations/versions/20251011_000901_add_checks_table.py
|
||||||
|
migrations/versions/20251011_010001_replace_accounts_chart_seed.py
|
||||||
|
migrations/versions/20251012_000101_update_accounts_account_type_to_english.py
|
||||||
migrations/versions/4b2ea782bcb3_merge_heads.py
|
migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '20251011_000901_add_checks_table'
|
||||||
|
down_revision: Union[str, None] = '1f0abcdd7300'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ایجاد ایمن جدول و ایندکسها در صورت نبود
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
# ایجاد type در صورت نیاز
|
||||||
|
try:
|
||||||
|
op.execute("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_NAME='checks' LIMIT 1")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 'checks' not in inspector.get_table_names():
|
||||||
|
# Enum برای نوع چک
|
||||||
|
try:
|
||||||
|
# برخی درایورها ایجاد Enum را قبل از استفاده میخواهند
|
||||||
|
sa.Enum('received', 'transferred', name='check_type')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
op.create_table(
|
||||||
|
'checks',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('type', sa.Enum('received', 'transferred', name='check_type'), nullable=False),
|
||||||
|
sa.Column('person_id', sa.Integer(), sa.ForeignKey('persons.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('issue_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('due_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('check_number', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('sayad_code', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('bank_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('branch_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('amount', sa.Numeric(18, 2), nullable=False),
|
||||||
|
sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'),
|
||||||
|
sa.UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ایجاد ایندکسها اگر وجود ندارند
|
||||||
|
try:
|
||||||
|
existing_indexes = {idx['name'] for idx in inspector.get_indexes('checks')}
|
||||||
|
if 'ix_checks_business_type' not in existing_indexes:
|
||||||
|
op.create_index('ix_checks_business_type', 'checks', ['business_id', 'type'])
|
||||||
|
if 'ix_checks_business_person' not in existing_indexes:
|
||||||
|
op.create_index('ix_checks_business_person', 'checks', ['business_id', 'person_id'])
|
||||||
|
if 'ix_checks_business_issue_date' not in existing_indexes:
|
||||||
|
op.create_index('ix_checks_business_issue_date', 'checks', ['business_id', 'issue_date'])
|
||||||
|
if 'ix_checks_business_due_date' not in existing_indexes:
|
||||||
|
op.create_index('ix_checks_business_due_date', 'checks', ['business_id', 'due_date'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop indices
|
||||||
|
op.drop_index('ix_checks_business_due_date', table_name='checks')
|
||||||
|
op.drop_index('ix_checks_business_issue_date', table_name='checks')
|
||||||
|
op.drop_index('ix_checks_business_person', table_name='checks')
|
||||||
|
op.drop_index('ix_checks_business_type', table_name='checks')
|
||||||
|
# Drop table
|
||||||
|
op.drop_table('checks')
|
||||||
|
# Drop enum type (if supported)
|
||||||
|
try:
|
||||||
|
op.execute("DROP TYPE check_type")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Replace accounts chart seed with the provided list (public accounts only)
|
||||||
|
|
||||||
|
Revision ID: 20251011_010001_replace_accounts_chart_seed
|
||||||
|
Revises: 20251006_000001_add_tax_types_table_and_product_fks
|
||||||
|
Create Date: 2025-10-11 01:00:01.000001
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251011_010001_replace_accounts_chart_seed'
|
||||||
|
down_revision = '20251011_000901_add_checks_table'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# لیست کامل از کاربر (فقط فیلدهای لازم برای جدول accounts نگه داشته شده)
|
||||||
|
# نگاشت: id => extId (صرفاً برای حلقه والد/فرزند). در جدول id خودکار است
|
||||||
|
items = [
|
||||||
|
{"id": 2452, "level": 1, "code": "1", "name": "دارایی ها", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2453, "level": 2, "code": "101", "name": "دارایی های جاری", "parentId": 2452, "accountType": 0},
|
||||||
|
{"id": 2454, "level": 3, "code": "102", "name": "موجودی نقد و بانک", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2455, "level": 4, "code": "10201", "name": "تنخواه گردان", "parentId": 2454, "accountType": 2},
|
||||||
|
{"id": 2456, "level": 4, "code": "10202", "name": "صندوق", "parentId": 2454, "accountType": 1},
|
||||||
|
{"id": 2457, "level": 4, "code": "10203", "name": "بانک", "parentId": 2454, "accountType": 3},
|
||||||
|
{"id": 2458, "level": 4, "code": "10204", "name": "وجوه در راه", "parentId": 2454, "accountType": 0},
|
||||||
|
{"id": 2459, "level": 3, "code": "103", "name": "سپرده های کوتاه مدت", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2460, "level": 4, "code": "10301", "name": "سپرده شرکت در مناقصه و مزایده", "parentId": 2459, "accountType": 0},
|
||||||
|
{"id": 2461, "level": 4, "code": "10302", "name": "ضمانت نامه بانکی", "parentId": 2459, "accountType": 0},
|
||||||
|
{"id": 2462, "level": 4, "code": "10303", "name": "سایر سپرده ها", "parentId": 2459, "accountType": 0},
|
||||||
|
{"id": 2463, "level": 3, "code": "104", "name": "حساب های دریافتنی", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2464, "level": 4, "code": "10401", "name": "حساب های دریافتنی", "parentId": 2463, "accountType": 4},
|
||||||
|
{"id": 2465, "level": 4, "code": "10402", "name": "ذخیره مطالبات مشکوک الوصول", "parentId": 2463, "accountType": 0},
|
||||||
|
{"id": 2466, "level": 4, "code": "10403", "name": "اسناد دریافتنی", "parentId": 2463, "accountType": 5},
|
||||||
|
{"id": 2467, "level": 4, "code": "10404", "name": "اسناد در جریان وصول", "parentId": 2463, "accountType": 6},
|
||||||
|
{"id": 2468, "level": 3, "code": "105", "name": "سایر حساب های دریافتنی", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2469, "level": 4, "code": "10501", "name": "وام کارکنان", "parentId": 2468, "accountType": 0},
|
||||||
|
{"id": 2470, "level": 4, "code": "10502", "name": "سایر حساب های دریافتنی", "parentId": 2468, "accountType": 0},
|
||||||
|
{"id": 2471, "level": 3, "code": "10101", "name": "پیش پرداخت ها", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2472, "level": 3, "code": "10102", "name": "موجودی کالا", "parentId": 2453, "accountType": 7},
|
||||||
|
{"id": 2473, "level": 3, "code": "10103", "name": "ملزومات", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2474, "level": 3, "code": "10104", "name": "مالیات بر ارزش افزوده خرید", "parentId": 2453, "accountType": 8},
|
||||||
|
{"id": 2475, "level": 2, "code": "106", "name": "دارایی های غیر جاری", "parentId": 2452, "accountType": 0},
|
||||||
|
{"id": 2476, "level": 3, "code": "107", "name": "دارایی های ثابت", "parentId": 2475, "accountType": 0},
|
||||||
|
{"id": 2477, "level": 4, "code": "10701", "name": "زمین", "parentId": 2476, "accountType": 0},
|
||||||
|
{"id": 2478, "level": 4, "code": "10702", "name": "ساختمان", "parentId": 2476, "accountType": 0},
|
||||||
|
{"id": 2479, "level": 4, "code": "10703", "name": "وسائط نقلیه", "parentId": 2476, "accountType": 0},
|
||||||
|
{"id": 2480, "level": 4, "code": "10704", "name": "اثاثیه اداری", "parentId": 2476, "accountType": 0},
|
||||||
|
{"id": 2481, "level": 3, "code": "108", "name": "استهلاک انباشته", "parentId": 2475, "accountType": 0},
|
||||||
|
{"id": 2482, "level": 4, "code": "10801", "name": "استهلاک انباشته ساختمان", "parentId": 2481, "accountType": 0},
|
||||||
|
{"id": 2483, "level": 4, "code": "10802", "name": "استهلاک انباشته وسائط نقلیه", "parentId": 2481, "accountType": 0},
|
||||||
|
{"id": 2484, "level": 4, "code": "10803", "name": "استهلاک انباشته اثاثیه اداری", "parentId": 2481, "accountType": 0},
|
||||||
|
{"id": 2485, "level": 3, "code": "109", "name": "سپرده های بلندمدت", "parentId": 2475, "accountType": 0},
|
||||||
|
{"id": 2486, "level": 3, "code": "110", "name": "سایر دارائی ها", "parentId": 2475, "accountType": 0},
|
||||||
|
{"id": 2487, "level": 4, "code": "11001", "name": "حق الامتیازها", "parentId": 2486, "accountType": 0},
|
||||||
|
{"id": 2488, "level": 4, "code": "11002", "name": "نرم افزارها", "parentId": 2486, "accountType": 0},
|
||||||
|
{"id": 2489, "level": 4, "code": "11003", "name": "سایر دارایی های نامشهود", "parentId": 2486, "accountType": 0},
|
||||||
|
{"id": 2490, "level": 1, "code": "2", "name": "بدهی ها", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2491, "level": 2, "code": "201", "name": "بدهیهای جاری", "parentId": 2490, "accountType": 0},
|
||||||
|
{"id": 2492, "level": 3, "code": "202", "name": "حساب ها و اسناد پرداختنی", "parentId": 2491, "accountType": 0},
|
||||||
|
{"id": 2493, "level": 4, "code": "20201", "name": "حساب های پرداختنی", "parentId": 2492, "accountType": 9},
|
||||||
|
{"id": 2494, "level": 4, "code": "20202", "name": "اسناد پرداختنی", "parentId": 2492, "accountType": 10},
|
||||||
|
{"id": 2495, "level": 3, "code": "203", "name": "سایر حساب های پرداختنی", "parentId": 2491, "accountType": 0},
|
||||||
|
{"id": 2496, "level": 4, "code": "20301", "name": "ذخیره مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 40},
|
||||||
|
{"id": 2497, "level": 4, "code": "20302", "name": "مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 12},
|
||||||
|
{"id": 2498, "level": 4, "code": "20303", "name": "مالیات حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 0},
|
||||||
|
{"id": 2499, "level": 4, "code": "20304", "name": "حق بیمه پرداختنی", "parentId": 2495, "accountType": 0},
|
||||||
|
{"id": 2500, "level": 4, "code": "20305", "name": "حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 42},
|
||||||
|
{"id": 2501, "level": 4, "code": "20306", "name": "عیدی و پاداش پرداختنی", "parentId": 2495, "accountType": 0},
|
||||||
|
{"id": 2502, "level": 4, "code": "20307", "name": "سایر هزینه های پرداختنی", "parentId": 2495, "accountType": 0},
|
||||||
|
{"id": 2503, "level": 3, "code": "204", "name": "پیش دریافت ها", "parentId": 2491, "accountType": 0},
|
||||||
|
{"id": 2504, "level": 4, "code": "20401", "name": "پیش دریافت فروش", "parentId": 2503, "accountType": 0},
|
||||||
|
{"id": 2505, "level": 4, "code": "20402", "name": "سایر پیش دریافت ها", "parentId": 2503, "accountType": 0},
|
||||||
|
{"id": 2506, "level": 3, "code": "20101", "name": "مالیات بر ارزش افزوده فروش", "parentId": 2491, "accountType": 11},
|
||||||
|
{"id": 2507, "level": 2, "code": "205", "name": "بدهیهای غیر جاری", "parentId": 2490, "accountType": 0},
|
||||||
|
{"id": 2508, "level": 3, "code": "206", "name": "حساب ها و اسناد پرداختنی بلندمدت", "parentId": 2507, "accountType": 0},
|
||||||
|
{"id": 2509, "level": 4, "code": "20601", "name": "حساب های پرداختنی بلندمدت", "parentId": 2508, "accountType": 0},
|
||||||
|
{"id": 2510, "level": 4, "code": "20602", "name": "اسناد پرداختنی بلندمدت", "parentId": 2508, "accountType": 0},
|
||||||
|
{"id": 2511, "level": 3, "code": "20501", "name": "وام پرداختنی", "parentId": 2507, "accountType": 0},
|
||||||
|
{"id": 2512, "level": 3, "code": "20502", "name": "ذخیره مزایای پایان خدمت کارکنان", "parentId": 2507, "accountType": 0},
|
||||||
|
{"id": 2513, "level": 1, "code": "3", "name": "حقوق صاحبان سهام", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2514, "level": 2, "code": "301", "name": "سرمایه", "parentId": 2513, "accountType": 0},
|
||||||
|
{"id": 2515, "level": 3, "code": "30101", "name": "سرمایه اولیه", "parentId": 2514, "accountType": 13},
|
||||||
|
{"id": 2516, "level": 3, "code": "30102", "name": "افزایش یا کاهش سرمایه", "parentId": 2514, "accountType": 14},
|
||||||
|
{"id": 2517, "level": 3, "code": "30103", "name": "اندوخته قانونی", "parentId": 2514, "accountType": 15},
|
||||||
|
{"id": 2518, "level": 3, "code": "30104", "name": "برداشت ها", "parentId": 2514, "accountType": 16},
|
||||||
|
{"id": 2519, "level": 3, "code": "30105", "name": "سهم سود و زیان", "parentId": 2514, "accountType": 17},
|
||||||
|
{"id": 2520, "level": 3, "code": "30106", "name": "سود یا زیان انباشته (سنواتی)", "parentId": 2514, "accountType": 18},
|
||||||
|
{"id": 2521, "level": 1, "code": "4", "name": "بهای تمام شده کالای فروخته شده", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2522, "level": 2, "code": "40001", "name": "بهای تمام شده کالای فروخته شده", "parentId": 2521, "accountType": 19},
|
||||||
|
{"id": 2523, "level": 2, "code": "40002", "name": "برگشت از خرید", "parentId": 2521, "accountType": 20},
|
||||||
|
{"id": 2524, "level": 2, "code": "40003", "name": "تخفیفات نقدی خرید", "parentId": 2521, "accountType": 21},
|
||||||
|
{"id": 2525, "level": 1, "code": "5", "name": "فروش", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2526, "level": 2, "code": "50001", "name": "فروش کالا", "parentId": 2525, "accountType": 22},
|
||||||
|
{"id": 2527, "level": 2, "code": "50002", "name": "برگشت از فروش", "parentId": 2525, "accountType": 23},
|
||||||
|
{"id": 2528, "level": 2, "code": "50003", "name": "تخفیفات نقدی فروش", "parentId": 2525, "accountType": 24},
|
||||||
|
{"id": 2529, "level": 1, "code": "6", "name": "درآمد", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2530, "level": 2, "code": "601", "name": "درآمد های عملیاتی", "parentId": 2529, "accountType": 0},
|
||||||
|
{"id": 2531, "level": 3, "code": "60101", "name": "درآمد حاصل از فروش خدمات", "parentId": 2530, "accountType": 25},
|
||||||
|
{"id": 2532, "level": 3, "code": "60102", "name": "برگشت از خرید خدمات", "parentId": 2530, "accountType": 26},
|
||||||
|
{"id": 2533, "level": 3, "code": "60103", "name": "درآمد اضافه کالا", "parentId": 2530, "accountType": 27},
|
||||||
|
{"id": 2534, "level": 3, "code": "60104", "name": "درآمد حمل کالا", "parentId": 2530, "accountType": 28},
|
||||||
|
{"id": 2535, "level": 2, "code": "602", "name": "درآمد های غیر عملیاتی", "parentId": 2529, "accountType": 0},
|
||||||
|
{"id": 2536, "level": 3, "code": "60201", "name": "درآمد حاصل از سرمایه گذاری", "parentId": 2535, "accountType": 0},
|
||||||
|
{"id": 2537, "level": 3, "code": "60202", "name": "درآمد سود سپرده ها", "parentId": 2535, "accountType": 0},
|
||||||
|
{"id": 2538, "level": 3, "code": "60203", "name": "سایر درآمد ها", "parentId": 2535, "accountType": 0},
|
||||||
|
{"id": 2539, "level": 3, "code": "60204", "name": "درآمد تسعیر ارز", "parentId": 2535, "accountType": 36},
|
||||||
|
{"id": 2540, "level": 1, "code": "7", "name": "هزینه ها", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2541, "level": 2, "code": "701", "name": "هزینه های پرسنلی", "parentId": 2540, "accountType": 0},
|
||||||
|
{"id": 2542, "level": 3, "code": "702", "name": "هزینه حقوق و دستمزد", "parentId": 2541, "accountType": 0},
|
||||||
|
{"id": 2543, "level": 4, "code": "70201", "name": "حقوق پایه", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2544, "level": 4, "code": "70202", "name": "اضافه کار", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2545, "level": 4, "code": "70203", "name": "حق شیفت و شب کاری", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2546, "level": 4, "code": "70204", "name": "حق نوبت کاری", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2547, "level": 4, "code": "70205", "name": "حق ماموریت", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2548, "level": 4, "code": "70206", "name": "فوق العاده مسکن و خاروبار", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2549, "level": 4, "code": "70207", "name": "حق اولاد", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2550, "level": 4, "code": "70208", "name": "عیدی و پاداش", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2551, "level": 4, "code": "70209", "name": "بازخرید سنوات خدمت کارکنان", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2552, "level": 4, "code": "70210", "name": "بازخرید مرخصی", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2553, "level": 4, "code": "70211", "name": "بیمه سهم کارفرما", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2554, "level": 4, "code": "70212", "name": "بیمه بیکاری", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2555, "level": 4, "code": "70213", "name": "حقوق مزایای متفرقه", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2556, "level": 3, "code": "703", "name": "سایر هزینه های کارکنان", "parentId": 2541, "accountType": 0},
|
||||||
|
{"id": 2557, "level": 4, "code": "70301", "name": "سفر و ماموریت", "parentId": 2556, "accountType": 0},
|
||||||
|
{"id": 2558, "level": 4, "code": "70302", "name": "ایاب و ذهاب", "parentId": 2556, "accountType": 0},
|
||||||
|
{"id": 2559, "level": 4, "code": "70303", "name": "سایر هزینه های کارکنان", "parentId": 2556, "accountType": 0},
|
||||||
|
{"id": 2560, "level": 2, "code": "704", "name": "هزینه های عملیاتی", "parentId": 2540, "accountType": 0},
|
||||||
|
{"id": 2561, "level": 3, "code": "70401", "name": "خرید خدمات", "parentId": 2560, "accountType": 30},
|
||||||
|
{"id": 2562, "level": 3, "code": "70402", "name": "برگشت از فروش خدمات", "parentId": 2560, "accountType": 29},
|
||||||
|
{"id": 2563, "level": 3, "code": "70403", "name": "هزینه حمل کالا", "parentId": 2560, "accountType": 31},
|
||||||
|
{"id": 2564, "level": 3, "code": "70404", "name": "تعمیر و نگهداری اموال و اثاثیه", "parentId": 2560, "accountType": 0},
|
||||||
|
{"id": 2565, "level": 3, "code": "70405", "name": "هزینه اجاره محل", "parentId": 2560, "accountType": 0},
|
||||||
|
{"id": 2566, "level": 3, "code": "705", "name": "هزینه های عمومی", "parentId": 2560, "accountType": 0},
|
||||||
|
{"id": 2567, "level": 4, "code": "70501", "name": "هزینه آب و برق و گاز و تلفن", "parentId": 2566, "accountType": 0},
|
||||||
|
{"id": 2568, "level": 4, "code": "70502", "name": "هزینه پذیرایی و آبدارخانه", "parentId": 2566, "accountType": 0},
|
||||||
|
{"id": 2569, "level": 3, "code": "70406", "name": "هزینه ملزومات مصرفی", "parentId": 2560, "accountType": 0},
|
||||||
|
{"id": 2570, "level": 3, "code": "70407", "name": "هزینه کسری و ضایعات کالا", "parentId": 2560, "accountType": 32},
|
||||||
|
{"id": 2571, "level": 3, "code": "70408", "name": "بیمه دارایی های ثابت", "parentId": 2560, "accountType": 0},
|
||||||
|
{"id": 2572, "level": 2, "code": "706", "name": "هزینه های استهلاک", "parentId": 2540, "accountType": 0},
|
||||||
|
{"id": 2573, "level": 3, "code": "70601", "name": "هزینه استهلاک ساختمان", "parentId": 2572, "accountType": 0},
|
||||||
|
{"id": 2574, "level": 3, "code": "70602", "name": "هزینه استهلاک وسائط نقلیه", "parentId": 2572, "accountType": 0},
|
||||||
|
{"id": 2575, "level": 3, "code": "70603", "name": "هزینه استهلاک اثاثیه", "parentId": 2572, "accountType": 0},
|
||||||
|
{"id": 2576, "level": 2, "code": "707", "name": "هزینه های بازاریابی و توزیع و فروش", "parentId": 2540, "accountType": 0},
|
||||||
|
{"id": 2577, "level": 3, "code": "70701", "name": "هزینه آگهی و تبلیغات", "parentId": 2576, "accountType": 0},
|
||||||
|
{"id": 2578, "level": 3, "code": "70702", "name": "هزینه بازاریابی و پورسانت", "parentId": 2576, "accountType": 0},
|
||||||
|
{"id": 2579, "level": 3, "code": "70703", "name": "سایر هزینه های توزیع و فروش", "parentId": 2576, "accountType": 0},
|
||||||
|
{"id": 2580, "level": 2, "code": "708", "name": "هزینه های غیرعملیاتی", "parentId": 2540, "accountType": 0},
|
||||||
|
{"id": 2581, "level": 3, "code": "709", "name": "هزینه های بانکی", "parentId": 2580, "accountType": 0},
|
||||||
|
{"id": 2582, "level": 4, "code": "70901", "name": "سود و کارمزد وامها", "parentId": 2581, "accountType": 0},
|
||||||
|
{"id": 2583, "level": 4, "code": "70902", "name": "کارمزد خدمات بانکی", "parentId": 2581, "accountType": 33},
|
||||||
|
{"id": 2584, "level": 4, "code": "70903", "name": "جرائم دیرکرد بانکی", "parentId": 2581, "accountType": 0},
|
||||||
|
{"id": 2585, "level": 3, "code": "70801", "name": "هزینه تسعیر ارز", "parentId": 2580, "accountType": 37},
|
||||||
|
{"id": 2586, "level": 3, "code": "70802", "name": "هزینه مطالبات سوخت شده", "parentId": 2580, "accountType": 0},
|
||||||
|
{"id": 2587, "level": 1, "code": "8", "name": "سایر حساب ها", "parentId": 0, "accountType": 0},
|
||||||
|
{"id": 2588, "level": 2, "code": "801", "name": "حساب های انتظامی", "parentId": 2587, "accountType": 0},
|
||||||
|
{"id": 2589, "level": 3, "code": "80101", "name": "حساب های انتظامی", "parentId": 2588, "accountType": 0},
|
||||||
|
{"id": 2590, "level": 3, "code": "80102", "name": "طرف حساب های انتظامی", "parentId": 2588, "accountType": 0},
|
||||||
|
{"id": 2591, "level": 2, "code": "802", "name": "حساب های کنترلی", "parentId": 2587, "accountType": 0},
|
||||||
|
{"id": 2592, "level": 3, "code": "80201", "name": "کنترل کسری و اضافه کالا", "parentId": 2591, "accountType": 34},
|
||||||
|
{"id": 2593, "level": 2, "code": "803", "name": "حساب خلاصه سود و زیان", "parentId": 2587, "accountType": 0},
|
||||||
|
{"id": 2594, "level": 3, "code": "80301", "name": "خلاصه سود و زیان", "parentId": 2593, "accountType": 35},
|
||||||
|
{"id": 2595, "level": 5, "code": "70503", "name": "هزینه آب", "parentId": 2567, "accountType": 0},
|
||||||
|
{"id": 2596, "level": 5, "code": "70504", "name": "هزینه برق", "parentId": 2567, "accountType": 0},
|
||||||
|
{"id": 2597, "level": 5, "code": "70505", "name": "هزینه گاز", "parentId": 2567, "accountType": 0},
|
||||||
|
{"id": 2598, "level": 5, "code": "70506", "name": "هزینه تلفن", "parentId": 2567, "accountType": 0},
|
||||||
|
{"id": 2600, "level": 4, "code": "20503", "name": "وام از بانک ملت", "parentId": 2511, "accountType": 0},
|
||||||
|
{"id": 2601, "level": 4, "code": "10405", "name": "سود تحقق نیافته فروش اقساطی", "parentId": 2463, "accountType": 39},
|
||||||
|
{"id": 2602, "level": 3, "code": "60205", "name": "سود فروش اقساطی", "parentId": 2535, "accountType": 38},
|
||||||
|
{"id": 2603, "level": 4, "code": "70214", "name": "حق تاهل", "parentId": 2542, "accountType": 0},
|
||||||
|
{"id": 2604, "level": 4, "code": "20504", "name": "وام از بانک پارسیان", "parentId": 2511, "accountType": 0},
|
||||||
|
{"id": 2605, "level": 3, "code": "10105", "name": "مساعده", "parentId": 2453, "accountType": 0},
|
||||||
|
{"id": 2606, "level": 3, "code": "60105", "name": "تعمیرات لوازم آشپزخانه", "parentId": 2530, "accountType": 0},
|
||||||
|
{"id": 2607, "level": 4, "code": "10705", "name": "کامپیوتر", "parentId": 2476, "accountType": 0},
|
||||||
|
{"id": 2608, "level": 3, "code": "60206", "name": "درامد حاصل از فروش ضایعات", "parentId": 2535, "accountType": 0},
|
||||||
|
{"id": 2609, "level": 3, "code": "60207", "name": "سود فروش دارایی", "parentId": 2535, "accountType": 0},
|
||||||
|
{"id": 2610, "level": 3, "code": "70803", "name": "زیان فروش دارایی", "parentId": 2580, "accountType": 0},
|
||||||
|
{"id": 2611, "level": 3, "code": "10106", "name": "موجودی کالای در جریان ساخت", "parentId": 2453, "accountType": 41},
|
||||||
|
{"id": 2612, "level": 3, "code": "20102", "name": "سربار تولید پرداختنی", "parentId": 2491, "accountType": 43},
|
||||||
|
{"id": 2613, "level": 4, "code": "70507", "name": "هزینه جدید", "parentId": 2566, "accountType": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ۱) حذف حسابهای عمومی موجود که در لیست جدید نیستند
|
||||||
|
existing_codes = set(r[0] for r in conn.execute(sa.text("SELECT code FROM accounts WHERE business_id IS NULL")).fetchall())
|
||||||
|
new_codes = {row["code"] for row in items}
|
||||||
|
to_delete = tuple(sorted(existing_codes - new_codes))
|
||||||
|
if to_delete:
|
||||||
|
# حذف امن بر اساس کد و فقط عمومی
|
||||||
|
del_sql = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
|
||||||
|
for c in to_delete:
|
||||||
|
conn.execute(del_sql, {"code": c})
|
||||||
|
|
||||||
|
# ۲) درج/بهروزرسانی حسابها بههمراه نگاشت والدین
|
||||||
|
ext_to_internal: dict[int, int] = {}
|
||||||
|
select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1")
|
||||||
|
insert_q = sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at)
|
||||||
|
VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW())
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
update_q = sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE accounts
|
||||||
|
SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW()
|
||||||
|
WHERE id = :id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
parent_internal = None
|
||||||
|
if item.get("parentId") and item["parentId"] in ext_to_internal:
|
||||||
|
parent_internal = ext_to_internal[item["parentId"]]
|
||||||
|
|
||||||
|
res = conn.execute(select_existing, {"code": item["code"]})
|
||||||
|
row = res.fetchone()
|
||||||
|
if row is None:
|
||||||
|
result = conn.execute(
|
||||||
|
insert_q,
|
||||||
|
{
|
||||||
|
"name": item["name"],
|
||||||
|
"account_type": str(item.get("accountType", 0)),
|
||||||
|
"code": item["code"],
|
||||||
|
"parent_id": parent_internal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
new_id = result.lastrowid if hasattr(result, "lastrowid") else None
|
||||||
|
if new_id is None:
|
||||||
|
# fallback: انتخاب مجدد بر اساس code
|
||||||
|
res2 = conn.execute(select_existing, {"code": item["code"]})
|
||||||
|
row2 = res2.fetchone()
|
||||||
|
if row2:
|
||||||
|
new_id = row2[0]
|
||||||
|
if new_id is not None:
|
||||||
|
ext_to_internal[item["id"]] = int(new_id)
|
||||||
|
else:
|
||||||
|
acc_id = int(row[0])
|
||||||
|
conn.execute(
|
||||||
|
update_q,
|
||||||
|
{
|
||||||
|
"id": acc_id,
|
||||||
|
"name": item["name"],
|
||||||
|
"account_type": str(item.get("accountType", 0)),
|
||||||
|
"parent_id": parent_internal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ext_to_internal[item["id"]] = acc_id
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# در downgrade صرفاً کدهایی که در این میگریشن اضافه/بروز شدهاند حذف میشوند
|
||||||
|
conn = op.get_bind()
|
||||||
|
codes = [
|
||||||
|
"1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102","70507"
|
||||||
|
]
|
||||||
|
delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code")
|
||||||
|
for code in codes:
|
||||||
|
conn.execute(delete_q, {"code": code})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Normalize accounts.account_type to English values and add constraint
|
||||||
|
|
||||||
|
Revision ID: 20251012_000101_update_accounts_account_type_to_english
|
||||||
|
Revises: 20251011_010001_replace_accounts_chart_seed
|
||||||
|
Create Date: 2025-10-12 00:01:01.000001
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251012_000101_update_accounts_account_type_to_english'
|
||||||
|
down_revision = '20251011_010001_replace_accounts_chart_seed'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_TYPES = (
|
||||||
|
"bank",
|
||||||
|
"cash_register",
|
||||||
|
"petty_cash",
|
||||||
|
"check",
|
||||||
|
"person",
|
||||||
|
"product",
|
||||||
|
"service",
|
||||||
|
"accounting_document",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# نگاشت مقادیر عددی/قدیمی به مقادیر انگلیسی جدید
|
||||||
|
mapping_updates: list[tuple[str, tuple[str, ...]]] = [
|
||||||
|
("bank", ("3",)),
|
||||||
|
("cash_register", ("1",)),
|
||||||
|
("petty_cash", ("2",)),
|
||||||
|
("check", ("5", "6", "10")),
|
||||||
|
("person", ("4", "9")),
|
||||||
|
("product", ("7",)),
|
||||||
|
("service", ("25", "26", "29", "30", "31")),
|
||||||
|
]
|
||||||
|
|
||||||
|
for new_val, old_vals in mapping_updates:
|
||||||
|
for old_val in old_vals:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE accounts SET account_type = :new_val WHERE account_type = :old_val"
|
||||||
|
),
|
||||||
|
{"new_val": new_val, "old_val": old_val},
|
||||||
|
)
|
||||||
|
|
||||||
|
# سایر مقادیر ناشناخته را به accounting_document تنظیم کن
|
||||||
|
placeholders = ", ".join([":v" + str(i) for i in range(len(ALLOWED_TYPES))])
|
||||||
|
params = {("v" + str(i)): v for i, v in enumerate(ALLOWED_TYPES)}
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
f"UPDATE accounts SET account_type = 'accounting_document' WHERE account_type NOT IN ({placeholders})"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# افزودن چککانسترینت برای اطمینان از مقادیر مجاز (در صورت نبود)
|
||||||
|
# برخی پایگاهها CHECK را نادیده میگیرند؛ این بخش ایمن با try/except است
|
||||||
|
try:
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_accounts_account_type_allowed",
|
||||||
|
"accounts",
|
||||||
|
"account_type IN ('bank', 'cash_register', 'petty_cash', 'check', 'person', 'product', 'service', 'accounting_document')",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# اگر از قبل وجود داشته باشد، نادیده بگیر
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# حذف چککانسترینت
|
||||||
|
op.drop_constraint("ck_accounts_account_type_allowed", "accounts", type_="check")
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# نگاشت معکوس ساده برای بازگشت به مقادیر عددی پایه
|
||||||
|
reverse_mapping: list[tuple[str, str]] = [
|
||||||
|
("bank", "3"),
|
||||||
|
("cash_register", "1"),
|
||||||
|
("petty_cash", "2"),
|
||||||
|
("check", "5"),
|
||||||
|
("person", "4"),
|
||||||
|
("product", "7"),
|
||||||
|
("service", "25"),
|
||||||
|
("accounting_document", "0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for eng_val, legacy_val in reverse_mapping:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE accounts SET account_type = :legacy WHERE account_type = :eng"
|
||||||
|
),
|
||||||
|
{"legacy": legacy_val, "eng": eng_val},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251014_000201_add_person_id_to_document_lines'
|
||||||
|
down_revision = '20250927_000017_add_account_id_to_document_lines'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('person_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_person_id_persons', 'persons', ['person_id'], ['id'], ondelete='SET NULL')
|
||||||
|
batch_op.create_index('ix_document_lines_person_id', ['person_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.drop_index('ix_document_lines_person_id')
|
||||||
|
batch_op.drop_constraint('fk_document_lines_person_id_persons', type_='foreignkey')
|
||||||
|
batch_op.drop_column('person_id')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251014_000301_add_product_id_to_document_lines'
|
||||||
|
down_revision = '20251014_000201_add_person_id_to_document_lines'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('product_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_product_id_products', 'products', ['product_id'], ['id'], ondelete='SET NULL')
|
||||||
|
batch_op.create_index('ix_document_lines_product_id', ['product_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.drop_index('ix_document_lines_product_id')
|
||||||
|
batch_op.drop_constraint('fk_document_lines_product_id_products', type_='foreignkey')
|
||||||
|
batch_op.drop_column('product_id')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251014_000401_add_payment_refs_to_document_lines'
|
||||||
|
down_revision = '20251014_000301_add_product_id_to_document_lines'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL')
|
||||||
|
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL')
|
||||||
|
|
||||||
|
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
|
||||||
|
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
|
||||||
|
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
|
||||||
|
batch_op.create_index('ix_document_lines_check_id', ['check_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.drop_index('ix_document_lines_check_id')
|
||||||
|
batch_op.drop_index('ix_document_lines_petty_cash_id')
|
||||||
|
batch_op.drop_index('ix_document_lines_cash_register_id')
|
||||||
|
batch_op.drop_index('ix_document_lines_bank_account_id')
|
||||||
|
|
||||||
|
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
|
||||||
|
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
|
||||||
|
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
|
||||||
|
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey')
|
||||||
|
|
||||||
|
batch_op.drop_column('check_id')
|
||||||
|
batch_op.drop_column('petty_cash_id')
|
||||||
|
batch_op.drop_column('cash_register_id')
|
||||||
|
batch_op.drop_column('bank_account_id')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20251014_000501_add_quantity_to_document_lines'
|
||||||
|
down_revision = '20251014_000401_add_payment_refs_to_document_lines'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('quantity', sa.Numeric(18, 6), nullable=True, server_default=sa.text('0')))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
|
batch_op.drop_column('quantity')
|
||||||
|
|
||||||
|
|
||||||
24
hesabixAPI/migrations/versions/7ecb63029764_merge_heads.py
Normal file
24
hesabixAPI/migrations/versions/7ecb63029764_merge_heads.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""merge heads
|
||||||
|
|
||||||
|
Revision ID: 7ecb63029764
|
||||||
|
Revises: 20250106_000004, 20251012_000101_update_accounts_account_type_to_english, 20251014_000501_add_quantity_to_document_lines
|
||||||
|
Create Date: 2025-10-14 12:36:58.259190
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7ecb63029764'
|
||||||
|
down_revision = ('20250106_000004', '20251012_000101_update_accounts_account_type_to_english', '20251014_000501_add_quantity_to_document_lines')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
@ -1091,5 +1091,14 @@
|
||||||
"pettyCashExportExcel": "Export petty cash to Excel",
|
"pettyCashExportExcel": "Export petty cash to Excel",
|
||||||
"pettyCashExportPdf": "Export petty cash to PDF",
|
"pettyCashExportPdf": "Export petty cash to PDF",
|
||||||
"pettyCashReport": "Petty Cash Report"
|
"pettyCashReport": "Petty Cash Report"
|
||||||
|
,
|
||||||
|
"accountTypeBank": "Bank",
|
||||||
|
"accountTypeCashRegister": "Cash Register",
|
||||||
|
"accountTypePettyCash": "Petty Cash",
|
||||||
|
"accountTypeCheck": "Check",
|
||||||
|
"accountTypePerson": "Person",
|
||||||
|
"accountTypeProduct": "Product",
|
||||||
|
"accountTypeService": "Service",
|
||||||
|
"accountTypeAccountingDocument": "Accounting Document"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1073,6 +1073,14 @@
|
||||||
"pettyCashDetails": "جزئیات تنخواه گردان",
|
"pettyCashDetails": "جزئیات تنخواه گردان",
|
||||||
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
||||||
"pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
|
"pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
|
||||||
"pettyCashReport": "گزارش تنخواه گردانها"
|
"pettyCashReport": "گزارش تنخواه گردانها",
|
||||||
|
"accountTypeBank": "بانک",
|
||||||
|
"accountTypeCashRegister": "صندوق",
|
||||||
|
"accountTypePettyCash": "تنخواه گردان",
|
||||||
|
"accountTypeCheck": "چک",
|
||||||
|
"accountTypePerson": "شخص",
|
||||||
|
"accountTypeProduct": "کالا",
|
||||||
|
"accountTypeService": "خدمات",
|
||||||
|
"accountTypeAccountingDocument": "سند حسابداری"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5719,6 +5719,54 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Petty Cash Report'**
|
/// **'Petty Cash Report'**
|
||||||
String get pettyCashReport;
|
String get pettyCashReport;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeBank.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bank'**
|
||||||
|
String get accountTypeBank;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeCashRegister.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cash Register'**
|
||||||
|
String get accountTypeCashRegister;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypePettyCash.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash'**
|
||||||
|
String get accountTypePettyCash;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeCheck.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check'**
|
||||||
|
String get accountTypeCheck;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypePerson.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Person'**
|
||||||
|
String get accountTypePerson;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeProduct.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Product'**
|
||||||
|
String get accountTypeProduct;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeService.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Service'**
|
||||||
|
String get accountTypeService;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeAccountingDocument.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Accounting Document'**
|
||||||
|
String get accountTypeAccountingDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2897,4 +2897,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pettyCashReport => 'Petty Cash Report';
|
String get pettyCashReport => 'Petty Cash Report';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeBank => 'Bank';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCashRegister => 'Cash Register';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePettyCash => 'Petty Cash';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCheck => 'Check';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePerson => 'Person';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeProduct => 'Product';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeService => 'Service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeAccountingDocument => 'Accounting Document';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2876,4 +2876,28 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pettyCashReport => 'گزارش تنخواه گردانها';
|
String get pettyCashReport => 'گزارش تنخواه گردانها';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeBank => 'بانک';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCashRegister => 'صندوق';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePettyCash => 'تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCheck => 'چک';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePerson => 'شخص';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeProduct => 'کالا';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeService => 'خدمات';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeAccountingDocument => 'سند حسابداری';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import 'pages/business/cash_registers_page.dart';
|
||||||
import 'pages/business/petty_cash_page.dart';
|
import 'pages/business/petty_cash_page.dart';
|
||||||
import 'pages/business/checks_page.dart';
|
import 'pages/business/checks_page.dart';
|
||||||
import 'pages/business/check_form_page.dart';
|
import 'pages/business/check_form_page.dart';
|
||||||
|
import 'pages/business/receipts_payments_page.dart';
|
||||||
import 'pages/error_404_page.dart';
|
import 'pages/error_404_page.dart';
|
||||||
import 'core/locale_controller.dart';
|
import 'core/locale_controller.dart';
|
||||||
import 'core/calendar_controller.dart';
|
import 'core/calendar_controller.dart';
|
||||||
|
|
@ -795,6 +796,26 @@ class _MyAppState extends State<MyApp> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Checks: list, new, edit
|
// Checks: list, new, edit
|
||||||
|
GoRoute(
|
||||||
|
path: 'receipts-payments',
|
||||||
|
name: 'business_receipts_payments',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: ReceiptsPaymentsPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
authStore: _authStore!,
|
||||||
|
apiClient: ApiClient(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'checks',
|
path: 'checks',
|
||||||
name: 'business_checks',
|
name: 'business_checks',
|
||||||
|
|
@ -827,6 +848,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
child: CheckFormPage(
|
child: CheckFormPage(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
|
calendarController: _calendarController!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -847,6 +869,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
checkId: checkId,
|
checkId: checkId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,45 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/api_client.dart';
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
|
||||||
|
class AccountNode {
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String? accountType;
|
||||||
|
final List<AccountNode> children;
|
||||||
|
final bool hasChildren;
|
||||||
|
|
||||||
|
const AccountNode({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
this.accountType,
|
||||||
|
this.children = const [],
|
||||||
|
this.hasChildren = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AccountNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawChildren = (json['children'] as List?) ?? const [];
|
||||||
|
final parsedChildren = rawChildren
|
||||||
|
.map((c) => AccountNode.fromJson(Map<String, dynamic>.from(c as Map)))
|
||||||
|
.toList();
|
||||||
|
return AccountNode(
|
||||||
|
id: (json['id']?.toString() ?? json['code']?.toString() ?? UniqueKey().toString()),
|
||||||
|
code: json['code']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '',
|
||||||
|
accountType: json['account_type']?.toString(),
|
||||||
|
children: parsedChildren,
|
||||||
|
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VisibleNode {
|
||||||
|
final AccountNode node;
|
||||||
|
final int level;
|
||||||
|
const _VisibleNode(this.node, this.level);
|
||||||
|
}
|
||||||
|
|
||||||
class AccountsPage extends StatefulWidget {
|
class AccountsPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
const AccountsPage({super.key, required this.businessId});
|
const AccountsPage({super.key, required this.businessId});
|
||||||
|
|
@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget {
|
||||||
class _AccountsPageState extends State<AccountsPage> {
|
class _AccountsPageState extends State<AccountsPage> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<dynamic> _tree = const [];
|
List<AccountNode> _roots = const [];
|
||||||
|
final Set<String> _expandedIds = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -26,7 +66,11 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
try {
|
try {
|
||||||
final api = ApiClient();
|
final api = ApiClient();
|
||||||
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
|
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
|
||||||
setState(() { _tree = res.data['data']['items'] ?? []; });
|
final items = (res.data['data']['items'] as List?) ?? const [];
|
||||||
|
final parsed = items
|
||||||
|
.map((n) => AccountNode.fromJson(Map<String, dynamic>.from(n as Map)))
|
||||||
|
.toList();
|
||||||
|
setState(() { _roots = parsed; });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() { _error = e.toString(); });
|
setState(() { _error = e.toString(); });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -34,17 +78,86 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNode(Map<String, dynamic> node) {
|
List<_VisibleNode> _buildVisibleNodes() {
|
||||||
final children = (node['children'] as List?) ?? const [];
|
final List<_VisibleNode> result = <_VisibleNode>[];
|
||||||
if (children.isEmpty) {
|
void dfs(AccountNode node, int level) {
|
||||||
return ListTile(
|
result.add(_VisibleNode(node, level));
|
||||||
title: Text('${node['code']} - ${node['name']}'),
|
if (_expandedIds.contains(node.id)) {
|
||||||
);
|
for (final child in node.children) {
|
||||||
|
dfs(child, level + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final r in _roots) {
|
||||||
|
dfs(r, 0);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleExpand(AccountNode node) {
|
||||||
|
setState(() {
|
||||||
|
if (_expandedIds.contains(node.id)) {
|
||||||
|
_expandedIds.remove(node.id);
|
||||||
|
} else {
|
||||||
|
if (node.hasChildren) {
|
||||||
|
_expandedIds.add(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedAccountType(AppLocalizations t, String? value) {
|
||||||
|
if (value == null || value.isEmpty) return '-';
|
||||||
|
final ln = t.localeName;
|
||||||
|
if (ln.startsWith('fa')) {
|
||||||
|
switch (value) {
|
||||||
|
case 'bank':
|
||||||
|
return t.accountTypeBank;
|
||||||
|
case 'cash_register':
|
||||||
|
return t.accountTypeCashRegister;
|
||||||
|
case 'petty_cash':
|
||||||
|
return t.accountTypePettyCash;
|
||||||
|
case 'check':
|
||||||
|
return t.accountTypeCheck;
|
||||||
|
case 'person':
|
||||||
|
return t.accountTypePerson;
|
||||||
|
case 'product':
|
||||||
|
return t.accountTypeProduct;
|
||||||
|
case 'service':
|
||||||
|
return t.accountTypeService;
|
||||||
|
case 'accounting_document':
|
||||||
|
return t.accountTypeAccountingDocument;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// English and other locales: humanize
|
||||||
|
String humanize(String v) {
|
||||||
|
return v
|
||||||
|
.split('_')
|
||||||
|
.map((p) => p.isEmpty ? p : (p[0].toUpperCase() + p.substring(1)))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
switch (value) {
|
||||||
|
case 'bank':
|
||||||
|
return t.accountTypeBank;
|
||||||
|
case 'cash_register':
|
||||||
|
return t.accountTypeCashRegister;
|
||||||
|
case 'petty_cash':
|
||||||
|
return t.accountTypePettyCash;
|
||||||
|
case 'check':
|
||||||
|
return t.accountTypeCheck;
|
||||||
|
case 'person':
|
||||||
|
return t.accountTypePerson;
|
||||||
|
case 'product':
|
||||||
|
return t.accountTypeProduct;
|
||||||
|
case 'service':
|
||||||
|
return t.accountTypeService;
|
||||||
|
case 'accounting_document':
|
||||||
|
return t.accountTypeAccountingDocument;
|
||||||
|
default:
|
||||||
|
return humanize(value);
|
||||||
}
|
}
|
||||||
return ExpansionTile(
|
|
||||||
title: Text('${node['code']} - ${node['name']}'),
|
|
||||||
children: children.map<Widget>((c) => _buildNode(Map<String, dynamic>.from(c))).toList(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -52,14 +165,66 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) return Center(child: Text(_error!));
|
if (_error != null) return Center(child: Text(_error!));
|
||||||
|
final visible = _buildVisibleNodes();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
||||||
body: RefreshIndicator(
|
body: Column(
|
||||||
onRefresh: _fetch,
|
children: [
|
||||||
child: ListView(
|
Container(
|
||||||
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 28), // expander space
|
||||||
|
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
Expanded(flex: 5, child: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
Expanded(flex: 3, child: Text(t.type, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetch,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: visible.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = visible[index];
|
||||||
|
final node = item.node;
|
||||||
|
final level = item.level;
|
||||||
|
final isExpanded = _expandedIds.contains(node.id);
|
||||||
|
final canExpand = node.hasChildren;
|
||||||
|
return InkWell(
|
||||||
|
onTap: canExpand ? () => _toggleExpand(node) : null,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 12.0 * level),
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: canExpand
|
||||||
|
? IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 20,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
||||||
|
onPressed: () => _toggleExpand(node),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
||||||
|
Expanded(flex: 5, child: Text(node.name)),
|
||||||
|
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,349 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import '../../core/auth_store.dart';
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/calendar_controller.dart';
|
||||||
|
import '../../widgets/invoice/person_combobox_widget.dart';
|
||||||
|
import '../../widgets/date_input_field.dart';
|
||||||
|
import '../../widgets/banking/currency_picker_widget.dart';
|
||||||
|
import '../../widgets/permission/access_denied_page.dart';
|
||||||
|
import '../../services/check_service.dart';
|
||||||
|
|
||||||
class CheckFormPage extends StatelessWidget {
|
class CheckFormPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
final int? checkId; // null => new, not null => edit
|
final int? checkId; // null => new, not null => edit
|
||||||
|
final CalendarController? calendarController;
|
||||||
|
|
||||||
const CheckFormPage({
|
const CheckFormPage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
this.checkId,
|
this.checkId,
|
||||||
|
this.calendarController,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CheckFormPage> createState() => _CheckFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckFormPageState extends State<CheckFormPage> {
|
||||||
|
final _service = CheckService();
|
||||||
|
|
||||||
|
String? _type; // 'received' | 'transferred'
|
||||||
|
DateTime? _issueDate;
|
||||||
|
DateTime? _dueDate;
|
||||||
|
int? _currencyId;
|
||||||
|
dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity
|
||||||
|
|
||||||
|
final _checkNumberCtrl = TextEditingController();
|
||||||
|
final _sayadCtrl = TextEditingController();
|
||||||
|
final _bankCtrl = TextEditingController();
|
||||||
|
final _branchCtrl = TextEditingController();
|
||||||
|
final _amountCtrl = TextEditingController();
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_type = 'received';
|
||||||
|
_currencyId = widget.authStore.selectedCurrencyId;
|
||||||
|
_issueDate = DateTime.now();
|
||||||
|
_dueDate = DateTime.now();
|
||||||
|
if (widget.checkId != null) {
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final data = await _service.getById(widget.checkId!);
|
||||||
|
setState(() {
|
||||||
|
_type = (data['type'] as String?) ?? 'received';
|
||||||
|
_checkNumberCtrl.text = (data['check_number'] ?? '') as String;
|
||||||
|
_sayadCtrl.text = (data['sayad_code'] ?? '') as String;
|
||||||
|
_bankCtrl.text = (data['bank_name'] ?? '') as String;
|
||||||
|
_branchCtrl.text = (data['branch_name'] ?? '') as String;
|
||||||
|
final amount = data['amount'];
|
||||||
|
_amountCtrl.text = amount == null ? '' : amount.toString();
|
||||||
|
final issue = data['issue_date'] as String?;
|
||||||
|
final due = data['due_date'] as String?;
|
||||||
|
_issueDate = issue != null ? DateTime.tryParse(issue) : _issueDate;
|
||||||
|
_dueDate = due != null ? DateTime.tryParse(due) : _dueDate;
|
||||||
|
_currencyId = (data['currency_id'] is int) ? data['currency_id'] as int : _currencyId;
|
||||||
|
// person_id exists but PersonComboboxWidget needs model; leave unselected for now
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// ignore load errors for now
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validate() {
|
||||||
|
if (_type != 'received' && _type != 'transferred') return 'نوع چک الزامی است';
|
||||||
|
if (_type == 'received' && _selectedPerson == null) return 'انتخاب شخص برای چک دریافتی الزامی است';
|
||||||
|
if ((_checkNumberCtrl.text.trim()).isEmpty) return 'شماره چک الزامی است';
|
||||||
|
if (_sayadCtrl.text.trim().isNotEmpty && _sayadCtrl.text.trim().length != 16) return 'شناسه صیاد باید 16 رقم باشد';
|
||||||
|
if (_issueDate == null) return 'تاریخ صدور الزامی است';
|
||||||
|
if (_dueDate == null) return 'تاریخ سررسید الزامی است';
|
||||||
|
if (_issueDate != null && _dueDate != null && _dueDate!.isBefore(_issueDate!)) return 'تاریخ سررسید نمیتواند قبل از تاریخ صدور باشد';
|
||||||
|
final amount = num.tryParse(_amountCtrl.text.replaceAll(',', '').trim());
|
||||||
|
if (amount == null || amount <= 0) return 'مبلغ باید عددی بزرگتر از صفر باشد';
|
||||||
|
if (_currencyId == null) return 'واحد پول الزامی است';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final error = _validate();
|
||||||
|
if (error != null) {
|
||||||
|
_showError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'type': _type,
|
||||||
|
if (_selectedPerson != null) 'person_id': (_selectedPerson as dynamic).id,
|
||||||
|
'issue_date': _issueDate!.toIso8601String(),
|
||||||
|
'due_date': _dueDate!.toIso8601String(),
|
||||||
|
'check_number': _checkNumberCtrl.text.trim(),
|
||||||
|
if (_sayadCtrl.text.trim().isNotEmpty) 'sayad_code': _sayadCtrl.text.trim(),
|
||||||
|
if (_bankCtrl.text.trim().isNotEmpty) 'bank_name': _bankCtrl.text.trim(),
|
||||||
|
if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(),
|
||||||
|
'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()),
|
||||||
|
'currency_id': _currencyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (widget.checkId == null) {
|
||||||
|
await _service.create(businessId: widget.businessId, payload: payload);
|
||||||
|
} else {
|
||||||
|
await _service.update(id: widget.checkId!, payload: payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(widget.checkId == null ? 'چک ثبت شد' : 'چک ویرایش شد'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
} catch (e) {
|
||||||
|
_showError('خطا در ذخیره: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_checkNumberCtrl.dispose();
|
||||||
|
_sayadCtrl.dispose();
|
||||||
|
_bankCtrl.dispose();
|
||||||
|
_branchCtrl.dispose();
|
||||||
|
_amountCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const SizedBox.expand();
|
final t = AppLocalizations.of(context);
|
||||||
|
final isEdit = widget.checkId != null;
|
||||||
|
|
||||||
|
if (!widget.authStore.canWriteSection('checks')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(isEdit ? t.edit : t.add),
|
||||||
|
actions: [
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: IgnorePointer(
|
||||||
|
ignoring: _loading,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: _loading,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1000),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_loading) const LinearProgressIndicator(),
|
||||||
|
|
||||||
|
// نوع چک
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _type,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'received', child: Text('دریافتی')),
|
||||||
|
DropdownMenuItem(value: 'transferred', child: Text('واگذار شده')),
|
||||||
|
],
|
||||||
|
onChanged: (val) => setState(() => _type = val),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'نوع چک *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// شخص (برای دریافتی)
|
||||||
|
if (_type == 'received') ...[
|
||||||
|
PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: _selectedPerson,
|
||||||
|
onChanged: (p) => setState(() => _selectedPerson = p),
|
||||||
|
isRequired: true,
|
||||||
|
label: 'شخص (برای چک دریافتی)',
|
||||||
|
hintText: 'جستوجو و انتخاب شخص',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// تاریخها
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _issueDate,
|
||||||
|
labelText: 'تاریخ صدور *',
|
||||||
|
hintText: 'انتخاب تاریخ صدور',
|
||||||
|
calendarController: widget.calendarController!,
|
||||||
|
onChanged: (d) => setState(() => _issueDate = d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _dueDate,
|
||||||
|
labelText: 'تاریخ سررسید *',
|
||||||
|
hintText: 'انتخاب تاریخ سررسید',
|
||||||
|
calendarController: widget.calendarController!,
|
||||||
|
onChanged: (d) => setState(() => _dueDate = d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// شماره چک و شناسه صیاد
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _checkNumberCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'شماره چک *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _sayadCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'شناسه صیاد',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// بانک و شعبه
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _bankCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'بانک صادرکننده',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _branchCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'شعبه',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// مبلغ و ارز
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _amountCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'مبلغ *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _currencyId,
|
||||||
|
onChanged: (id) => setState(() => _currencyId = id),
|
||||||
|
label: 'واحد پول',
|
||||||
|
hintText: 'انتخاب واحد پول',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _loading ? null : _save,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: Text(t.save),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _loading ? null : () => Navigator.of(context).maybePop(),
|
||||||
|
child: Text(t.cancel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth_store.dart';
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../widgets/data_table/data_table_widget.dart';
|
||||||
|
import '../../widgets/data_table/data_table_config.dart';
|
||||||
|
import '../../widgets/permission/permission_widgets.dart';
|
||||||
|
import '../../widgets/invoice/person_combobox_widget.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
|
||||||
class ChecksPage extends StatelessWidget {
|
class ChecksPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
|
@ -11,9 +18,152 @@ class ChecksPage extends StatelessWidget {
|
||||||
required this.authStore,
|
required this.authStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChecksPage> createState() => _ChecksPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecksPageState extends State<ChecksPage> {
|
||||||
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
|
Person? _selectedPerson;
|
||||||
|
|
||||||
|
void _refresh() {
|
||||||
|
try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const SizedBox.expand();
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
if (!widget.authStore.canReadSection('checks')) {
|
||||||
|
return AccessDeniedPage(message: t.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: DataTableWidget<Map<String, dynamic>>(
|
||||||
|
key: _tableKey,
|
||||||
|
config: _buildConfig(t, context),
|
||||||
|
fromJson: (json) => json,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTableConfig<Map<String, dynamic>> _buildConfig(AppLocalizations t, BuildContext context) {
|
||||||
|
return DataTableConfig<Map<String, dynamic>>(
|
||||||
|
endpoint: '/api/v1/checks/businesses/${widget.businessId}/checks',
|
||||||
|
title: (t.localeName == 'fa') ? 'چکها' : 'Checks',
|
||||||
|
excelEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/excel',
|
||||||
|
pdfEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/pdf',
|
||||||
|
getExportParams: () => {'business_id': widget.businessId, if (_selectedPerson != null) 'person_id': _selectedPerson!.id},
|
||||||
|
additionalParams: { if (_selectedPerson != null) 'person_id': _selectedPerson!.id },
|
||||||
|
showBackButton: true,
|
||||||
|
onBack: () => Navigator.of(context).maybePop(),
|
||||||
|
showTableIcon: false,
|
||||||
|
showRowNumbers: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
showColumnSearch: true,
|
||||||
|
showActiveFilters: true,
|
||||||
|
showClearFiltersButton: true,
|
||||||
|
columns: [
|
||||||
|
TextColumn(
|
||||||
|
'type',
|
||||||
|
'نوع',
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
filterType: ColumnFilterType.multiSelect,
|
||||||
|
filterOptions: const [
|
||||||
|
FilterOption(value: 'received', label: 'دریافتی'),
|
||||||
|
FilterOption(value: 'transferred', label: 'واگذار شده'),
|
||||||
|
],
|
||||||
|
formatter: (row) => (row['type'] == 'received') ? 'دریافتی' : (row['type'] == 'transferred' ? 'واگذار شده' : '-'),
|
||||||
|
),
|
||||||
|
TextColumn('person_name', 'شخص', width: ColumnWidth.large,
|
||||||
|
formatter: (row) => (row['person_name'] ?? '-'),
|
||||||
|
),
|
||||||
|
DateColumn(
|
||||||
|
'issue_date',
|
||||||
|
'تاریخ صدور',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
filterType: ColumnFilterType.dateRange,
|
||||||
|
formatter: (row) => (row['issue_date'] ?? '-'),
|
||||||
|
),
|
||||||
|
DateColumn(
|
||||||
|
'due_date',
|
||||||
|
'تاریخ سررسید',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
filterType: ColumnFilterType.dateRange,
|
||||||
|
formatter: (row) => (row['due_date'] ?? '-'),
|
||||||
|
),
|
||||||
|
TextColumn('check_number', 'شماره چک', width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => (row['check_number'] ?? '-'),
|
||||||
|
),
|
||||||
|
TextColumn('sayad_code', 'شناسه صیاد', width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => (row['sayad_code'] ?? '-'),
|
||||||
|
),
|
||||||
|
TextColumn('bank_name', 'بانک', width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => (row['bank_name'] ?? '-'),
|
||||||
|
),
|
||||||
|
TextColumn('branch_name', 'شعبه', width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => (row['branch_name'] ?? '-'),
|
||||||
|
),
|
||||||
|
NumberColumn('amount', 'مبلغ', width: ColumnWidth.medium,
|
||||||
|
formatter: (row) => (row['amount']?.toString() ?? '-'),
|
||||||
|
),
|
||||||
|
TextColumn('currency', 'ارز', width: ColumnWidth.small,
|
||||||
|
formatter: (row) => (row['currency'] ?? '-'),
|
||||||
|
),
|
||||||
|
ActionColumn('actions', t.actions, actions: [
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.edit,
|
||||||
|
label: t.edit,
|
||||||
|
onTap: (row) {
|
||||||
|
final id = row is Map<String, dynamic> ? row['id'] : null;
|
||||||
|
if (id is int) {
|
||||||
|
context.go('/business/${widget.businessId}/checks/$id/edit');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'],
|
||||||
|
filterFields: ['type','currency','issue_date','due_date'],
|
||||||
|
defaultPageSize: 20,
|
||||||
|
customHeaderActions: [
|
||||||
|
// فیلتر شخص
|
||||||
|
SizedBox(
|
||||||
|
width: 280,
|
||||||
|
child: PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: _selectedPerson,
|
||||||
|
onChanged: (p) {
|
||||||
|
setState(() { _selectedPerson = p; });
|
||||||
|
_refresh();
|
||||||
|
},
|
||||||
|
isRequired: false,
|
||||||
|
label: 'شخص',
|
||||||
|
hintText: 'جستوجوی شخص',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PermissionButton(
|
||||||
|
section: 'checks',
|
||||||
|
action: 'add',
|
||||||
|
authStore: widget.authStore,
|
||||||
|
child: Tooltip(
|
||||||
|
message: t.add,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => context.go('/business/${widget.businessId}/checks/new'),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onRowTap: (row) {
|
||||||
|
final id = row is Map<String, dynamic> ? row['id'] : null;
|
||||||
|
if (id is int) {
|
||||||
|
context.go('/business/${widget.businessId}/checks/$id/edit');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,691 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import '../../core/calendar_controller.dart';
|
||||||
|
import '../../core/date_utils.dart' show HesabixDateUtils;
|
||||||
|
import '../../utils/number_formatters.dart' show formatWithThousands;
|
||||||
|
import '../../widgets/invoice/person_combobox_widget.dart';
|
||||||
|
import '../../widgets/invoice/invoice_transactions_widget.dart';
|
||||||
|
import '../../widgets/date_input_field.dart';
|
||||||
|
import '../../models/invoice_transaction.dart';
|
||||||
|
import '../../models/invoice_type_model.dart';
|
||||||
|
import '../../models/person_model.dart';
|
||||||
|
import '../../models/business_dashboard_models.dart';
|
||||||
|
import '../../widgets/banking/currency_picker_widget.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/api_client.dart';
|
||||||
|
import '../../services/receipt_payment_service.dart';
|
||||||
|
|
||||||
|
class ReceiptsPaymentsPage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
const ReceiptsPaymentsPage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.calendarController,
|
||||||
|
required this.authStore,
|
||||||
|
required this.apiClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReceiptsPaymentsPage> createState() => _ReceiptsPaymentsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceiptsPaymentsPageState extends State<ReceiptsPaymentsPage> {
|
||||||
|
int _tabIndex = 0;
|
||||||
|
final List<_BulkSettlementDraft> _drafts = <_BulkSettlementDraft>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.receiptsAndPayments,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final draft = await showDialog<_BulkSettlementDraft>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _BulkSettlementDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
isReceipt: _tabIndex == 0,
|
||||||
|
businessInfo: widget.authStore.currentBusiness,
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (draft != null) {
|
||||||
|
setState(() {
|
||||||
|
_drafts.removeWhere((d) => d.id == draft.id);
|
||||||
|
_drafts.add(draft);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(t.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SegmentedButton<int>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<int>(value: 0, label: Text(t.receipts), icon: const Icon(Icons.download_done_outlined)),
|
||||||
|
ButtonSegment<int>(value: 1, label: Text(t.payments), icon: const Icon(Icons.upload_outlined)),
|
||||||
|
],
|
||||||
|
selected: {_tabIndex},
|
||||||
|
onSelectionChanged: (set) => setState(() => _tabIndex = set.first),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: _DraftsList(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
drafts: _drafts.where((d) => d.isReceipt == (_tabIndex == 0)).toList(),
|
||||||
|
onEdit: (d) async {
|
||||||
|
final updated = await showDialog<_BulkSettlementDraft>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _BulkSettlementDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
isReceipt: d.isReceipt,
|
||||||
|
initial: d,
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (updated != null) {
|
||||||
|
setState(() {
|
||||||
|
final idx = _drafts.indexWhere((x) => x.id == updated.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
_drafts[idx] = updated;
|
||||||
|
} else {
|
||||||
|
_drafts.add(updated);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: (d) {
|
||||||
|
setState(() => _drafts.removeWhere((x) => x.id == d.id));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraftsList extends StatelessWidget {
|
||||||
|
final int businessId;
|
||||||
|
final List<_BulkSettlementDraft> drafts;
|
||||||
|
final ValueChanged<_BulkSettlementDraft> onEdit;
|
||||||
|
final ValueChanged<_BulkSettlementDraft> onDelete;
|
||||||
|
const _DraftsList({
|
||||||
|
required this.businessId,
|
||||||
|
required this.drafts,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(t.receiptsAndPayments, style: Theme.of(context).textTheme.titleMedium)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: drafts.isEmpty
|
||||||
|
? Center(child: Text(t.noDataFound))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: drafts.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final d = drafts[i];
|
||||||
|
final sumPersons = d.personLines.fold<double>(0, (p, e) => p + e.amount);
|
||||||
|
final sumCenters = d.centerTransactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
|
||||||
|
return ListTile(
|
||||||
|
title: Text('${formatWithThousands(sumPersons)} | ${formatWithThousands(sumCenters)}'),
|
||||||
|
subtitle: Text('${HesabixDateUtils.formatForDisplay(d.documentDate, true)} • ${d.isReceipt ? t.receipts : t.payments}'),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => onEdit(d)),
|
||||||
|
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => onDelete(d)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulkSettlementDialog extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
final bool isReceipt;
|
||||||
|
final BusinessWithPermission? businessInfo;
|
||||||
|
final _BulkSettlementDraft? initial;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
const _BulkSettlementDialog({
|
||||||
|
required this.businessId,
|
||||||
|
required this.calendarController,
|
||||||
|
required this.isReceipt,
|
||||||
|
this.businessInfo,
|
||||||
|
this.initial,
|
||||||
|
required this.apiClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BulkSettlementDialog> createState() => _BulkSettlementDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulkSettlementDialogState extends State<_BulkSettlementDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late DateTime _docDate;
|
||||||
|
late bool _isReceipt;
|
||||||
|
int? _selectedCurrencyId;
|
||||||
|
final List<_PersonLine> _personLines = <_PersonLine>[];
|
||||||
|
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_docDate = widget.initial?.documentDate ?? DateTime.now();
|
||||||
|
_isReceipt = widget.initial?.isReceipt ?? widget.isReceipt;
|
||||||
|
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
|
||||||
|
if (widget.initial != null) {
|
||||||
|
_personLines.addAll(widget.initial!.personLines);
|
||||||
|
_centerTransactions.addAll(widget.initial!.centerTransactions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final sumPersons = _personLines.fold<double>(0, (p, e) => p + e.amount);
|
||||||
|
final sumCenters = _centerTransactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
|
||||||
|
final diff = (_isReceipt ? sumCenters - sumPersons : sumPersons - sumCenters).toDouble();
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.receiptsAndPayments,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
|
||||||
|
ButtonSegment<bool>(value: false, label: Text(t.payments)),
|
||||||
|
],
|
||||||
|
selected: {_isReceipt},
|
||||||
|
onSelectionChanged: (s) => setState(() => _isReceipt = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DateInputField(
|
||||||
|
value: _docDate,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (d) => setState(() => _docDate = d ?? DateTime.now()),
|
||||||
|
labelText: 'تاریخ سند',
|
||||||
|
hintText: 'انتخاب تاریخ',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) => setState(() => _selectedCurrencyId = currencyId),
|
||||||
|
label: 'ارز',
|
||||||
|
hintText: 'انتخاب ارز',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _PersonsPanel(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
lines: _personLines,
|
||||||
|
onChanged: (ls) => setState(() {
|
||||||
|
_personLines.clear();
|
||||||
|
_personLines.addAll(ls);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: InvoiceTransactionsWidget(
|
||||||
|
transactions: _centerTransactions,
|
||||||
|
onChanged: (txs) => setState(() {
|
||||||
|
_centerTransactions.clear();
|
||||||
|
_centerTransactions.addAll(txs);
|
||||||
|
}),
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
invoiceType: InvoiceType.sales,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_TotalChip(label: t.people, value: sumPersons),
|
||||||
|
_TotalChip(label: t.accounts, value: sumCenters),
|
||||||
|
_TotalChip(label: 'اختلاف', value: diff, isError: diff != 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(t.cancel),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: diff == 0 && _personLines.isNotEmpty && _centerTransactions.isNotEmpty
|
||||||
|
? _onSave
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: Text(t.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSave() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// نمایش loading
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final service = ReceiptPaymentService(widget.apiClient);
|
||||||
|
|
||||||
|
// تبدیل personLines به فرمت مورد نیاز API
|
||||||
|
final personLinesData = _personLines.map((line) => {
|
||||||
|
'person_id': int.parse(line.personId!),
|
||||||
|
'person_name': line.personName,
|
||||||
|
'amount': line.amount,
|
||||||
|
if (line.description != null && line.description!.isNotEmpty)
|
||||||
|
'description': line.description,
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// تبدیل centerTransactions به فرمت مورد نیاز API
|
||||||
|
final accountLinesData = _centerTransactions.map((tx) => {
|
||||||
|
'account_id': tx.accountId,
|
||||||
|
'amount': tx.amount.toDouble(),
|
||||||
|
'transaction_type': tx.type.value,
|
||||||
|
'transaction_date': tx.transactionDate.toIso8601String(),
|
||||||
|
if (tx.commission != null && tx.commission! > 0)
|
||||||
|
'commission': tx.commission!.toDouble(),
|
||||||
|
if (tx.description != null && tx.description!.isNotEmpty)
|
||||||
|
'description': tx.description,
|
||||||
|
// اطلاعات اضافی بر اساس نوع تراکنش
|
||||||
|
if (tx.type == TransactionType.bank) ...{
|
||||||
|
'bank_id': tx.bankId,
|
||||||
|
'bank_name': tx.bankName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.cashRegister) ...{
|
||||||
|
'cash_register_id': tx.cashRegisterId,
|
||||||
|
'cash_register_name': tx.cashRegisterName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.pettyCash) ...{
|
||||||
|
'petty_cash_id': tx.pettyCashId,
|
||||||
|
'petty_cash_name': tx.pettyCashName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.check) ...{
|
||||||
|
'check_id': tx.checkId,
|
||||||
|
'check_number': tx.checkNumber,
|
||||||
|
},
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// ارسال به سرور
|
||||||
|
await service.createReceiptPayment(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
documentType: _isReceipt ? 'receipt' : 'payment',
|
||||||
|
documentDate: _docDate,
|
||||||
|
currencyId: _selectedCurrencyId!,
|
||||||
|
personLines: personLinesData,
|
||||||
|
accountLines: accountLinesData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// بستن dialog loading
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// بستن dialog اصلی با موفقیت
|
||||||
|
Navigator.pop(context, null);
|
||||||
|
|
||||||
|
// نمایش پیام موفقیت
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isReceipt
|
||||||
|
? 'سند دریافت با موفقیت ثبت شد'
|
||||||
|
: 'سند پرداخت با موفقیت ثبت شد',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// بستن dialog loading
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// نمایش خطا
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('خطا: ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonsPanel extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final List<_PersonLine> lines;
|
||||||
|
final ValueChanged<List<_PersonLine>> onChanged;
|
||||||
|
const _PersonsPanel({
|
||||||
|
required this.businessId,
|
||||||
|
required this.lines,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PersonsPanel> createState() => _PersonsPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonsPanelState extends State<_PersonsPanel> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(t.people, style: Theme.of(context).textTheme.titleMedium)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines.add(_PersonLine.empty());
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: t.add,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: widget.lines.isEmpty
|
||||||
|
? Center(child: Text(t.noDataFound))
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: widget.lines.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final line = widget.lines[i];
|
||||||
|
return _PersonLineTile(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
line: line,
|
||||||
|
onChanged: (l) {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines[i] = l;
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines.removeAt(i);
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLineTile extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final _PersonLine line;
|
||||||
|
final ValueChanged<_PersonLine> onChanged;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
const _PersonLineTile({
|
||||||
|
required this.businessId,
|
||||||
|
required this.line,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PersonLineTile> createState() => _PersonLineTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLineTileState extends State<_PersonLineTile> {
|
||||||
|
final _amountController = TextEditingController();
|
||||||
|
final _descController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_amountController.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0);
|
||||||
|
_descController.text = widget.line.description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amountController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: widget.line.personId != null
|
||||||
|
? Person(
|
||||||
|
id: int.tryParse(widget.line.personId!),
|
||||||
|
businessId: widget.businessId,
|
||||||
|
aliasName: widget.line.personName ?? '',
|
||||||
|
personTypes: const [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onChanged: (opt) {
|
||||||
|
widget.onChanged(widget.line.copyWith(personId: opt?.id?.toString(), personName: opt?.displayName));
|
||||||
|
},
|
||||||
|
label: t.people,
|
||||||
|
hintText: t.search,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _amountController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.amount,
|
||||||
|
hintText: '1,000,000',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) {
|
||||||
|
final val = double.tryParse((v ?? '').replaceAll(',', ''));
|
||||||
|
if (val == null || val <= 0) return t.mustBePositiveNumber;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (v) {
|
||||||
|
final val = double.tryParse(v.replaceAll(',', '')) ?? 0;
|
||||||
|
widget.onChanged(widget.line.copyWith(amount: val));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.description,
|
||||||
|
),
|
||||||
|
onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TotalChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final bool isError;
|
||||||
|
const _TotalChip({required this.label, required this.value, this.isError = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ColorScheme scheme = Theme.of(context).colorScheme;
|
||||||
|
return Chip(
|
||||||
|
label: Text('$label: ${formatWithThousands(value)}'),
|
||||||
|
backgroundColor: isError ? scheme.errorContainer : scheme.surfaceContainerHighest,
|
||||||
|
labelStyle: TextStyle(color: isError ? scheme.onErrorContainer : scheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulkSettlementDraft {
|
||||||
|
final String id;
|
||||||
|
final bool isReceipt;
|
||||||
|
final DateTime documentDate;
|
||||||
|
final List<_PersonLine> personLines;
|
||||||
|
final List<InvoiceTransaction> centerTransactions;
|
||||||
|
_BulkSettlementDraft({
|
||||||
|
required this.id,
|
||||||
|
required this.isReceipt,
|
||||||
|
required this.documentDate,
|
||||||
|
required this.personLines,
|
||||||
|
required this.centerTransactions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLine {
|
||||||
|
final String? personId;
|
||||||
|
final String? personName;
|
||||||
|
final double amount;
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
const _PersonLine({this.personId, this.personName, required this.amount, this.description});
|
||||||
|
|
||||||
|
factory _PersonLine.empty() => const _PersonLine(amount: 0);
|
||||||
|
|
||||||
|
_PersonLine copyWith({String? personId, String? personName, double? amount, String? description}) {
|
||||||
|
return _PersonLine(
|
||||||
|
personId: personId ?? this.personId,
|
||||||
|
personName: personName ?? this.personName,
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
description: description ?? this.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
72
hesabixUI/hesabix_ui/lib/services/check_service.dart
Normal file
72
hesabixUI/hesabix_ui/lib/services/check_service.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
class CheckService {
|
||||||
|
final ApiClient _client;
|
||||||
|
CheckService({ApiClient? client}) : _client = client ?? ApiClient();
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> list({required int businessId, required Map<String, dynamic> queryInfo}) async {
|
||||||
|
try {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/checks/businesses/$businessId/checks',
|
||||||
|
data: queryInfo,
|
||||||
|
);
|
||||||
|
final data = res.data ?? <String, dynamic>{};
|
||||||
|
data['items'] ??= <dynamic>[];
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
'items': <dynamic>[],
|
||||||
|
'pagination': {
|
||||||
|
'total': 0,
|
||||||
|
'page': 1,
|
||||||
|
'per_page': queryInfo['take'] ?? 10,
|
||||||
|
'total_pages': 0,
|
||||||
|
'has_next': false,
|
||||||
|
'has_prev': false,
|
||||||
|
},
|
||||||
|
'query_info': queryInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getById(int id) async {
|
||||||
|
final res = await _client.get<Map<String, dynamic>>('/api/v1/checks/checks/$id');
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> create({required int businessId, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.post<Map<String, dynamic>>(
|
||||||
|
'/api/v1/checks/businesses/$businessId/checks/create',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> update({required int id, required Map<String, dynamic> payload}) async {
|
||||||
|
final res = await _client.put<Map<String, dynamic>>('/api/v1/checks/checks/$id', data: payload);
|
||||||
|
return (res.data?['data'] as Map<String, dynamic>? ?? <String, dynamic>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await _client.delete<Map<String, dynamic>>('/api/v1/checks/checks/$id');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportExcel({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return await _client.post<List<int>>(
|
||||||
|
'/api/v1/checks/businesses/$businessId/checks/export/excel',
|
||||||
|
data: body,
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response<List<int>>> exportPdf({required int businessId, required Map<String, dynamic> body}) async {
|
||||||
|
return await _client.post<List<int>>(
|
||||||
|
'/api/v1/checks/businesses/$businessId/checks/export/pdf',
|
||||||
|
data: body,
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
185
hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart
Normal file
185
hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
/// سرویس دریافت و پرداخت
|
||||||
|
class ReceiptPaymentService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
ReceiptPaymentService(this._apiClient);
|
||||||
|
|
||||||
|
/// ایجاد سند دریافت یا پرداخت
|
||||||
|
///
|
||||||
|
/// [businessId] شناسه کسبوکار
|
||||||
|
/// [documentType] نوع سند: "receipt" یا "payment"
|
||||||
|
/// [documentDate] تاریخ سند
|
||||||
|
/// [currencyId] شناسه ارز
|
||||||
|
/// [personLines] لیست تراکنشهای اشخاص
|
||||||
|
/// [accountLines] لیست تراکنشهای حسابها
|
||||||
|
/// [extraInfo] اطلاعات اضافی (اختیاری)
|
||||||
|
Future<Map<String, dynamic>> createReceiptPayment({
|
||||||
|
required int businessId,
|
||||||
|
required String documentType,
|
||||||
|
required DateTime documentDate,
|
||||||
|
required int currencyId,
|
||||||
|
required List<Map<String, dynamic>> personLines,
|
||||||
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
Map<String, dynamic>? extraInfo,
|
||||||
|
}) async {
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/businesses/$businessId/receipts-payments/create',
|
||||||
|
data: {
|
||||||
|
'document_type': documentType,
|
||||||
|
'document_date': documentDate.toIso8601String(),
|
||||||
|
'currency_id': currencyId,
|
||||||
|
'person_lines': personLines,
|
||||||
|
'account_lines': accountLines,
|
||||||
|
if (extraInfo != null) 'extra_info': extraInfo,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data['data'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت لیست اسناد دریافت و پرداخت
|
||||||
|
///
|
||||||
|
/// [businessId] شناسه کسبوکار
|
||||||
|
/// [documentType] فیلتر بر اساس نوع سند (اختیاری)
|
||||||
|
/// [fromDate] فیلتر تاریخ از (اختیاری)
|
||||||
|
/// [toDate] فیلتر تاریخ تا (اختیاری)
|
||||||
|
/// [skip] تعداد رکورد برای رد کردن
|
||||||
|
/// [take] تعداد رکورد برای دریافت
|
||||||
|
/// [search] عبارت جستجو (اختیاری)
|
||||||
|
Future<Map<String, dynamic>> listReceiptsPayments({
|
||||||
|
required int businessId,
|
||||||
|
String? documentType,
|
||||||
|
DateTime? fromDate,
|
||||||
|
DateTime? toDate,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20,
|
||||||
|
String? search,
|
||||||
|
String? sortBy,
|
||||||
|
bool sortDesc = true,
|
||||||
|
}) async {
|
||||||
|
final body = {
|
||||||
|
'skip': skip,
|
||||||
|
'take': take,
|
||||||
|
'sort_desc': sortDesc,
|
||||||
|
if (sortBy != null) 'sort_by': sortBy,
|
||||||
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
|
if (documentType != null) 'document_type': documentType,
|
||||||
|
if (fromDate != null) 'from_date': fromDate.toIso8601String(),
|
||||||
|
if (toDate != null) 'to_date': toDate.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/businesses/$businessId/receipts-payments',
|
||||||
|
data: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data['data'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت جزئیات یک سند دریافت/پرداخت
|
||||||
|
///
|
||||||
|
/// [documentId] شناسه سند
|
||||||
|
Future<Map<String, dynamic>> getReceiptPayment(int documentId) async {
|
||||||
|
final response = await _apiClient.get(
|
||||||
|
'/receipts-payments/$documentId',
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data['data'] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// حذف سند دریافت/پرداخت
|
||||||
|
///
|
||||||
|
/// [documentId] شناسه سند
|
||||||
|
Future<void> deleteReceiptPayment(int documentId) async {
|
||||||
|
await _apiClient.delete(
|
||||||
|
'/receipts-payments/$documentId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ایجاد سند دریافت
|
||||||
|
///
|
||||||
|
/// این متد یک wrapper ساده برای createReceiptPayment است
|
||||||
|
Future<Map<String, dynamic>> createReceipt({
|
||||||
|
required int businessId,
|
||||||
|
required DateTime documentDate,
|
||||||
|
required int currencyId,
|
||||||
|
required List<Map<String, dynamic>> personLines,
|
||||||
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
Map<String, dynamic>? extraInfo,
|
||||||
|
}) {
|
||||||
|
return createReceiptPayment(
|
||||||
|
businessId: businessId,
|
||||||
|
documentType: 'receipt',
|
||||||
|
documentDate: documentDate,
|
||||||
|
currencyId: currencyId,
|
||||||
|
personLines: personLines,
|
||||||
|
accountLines: accountLines,
|
||||||
|
extraInfo: extraInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ایجاد سند پرداخت
|
||||||
|
///
|
||||||
|
/// این متد یک wrapper ساده برای createReceiptPayment است
|
||||||
|
Future<Map<String, dynamic>> createPayment({
|
||||||
|
required int businessId,
|
||||||
|
required DateTime documentDate,
|
||||||
|
required int currencyId,
|
||||||
|
required List<Map<String, dynamic>> personLines,
|
||||||
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
Map<String, dynamic>? extraInfo,
|
||||||
|
}) {
|
||||||
|
return createReceiptPayment(
|
||||||
|
businessId: businessId,
|
||||||
|
documentType: 'payment',
|
||||||
|
documentDate: documentDate,
|
||||||
|
currencyId: currencyId,
|
||||||
|
personLines: personLines,
|
||||||
|
accountLines: accountLines,
|
||||||
|
extraInfo: extraInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت لیست فقط دریافتها
|
||||||
|
Future<Map<String, dynamic>> listReceipts({
|
||||||
|
required int businessId,
|
||||||
|
DateTime? fromDate,
|
||||||
|
DateTime? toDate,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20,
|
||||||
|
String? search,
|
||||||
|
}) {
|
||||||
|
return listReceiptsPayments(
|
||||||
|
businessId: businessId,
|
||||||
|
documentType: 'receipt',
|
||||||
|
fromDate: fromDate,
|
||||||
|
toDate: toDate,
|
||||||
|
skip: skip,
|
||||||
|
take: take,
|
||||||
|
search: search,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت لیست فقط پرداختها
|
||||||
|
Future<Map<String, dynamic>> listPayments({
|
||||||
|
required int businessId,
|
||||||
|
DateTime? fromDate,
|
||||||
|
DateTime? toDate,
|
||||||
|
int skip = 0,
|
||||||
|
int take = 20,
|
||||||
|
String? search,
|
||||||
|
}) {
|
||||||
|
return listReceiptsPayments(
|
||||||
|
businessId: businessId,
|
||||||
|
documentType: 'payment',
|
||||||
|
fromDate: fromDate,
|
||||||
|
toDate: toDate,
|
||||||
|
skip: skip,
|
||||||
|
take: take,
|
||||||
|
search: search,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'helpers/file_saver.dart';
|
import 'helpers/file_saver.dart';
|
||||||
// // import 'dart:html' as html; // Not available on Linux // Not available on Linux
|
// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:data_table_2/data_table_2.dart';
|
import 'package:data_table_2/data_table_2.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
@ -698,13 +698,18 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
await FileSaver.saveBytes(bytes, filename);
|
await FileSaver.saveBytes(bytes, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform-specific download functions for Linux
|
||||||
// Platform-specific download functions for Linux
|
// Platform-specific download functions for Linux
|
||||||
Future<void> _downloadPdf(dynamic data, String filename) async {
|
Future<void> _downloadPdf(dynamic data, String filename) async {
|
||||||
await _saveBytesToDownloads(data, filename);
|
// For Linux desktop, we'll save to Downloads folder
|
||||||
|
print('Download PDF: $filename (Linux desktop - save to Downloads folder)');
|
||||||
|
// TODO: Implement proper file saving for Linux
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _downloadExcel(dynamic data, String filename) async {
|
Future<void> _downloadExcel(dynamic data, String filename) async {
|
||||||
await _saveBytesToDownloads(data, filename);
|
// For Linux desktop, we'll save to Downloads folder
|
||||||
|
print('Download Excel: $filename (Linux desktop - save to Downloads folder)');
|
||||||
|
// TODO: Implement proper file saving for Linux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -54,7 +54,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'تراکنشهای فاکتور',
|
'تراکنشها',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue