Compare commits
10 commits
6c1606fe24
...
4d7a31409c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7a31409c | ||
|
|
4c9283ab98 | ||
|
|
37f4e0b6b4 | ||
|
|
76ab27aa24 | ||
|
|
ff968aed7a | ||
|
|
09c17b580d | ||
|
|
14d9024e8e | ||
|
|
2591e9a7a9 | ||
|
|
7f6a78f642 | ||
|
|
0edff7d020 |
79
docs/COMMISSION_IMPLEMENTATION.md
Normal file
79
docs/COMMISSION_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# پیادهسازی کارمزد در بخش دریافت و پرداخت
|
||||
|
||||
## تغییرات اعمال شده
|
||||
|
||||
### 1. سرویس دریافت و پرداخت (`receipt_payment_service.py`)
|
||||
|
||||
#### تغییرات اصلی:
|
||||
- **محاسبه مجموع کارمزدها**: مجموع کارمزدهای همه تراکنشها محاسبه میشود
|
||||
- **ایجاد خطوط کارمزد جداگانه**: برای هر کارمزد، دو خط جداگانه ایجاد میشود:
|
||||
- خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
|
||||
- خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
|
||||
|
||||
#### منطق کارمزد:
|
||||
|
||||
**در دریافت (Receipt):**
|
||||
- کارمزد از حساب بانک/صندوق/تنخواهگردان کم میشود (Credit)
|
||||
- کارمزد به حساب کارمزد خدمات بانکی اضافه میشود (Debit)
|
||||
|
||||
**در پرداخت (Payment):**
|
||||
- کارمزد به حساب بانک/صندوق/تنخواهگردان اضافه میشود (Debit)
|
||||
- کارمزد از حساب کارمزد خدمات بانکی کم میشود (Credit)
|
||||
|
||||
#### کدهای حساب:
|
||||
- بانک: `10203`
|
||||
- صندوق: `10202`
|
||||
- تنخواهگردان: `10201`
|
||||
- چک دریافتی: `10403`
|
||||
- چک پرداختی: `20202`
|
||||
- **کارمزد خدمات بانکی: `70902`**
|
||||
|
||||
### 2. نمایش خطوط کارمزد
|
||||
|
||||
در تابع `document_to_dict`:
|
||||
- خطوط کارمزد با فلگ `is_commission_line: true` تشخیص داده میشوند
|
||||
- خطوط کارمزد همیشه در `account_lines` نمایش داده میشوند
|
||||
|
||||
## نحوه استفاده
|
||||
|
||||
### فرانتاند:
|
||||
```dart
|
||||
// در InvoiceTransaction
|
||||
final transaction = InvoiceTransaction(
|
||||
// ... سایر فیلدها
|
||||
commission: 5000, // کارمزد به ریال
|
||||
);
|
||||
```
|
||||
|
||||
### API:
|
||||
```json
|
||||
{
|
||||
"account_lines": [
|
||||
{
|
||||
"transaction_type": "bank",
|
||||
"amount": 1000000,
|
||||
"commission": 5000,
|
||||
"bank_id": "123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## نتیجه
|
||||
|
||||
✅ کارمزد از فرانت به سرور ارسال میشود
|
||||
✅ کارمزد به عنوان سطر جداگانه در `document_lines` ثبت میشود
|
||||
✅ کارمزد برای بانک، صندوق و تنخواهگردان به صورت جداگانه ثبت میشود
|
||||
✅ کارمزد در حساب کارمزد خدمات بانکی (کد 70902) ثبت میشود
|
||||
✅ تعادل حسابداری حفظ میشود
|
||||
|
||||
## مثال عملی
|
||||
|
||||
**دریافت 1,000,000 ریال از شخص با کارمزد 5,000 ریال:**
|
||||
|
||||
1. خط اصلی: شخص بستانکار 1,000,000 ریال
|
||||
2. خط اصلی: بانک بدهکار 1,000,000 ریال
|
||||
3. خط کارمزد: بانک بستانکار 5,000 ریال (کم شدن کارمزد)
|
||||
4. خط کارمزد: کارمزد خدمات بانکی بدهکار 5,000 ریال (اضافه شدن کارمزد)
|
||||
|
||||
**مجموع:** شخص = 1,000,000 ریال، بانک = 995,000 ریال، کارمزد خدمات بانکی = 5,000 ریال ✅
|
||||
236
docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md
Normal file
236
docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# پیادهسازی صفحه لیست دریافت و پرداخت با ویجت جدول
|
||||
|
||||
## 📋 خلاصه
|
||||
این سند توضیح میدهد که چگونه بخش لیست دریافت و پرداخت از یک ListView ساده به یک ویجت جدول پیشرفته تبدیل شده است.
|
||||
|
||||
## 🎯 اهداف
|
||||
- جایگزینی ListView ساده با DataTableWidget پیشرفته
|
||||
- افزودن قابلیتهای جستجو، فیلتر و صفحهبندی
|
||||
- بهبود تجربه کاربری و عملکرد
|
||||
- استفاده مجدد از ویجت جدول در بخشهای دیگر
|
||||
|
||||
## 📁 فایلهای ایجاد شده
|
||||
|
||||
### 1. مدل داده
|
||||
**مسیر:** `lib/models/receipt_payment_document.dart`
|
||||
|
||||
#### کلاسهای اصلی:
|
||||
- `ReceiptPaymentDocument`: مدل اصلی سند دریافت/پرداخت
|
||||
- `PersonLine`: مدل خط شخص در سند
|
||||
- `AccountLine`: مدل خط حساب در سند
|
||||
|
||||
#### ویژگیهای کلیدی:
|
||||
- پشتیبانی از JSON serialization
|
||||
- محاسبه خودکار مجموع مبالغ
|
||||
- تشخیص نوع سند (دریافت/پرداخت)
|
||||
- فرمتبندی مناسب برای نمایش
|
||||
|
||||
### 2. سرویس
|
||||
**مسیر:** `lib/services/receipt_payment_list_service.dart`
|
||||
|
||||
#### کلاس اصلی:
|
||||
- `ReceiptPaymentListService`: مدیریت API calls
|
||||
|
||||
#### متدهای اصلی:
|
||||
- `getList()`: دریافت لیست اسناد با فیلتر
|
||||
- `getById()`: دریافت جزئیات یک سند
|
||||
- `delete()`: حذف یک سند
|
||||
- `deleteMultiple()`: حذف چندین سند
|
||||
- `getStats()`: دریافت آمار کلی
|
||||
|
||||
### 3. صفحه جدید
|
||||
**مسیر:** `lib/pages/business/receipts_payments_list_page.dart`
|
||||
|
||||
#### ویژگیهای صفحه:
|
||||
- استفاده از DataTableWidget
|
||||
- فیلتر نوع سند (دریافت/پرداخت/همه)
|
||||
- فیلتر بازه زمانی
|
||||
- جستجوی پیشرفته
|
||||
- عملیات CRUD کامل
|
||||
|
||||
## 🔧 تنظیمات جدول
|
||||
|
||||
### ستونهای تعریف شده:
|
||||
1. **کد سند** (TextColumn): نمایش کد سند
|
||||
2. **نوع سند** (TextColumn): دریافت/پرداخت
|
||||
3. **تاریخ سند** (DateColumn): تاریخ با فرمت جلالی
|
||||
4. **مبلغ کل** (NumberColumn): مجموع مبالغ
|
||||
5. **تعداد اشخاص** (NumberColumn): تعداد خطوط اشخاص
|
||||
6. **تعداد حسابها** (NumberColumn): تعداد خطوط حسابها
|
||||
7. **ایجادکننده** (TextColumn): نام کاربر
|
||||
8. **تاریخ ثبت** (DateColumn): زمان ثبت
|
||||
9. **عملیات** (ActionColumn): دکمههای عملیات
|
||||
|
||||
### قابلیتهای فعال:
|
||||
- ✅ جستجوی کلی
|
||||
- ✅ فیلتر ستونی
|
||||
- ✅ فیلتر بازه زمانی
|
||||
- ✅ مرتبسازی
|
||||
- ✅ صفحهبندی
|
||||
- ✅ انتخاب چندتایی
|
||||
- ✅ دکمه refresh
|
||||
- ✅ دکمه clear filters
|
||||
|
||||
## 🚀 نحوه استفاده
|
||||
|
||||
### 1. Navigation
|
||||
```dart
|
||||
// در routing موجود
|
||||
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: ReceiptsPaymentsListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
authStore: _authStore!,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
```
|
||||
|
||||
### 2. استفاده مستقیم
|
||||
```dart
|
||||
ReceiptsPaymentsListPage(
|
||||
businessId: 123,
|
||||
calendarController: calendarController,
|
||||
authStore: authStore,
|
||||
apiClient: apiClient,
|
||||
)
|
||||
```
|
||||
|
||||
## 🔄 تغییرات در Routing
|
||||
|
||||
### قبل:
|
||||
```dart
|
||||
child: ReceiptsPaymentsPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
authStore: _authStore!,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
```
|
||||
|
||||
### بعد:
|
||||
```dart
|
||||
child: ReceiptsPaymentsListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
authStore: _authStore!,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
```
|
||||
|
||||
## 📊 API Integration
|
||||
|
||||
### Endpoint استفاده شده:
|
||||
```
|
||||
POST /businesses/{business_id}/receipts-payments
|
||||
```
|
||||
|
||||
### پارامترهای پشتیبانی شده:
|
||||
- `search`: جستجوی کلی
|
||||
- `document_type`: نوع سند (receipt/payment)
|
||||
- `from_date`: تاریخ شروع
|
||||
- `to_date`: تاریخ پایان
|
||||
- `sort_by`: فیلد مرتبسازی
|
||||
- `sort_desc`: جهت مرتبسازی
|
||||
- `take`: تعداد رکورد در صفحه
|
||||
- `skip`: تعداد رکورد رد شده
|
||||
|
||||
## 🎨 UI/UX بهبودها
|
||||
|
||||
### قبل:
|
||||
- ListView ساده
|
||||
- فقط نمایش draft های محلی
|
||||
- عدم وجود جستجو و فیلتر
|
||||
- UI محدود
|
||||
|
||||
### بعد:
|
||||
- DataTableWidget پیشرفته
|
||||
- اتصال مستقیم به API
|
||||
- جستجو و فیلتر کامل
|
||||
- UI مدرن و responsive
|
||||
- عملیات CRUD کامل
|
||||
|
||||
## 🔧 تنظیمات پیشرفته
|
||||
|
||||
### فیلترهای اضافی:
|
||||
```dart
|
||||
additionalParams: {
|
||||
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
||||
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
||||
},
|
||||
```
|
||||
|
||||
### تنظیمات جدول:
|
||||
```dart
|
||||
DataTableConfig<ReceiptPaymentDocument>(
|
||||
endpoint: '/businesses/${widget.businessId}/receipts-payments',
|
||||
searchFields: ['code', 'created_by_name'],
|
||||
filterFields: ['document_type'],
|
||||
dateRangeField: 'document_date',
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
)
|
||||
```
|
||||
|
||||
## 🚧 TODO های آینده
|
||||
|
||||
1. **صفحه افزودن سند جدید**
|
||||
- استفاده از dialog موجود
|
||||
- یکپارچهسازی با API
|
||||
|
||||
2. **صفحه جزئیات سند**
|
||||
- نمایش کامل خطوط اشخاص و حسابها
|
||||
- امکان ویرایش
|
||||
|
||||
3. **عملیات گروهی**
|
||||
- حذف چندتایی
|
||||
- خروجی اکسل
|
||||
- چاپ اسناد
|
||||
|
||||
4. **بهبودهای UX**
|
||||
- انیمیشنهای بهتر
|
||||
- حالتهای loading پیشرفته
|
||||
- پیامهای خطای بهتر
|
||||
|
||||
## 📝 نکات مهم
|
||||
|
||||
1. **سازگاری**: صفحه قدیمی `ReceiptsPaymentsPage` همچنان موجود است
|
||||
2. **API**: از همان API موجود استفاده میکند
|
||||
3. **مدلها**: مدلهای جدید با ساختار API سازگار هستند
|
||||
4. **Performance**: صفحهبندی و lazy loading برای عملکرد بهتر
|
||||
|
||||
## 🔍 تست
|
||||
|
||||
### بررسی syntax:
|
||||
```bash
|
||||
flutter analyze lib/pages/business/receipts_payments_list_page.dart
|
||||
flutter analyze lib/models/receipt_payment_document.dart
|
||||
flutter analyze lib/services/receipt_payment_list_service.dart
|
||||
```
|
||||
|
||||
### تست runtime:
|
||||
1. اجرای اپلیکیشن
|
||||
2. رفتن به بخش دریافت و پرداخت
|
||||
3. تست فیلترها و جستجو
|
||||
4. تست عملیات CRUD
|
||||
|
||||
## 📚 منابع
|
||||
|
||||
- [DataTableWidget Documentation](../hesabixUI/hesabix_ui/lib/widgets/data_table/README.md)
|
||||
- [API Documentation](../hesabixAPI/adapters/api/v1/receipts_payments.py)
|
||||
- [Service Implementation](../hesabixAPI/app/services/receipt_payment_service.py)
|
||||
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
|
||||
|
||||
|
|
@ -2,4 +2,5 @@ from .health import router as health # noqa: F401
|
|||
from .categories import router as categories # noqa: F401
|
||||
from .products import router as products # noqa: F401
|
||||
from .price_lists import router as price_lists # noqa: F401
|
||||
from .invoices import router as invoices # noqa: F401
|
||||
|
||||
|
|
|
|||
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")
|
||||
|
||||
|
||||
228
hesabixAPI/adapters/api/v1/customers.py
Normal file
228
hesabixAPI/adapters/api/v1/customers.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from app.core.responses import success_response, format_datetime_fields
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access_dep
|
||||
from app.services.person_service import search_persons, count_persons, get_person_by_id
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["customers"])
|
||||
|
||||
|
||||
class CustomerSearchRequest(BaseModel):
|
||||
business_id: int
|
||||
page: int = 1
|
||||
limit: int = 20
|
||||
search: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
code: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
is_active: bool = True
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerSearchResponse(BaseModel):
|
||||
customers: List[CustomerResponse]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
@router.post("/search",
|
||||
summary="جستوجوی مشتریها",
|
||||
description="جستوجو در لیست مشتریها (اشخاص) با قابلیت فیلتر و صفحهبندی",
|
||||
response_model=CustomerSearchResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "لیست مشتریها با موفقیت دریافت شد",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"customers": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "احمد احمدی",
|
||||
"code": "CUST001",
|
||||
"phone": "09123456789",
|
||||
"email": "ahmad@example.com",
|
||||
"address": "تهران، خیابان ولیعصر",
|
||||
"is_active": True,
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"has_more": False
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز - نیاز به دسترسی به کسب و کار"
|
||||
}
|
||||
}
|
||||
)
|
||||
async def search_customers(
|
||||
request: Request,
|
||||
search_request: CustomerSearchRequest,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(require_business_access_dep)
|
||||
):
|
||||
"""جستوجو در لیست مشتریها"""
|
||||
|
||||
# بررسی دسترسی به بخش اشخاص (یا join permission)
|
||||
# در اینجا میتوانید منطق بررسی دسترسی join را پیادهسازی کنید
|
||||
# برای مثال: اگر کاربر دسترسی مستقیم به اشخاص ندارد، اما دسترسی join دارد
|
||||
|
||||
# جستوجو در اشخاص
|
||||
persons = search_persons(
|
||||
db=db,
|
||||
business_id=search_request.business_id,
|
||||
search_query=search_request.search,
|
||||
page=search_request.page,
|
||||
limit=search_request.limit
|
||||
)
|
||||
|
||||
# تبدیل به فرمت مشتری
|
||||
customers = []
|
||||
for person in persons:
|
||||
# ساخت نام کامل
|
||||
name_parts = []
|
||||
if person.alias_name:
|
||||
name_parts.append(person.alias_name)
|
||||
if person.first_name:
|
||||
name_parts.append(person.first_name)
|
||||
if person.last_name:
|
||||
name_parts.append(person.last_name)
|
||||
full_name = " ".join(name_parts) if name_parts else person.alias_name or "نامشخص"
|
||||
|
||||
customer = CustomerResponse(
|
||||
id=person.id,
|
||||
name=full_name,
|
||||
code=str(person.code) if person.code else None,
|
||||
phone=person.phone or person.mobile,
|
||||
email=person.email,
|
||||
address=person.address,
|
||||
is_active=True, # اشخاص همیشه فعال در نظر گرفته میشوند
|
||||
created_at=person.created_at.isoformat() if person.created_at else None
|
||||
)
|
||||
customers.append(customer)
|
||||
|
||||
# محاسبه تعداد کل
|
||||
total_count = count_persons(
|
||||
db=db,
|
||||
business_id=search_request.business_id,
|
||||
search_query=search_request.search
|
||||
)
|
||||
|
||||
has_more = len(customers) == search_request.limit
|
||||
|
||||
return CustomerSearchResponse(
|
||||
customers=customers,
|
||||
total=total_count,
|
||||
page=search_request.page,
|
||||
limit=search_request.limit,
|
||||
has_more=has_more
|
||||
)
|
||||
|
||||
|
||||
@router.get("/detail/{customer_id}",
|
||||
summary="دریافت اطلاعات مشتری",
|
||||
description="دریافت اطلاعات کامل یک مشتری بر اساس شناسه",
|
||||
response_model=CustomerResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "اطلاعات مشتری با موفقیت دریافت شد"
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز"
|
||||
},
|
||||
404: {
|
||||
"description": "مشتری یافت نشد"
|
||||
}
|
||||
}
|
||||
)
|
||||
async def get_customer(
|
||||
customer_id: int,
|
||||
business_id: int,
|
||||
request: Request,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(require_business_access_dep)
|
||||
):
|
||||
"""دریافت اطلاعات یک مشتری"""
|
||||
|
||||
# دریافت اطلاعات شخص
|
||||
person_data = get_person_by_id(db, customer_id, business_id)
|
||||
|
||||
if not person_data:
|
||||
raise HTTPException(status_code=404, detail="مشتری یافت نشد")
|
||||
|
||||
# ساخت نام کامل
|
||||
name_parts = []
|
||||
if person_data.get('alias_name'):
|
||||
name_parts.append(person_data['alias_name'])
|
||||
if person_data.get('first_name'):
|
||||
name_parts.append(person_data['first_name'])
|
||||
if person_data.get('last_name'):
|
||||
name_parts.append(person_data['last_name'])
|
||||
full_name = " ".join(name_parts) if name_parts else person_data.get('alias_name', 'نامشخص')
|
||||
|
||||
customer = CustomerResponse(
|
||||
id=person_data['id'],
|
||||
name=full_name,
|
||||
code=str(person_data['code']) if person_data.get('code') else None,
|
||||
phone=person_data.get('phone') or person_data.get('mobile'),
|
||||
email=person_data.get('email'),
|
||||
address=person_data.get('address'),
|
||||
is_active=True, # اشخاص همیشه فعال در نظر گرفته میشوند
|
||||
created_at=person_data.get('created_at')
|
||||
)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
@router.get("/check-access",
|
||||
summary="بررسی دسترسی به مشتریها",
|
||||
description="بررسی دسترسی کاربر به بخش مشتریها",
|
||||
responses={
|
||||
200: {
|
||||
"description": "دسترسی مجاز است"
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز"
|
||||
}
|
||||
}
|
||||
)
|
||||
async def check_customer_access(
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
_: None = Depends(require_business_access_dep)
|
||||
):
|
||||
"""بررسی دسترسی به بخش مشتریها"""
|
||||
|
||||
# در اینجا میتوانید منطق بررسی دسترسی join را پیادهسازی کنید
|
||||
# برای مثال: بررسی اینکه آیا کاربر دسترسی به اشخاص یا join permission دارد
|
||||
|
||||
return {"access": True, "message": "دسترسی مجاز است"}
|
||||
71
hesabixAPI/adapters/api/v1/fiscal_years.py
Normal file
71
hesabixAPI/adapters/api/v1/fiscal_years.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
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.permissions import require_business_access
|
||||
from app.core.responses import success_response, ApiError, format_datetime_fields
|
||||
from adapters.db.repositories.fiscal_year_repo import FiscalYearRepository
|
||||
|
||||
|
||||
router = APIRouter(prefix="/business", tags=["fiscal-years"])
|
||||
|
||||
|
||||
@router.get("/{business_id}/fiscal-years")
|
||||
@require_business_access("business_id")
|
||||
def list_fiscal_years(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
repo = FiscalYearRepository(db)
|
||||
|
||||
# اطمینان از دسترسی کاربر به کسب و کار
|
||||
if not ctx.can_access_business(business_id):
|
||||
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
|
||||
|
||||
items = repo.list_by_business(business_id)
|
||||
|
||||
data = [
|
||||
{
|
||||
"id": fy.id,
|
||||
"title": fy.title,
|
||||
"start_date": fy.start_date,
|
||||
"end_date": fy.end_date,
|
||||
"is_current": fy.is_last,
|
||||
}
|
||||
for fy in items
|
||||
]
|
||||
|
||||
return success_response(data=format_datetime_fields({"items": data}, request), request=request, message="FISCAL_YEARS_LIST_FETCHED")
|
||||
|
||||
|
||||
@router.get("/{business_id}/fiscal-years/current")
|
||||
@require_business_access("business_id")
|
||||
def get_current_fiscal_year(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
repo = FiscalYearRepository(db)
|
||||
|
||||
if not ctx.can_access_business(business_id):
|
||||
raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403)
|
||||
|
||||
fy = repo.get_current_for_business(business_id)
|
||||
if not fy:
|
||||
return success_response(data=None, request=request, message="NO_CURRENT_FISCAL_YEAR")
|
||||
|
||||
data = {
|
||||
"id": fy.id,
|
||||
"title": fy.title,
|
||||
"start_date": fy.start_date,
|
||||
"end_date": fy.end_date,
|
||||
"is_current": fy.is_last,
|
||||
}
|
||||
return success_response(data=format_datetime_fields(data, request), request=request, message="FISCAL_YEAR_CURRENT_FETCHED")
|
||||
|
||||
|
||||
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
67
hesabixAPI/adapters/api/v1/invoices.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
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.permissions import require_business_access
|
||||
from app.core.responses import success_response
|
||||
from adapters.api.v1.schemas import QueryInfo
|
||||
|
||||
|
||||
router = APIRouter(prefix="/invoices", tags=["invoices"]) # Stubs only
|
||||
|
||||
|
||||
@router.post("/business/{business_id}")
|
||||
@require_business_access("business_id")
|
||||
def create_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
payload: Dict[str, Any],
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={}, request=request, message="INVOICE_CREATE_STUB")
|
||||
|
||||
|
||||
@router.put("/business/{business_id}/{invoice_id}")
|
||||
@require_business_access("business_id")
|
||||
def update_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
invoice_id: int,
|
||||
payload: Dict[str, Any],
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={}, request=request, message="INVOICE_UPDATE_STUB")
|
||||
|
||||
|
||||
@router.get("/business/{business_id}/{invoice_id}")
|
||||
@require_business_access("business_id")
|
||||
def get_invoice_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
invoice_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={"item": None}, request=request, message="INVOICE_GET_STUB")
|
||||
|
||||
|
||||
@router.post("/business/{business_id}/search")
|
||||
@require_business_access("business_id")
|
||||
def search_invoices_endpoint(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
query_info: QueryInfo,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Stub only: no implementation yet
|
||||
return success_response(data={"items": [], "total": 0, "take": query_info.take, "skip": query_info.skip}, request=request, message="INVOICE_SEARCH_STUB")
|
||||
|
||||
|
||||
|
|
@ -242,8 +242,8 @@ async def export_products_excel(
|
|||
("category_id", "دسته"),
|
||||
("base_sales_price", "قیمت فروش"),
|
||||
("base_purchase_price", "قیمت خرید"),
|
||||
("main_unit_id", "واحد اصلی"),
|
||||
("secondary_unit_id", "واحد فرعی"),
|
||||
("main_unit", "واحد اصلی"),
|
||||
("secondary_unit", "واحد فرعی"),
|
||||
("track_inventory", "کنترل موجودی"),
|
||||
("created_at_formatted", "ایجاد"),
|
||||
]
|
||||
|
|
@ -358,7 +358,7 @@ async def download_products_import_template(
|
|||
|
||||
headers = [
|
||||
"code","name","item_type","description","category_id",
|
||||
"main_unit_id","secondary_unit_id","unit_conversion_factor",
|
||||
"main_unit","secondary_unit","unit_conversion_factor",
|
||||
"base_sales_price","base_purchase_price","track_inventory",
|
||||
"reorder_point","min_order_qty","lead_time_days",
|
||||
"is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate",
|
||||
|
|
@ -523,7 +523,7 @@ async def import_products_excel(
|
|||
for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
|
||||
if k in item:
|
||||
item[k] = _parse_decimal(item.get(k))
|
||||
for k in ['reorder_point','min_order_qty','lead_time_days','category_id','main_unit_id','secondary_unit_id','tax_type_id','tax_unit_id']:
|
||||
for k in ['reorder_point','min_order_qty','lead_time_days','category_id','tax_type_id','tax_unit_id']:
|
||||
if k in item:
|
||||
item[k] = _parse_int(item.get(k))
|
||||
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
|
||||
|
|
@ -673,8 +673,8 @@ async def export_products_pdf(
|
|||
("category_id", "دسته"),
|
||||
("base_sales_price", "قیمت فروش"),
|
||||
("base_purchase_price", "قیمت خرید"),
|
||||
("main_unit_id", "واحد اصلی"),
|
||||
("secondary_unit_id", "واحد فرعی"),
|
||||
("main_unit", "واحد اصلی"),
|
||||
("secondary_unit", "واحد فرعی"),
|
||||
("track_inventory", "کنترل موجودی"),
|
||||
("created_at_formatted", "ایجاد"),
|
||||
]
|
||||
|
|
|
|||
685
hesabixAPI/adapters/api/v1/receipts_payments.py
Normal file
685
hesabixAPI/adapters/api/v1/receipts_payments.py
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
"""
|
||||
API endpoints برای دریافت و پرداخت (Receipt & Payment)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, Depends, Request, Body
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
import io
|
||||
import json
|
||||
import datetime
|
||||
import re
|
||||
|
||||
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,
|
||||
update_receipt_payment,
|
||||
)
|
||||
from adapters.db.models.business import Business
|
||||
|
||||
|
||||
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
|
||||
|
||||
# دریافت fiscal_year_id از هدر برای اولویت دادن به انتخاب کاربر
|
||||
try:
|
||||
fy_header = request.headers.get("X-Fiscal-Year-ID")
|
||||
if fy_header:
|
||||
query_dict["fiscal_year_id"] = int(fy_header)
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/receipts-payments/{document_id}",
|
||||
summary="ویرایش سند دریافت/پرداخت",
|
||||
description="بهروزرسانی یک سند دریافت یا پرداخت",
|
||||
)
|
||||
async def update_receipt_payment_endpoint(
|
||||
request: Request,
|
||||
document_id: int,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
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 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)
|
||||
|
||||
updated = update_receipt_payment(db, document_id, ctx.get_user_id(), body)
|
||||
return success_response(
|
||||
data=format_datetime_fields(updated, request),
|
||||
request=request,
|
||||
message="RECEIPT_PAYMENT_UPDATED",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/businesses/{business_id}/receipts-payments/export/excel",
|
||||
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
|
||||
description="خروجی Excel لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||
)
|
||||
@require_business_access("business_id")
|
||||
async def export_receipts_payments_excel(
|
||||
business_id: int,
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
auth_context: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""خروجی Excel لیست اسناد دریافت و پرداخت"""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from app.core.i18n import negotiate_locale
|
||||
|
||||
# Build query dict from flat body
|
||||
# For export, we limit to reasonable number to prevent memory issues
|
||||
max_export_records = 10000
|
||||
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||
|
||||
query_dict = {
|
||||
"take": take_value,
|
||||
"skip": int(body.get("skip", 0)),
|
||||
"sort_by": body.get("sort_by"),
|
||||
"sort_desc": bool(body.get("sort_desc", False)),
|
||||
"search": body.get("search"),
|
||||
"search_fields": body.get("search_fields"),
|
||||
"filters": body.get("filters"),
|
||||
"document_type": body.get("document_type"),
|
||||
"from_date": body.get("from_date"),
|
||||
"to_date": body.get("to_date"),
|
||||
}
|
||||
|
||||
result = list_receipts_payments(db, business_id, query_dict)
|
||||
items = result.get('items', [])
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
|
||||
# Check if we hit the limit
|
||||
if len(items) >= max_export_records:
|
||||
# Add a warning row to indicate data was truncated
|
||||
warning_item = {
|
||||
"code": "⚠️ هشدار",
|
||||
"document_type": "حداکثر ۱۰,۰۰۰ رکورد قابل export است",
|
||||
"document_date": "",
|
||||
"total_amount": "",
|
||||
"person_lines_count": "",
|
||||
"account_lines_count": "",
|
||||
"created_by_name": "",
|
||||
"registered_at": "",
|
||||
}
|
||||
items.append(warning_item)
|
||||
|
||||
# Handle selected rows
|
||||
selected_only = bool(body.get('selected_only', False))
|
||||
selected_indices = body.get('selected_indices')
|
||||
if selected_only and selected_indices is not None:
|
||||
indices = None
|
||||
if isinstance(selected_indices, str):
|
||||
try:
|
||||
indices = json.loads(selected_indices)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
indices = None
|
||||
elif isinstance(selected_indices, list):
|
||||
indices = selected_indices
|
||||
if isinstance(indices, list):
|
||||
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||
|
||||
# Prepare headers based on export_columns (order + visibility)
|
||||
headers: List[str] = []
|
||||
keys: List[str] = []
|
||||
export_columns = body.get('export_columns')
|
||||
if export_columns:
|
||||
for col in export_columns:
|
||||
key = col.get('key')
|
||||
label = col.get('label', key)
|
||||
if key:
|
||||
keys.append(str(key))
|
||||
headers.append(str(label))
|
||||
else:
|
||||
# Default columns for receipts/payments
|
||||
default_columns = [
|
||||
('code', 'کد سند'),
|
||||
('document_type_name', 'نوع سند'),
|
||||
('document_date', 'تاریخ سند'),
|
||||
('total_amount', 'مبلغ کل'),
|
||||
('person_names', 'اشخاص'),
|
||||
('account_lines_count', 'تعداد حسابها'),
|
||||
('created_by_name', 'ایجادکننده'),
|
||||
('registered_at', 'تاریخ ثبت'),
|
||||
]
|
||||
for key, label in default_columns:
|
||||
if items and key in items[0]:
|
||||
keys.append(key)
|
||||
headers.append(label)
|
||||
|
||||
# Create workbook
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Receipts & Payments"
|
||||
|
||||
# Locale and RTL/LTR handling
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
if locale == 'fa':
|
||||
try:
|
||||
ws.sheet_view.rightToLeft = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||
|
||||
# Write header row
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
cell.border = border
|
||||
|
||||
# Write data rows
|
||||
for row_idx, item in enumerate(items, 2):
|
||||
for col_idx, key in enumerate(keys, 1):
|
||||
value = item.get(key, "")
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
value = str(value)
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = border
|
||||
|
||||
# RTL alignment for Persian text
|
||||
if locale == 'fa' and isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in value):
|
||||
cell.alignment = Alignment(horizontal="right")
|
||||
|
||||
# Auto-width columns
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except Exception:
|
||||
pass
|
||||
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||||
|
||||
# Save to bytes
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
# Build meaningful filename
|
||||
biz_name = ""
|
||||
try:
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
biz_name = b.name or ""
|
||||
except Exception:
|
||||
biz_name = ""
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||||
|
||||
base = "receipts_payments"
|
||||
if biz_name:
|
||||
base += f"_{slugify(biz_name)}"
|
||||
if selected_only:
|
||||
base += "_selected"
|
||||
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
content = buffer.getvalue()
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(content)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/businesses/{business_id}/receipts-payments/export/pdf",
|
||||
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
|
||||
description="خروجی PDF لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||
)
|
||||
@require_business_access("business_id")
|
||||
async def export_receipts_payments_pdf(
|
||||
business_id: int,
|
||||
request: Request,
|
||||
body: Dict[str, Any] = Body(...),
|
||||
auth_context: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""خروجی PDF لیست اسناد دریافت و پرداخت"""
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
from app.core.i18n import negotiate_locale
|
||||
from html import escape
|
||||
|
||||
# Build query dict from flat body
|
||||
# For export, we limit to reasonable number to prevent memory issues
|
||||
max_export_records = 10000
|
||||
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||
|
||||
query_dict = {
|
||||
"take": take_value,
|
||||
"skip": int(body.get("skip", 0)),
|
||||
"sort_by": body.get("sort_by"),
|
||||
"sort_desc": bool(body.get("sort_desc", False)),
|
||||
"search": body.get("search"),
|
||||
"search_fields": body.get("search_fields"),
|
||||
"filters": body.get("filters"),
|
||||
"document_type": body.get("document_type"),
|
||||
"from_date": body.get("from_date"),
|
||||
"to_date": body.get("to_date"),
|
||||
}
|
||||
|
||||
result = list_receipts_payments(db, business_id, query_dict)
|
||||
items = result.get('items', [])
|
||||
items = [format_datetime_fields(item, request) for item in items]
|
||||
|
||||
# Handle selected rows
|
||||
selected_only = bool(body.get('selected_only', False))
|
||||
selected_indices = body.get('selected_indices')
|
||||
if selected_only and selected_indices is not None:
|
||||
indices = None
|
||||
if isinstance(selected_indices, str):
|
||||
try:
|
||||
indices = json.loads(selected_indices)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
indices = None
|
||||
elif isinstance(selected_indices, list):
|
||||
indices = selected_indices
|
||||
if isinstance(indices, list):
|
||||
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||
|
||||
# Prepare headers and data
|
||||
headers: List[str] = []
|
||||
keys: List[str] = []
|
||||
export_columns = body.get('export_columns')
|
||||
if export_columns:
|
||||
for col in export_columns:
|
||||
key = col.get('key')
|
||||
label = col.get('label', key)
|
||||
if key:
|
||||
keys.append(str(key))
|
||||
headers.append(str(label))
|
||||
else:
|
||||
# Default columns for receipts/payments
|
||||
default_columns = [
|
||||
('code', 'کد سند'),
|
||||
('document_type_name', 'نوع سند'),
|
||||
('document_date', 'تاریخ سند'),
|
||||
('total_amount', 'مبلغ کل'),
|
||||
('person_names', 'اشخاص'),
|
||||
('account_lines_count', 'تعداد حسابها'),
|
||||
('created_by_name', 'ایجادکننده'),
|
||||
('registered_at', 'تاریخ ثبت'),
|
||||
]
|
||||
for key, label in default_columns:
|
||||
if items and key in items[0]:
|
||||
keys.append(key)
|
||||
headers.append(label)
|
||||
|
||||
# Get business name
|
||||
business_name = ""
|
||||
try:
|
||||
b = db.query(Business).filter(Business.id == business_id).first()
|
||||
if b is not None:
|
||||
business_name = b.name or ""
|
||||
except Exception:
|
||||
business_name = ""
|
||||
|
||||
# Locale handling
|
||||
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||
is_fa = locale == 'fa'
|
||||
|
||||
# Prepare data for HTML
|
||||
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||
title_text = "لیست اسناد دریافت و پرداخت" if is_fa else "Receipts & Payments List"
|
||||
label_biz = "کسب و کار" if is_fa else "Business"
|
||||
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||
|
||||
# Create headers HTML
|
||||
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
||||
|
||||
# Create rows HTML
|
||||
rows_html = []
|
||||
for item in items:
|
||||
row_cells = []
|
||||
for key in keys:
|
||||
value = item.get(key, "")
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(str(v) for v in value)
|
||||
elif isinstance(value, dict):
|
||||
value = str(value)
|
||||
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||
|
||||
# Create HTML table
|
||||
table_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{title_text}</title>
|
||||
<style>
|
||||
@page {{
|
||||
margin: 1cm;
|
||||
size: A4;
|
||||
}}
|
||||
body {{
|
||||
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
direction: {'rtl' if is_fa else 'ltr'};
|
||||
}}
|
||||
.header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #366092;
|
||||
}}
|
||||
.title {{
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #366092;
|
||||
}}
|
||||
.meta {{
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}}
|
||||
.table-wrapper {{
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.report-table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
}}
|
||||
.report-table thead {{
|
||||
background-color: #366092;
|
||||
color: white;
|
||||
}}
|
||||
.report-table th {{
|
||||
border: 1px solid #d7dde6;
|
||||
padding: 8px 6px;
|
||||
text-align: {'right' if is_fa else 'left'};
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
.report-table tbody tr:nth-child(even) {{
|
||||
background-color: #f8f9fa;
|
||||
}}
|
||||
.report-table tbody tr:hover {{
|
||||
background-color: #e9ecef;
|
||||
}}
|
||||
tbody td {{
|
||||
border: 1px solid #d7dde6;
|
||||
padding: 5px 4px;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
text-align: {'right' if is_fa else 'left'};
|
||||
}}
|
||||
.footer {{
|
||||
position: running(footer);
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
text-align: {'left' if is_fa else 'right'};
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">{title_text}</div>
|
||||
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
||||
</div>
|
||||
<div class="meta">{label_date}: {escape(now)}</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>{headers_html}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{''.join(rows_html)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer">{footer_text}</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
font_config = FontConfiguration()
|
||||
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||
|
||||
# Build meaningful filename
|
||||
def slugify(text: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||||
|
||||
base = "receipts_payments"
|
||||
if business_name:
|
||||
base += f"_{slugify(business_name)}"
|
||||
if selected_only:
|
||||
base += "_selected"
|
||||
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Length": str(len(pdf_bytes)),
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
# Import from file_storage module
|
||||
from .file_storage import *
|
||||
|
||||
# Import document line schemas
|
||||
from .document_line import *
|
||||
|
||||
# Re-export from parent schemas module
|
||||
import sys
|
||||
import os
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
69
hesabixAPI/adapters/api/v1/schema_models/document_line.py
Normal file
69
hesabixAPI/adapters/api/v1/schema_models/document_line.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DocumentLineCreateRequest(BaseModel):
|
||||
"""درخواست ایجاد خط سند جدید"""
|
||||
account_id: Optional[int] = Field(default=None, description="شناسه حساب")
|
||||
person_id: Optional[int] = Field(default=None, description="شناسه شخص")
|
||||
product_id: Optional[int] = Field(default=None, description="شناسه محصول")
|
||||
bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی")
|
||||
cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق")
|
||||
petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه گردان")
|
||||
check_id: Optional[int] = Field(default=None, description="شناسه چک")
|
||||
quantity: Optional[Decimal] = Field(default=0, description="تعداد کالا")
|
||||
debit: Decimal = Field(default=0, description="مبلغ بدهکار")
|
||||
credit: Decimal = Field(default=0, description="مبلغ بستانکار")
|
||||
description: Optional[str] = Field(default=None, description="توضیحات")
|
||||
extra_info: Optional[dict] = Field(default=None, description="اطلاعات اضافی")
|
||||
|
||||
|
||||
class DocumentLineUpdateRequest(BaseModel):
|
||||
"""درخواست بهروزرسانی خط سند"""
|
||||
account_id: Optional[int] = None
|
||||
person_id: Optional[int] = None
|
||||
product_id: Optional[int] = None
|
||||
bank_account_id: Optional[int] = None
|
||||
cash_register_id: Optional[int] = None
|
||||
petty_cash_id: Optional[int] = None
|
||||
check_id: Optional[int] = None
|
||||
quantity: Optional[Decimal] = None
|
||||
debit: Optional[Decimal] = None
|
||||
credit: Optional[Decimal] = None
|
||||
description: Optional[str] = None
|
||||
extra_info: Optional[dict] = None
|
||||
|
||||
|
||||
class DocumentLineResponse(BaseModel):
|
||||
"""پاسخ خط سند"""
|
||||
id: int
|
||||
document_id: int
|
||||
account_id: Optional[int]
|
||||
person_id: Optional[int]
|
||||
product_id: Optional[int]
|
||||
bank_account_id: Optional[int]
|
||||
cash_register_id: Optional[int]
|
||||
petty_cash_id: Optional[int]
|
||||
check_id: Optional[int]
|
||||
quantity: Optional[Decimal]
|
||||
debit: Decimal
|
||||
credit: Decimal
|
||||
description: Optional[str]
|
||||
extra_info: Optional[dict]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
# اطلاعات مرتبط
|
||||
account_name: Optional[str] = None
|
||||
person_name: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
bank_account_name: Optional[str] = None
|
||||
cash_register_name: Optional[str] = None
|
||||
petty_cash_name: Optional[str] = None
|
||||
check_number: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -18,8 +18,8 @@ class ProductCreateRequest(BaseModel):
|
|||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
category_id: Optional[int] = None
|
||||
|
||||
main_unit_id: Optional[int] = None
|
||||
secondary_unit_id: Optional[int] = None
|
||||
main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
|
||||
secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
|
||||
unit_conversion_factor: Optional[Decimal] = None
|
||||
|
||||
base_sales_price: Optional[Decimal] = None
|
||||
|
|
@ -50,8 +50,8 @@ class ProductUpdateRequest(BaseModel):
|
|||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
category_id: Optional[int] = None
|
||||
|
||||
main_unit_id: Optional[int] = None
|
||||
secondary_unit_id: Optional[int] = None
|
||||
main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
|
||||
secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
|
||||
unit_conversion_factor: Optional[Decimal] = None
|
||||
|
||||
base_sales_price: Optional[Decimal] = None
|
||||
|
|
@ -83,8 +83,8 @@ class ProductResponse(BaseModel):
|
|||
name: str
|
||||
description: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
main_unit_id: Optional[int] = None
|
||||
secondary_unit_id: Optional[int] = None
|
||||
main_unit: Optional[str] = None
|
||||
secondary_unit: Optional[str] = None
|
||||
unit_conversion_factor: Optional[Decimal] = None
|
||||
base_sales_price: Optional[Decimal] = None
|
||||
base_sales_note: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -1,49 +1,59 @@
|
|||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from adapters.api.v1.schemas import SuccessResponse
|
||||
from adapters.db.session import get_db # noqa: F401 (kept for consistency/future use)
|
||||
from adapters.db.session import get_db
|
||||
from app.core.responses import success_response
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access
|
||||
from sqlalchemy.orm import Session # noqa: F401
|
||||
from sqlalchemy.orm import Session
|
||||
from adapters.db.models.tax_type import TaxType
|
||||
|
||||
|
||||
router = APIRouter(prefix="/tax-types", tags=["tax-types"])
|
||||
|
||||
|
||||
def _static_tax_types() -> List[Dict[str, Any]]:
|
||||
titles = [
|
||||
"دارو",
|
||||
"دخانیات",
|
||||
"موبایل",
|
||||
"لوازم خانگی برقی",
|
||||
"قطعات مصرفی و یدکی وسایل نقلیه",
|
||||
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
|
||||
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
|
||||
"منسوجات و پوشاک",
|
||||
"اسباب بازی",
|
||||
"دام زنده، گوشت سفید و قرمز",
|
||||
"محصولات اساسی کشاورزی",
|
||||
"سایر کالا ها",
|
||||
]
|
||||
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/business/{business_id}",
|
||||
@router.get("/",
|
||||
summary="لیست نوعهای مالیات",
|
||||
description="دریافت لیست نوعهای مالیات (ثابت)",
|
||||
description="دریافت لیست تمام نوعهای مالیات استاندارد",
|
||||
response_model=SuccessResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "لیست نوعهای مالیات با موفقیت دریافت شد",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "لیست نوعهای مالیات دریافت شد",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "ارزش افزوده گروه دارو",
|
||||
"code": "VAT_DRUG",
|
||||
"description": "مالیات ارزش افزوده برای گروه دارو و تجهیزات پزشکی",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@require_business_access()
|
||||
def list_tax_types(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Dict[str, Any]:
|
||||
# Currently returns a static list; later can be sourced from DB if needed
|
||||
items = _static_tax_types()
|
||||
"""دریافت لیست تمام نوعهای مالیات استاندارد"""
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": it.id,
|
||||
"title": it.title,
|
||||
"code": it.code,
|
||||
"description": it.description,
|
||||
"created_at": it.created_at.isoformat(),
|
||||
"updated_at": it.updated_at.isoformat(),
|
||||
}
|
||||
for it in db.query(TaxType).order_by(TaxType.title).all()
|
||||
]
|
||||
return success_response(items, request)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +1,19 @@
|
|||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from adapters.db.models.tax_unit import TaxUnit
|
||||
from adapters.api.v1.schemas import SuccessResponse
|
||||
from app.core.responses import success_response, format_datetime_fields
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
from app.core.permissions import require_business_access
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.responses import success_response
|
||||
|
||||
|
||||
router = APIRouter(prefix="/tax-units", tags=["tax-units"])
|
||||
alias_router = APIRouter(prefix="/units", tags=["units"])
|
||||
|
||||
|
||||
class TaxUnitCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی")
|
||||
code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی")
|
||||
description: Optional[str] = Field(default=None, description="توضیحات")
|
||||
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
|
||||
is_active: bool = Field(default=True, description="وضعیت فعال/غیرفعال")
|
||||
|
||||
|
||||
class TaxUnitUpdateRequest(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255, description="نام واحد مالیاتی")
|
||||
code: Optional[str] = Field(default=None, min_length=1, max_length=64, description="کد واحد مالیاتی")
|
||||
description: Optional[str] = Field(default=None, description="توضیحات")
|
||||
tax_rate: Optional[Decimal] = Field(default=None, ge=0, le=100, description="نرخ مالیات (درصد)")
|
||||
is_active: Optional[bool] = Field(default=None, description="وضعیت فعال/غیرفعال")
|
||||
|
||||
|
||||
class TaxUnitResponse(BaseModel):
|
||||
id: int
|
||||
business_id: int
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
tax_rate: Optional[Decimal] = None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/business/{business_id}",
|
||||
summary="لیست واحدهای مالیاتی کسبوکار",
|
||||
description="دریافت لیست واحدهای مالیاتی یک کسبوکار",
|
||||
@router.get("/",
|
||||
summary="لیست واحدهای مالیاتی",
|
||||
description="دریافت لیست تمام واحدهای مالیاتی استاندارد",
|
||||
response_model=SuccessResponse,
|
||||
responses={
|
||||
200: {
|
||||
|
|
@ -62,12 +26,9 @@ class TaxUnitResponse(BaseModel):
|
|||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"business_id": 1,
|
||||
"name": "مالیات بر ارزش افزوده",
|
||||
"code": "VAT",
|
||||
"description": "مالیات بر ارزش افزوده 9 درصد",
|
||||
"tax_rate": 9.0,
|
||||
"is_active": True,
|
||||
"name": "کیلوگرم",
|
||||
"code": "کیلوگرم",
|
||||
"description": None,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
|
|
@ -75,313 +36,29 @@ class TaxUnitResponse(BaseModel):
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز به کسبوکار"
|
||||
},
|
||||
404: {
|
||||
"description": "کسبوکار یافت نشد"
|
||||
}
|
||||
}
|
||||
)
|
||||
@alias_router.get("/business/{business_id}")
|
||||
@require_business_access()
|
||||
def get_tax_units(
|
||||
def list_tax_units(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""دریافت لیست واحدهای مالیاتی یک کسبوکار"""
|
||||
) -> Dict[str, Any]:
|
||||
"""دریافت لیست تمام واحدهای مالیاتی استاندارد"""
|
||||
|
||||
# Query tax units for the business
|
||||
tax_units = db.query(TaxUnit).filter(
|
||||
TaxUnit.business_id == business_id
|
||||
).order_by(TaxUnit.name).all()
|
||||
# Query all tax units (they are global now)
|
||||
tax_units = db.query(TaxUnit).order_by(TaxUnit.name).all()
|
||||
|
||||
# Convert to response format
|
||||
tax_unit_dicts = []
|
||||
for tax_unit in tax_units:
|
||||
tax_unit_dict = {
|
||||
"id": tax_unit.id,
|
||||
"business_id": tax_unit.business_id,
|
||||
"name": tax_unit.name,
|
||||
"code": tax_unit.code,
|
||||
"description": tax_unit.description,
|
||||
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
|
||||
"is_active": tax_unit.is_active,
|
||||
"created_at": tax_unit.created_at.isoformat(),
|
||||
"updated_at": tax_unit.updated_at.isoformat()
|
||||
}
|
||||
tax_unit_dicts.append(format_datetime_fields(tax_unit_dict, request))
|
||||
tax_unit_dicts.append(tax_unit_dict)
|
||||
|
||||
return success_response(tax_unit_dicts, request)
|
||||
|
||||
|
||||
@router.post("/business/{business_id}",
|
||||
summary="ایجاد واحد مالیاتی جدید",
|
||||
description="ایجاد یک واحد مالیاتی جدید برای کسبوکار",
|
||||
response_model=SuccessResponse,
|
||||
responses={
|
||||
201: {
|
||||
"description": "واحد مالیاتی با موفقیت ایجاد شد",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "واحد مالیاتی با موفقیت ایجاد شد",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"business_id": 1,
|
||||
"name": "مالیات بر ارزش افزوده",
|
||||
"code": "VAT",
|
||||
"description": "مالیات بر ارزش افزوده 9 درصد",
|
||||
"tax_rate": 9.0,
|
||||
"is_active": True,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "خطا در اعتبارسنجی دادهها"
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز به کسبوکار"
|
||||
},
|
||||
404: {
|
||||
"description": "کسبوکار یافت نشد"
|
||||
}
|
||||
}
|
||||
)
|
||||
@alias_router.post("/business/{business_id}")
|
||||
@require_business_access()
|
||||
def create_tax_unit(
|
||||
request: Request,
|
||||
business_id: int,
|
||||
tax_unit_data: TaxUnitCreateRequest,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""ایجاد واحد مالیاتی جدید"""
|
||||
|
||||
# Check if code already exists for this business
|
||||
existing_tax_unit = db.query(TaxUnit).filter(
|
||||
TaxUnit.business_id == business_id,
|
||||
TaxUnit.code == tax_unit_data.code
|
||||
).first()
|
||||
|
||||
if existing_tax_unit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="کد واحد مالیاتی قبلاً استفاده شده است"
|
||||
)
|
||||
|
||||
# Create new tax unit
|
||||
tax_unit = TaxUnit(
|
||||
business_id=business_id,
|
||||
name=tax_unit_data.name,
|
||||
code=tax_unit_data.code,
|
||||
description=tax_unit_data.description,
|
||||
tax_rate=tax_unit_data.tax_rate,
|
||||
is_active=tax_unit_data.is_active
|
||||
)
|
||||
|
||||
db.add(tax_unit)
|
||||
db.commit()
|
||||
db.refresh(tax_unit)
|
||||
|
||||
# Convert to response format
|
||||
tax_unit_dict = {
|
||||
"id": tax_unit.id,
|
||||
"business_id": tax_unit.business_id,
|
||||
"name": tax_unit.name,
|
||||
"code": tax_unit.code,
|
||||
"description": tax_unit.description,
|
||||
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
|
||||
"is_active": tax_unit.is_active,
|
||||
"created_at": tax_unit.created_at.isoformat(),
|
||||
"updated_at": tax_unit.updated_at.isoformat()
|
||||
}
|
||||
|
||||
formatted_response = format_datetime_fields(tax_unit_dict, request)
|
||||
|
||||
return success_response(formatted_response, request)
|
||||
|
||||
|
||||
@router.put("/{tax_unit_id}",
|
||||
summary="بهروزرسانی واحد مالیاتی",
|
||||
description="بهروزرسانی اطلاعات یک واحد مالیاتی",
|
||||
response_model=SuccessResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "واحد مالیاتی با موفقیت بهروزرسانی شد",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "واحد مالیاتی با موفقیت بهروزرسانی شد",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"business_id": 1,
|
||||
"name": "مالیات بر ارزش افزوده",
|
||||
"code": "VAT",
|
||||
"description": "مالیات بر ارزش افزوده 9 درصد",
|
||||
"tax_rate": 9.0,
|
||||
"is_active": True,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
400: {
|
||||
"description": "خطا در اعتبارسنجی دادهها"
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز به کسبوکار"
|
||||
},
|
||||
404: {
|
||||
"description": "واحد مالیاتی یافت نشد"
|
||||
}
|
||||
}
|
||||
)
|
||||
@alias_router.put("/{tax_unit_id}")
|
||||
@require_business_access()
|
||||
def update_tax_unit(
|
||||
request: Request,
|
||||
tax_unit_id: int,
|
||||
tax_unit_data: TaxUnitUpdateRequest,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""بهروزرسانی واحد مالیاتی"""
|
||||
|
||||
# Find the tax unit
|
||||
tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
|
||||
if not tax_unit:
|
||||
raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
|
||||
|
||||
# Check business access
|
||||
if tax_unit.business_id not in ctx.business_ids:
|
||||
raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
|
||||
|
||||
# Check if new code conflicts with existing ones
|
||||
if tax_unit_data.code and tax_unit_data.code != tax_unit.code:
|
||||
existing_tax_unit = db.query(TaxUnit).filter(
|
||||
TaxUnit.business_id == tax_unit.business_id,
|
||||
TaxUnit.code == tax_unit_data.code,
|
||||
TaxUnit.id != tax_unit_id
|
||||
).first()
|
||||
|
||||
if existing_tax_unit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="کد واحد مالیاتی قبلاً استفاده شده است"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = tax_unit_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tax_unit, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tax_unit)
|
||||
|
||||
# Convert to response format
|
||||
tax_unit_dict = {
|
||||
"id": tax_unit.id,
|
||||
"business_id": tax_unit.business_id,
|
||||
"name": tax_unit.name,
|
||||
"code": tax_unit.code,
|
||||
"description": tax_unit.description,
|
||||
"tax_rate": float(tax_unit.tax_rate) if tax_unit.tax_rate else None,
|
||||
"is_active": tax_unit.is_active,
|
||||
"created_at": tax_unit.created_at.isoformat(),
|
||||
"updated_at": tax_unit.updated_at.isoformat()
|
||||
}
|
||||
|
||||
formatted_response = format_datetime_fields(tax_unit_dict, request)
|
||||
|
||||
return success_response(formatted_response, request)
|
||||
|
||||
|
||||
@router.delete("/{tax_unit_id}",
|
||||
summary="حذف واحد مالیاتی",
|
||||
description="حذف یک واحد مالیاتی",
|
||||
response_model=SuccessResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "واحد مالیاتی با موفقیت حذف شد",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "واحد مالیاتی با موفقیت حذف شد",
|
||||
"data": None
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
"description": "کاربر احراز هویت نشده است"
|
||||
},
|
||||
403: {
|
||||
"description": "دسترسی غیرمجاز به کسبوکار"
|
||||
},
|
||||
404: {
|
||||
"description": "واحد مالیاتی یافت نشد"
|
||||
},
|
||||
409: {
|
||||
"description": "امکان حذف واحد مالیاتی به دلیل استفاده در محصولات وجود ندارد"
|
||||
}
|
||||
}
|
||||
)
|
||||
@alias_router.delete("/{tax_unit_id}")
|
||||
@require_business_access()
|
||||
def delete_tax_unit(
|
||||
request: Request,
|
||||
tax_unit_id: int,
|
||||
ctx: AuthContext = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""حذف واحد مالیاتی"""
|
||||
|
||||
# Find the tax unit
|
||||
tax_unit = db.query(TaxUnit).filter(TaxUnit.id == tax_unit_id).first()
|
||||
if not tax_unit:
|
||||
raise HTTPException(status_code=404, detail="واحد مالیاتی یافت نشد")
|
||||
|
||||
# Check business access
|
||||
if tax_unit.business_id not in ctx.business_ids:
|
||||
raise HTTPException(status_code=403, detail="دسترسی غیرمجاز به این کسبوکار")
|
||||
|
||||
# Check if tax unit is used in products
|
||||
from adapters.db.models.product import Product
|
||||
products_using_tax_unit = db.query(Product).filter(
|
||||
Product.tax_unit_id == tax_unit_id
|
||||
).count()
|
||||
|
||||
if products_using_tax_unit > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"امکان حذف واحد مالیاتی به دلیل استفاده در {products_using_tax_unit} محصول وجود ندارد"
|
||||
)
|
||||
|
||||
# Delete the tax unit
|
||||
db.delete(tax_unit)
|
||||
db.commit()
|
||||
|
||||
return success_response(None, request)
|
||||
|
|
|
|||
|
|
@ -36,5 +36,8 @@ from .product import Product # noqa: F401
|
|||
from .price_list import PriceList, PriceItem # noqa: F401
|
||||
from .product_attribute_link import ProductAttributeLink # noqa: F401
|
||||
from .tax_unit import TaxUnit # noqa: F401
|
||||
from .tax_type import TaxType # noqa: F401
|
||||
from .bank_account import BankAccount # noqa: F401
|
||||
from .cash_register import CashRegister # 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")
|
||||
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ class Document(Base):
|
|||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
fiscal_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("fiscal_years.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
|
@ -30,6 +31,7 @@ class Document(Base):
|
|||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="documents")
|
||||
fiscal_year = relationship("FiscalYear", back_populates="documents")
|
||||
currency = relationship("Currency", back_populates="documents")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ class DocumentLine(Base):
|
|||
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)
|
||||
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)
|
||||
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
@ -26,5 +33,11 @@ class DocumentLine(Base):
|
|||
# Relationships
|
||||
document = relationship("Document", back_populates="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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@ class FiscalYear(Base):
|
|||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="fiscal_years")
|
||||
documents = relationship("Document", back_populates="fiscal_year", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,12 +34,7 @@ class Person(Base):
|
|||
alias_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="نام مستعار (الزامی)")
|
||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
|
||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام خانوادگی")
|
||||
person_type: Mapped[PersonType] = mapped_column(
|
||||
SQLEnum(PersonType, values_callable=lambda obj: [e.value for e in obj], name="person_type_enum"),
|
||||
nullable=False,
|
||||
comment="نوع شخص"
|
||||
)
|
||||
person_types: Mapped[str | None] = mapped_column(Text, nullable=True, comment="لیست انواع شخص به صورت JSON")
|
||||
person_types: Mapped[str] = mapped_column(Text, nullable=False, comment="لیست انواع شخص به صورت JSON")
|
||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
|
||||
payment_id: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="شناسه پرداخت")
|
||||
# سهام
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ class Product(Base):
|
|||
category_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
# واحدها
|
||||
main_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
main_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد اصلی شمارش")
|
||||
secondary_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد فرعی شمارش")
|
||||
unit_conversion_factor: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True)
|
||||
|
||||
# قیمتهای پایه (نمایشی)
|
||||
|
|
|
|||
24
hesabixAPI/adapters/db/models/tax_type.py
Normal file
24
hesabixAPI/adapters/db/models/tax_type.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
||||
class TaxType(Base):
|
||||
"""
|
||||
موجودیت نوع مالیات
|
||||
- نگهداری انواع مالیات استاندارد سازمان امور مالیاتی
|
||||
- عمومی برای همه کسبوکارها (بدون وابستگی به کسبوکار خاص)
|
||||
- مثال: ارزش افزوده گروه «دارو»، «دخانیات» و ...
|
||||
"""
|
||||
|
||||
__tablename__ = "tax_types"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, comment="عنوان نوع مالیات")
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True, comment="کد یکتا برای نوع مالیات")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات")
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, Text, Numeric
|
||||
from sqlalchemy import String, Integer, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from adapters.db.session import Base
|
||||
|
||||
|
|
@ -14,11 +14,8 @@ class TaxUnit(Base):
|
|||
__tablename__ = "tax_units"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True, comment="شناسه کسبوکار")
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="توضیحات")
|
||||
tax_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="نرخ مالیات (درصد)")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="وضعیت فعال/غیرفعال")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -34,4 +34,18 @@ class FiscalYearRepository(BaseRepository[FiscalYear]):
|
|||
self.db.refresh(fiscal_year)
|
||||
return fiscal_year
|
||||
|
||||
def list_by_business(self, business_id: int) -> list[FiscalYear]:
|
||||
"""لیست سالهای مالی یک کسبوکار بر اساس business_id"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(FiscalYear).where(FiscalYear.business_id == business_id).order_by(FiscalYear.start_date.desc())
|
||||
return list(self.db.execute(stmt).scalars().all())
|
||||
|
||||
def get_current_for_business(self, business_id: int) -> FiscalYear | None:
|
||||
"""دریافت سال مالی جاری یک کسب و کار (بر اساس is_last)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(FiscalYear).where(FiscalYear.business_id == business_id, FiscalYear.is_last == True) # noqa: E712
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ class ProductRepository(BaseRepository[Product]):
|
|||
"name": p.name,
|
||||
"description": p.description,
|
||||
"category_id": p.category_id,
|
||||
"main_unit_id": p.main_unit_id,
|
||||
"secondary_unit_id": p.secondary_unit_id,
|
||||
"main_unit": p.main_unit,
|
||||
"secondary_unit": p.secondary_unit,
|
||||
"unit_conversion_factor": p.unit_conversion_factor,
|
||||
"base_sales_price": p.base_sales_price,
|
||||
"base_sales_note": p.base_sales_note,
|
||||
|
|
@ -125,9 +125,14 @@ class ProductRepository(BaseRepository[Product]):
|
|||
obj = self.db.get(Product, product_id)
|
||||
if not obj:
|
||||
return None
|
||||
# اجازه بده فیلدهای خاص حتی اگر None باشند هم ست شوند
|
||||
nullable_overrides = {"main_unit_id", "secondary_unit_id", "unit_conversion_factor"}
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k) and v is not None:
|
||||
setattr(obj, k, v)
|
||||
if hasattr(obj, k):
|
||||
if k in nullable_overrides:
|
||||
setattr(obj, k, v)
|
||||
elif v is not None:
|
||||
setattr(obj, k, v)
|
||||
self.db.commit()
|
||||
self.db.refresh(obj)
|
||||
return obj
|
||||
|
|
|
|||
|
|
@ -212,3 +212,10 @@ def require_business_management_dep(auth_context: AuthContext = Depends(get_curr
|
|||
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
|
||||
if not auth_context.has_app_permission("business_management"):
|
||||
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
|
||||
|
||||
|
||||
def require_business_access_dep(auth_context: AuthContext = Depends(get_current_user)) -> None:
|
||||
"""FastAPI dependency برای بررسی دسترسی به کسب و کار."""
|
||||
# در اینجا میتوانید منطق بررسی دسترسی به کسب و کار را پیادهسازی کنید
|
||||
# برای مثال: بررسی اینکه آیا کاربر دسترسی به کسب و کار دارد
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import HTTPException, status, Request
|
||||
from .calendar import CalendarConverter, CalendarType
|
||||
|
|
@ -57,6 +57,22 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
|
|||
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
|
||||
else:
|
||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
||||
elif isinstance(value, date):
|
||||
# Convert date to datetime for processing
|
||||
dt_value = datetime.combine(value, datetime.min.time())
|
||||
# Format the main date field based on calendar type
|
||||
if calendar_type == "jalali":
|
||||
formatted_data[key] = CalendarConverter.to_jalali(dt_value)["date_only"]
|
||||
else:
|
||||
formatted_data[key] = value.isoformat()
|
||||
|
||||
# Add formatted date as additional field
|
||||
formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(dt_value, calendar_type)
|
||||
# Convert raw date to the same calendar type as the formatted date
|
||||
if calendar_type == "jalali":
|
||||
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(dt_value)["date_only"]
|
||||
else:
|
||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
||||
elif isinstance(value, (dict, list)):
|
||||
formatted_data[key] = format_datetime_fields(value, request)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ from adapters.api.v1.categories import router as categories_router
|
|||
from adapters.api.v1.product_attributes import router as product_attributes_router
|
||||
from adapters.api.v1.products import router as products_router
|
||||
from adapters.api.v1.price_lists import router as price_lists_router
|
||||
from adapters.api.v1.invoices import router as invoices_router
|
||||
from adapters.api.v1.persons import router as persons_router
|
||||
from adapters.api.v1.customers import router as customers_router
|
||||
from adapters.api.v1.bank_accounts import router as bank_accounts_router
|
||||
from adapters.api.v1.cash_registers import router as cash_registers_router
|
||||
from adapters.api.v1.petty_cash import router as petty_cash_router
|
||||
from adapters.api.v1.tax_units import router as tax_units_router
|
||||
from adapters.api.v1.tax_units import alias_router as units_alias_router
|
||||
from adapters.api.v1.tax_types import router as tax_types_router
|
||||
from adapters.api.v1.support.tickets import router as support_tickets_router
|
||||
from adapters.api.v1.support.operator import router as support_operator_router
|
||||
|
|
@ -29,6 +30,8 @@ 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.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.receipts_payments import router as receipts_payments_router
|
||||
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||
from app.core.i18n import negotiate_locale, Translator
|
||||
from app.core.error_handlers import register_error_handlers
|
||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||
|
|
@ -294,13 +297,18 @@ def create_app() -> FastAPI:
|
|||
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(products_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(price_lists_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(invoices_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(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(petty_cash_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
|
||||
application.include_router(units_alias_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)
|
||||
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
# Support endpoints
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -68,7 +68,6 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
|
|||
first_name=person_data.first_name,
|
||||
last_name=person_data.last_name,
|
||||
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
|
||||
person_type=(mapped_single_type or (PersonType(types_list[0]) if types_list else PersonType.CUSTOMER)),
|
||||
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
|
||||
company_name=person_data.company_name,
|
||||
payment_id=person_data.payment_id,
|
||||
|
|
@ -198,14 +197,6 @@ def get_persons_by_business(
|
|||
query = query.filter(Person.code.in_(value))
|
||||
continue
|
||||
|
||||
# نوع شخص تکانتخابی
|
||||
if field == 'person_type':
|
||||
if operator == '=':
|
||||
query = query.filter(Person.person_type == value)
|
||||
elif operator == 'in' and isinstance(value, list):
|
||||
query = query.filter(Person.person_type.in_(value))
|
||||
continue
|
||||
|
||||
# انواع شخص چندانتخابی (رشته JSON)
|
||||
if field == 'person_types':
|
||||
if operator == '=' and isinstance(value, str):
|
||||
|
|
@ -295,8 +286,7 @@ def get_persons_by_business(
|
|||
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
|
||||
elif sort_by == 'last_name':
|
||||
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
|
||||
elif sort_by == 'person_type':
|
||||
query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
|
||||
# person_type sorting removed - use person_types instead
|
||||
elif sort_by == 'created_at':
|
||||
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
||||
elif sort_by == 'updated_at':
|
||||
|
|
@ -367,23 +357,7 @@ def update_person(
|
|||
types_list = [t.value if hasattr(t, 'value') else str(t) for t in incoming]
|
||||
person.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
|
||||
# همگام کردن person_type تکی برای سازگاری
|
||||
if types_list:
|
||||
# مقدار Enum را با مقدار فارسی ست میکنیم
|
||||
try:
|
||||
person.person_type = PersonType(types_list[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# مدیریت person_type تکی از اسکیما
|
||||
if 'person_type' in update_data and update_data['person_type'] is not None:
|
||||
single_type = update_data['person_type']
|
||||
# نگاشت به Enum (مقدار فارسی)
|
||||
try:
|
||||
person.person_type = PersonType(getattr(single_type, 'value', str(single_type)))
|
||||
except Exception:
|
||||
pass
|
||||
# پس از ست کردن مستقیم، از دیکشنری حذف شود تا در حلقه عمومی دوباره اعمال نشود
|
||||
update_data.pop('person_type', None)
|
||||
# person_type handling removed - only person_types is used now
|
||||
|
||||
# اگر شخص سهامدار شد، share_count معتبر باشد
|
||||
resulting_types: List[str] = []
|
||||
|
|
@ -394,7 +368,7 @@ def update_person(
|
|||
resulting_types = [str(x) for x in tmp]
|
||||
except Exception:
|
||||
resulting_types = []
|
||||
if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
|
||||
if 'سهامدار' in resulting_types:
|
||||
sc_val2 = update_data.get('share_count', person.share_count)
|
||||
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
||||
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
||||
|
|
@ -442,7 +416,7 @@ def get_person_summary(db: Session, business_id: int) -> Dict[str, Any]:
|
|||
by_type = {}
|
||||
for person_type in PersonType:
|
||||
count = db.query(Person).filter(
|
||||
and_(Person.business_id == business_id, Person.person_type == person_type)
|
||||
and_(Person.business_id == business_id, Person.person_types.ilike(f'%"{person_type.value}"%'))
|
||||
).count()
|
||||
by_type[person_type.value] = count
|
||||
|
||||
|
|
@ -473,7 +447,6 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
|||
'alias_name': person.alias_name,
|
||||
'first_name': person.first_name,
|
||||
'last_name': person.last_name,
|
||||
'person_type': person.person_type.value,
|
||||
'person_types': types_list,
|
||||
'company_name': person.company_name,
|
||||
'payment_id': person.payment_id,
|
||||
|
|
@ -514,3 +487,51 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
|||
for ba in person.bank_accounts
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def search_persons(db: Session, business_id: int, search_query: Optional[str] = None,
|
||||
page: int = 1, limit: int = 20) -> List[Person]:
|
||||
"""جستوجو در اشخاص"""
|
||||
query = db.query(Person).filter(Person.business_id == business_id)
|
||||
|
||||
if search_query:
|
||||
# جستوجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
|
||||
search_filter = or_(
|
||||
Person.alias_name.ilike(f"%{search_query}%"),
|
||||
Person.first_name.ilike(f"%{search_query}%"),
|
||||
Person.last_name.ilike(f"%{search_query}%"),
|
||||
Person.company_name.ilike(f"%{search_query}%"),
|
||||
Person.phone.ilike(f"%{search_query}%"),
|
||||
Person.mobile.ilike(f"%{search_query}%"),
|
||||
Person.email.ilike(f"%{search_query}%"),
|
||||
Person.code == int(search_query) if search_query.isdigit() else False
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
# مرتبسازی بر اساس نام مستعار
|
||||
query = query.order_by(Person.alias_name)
|
||||
|
||||
# صفحهبندی
|
||||
offset = (page - 1) * limit
|
||||
return query.offset(offset).limit(limit).all()
|
||||
|
||||
|
||||
def count_persons(db: Session, business_id: int, search_query: Optional[str] = None) -> int:
|
||||
"""شمارش تعداد اشخاص"""
|
||||
query = db.query(Person).filter(Person.business_id == business_id)
|
||||
|
||||
if search_query:
|
||||
# جستوجو در نام، نام خانوادگی، نام مستعار، کد، تلفن و ایمیل
|
||||
search_filter = or_(
|
||||
Person.alias_name.ilike(f"%{search_query}%"),
|
||||
Person.first_name.ilike(f"%{search_query}%"),
|
||||
Person.last_name.ilike(f"%{search_query}%"),
|
||||
Person.company_name.ilike(f"%{search_query}%"),
|
||||
Person.phone.ilike(f"%{search_query}%"),
|
||||
Person.mobile.ilike(f"%{search_query}%"),
|
||||
Person.email.ilike(f"%{search_query}%"),
|
||||
Person.code == int(search_query) if search_query.isdigit() else False
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
return query.count()
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def upsert_price_item(db: Session, business_id: int, price_list_id: int, payload
|
|||
if not pr or pr.business_id != business_id:
|
||||
raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
|
||||
# اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده
|
||||
if payload.unit_id is not None and payload.unit_id not in [pr.main_unit_id, pr.secondary_unit_id]:
|
||||
if payload.unit_id is not None and payload.unit_id not in [pr.main_unit, pr.secondary_unit]:
|
||||
raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400)
|
||||
|
||||
repo = PriceItemRepository(db)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, and_, func
|
||||
from decimal import Decimal
|
||||
|
|
@ -39,9 +39,20 @@ def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None:
|
||||
if secondary_unit_id and not factor:
|
||||
def _validate_units(main_unit: Optional[str], secondary_unit: Optional[str], factor: Optional[Decimal]) -> None:
|
||||
if secondary_unit and not factor:
|
||||
raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400)
|
||||
def _validate_unit_string(unit: Optional[str]) -> Optional[str]:
|
||||
"""Validate and clean unit string"""
|
||||
if unit is None:
|
||||
return None
|
||||
cleaned = str(unit).strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if len(cleaned) > 32:
|
||||
raise ApiError("INVALID_UNIT_LENGTH", "واحد شمارش نمیتواند بیش از 32 کاراکتر باشد", http_status=400)
|
||||
return cleaned
|
||||
|
||||
|
||||
|
||||
def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute_ids: Optional[List[int]]) -> None:
|
||||
|
|
@ -64,7 +75,10 @@ def _upsert_attributes(db: Session, product_id: int, business_id: int, attribute
|
|||
def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]:
|
||||
repo = ProductRepository(db)
|
||||
_validate_tax(payload)
|
||||
_validate_units(payload.main_unit_id, payload.secondary_unit_id, payload.unit_conversion_factor)
|
||||
# Validate and clean unit strings
|
||||
main_unit = _validate_unit_string(payload.main_unit)
|
||||
secondary_unit = _validate_unit_string(payload.secondary_unit)
|
||||
_validate_units(main_unit, secondary_unit, payload.unit_conversion_factor)
|
||||
|
||||
code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None
|
||||
if code:
|
||||
|
|
@ -81,8 +95,8 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
|
|||
name=payload.name.strip(),
|
||||
description=payload.description,
|
||||
category_id=payload.category_id,
|
||||
main_unit_id=payload.main_unit_id,
|
||||
secondary_unit_id=payload.secondary_unit_id,
|
||||
main_unit=main_unit,
|
||||
secondary_unit=secondary_unit,
|
||||
unit_conversion_factor=payload.unit_conversion_factor,
|
||||
base_sales_price=payload.base_sales_price,
|
||||
base_sales_note=payload.base_sales_note,
|
||||
|
|
@ -103,7 +117,14 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
|
|||
|
||||
_upsert_attributes(db, obj.id, business_id, payload.attribute_ids)
|
||||
|
||||
return {"message": "PRODUCT_CREATED", "data": _to_dict(obj)}
|
||||
data = _to_dict(obj)
|
||||
# enrich titles from payload if provided
|
||||
if getattr(payload, 'main_unit_title', None):
|
||||
data["main_unit_title"] = str(getattr(payload, 'main_unit_title'))
|
||||
if getattr(payload, 'secondary_unit_title', None):
|
||||
data["secondary_unit_title"] = str(getattr(payload, 'secondary_unit_title'))
|
||||
|
||||
return {"message": "PRODUCT_CREATED", "data": data}
|
||||
|
||||
|
||||
def list_products(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -144,9 +165,13 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
|
|||
raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
|
||||
|
||||
_validate_tax(payload)
|
||||
_validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id,
|
||||
payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id,
|
||||
payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor)
|
||||
# از فیلدهای explicitly-set برای تشخیص پاکسازی (None) استفاده کن
|
||||
fields_set = getattr(payload, 'model_fields_set', getattr(payload, '__fields_set__', set()))
|
||||
# Validate and clean unit strings
|
||||
main_unit_val = (_validate_unit_string(payload.main_unit) if 'main_unit' in fields_set else obj.main_unit)
|
||||
secondary_unit_val = (_validate_unit_string(payload.secondary_unit) if 'secondary_unit' in fields_set else obj.secondary_unit)
|
||||
factor_val = payload.unit_conversion_factor if 'unit_conversion_factor' in fields_set else obj.unit_conversion_factor
|
||||
_validate_units(main_unit_val, secondary_unit_val, factor_val)
|
||||
|
||||
updated = repo.update(
|
||||
product_id,
|
||||
|
|
@ -155,8 +180,8 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
|
|||
name=payload.name.strip() if isinstance(payload.name, str) else None,
|
||||
description=payload.description,
|
||||
category_id=payload.category_id,
|
||||
main_unit_id=payload.main_unit_id,
|
||||
secondary_unit_id=payload.secondary_unit_id,
|
||||
main_unit=main_unit_val if 'main_unit' in fields_set else None,
|
||||
secondary_unit=secondary_unit_val if 'secondary_unit' in fields_set else None,
|
||||
unit_conversion_factor=payload.unit_conversion_factor,
|
||||
base_sales_price=payload.base_sales_price,
|
||||
base_sales_note=payload.base_sales_note,
|
||||
|
|
@ -178,7 +203,8 @@ def update_product(db: Session, product_id: int, business_id: int, payload: Prod
|
|||
return None
|
||||
|
||||
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
|
||||
return {"message": "PRODUCT_UPDATED", "data": _to_dict(updated)}
|
||||
data = _to_dict(updated)
|
||||
return {"message": "PRODUCT_UPDATED", "data": data}
|
||||
|
||||
|
||||
def delete_product(db: Session, product_id: int, business_id: int) -> bool:
|
||||
|
|
@ -198,8 +224,8 @@ def _to_dict(obj: Product) -> Dict[str, Any]:
|
|||
"name": obj.name,
|
||||
"description": obj.description,
|
||||
"category_id": obj.category_id,
|
||||
"main_unit_id": obj.main_unit_id,
|
||||
"secondary_unit_id": obj.secondary_unit_id,
|
||||
"main_unit": obj.main_unit,
|
||||
"secondary_unit": obj.secondary_unit,
|
||||
"unit_conversion_factor": obj.unit_conversion_factor,
|
||||
"base_sales_price": obj.base_sales_price,
|
||||
"base_sales_note": obj.base_sales_note,
|
||||
|
|
|
|||
1143
hesabixAPI/app/services/receipt_payment_service.py
Normal file
1143
hesabixAPI/app/services/receipt_payment_service.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,13 +11,17 @@ adapters/api/v1/business_users.py
|
|||
adapters/api/v1/businesses.py
|
||||
adapters/api/v1/cash_registers.py
|
||||
adapters/api/v1/categories.py
|
||||
adapters/api/v1/checks.py
|
||||
adapters/api/v1/currencies.py
|
||||
adapters/api/v1/customers.py
|
||||
adapters/api/v1/health.py
|
||||
adapters/api/v1/invoices.py
|
||||
adapters/api/v1/persons.py
|
||||
adapters/api/v1/petty_cash.py
|
||||
adapters/api/v1/price_lists.py
|
||||
adapters/api/v1/product_attributes.py
|
||||
adapters/api/v1/products.py
|
||||
adapters/api/v1/receipts_payments.py
|
||||
adapters/api/v1/schemas.py
|
||||
adapters/api/v1/tax_types.py
|
||||
adapters/api/v1/tax_units.py
|
||||
|
|
@ -27,6 +31,8 @@ adapters/api/v1/admin/file_storage.py
|
|||
adapters/api/v1/schema_models/__init__.py
|
||||
adapters/api/v1/schema_models/account.py
|
||||
adapters/api/v1/schema_models/bank_account.py
|
||||
adapters/api/v1/schema_models/check.py
|
||||
adapters/api/v1/schema_models/document_line.py
|
||||
adapters/api/v1/schema_models/email.py
|
||||
adapters/api/v1/schema_models/file_storage.py
|
||||
adapters/api/v1/schema_models/person.py
|
||||
|
|
@ -51,6 +57,7 @@ adapters/db/models/business_permission.py
|
|||
adapters/db/models/captcha.py
|
||||
adapters/db/models/cash_register.py
|
||||
adapters/db/models/category.py
|
||||
adapters/db/models/check.py
|
||||
adapters/db/models/currency.py
|
||||
adapters/db/models/document.py
|
||||
adapters/db/models/document_line.py
|
||||
|
|
@ -64,6 +71,7 @@ adapters/db/models/price_list.py
|
|||
adapters/db/models/product.py
|
||||
adapters/db/models/product_attribute.py
|
||||
adapters/db/models/product_attribute_link.py
|
||||
adapters/db/models/tax_type.py
|
||||
adapters/db/models/tax_unit.py
|
||||
adapters/db/models/user.py
|
||||
adapters/db/models/support/__init__.py
|
||||
|
|
@ -116,6 +124,7 @@ app/services/business_dashboard_service.py
|
|||
app/services/business_service.py
|
||||
app/services/captcha_service.py
|
||||
app/services/cash_register_service.py
|
||||
app/services/check_service.py
|
||||
app/services/email_service.py
|
||||
app/services/file_storage_service.py
|
||||
app/services/person_service.py
|
||||
|
|
@ -124,6 +133,7 @@ app/services/price_list_service.py
|
|||
app/services/product_attribute_service.py
|
||||
app/services/product_service.py
|
||||
app/services/query_service.py
|
||||
app/services/receipt_payment_service.py
|
||||
app/services/pdf/__init__.py
|
||||
app/services/pdf/base_pdf_service.py
|
||||
app/services/pdf/modules/__init__.py
|
||||
|
|
@ -137,6 +147,10 @@ hesabix_api.egg-info/top_level.txt
|
|||
migrations/env.py
|
||||
migrations/versions/1f0abcdd7300_add_petty_cash_table.py
|
||||
migrations/versions/20250102_000001_seed_support_data.py
|
||||
migrations/versions/20250106_000001_fix_tax_types_structure.py
|
||||
migrations/versions/20250106_000002_remove_tax_fields.py
|
||||
migrations/versions/20250106_000003_cleanup_tax_units_table.py
|
||||
migrations/versions/20250106_000004_seed_tax_units_list.py
|
||||
migrations/versions/20250117_000003_add_business_table.py
|
||||
migrations/versions/20250117_000004_add_business_contact_fields.py
|
||||
migrations/versions/20250117_000005_add_business_geographic_fields.py
|
||||
|
|
@ -148,12 +162,12 @@ migrations/versions/20250120_000001_add_persons_tables.py
|
|||
migrations/versions/20250120_000002_add_join_permission.py
|
||||
migrations/versions/20250915_000001_init_auth_tables.py
|
||||
migrations/versions/20250916_000002_add_referral_fields.py
|
||||
migrations/versions/20250926_000010_add_person_code_and_types.py
|
||||
migrations/versions/20250926_000011_drop_person_is_active.py
|
||||
migrations/versions/20250927_000012_add_fiscal_years_table.py
|
||||
migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
|
||||
migrations/versions/20250927_000014_add_documents_table.py
|
||||
migrations/versions/20250927_000015_add_document_lines_table.py
|
||||
migrations/versions/20250926_000010_add_person_code.py
|
||||
migrations/versions/20250926_000011_drop_active.py
|
||||
migrations/versions/20250927_000012_add_fiscal_years.py
|
||||
migrations/versions/20250927_000013_add_currencies.py
|
||||
migrations/versions/20250927_000014_add_documents.py
|
||||
migrations/versions/20250927_000015_add_lines.py
|
||||
migrations/versions/20250927_000016_add_accounts_table.py
|
||||
migrations/versions/20250927_000017_add_account_id_to_document_lines.py
|
||||
migrations/versions/20250927_000018_seed_currencies.py
|
||||
|
|
@ -173,10 +187,23 @@ migrations/versions/20251001_001201_merge_heads_drop_currency_tax_units.py
|
|||
migrations/versions/20251002_000101_add_bank_accounts_table.py
|
||||
migrations/versions/20251003_000201_add_cash_registers_table.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/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/20251014_000201_add_person_id_to_document_lines.py
|
||||
migrations/versions/20251014_000301_add_product_id_to_document_lines.py
|
||||
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
|
||||
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
|
||||
migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||
migrations/versions/7ecb63029764_merge_heads.py
|
||||
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
||||
migrations/versions/a1443c153b47_merge_heads.py
|
||||
migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py
|
||||
migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py
|
||||
migrations/versions/c302bc2f2cb8_remove_person_type_column.py
|
||||
migrations/versions/caf3f4ef4b76_add_tax_units_table.py
|
||||
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
||||
migrations/versions/f876bfa36805_merge_multiple_heads.py
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -399,3 +399,35 @@ msgstr "Storage connection test - to be implemented"
|
|||
|
||||
msgid "TEST_STORAGE_CONFIG_ERROR"
|
||||
msgstr "Error testing storage connection"
|
||||
|
||||
# Receipts & Payments
|
||||
msgid "RECEIPTS_PAYMENTS_LIST_FETCHED"
|
||||
msgstr "Receipts & payments list retrieved successfully"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_CREATED"
|
||||
msgstr "Receipt/Payment created successfully"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_DETAILS"
|
||||
msgstr "Receipt/Payment details"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_DELETED"
|
||||
msgstr "Receipt/Payment deleted successfully"
|
||||
|
||||
# Common errors for receipts/payments
|
||||
msgid "DOCUMENT_NOT_FOUND"
|
||||
msgstr "Document not found"
|
||||
|
||||
msgid "FORBIDDEN"
|
||||
msgstr "Access denied"
|
||||
|
||||
msgid "FISCAL_YEAR_LOCKED"
|
||||
msgstr "Document does not belong to the current fiscal year and cannot be deleted"
|
||||
|
||||
msgid "DOCUMENT_LOCKED"
|
||||
msgstr "This document is locked and cannot be deleted"
|
||||
|
||||
msgid "DOCUMENT_REFERENCED"
|
||||
msgstr "This document has dependencies (e.g., check) and cannot be deleted"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_UPDATED"
|
||||
msgstr "Receipt/Payment updated successfully"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -422,3 +422,35 @@ msgid "TEST_STORAGE_CONFIG_ERROR"
|
|||
msgstr "خطا در تست اتصال"
|
||||
|
||||
|
||||
# Receipts & Payments
|
||||
msgid "RECEIPTS_PAYMENTS_LIST_FETCHED"
|
||||
msgstr "لیست اسناد دریافت و پرداخت با موفقیت دریافت شد"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_CREATED"
|
||||
msgstr "سند دریافت/پرداخت با موفقیت ایجاد شد"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_DETAILS"
|
||||
msgstr "جزئیات سند دریافت/پرداخت"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_DELETED"
|
||||
msgstr "سند دریافت/پرداخت با موفقیت حذف شد"
|
||||
|
||||
# Common errors for receipts/payments
|
||||
msgid "DOCUMENT_NOT_FOUND"
|
||||
msgstr "سند یافت نشد"
|
||||
|
||||
msgid "FORBIDDEN"
|
||||
msgstr "دسترسی مجاز نیست"
|
||||
|
||||
msgid "FISCAL_YEAR_LOCKED"
|
||||
msgstr "سند متعلق به سال مالی جاری نیست و قابل حذف نمیباشد"
|
||||
|
||||
msgid "DOCUMENT_LOCKED"
|
||||
msgstr "این سند قفل است و قابل حذف نمیباشد"
|
||||
|
||||
msgid "DOCUMENT_REFERENCED"
|
||||
msgstr "این سند دارای وابستگی (مانند چک) است و قابل حذف نیست"
|
||||
|
||||
msgid "RECEIPT_PAYMENT_UPDATED"
|
||||
msgstr "سند دریافت/پرداخت با موفقیت ویرایش شد"
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,17 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('petty_cash',
|
||||
# Check if table already exists
|
||||
connection = op.get_bind()
|
||||
result = connection.execute(sa.text("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'petty_cash'
|
||||
""")).fetchone()
|
||||
|
||||
if result[0] == 0:
|
||||
op.create_table('petty_cash',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
|
||||
|
|
@ -33,11 +42,11 @@ def upgrade() -> None:
|
|||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
|
||||
)
|
||||
op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
|
||||
)
|
||||
op.create_index(op.f('ix_petty_cash_business_id'), 'petty_cash', ['business_id'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_code'), 'petty_cash', ['code'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_currency_id'), 'petty_cash', ['currency_id'], unique=False)
|
||||
op.create_index(op.f('ix_petty_cash_name'), 'petty_cash', ['name'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
"""fix tax_types structure - remove business_id and make it global
|
||||
|
||||
Revision ID: 20250106_000001
|
||||
Revises: 20251006_000001
|
||||
Create Date: 2025-01-06 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250106_000001'
|
||||
down_revision = '20251006_000001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# First, clear existing data to avoid conflicts
|
||||
op.execute("DELETE FROM tax_types")
|
||||
|
||||
# Drop the business_id column (if it exists)
|
||||
try:
|
||||
op.drop_column('tax_types', 'business_id')
|
||||
except Exception:
|
||||
pass # Column doesn't exist
|
||||
|
||||
# Make code column NOT NULL and UNIQUE
|
||||
try:
|
||||
op.alter_column('tax_types', 'code',
|
||||
existing_type=sa.String(length=64),
|
||||
nullable=False)
|
||||
except Exception:
|
||||
pass # Already NOT NULL
|
||||
|
||||
try:
|
||||
op.create_unique_constraint('uq_tax_types_code', 'tax_types', ['code'])
|
||||
except Exception:
|
||||
pass # Constraint already exists
|
||||
|
||||
# Add tax_rate column (if it doesn't exist)
|
||||
try:
|
||||
op.add_column('tax_types', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)'))
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
# Drop the old business_id index (if it exists)
|
||||
try:
|
||||
op.drop_index('ix_tax_types_business_id', table_name='tax_types')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add business_id column back
|
||||
op.add_column('tax_types', sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسبوکار'))
|
||||
|
||||
# Remove unique constraint on code
|
||||
op.drop_constraint('uq_tax_types_code', 'tax_types', type_='unique')
|
||||
|
||||
# Make code nullable again
|
||||
op.alter_column('tax_types', 'code', nullable=True)
|
||||
|
||||
# Remove tax_rate column
|
||||
op.drop_column('tax_types', 'tax_rate')
|
||||
|
||||
# Recreate business_id index
|
||||
op.create_index('ix_tax_types_business_id', 'tax_types', ['business_id'], unique=False)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""remove is_active and tax_rate fields from tax_types
|
||||
|
||||
Revision ID: 20250106_000002
|
||||
Revises: 20250106_000001
|
||||
Create Date: 2025-01-06 12:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250106_000002'
|
||||
down_revision = '20250106_000001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Remove is_active column (if it exists)
|
||||
try:
|
||||
op.drop_column('tax_types', 'is_active')
|
||||
except Exception:
|
||||
pass # Column doesn't exist
|
||||
|
||||
# Remove tax_rate column (if it exists)
|
||||
try:
|
||||
op.drop_column('tax_types', 'tax_rate')
|
||||
except Exception:
|
||||
pass # Column doesn't exist
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add tax_rate column back
|
||||
op.add_column('tax_types', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)'))
|
||||
|
||||
# Add is_active column back
|
||||
op.add_column('tax_types', sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'))
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"""cleanup tax_units table: drop business_id, tax_rate, is_active
|
||||
|
||||
Revision ID: 20250106_000003
|
||||
Revises: 7891282548e9
|
||||
Create Date: 2025-10-06 12:55:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250106_000003'
|
||||
down_revision = '7891282548e9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop columns if exist (idempotent behavior)
|
||||
try:
|
||||
op.drop_index('ix_tax_units_business_id', table_name='tax_units')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for col in ('business_id', 'tax_rate', 'is_active'):
|
||||
try:
|
||||
op.drop_column('tax_units', col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate columns (best-effort)
|
||||
try:
|
||||
op.add_column('tax_units', sa.Column('business_id', sa.Integer(), nullable=False, comment='شناسه کسبوکار'))
|
||||
op.create_index('ix_tax_units_business_id', 'tax_units', ['business_id'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
op.add_column('tax_units', sa.Column('tax_rate', sa.Numeric(5, 2), nullable=True, comment='نرخ مالیات (درصد)'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
op.add_column('tax_units', sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""seed standard measurement units into tax_units
|
||||
|
||||
Revision ID: 20250106_000004
|
||||
Revises: 20250106_000003
|
||||
Create Date: 2025-10-06 13:10:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250106_000004'
|
||||
down_revision = '20250106_000003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
UNIT_NAMES = [
|
||||
"بانكه", "برگ", "بسته", "بشكه", "بطری", "بندیل", "پاکت", "پالت", "تانكر", "تخته",
|
||||
"تن", "تن کیلومتر", "توپ", "تیوب", "ثانیه", "ثوب", "جام", "جعبه", "جفت", "جلد",
|
||||
"چلیك", "حلب", "حلقه (رول)", "حلقه (دیسک)", "حلقه (رینگ)", "دبه", "دست", "دستگاه",
|
||||
"دقیقه", "دوجین", "روز", "رول", "ساشه", "ساعت", "سال", "سانتی متر",
|
||||
"سانتی متر مربع", "سبد", "ست", "سطل", "سیلندر", "شاخه", "شانه", "شعله", "شیت",
|
||||
"صفحه", "طاقه", "طغرا", "عدد", "عدل", "فاقد بسته بندی", "فروند", "فوت مربع", "قالب",
|
||||
"قراص", "قراصه (bundle)", "قرقره", "قطعه", "قوطي", "قیراط", "کارتن",
|
||||
"کارتن (master case)", "کلاف", "کپسول", "کیسه", "کیلوگرم", "کیلومتر", "کیلووات ساعت",
|
||||
"گالن", "گرم", "گیگابایت بر ثانیه", "لنگه", "لیتر", "لیوان", "ماه", "متر",
|
||||
"متر مربع", "متر مكعب", "مخزن", "مگاوات ساعت", "میلي گرم", "میلي لیتر", "میلي متر",
|
||||
"نخ", "نسخه (جلد)", "نفر", "نفر- ساعت", "نوبت", "نیم دوجین", "واحد", "ورق", "ویال",
|
||||
]
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
# Create a simple ASCII-ish code: replace spaces and special chars with underscore, keep letters/numbers
|
||||
code = name
|
||||
for ch in [' ', '-', '(', ')', '–', 'ـ', '،', '/', '\\']:
|
||||
code = code.replace(ch, '_')
|
||||
code = code.replace('', '_') # zero-width non-joiner
|
||||
# collapse underscores
|
||||
while '__' in code:
|
||||
code = code.replace('__', '_')
|
||||
return code.strip('_').upper()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Insert units if not already present (by code)
|
||||
for name in UNIT_NAMES:
|
||||
code = _slugify(name)
|
||||
exists = conn.execute(sa.text("SELECT id FROM tax_units WHERE code = :code LIMIT 1"), {"code": code}).fetchone()
|
||||
if not exists:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tax_units (name, code, description, created_at, updated_at)
|
||||
VALUES (:name, :code, :description, NOW(), NOW())
|
||||
"""
|
||||
),
|
||||
{"name": name, "code": code, "description": None},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# Remove only units we added (by code set)
|
||||
codes = [_slugify(n) for n in UNIT_NAMES]
|
||||
conn.execute(
|
||||
sa.text("DELETE FROM tax_units WHERE code IN :codes"),
|
||||
{"codes": tuple(codes)},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ import sqlalchemy as sa
|
|||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250926_000010_add_person_code_and_types'
|
||||
revision = '20250926_000010_add_person_code'
|
||||
down_revision = '20250916_000002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
|
@ -3,8 +3,8 @@ import sqlalchemy as sa
|
|||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250926_000011_drop_person_is_active'
|
||||
down_revision = '20250926_000010_add_person_code_and_types'
|
||||
revision = '20250926_000011_drop_active'
|
||||
down_revision = '20250926_000010_add_person_code'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
@ -6,8 +6,8 @@ from sqlalchemy import inspect
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000012_add_fiscal_years_table'
|
||||
down_revision = '20250926_000011_drop_person_is_active'
|
||||
revision = '20250927_000012_add_fiscal_years'
|
||||
down_revision = '20250926_000011_drop_active'
|
||||
branch_labels = None
|
||||
depends_on = ('20250117_000003',)
|
||||
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000013_add_currencies'
|
||||
down_revision = '20250927_000012_add_fiscal_years'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Create currencies table if it doesn't exist
|
||||
if 'currencies' not in tables:
|
||||
op.create_table(
|
||||
'currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||
sa.Column('code', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique constraints and indexes
|
||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||
|
||||
# Create business_currencies association table if it doesn't exist
|
||||
if 'business_currencies' not in tables:
|
||||
op.create_table(
|
||||
'business_currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique and indexes for association
|
||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||
|
||||
# Add default_currency_id to businesses if not exists
|
||||
if 'businesses' in tables:
|
||||
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
||||
if 'default_currency_id' not in cols:
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
||||
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/foreign key/column default_currency_id if exists
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
try:
|
||||
batch_op.drop_index('ix_businesses_default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_column('default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||
op.drop_table('business_currencies')
|
||||
|
||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||
op.drop_table('currencies')
|
||||
|
||||
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
down_revision = '20250927_000012_add_fiscal_years_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create currencies table
|
||||
op.create_table(
|
||||
'currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||
sa.Column('code', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique constraints and indexes
|
||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||
|
||||
# Create business_currencies association table
|
||||
op.create_table(
|
||||
'business_currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
# Add default_currency_id to businesses if not exists
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'businesses' in inspector.get_table_names():
|
||||
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
||||
if 'default_currency_id' not in cols:
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
||||
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
||||
# Unique and indexes for association
|
||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/foreign key/column default_currency_id if exists
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
try:
|
||||
batch_op.drop_index('ix_businesses_default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_column('default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||
op.drop_table('business_currencies')
|
||||
|
||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||
op.drop_table('currencies')
|
||||
|
||||
|
||||
|
|
@ -5,8 +5,8 @@ import sqlalchemy as sa
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000014_add_documents_table'
|
||||
down_revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
revision = '20250927_000014_add_documents'
|
||||
down_revision = '20250927_000013_add_currencies'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000015_add_document_lines_table'
|
||||
down_revision = '20250927_000014_add_documents_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'document_lines',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||
op.drop_table('document_lines')
|
||||
|
||||
|
||||
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000015_add_lines'
|
||||
down_revision = '20250927_000014_add_documents'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Create document_lines table if it doesn't exist
|
||||
if 'document_lines' not in tables:
|
||||
op.create_table(
|
||||
'document_lines',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||
op.drop_table('document_lines')
|
||||
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import sqlalchemy as sa
|
|||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000016_add_accounts_table'
|
||||
down_revision = '20250927_000015_add_document_lines_table'
|
||||
down_revision = '20250927_000015_add_lines'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
"""add tax_types table and ensure product FKs
|
||||
|
||||
Revision ID: 20251006_000001
|
||||
Revises: caf3f4ef4b76
|
||||
Create Date: 2025-10-06 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20251006_000001'
|
||||
down_revision = 'caf3f4ef4b76'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if table already exists before creating it
|
||||
try:
|
||||
op.create_table(
|
||||
'tax_types',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False, index=True, comment='شناسه کسبوکار'),
|
||||
sa.Column('title', sa.String(length=255), nullable=False, comment='عنوان نوع مالیات'),
|
||||
sa.Column('code', sa.String(length=64), nullable=True, comment='کد یکتا برای نوع مالیات'),
|
||||
sa.Column('description', sa.Text(), nullable=True, comment='توضیحات'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1'), comment='وضعیت فعال/غیرفعال'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
|
||||
)
|
||||
except Exception:
|
||||
pass # Table already exists
|
||||
|
||||
# Create indexes (if they don't exist)
|
||||
try:
|
||||
op.create_index(op.f('ix_tax_types_business_id'), 'tax_types', ['business_id'], unique=False)
|
||||
except Exception:
|
||||
pass # Index already exists
|
||||
|
||||
try:
|
||||
op.create_index(op.f('ix_tax_types_code'), 'tax_types', ['code'], unique=False)
|
||||
except Exception:
|
||||
pass # Index already exists
|
||||
|
||||
# Ensure product indices exist (idempotent)
|
||||
try:
|
||||
op.create_index(op.f('ix_products_tax_type_id'), 'products', ['tax_type_id'], unique=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
op.create_index(op.f('ix_products_tax_unit_id'), 'products', ['tax_unit_id'], unique=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
try:
|
||||
op.drop_index(op.f('ix_tax_types_code'), table_name='tax_types')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
op.drop_index(op.f('ix_tax_types_business_id'), table_name='tax_types')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_table('tax_types')
|
||||
|
||||
|
||||
|
|
@ -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,89 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# 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:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Check if document_lines table exists
|
||||
if 'document_lines' not in tables:
|
||||
return
|
||||
|
||||
# Get existing columns
|
||||
cols = {c['name'] for c in inspector.get_columns('document_lines')}
|
||||
|
||||
with op.batch_alter_table('document_lines') as batch_op:
|
||||
# Only add columns if they don't exist
|
||||
if 'bank_account_id' not in cols:
|
||||
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
|
||||
if 'cash_register_id' not in cols:
|
||||
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
|
||||
if 'petty_cash_id' not in cols:
|
||||
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
|
||||
if 'check_id' not in cols:
|
||||
batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True))
|
||||
|
||||
# Only create foreign keys if the referenced tables exist
|
||||
if 'bank_accounts' in tables and 'bank_account_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
|
||||
if 'cash_registers' in tables and 'cash_register_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
|
||||
if 'petty_cash' in tables and 'petty_cash_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL')
|
||||
if 'checks' in tables and 'check_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
# Only create indexes if columns were added
|
||||
if 'bank_account_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
|
||||
if 'cash_register_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
|
||||
if 'petty_cash_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
|
||||
if 'check_id' not in cols:
|
||||
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')
|
||||
|
||||
# Try to drop foreign keys, ignore if they don't exist
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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')
|
||||
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ Create Date: 2025-09-20 14:02:19.543853
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5553f8745c6e'
|
||||
|
|
@ -18,87 +19,104 @@ depends_on = None
|
|||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('support_categories',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
||||
op.create_table('support_priorities',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
||||
op.create_table('support_statuses',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('is_final', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
||||
op.create_table('support_tickets',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('priority_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status_id', sa.Integer(), nullable=False),
|
||||
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
||||
op.create_table('support_messages',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
|
||||
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
|
||||
op.alter_column('businesses', 'business_type',
|
||||
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
|
||||
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
|
||||
existing_nullable=False)
|
||||
op.alter_column('businesses', 'business_field',
|
||||
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
|
||||
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
|
||||
existing_nullable=False)
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Only create tables if they don't exist
|
||||
if 'support_categories' not in tables:
|
||||
op.create_table('support_categories',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
||||
|
||||
if 'support_priorities' not in tables:
|
||||
op.create_table('support_priorities',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
||||
|
||||
if 'support_statuses' not in tables:
|
||||
op.create_table('support_statuses',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('is_final', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
||||
|
||||
if 'support_tickets' not in tables:
|
||||
op.create_table('support_tickets',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('priority_id', sa.Integer(), nullable=False),
|
||||
sa.Column('status_id', sa.Integer(), nullable=False),
|
||||
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
||||
|
||||
if 'support_messages' not in tables:
|
||||
op.create_table('support_messages',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
|
||||
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
|
||||
|
||||
# Only alter columns if businesses table exists
|
||||
if 'businesses' in tables:
|
||||
op.alter_column('businesses', 'business_type',
|
||||
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
|
||||
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
|
||||
existing_nullable=False)
|
||||
op.alter_column('businesses', 'business_field',
|
||||
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
|
||||
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
"""merge tax_types and unit fields heads
|
||||
|
||||
Revision ID: 7891282548e9
|
||||
Revises: 20250106_000002, b2b68cf299a3
|
||||
Create Date: 2025-10-06 20:20:43.839460
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7891282548e9'
|
||||
down_revision = ('20250106_000002', 'b2b68cf299a3')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
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
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"""add_fiscal_year_to_documents
|
||||
|
||||
Revision ID: ac9e4b3dcffc
|
||||
Revises: 7ecb63029764
|
||||
Create Date: 2025-10-15 11:22:53.762056
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ac9e4b3dcffc'
|
||||
down_revision = '7ecb63029764'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Only add index and foreign key for fiscal_year_id (column already exists)
|
||||
op.create_index(op.f('ix_documents_fiscal_year_id'), 'documents', ['fiscal_year_id'], unique=False)
|
||||
op.create_foreign_key(None, 'documents', 'fiscal_years', ['fiscal_year_id'], ['id'], ondelete='RESTRICT')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Only remove fiscal_year_id from documents table
|
||||
op.drop_constraint(None, 'documents', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_documents_fiscal_year_id'), table_name='documents')
|
||||
op.drop_column('documents', 'fiscal_year_id')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"""convert_unit_fields_to_string
|
||||
|
||||
Revision ID: b2b68cf299a3
|
||||
Revises: c302bc2f2cb8
|
||||
Create Date: 2025-10-06 11:17:52.851690
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b2b68cf299a3'
|
||||
down_revision = 'c302bc2f2cb8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if columns already exist before adding them
|
||||
try:
|
||||
op.add_column('products', sa.Column('main_unit', sa.String(length=32), nullable=True, comment='واحد اصلی شمارش'))
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
try:
|
||||
op.add_column('products', sa.Column('secondary_unit', sa.String(length=32), nullable=True, comment='واحد فرعی شمارش'))
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
# Create indexes for new columns (if they don't exist)
|
||||
try:
|
||||
op.create_index('ix_products_main_unit', 'products', ['main_unit'])
|
||||
except Exception:
|
||||
pass # Index already exists
|
||||
|
||||
try:
|
||||
op.create_index('ix_products_secondary_unit', 'products', ['secondary_unit'])
|
||||
except Exception:
|
||||
pass # Index already exists
|
||||
|
||||
# Drop old integer columns and their indexes (if they exist)
|
||||
try:
|
||||
op.drop_index('ix_products_main_unit_id', table_name='products')
|
||||
except Exception:
|
||||
pass # Index doesn't exist
|
||||
|
||||
try:
|
||||
op.drop_index('ix_products_secondary_unit_id', table_name='products')
|
||||
except Exception:
|
||||
pass # Index doesn't exist
|
||||
|
||||
try:
|
||||
op.drop_column('products', 'main_unit_id')
|
||||
except Exception:
|
||||
pass # Column doesn't exist
|
||||
|
||||
try:
|
||||
op.drop_column('products', 'secondary_unit_id')
|
||||
except Exception:
|
||||
pass # Column doesn't exist
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add back integer columns
|
||||
op.add_column('products', sa.Column('main_unit_id', sa.Integer(), nullable=True))
|
||||
op.add_column('products', sa.Column('secondary_unit_id', sa.Integer(), nullable=True))
|
||||
|
||||
# Create indexes for integer columns
|
||||
op.create_index('ix_products_main_unit_id', 'products', ['main_unit_id'])
|
||||
op.create_index('ix_products_secondary_unit_id', 'products', ['secondary_unit_id'])
|
||||
|
||||
# Drop string columns and their indexes
|
||||
op.drop_index('ix_products_main_unit', table_name='products')
|
||||
op.drop_index('ix_products_secondary_unit', table_name='products')
|
||||
op.drop_column('products', 'main_unit')
|
||||
op.drop_column('products', 'secondary_unit')
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""remove_person_type_column
|
||||
|
||||
Revision ID: c302bc2f2cb8
|
||||
Revises: 1f0abcdd7300
|
||||
Create Date: 2025-10-04 19:04:30.866110
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c302bc2f2cb8'
|
||||
down_revision = '1f0abcdd7300'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column exists before dropping
|
||||
connection = op.get_bind()
|
||||
result = connection.execute(sa.text("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'persons'
|
||||
AND column_name = 'person_type'
|
||||
""")).fetchone()
|
||||
|
||||
if result[0] > 0:
|
||||
op.drop_column('persons', 'person_type')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# بازگردانی ستون person_type
|
||||
op.add_column('persons',
|
||||
sa.Column('person_type',
|
||||
sa.Enum('مشتری', 'بازاریاب', 'کارمند', 'تامینکننده', 'همکار', 'فروشنده', 'سهامدار', name='person_type_enum'),
|
||||
nullable=False,
|
||||
comment='نوع شخص'
|
||||
)
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ Create Date: 2025-09-30 14:46:58.614162
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'caf3f4ef4b76'
|
||||
|
|
@ -18,49 +19,81 @@ depends_on = None
|
|||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('persons', 'code',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='کد یکتا در هر کسب و کار',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'person_type',
|
||||
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
||||
comment='نوع شخص',
|
||||
existing_nullable=False)
|
||||
op.alter_column('persons', 'person_types',
|
||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||
comment='لیست انواع شخص به صورت JSON',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'commission_sale_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از فروش',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'commission_sales_return_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از برگشت از فروش',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'commission_sales_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'commission_sales_return_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
op.alter_column('persons', 'commission_exclude_discounts',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه تخفیف در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
op.alter_column('persons', 'commission_post_in_invoice_document',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Check if persons table exists and has the code column
|
||||
if 'persons' in inspector.get_table_names():
|
||||
cols = {c['name'] for c in inspector.get_columns('persons')}
|
||||
|
||||
# Only alter code column if it exists
|
||||
if 'code' in cols:
|
||||
op.alter_column('persons', 'code',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='کد یکتا در هر کسب و کار',
|
||||
existing_nullable=True)
|
||||
|
||||
# Only alter person_type column if it exists
|
||||
if 'person_type' in cols:
|
||||
op.alter_column('persons', 'person_type',
|
||||
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
||||
comment='نوع شخص',
|
||||
existing_nullable=False)
|
||||
|
||||
# Only alter person_types column if it exists
|
||||
if 'person_types' in cols:
|
||||
op.alter_column('persons', 'person_types',
|
||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||
comment='لیست انواع شخص به صورت JSON',
|
||||
existing_nullable=True)
|
||||
|
||||
# Only alter commission columns if they exist
|
||||
if 'commission_sale_percent' in cols:
|
||||
op.alter_column('persons', 'commission_sale_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از فروش',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_return_percent' in cols:
|
||||
op.alter_column('persons', 'commission_sales_return_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از برگشت از فروش',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_amount' in cols:
|
||||
op.alter_column('persons', 'commission_sales_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_return_amount' in cols:
|
||||
op.alter_column('persons', 'commission_sales_return_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_exclude_discounts' in cols:
|
||||
op.alter_column('persons', 'commission_exclude_discounts',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه تخفیف در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
if 'commission_exclude_additions_deductions' in cols:
|
||||
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
if 'commission_post_in_invoice_document' in cols:
|
||||
op.alter_column('persons', 'commission_post_in_invoice_document',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
# Continue with other operations
|
||||
op.alter_column('price_items', 'tier_name',
|
||||
existing_type=mysql.VARCHAR(length=64),
|
||||
comment='نام پله قیمت (تکی/عمده/همکار/...)',
|
||||
|
|
|
|||
99
hesabixAPI/scripts/seed_tax_types.py
Normal file
99
hesabixAPI/scripts/seed_tax_types.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to seed standard tax types from Iranian Tax Organization
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from adapters.db.session import get_db
|
||||
from adapters.db.models.tax_type import TaxType
|
||||
|
||||
def seed_tax_types():
|
||||
"""Seed standard tax types"""
|
||||
|
||||
# Standard tax types from Iranian Tax Organization
|
||||
tax_types = [
|
||||
{
|
||||
"title": "ارزش افزوده گروه دارو",
|
||||
"code": "VAT_DRUG",
|
||||
"description": "مالیات ارزش افزوده برای گروه دارو و تجهیزات پزشکی"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه دخانیات",
|
||||
"code": "VAT_TOBACCO",
|
||||
"description": "مالیات ارزش افزوده برای گروه دخانیات"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه خودرو",
|
||||
"code": "VAT_AUTO",
|
||||
"description": "مالیات ارزش افزوده برای گروه خودرو و قطعات"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه مواد غذایی",
|
||||
"code": "VAT_FOOD",
|
||||
"description": "مالیات ارزش افزوده برای گروه مواد غذایی"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه پوشاک",
|
||||
"code": "VAT_CLOTHING",
|
||||
"description": "مالیات ارزش افزوده برای گروه پوشاک و منسوجات"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه ساختمان",
|
||||
"code": "VAT_CONSTRUCTION",
|
||||
"description": "مالیات ارزش افزوده برای گروه ساختمان و مصالح"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه خدمات",
|
||||
"code": "VAT_SERVICES",
|
||||
"description": "مالیات ارزش افزوده برای گروه خدمات"
|
||||
},
|
||||
{
|
||||
"title": "ارزش افزوده گروه کالاهای عمومی",
|
||||
"code": "VAT_GENERAL",
|
||||
"description": "مالیات ارزش افزوده برای کالاهای عمومی"
|
||||
},
|
||||
{
|
||||
"title": "مالیات بر درآمد",
|
||||
"code": "INCOME_TAX",
|
||||
"description": "مالیات بر درآمد کسب و کار"
|
||||
},
|
||||
{
|
||||
"title": "مالیات بر ارزش افزوده صفر",
|
||||
"code": "VAT_ZERO",
|
||||
"description": "کالاها و خدمات معاف از مالیات ارزش افزوده"
|
||||
}
|
||||
]
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# Clear existing data
|
||||
db.query(TaxType).delete()
|
||||
db.commit()
|
||||
|
||||
# Insert new data
|
||||
for tax_data in tax_types:
|
||||
tax_type = TaxType(**tax_data)
|
||||
db.add(tax_type)
|
||||
|
||||
db.commit()
|
||||
print(f"✅ Successfully seeded {len(tax_types)} tax types")
|
||||
|
||||
# Display seeded data
|
||||
print("\n📋 Seeded tax types:")
|
||||
for tax_type in db.query(TaxType).order_by(TaxType.title).all():
|
||||
print(f" - {tax_type.title} ({tax_type.code})")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ Error seeding tax types: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_tax_types()
|
||||
|
|
@ -3,7 +3,6 @@ import '../models/product_form_data.dart';
|
|||
import '../services/product_service.dart';
|
||||
import '../services/category_service.dart';
|
||||
import '../services/product_attribute_service.dart';
|
||||
import '../services/unit_service.dart';
|
||||
import '../services/tax_service.dart';
|
||||
import '../services/price_list_service.dart';
|
||||
import '../services/currency_service.dart';
|
||||
|
|
@ -16,7 +15,6 @@ class ProductFormController extends ChangeNotifier {
|
|||
late final ProductService _productService;
|
||||
late final CategoryService _categoryService;
|
||||
late final ProductAttributeService _attributeService;
|
||||
late final UnitService _unitService;
|
||||
late final TaxService _taxService;
|
||||
late final PriceListService _priceListService;
|
||||
late final CurrencyService _currencyService;
|
||||
|
|
@ -29,7 +27,6 @@ class ProductFormController extends ChangeNotifier {
|
|||
// Reference data
|
||||
List<Map<String, dynamic>> _categories = [];
|
||||
List<Map<String, dynamic>> _attributes = [];
|
||||
List<Map<String, dynamic>> _units = [];
|
||||
List<Map<String, dynamic>> _taxTypes = [];
|
||||
List<Map<String, dynamic>> _taxUnits = [];
|
||||
List<Map<String, dynamic>> _priceLists = [];
|
||||
|
|
@ -49,7 +46,6 @@ class ProductFormController extends ChangeNotifier {
|
|||
_productService = ProductService(apiClient: _apiClient);
|
||||
_categoryService = CategoryService(_apiClient);
|
||||
_attributeService = ProductAttributeService(apiClient: _apiClient);
|
||||
_unitService = UnitService(apiClient: _apiClient);
|
||||
_taxService = TaxService(apiClient: _apiClient);
|
||||
_priceListService = PriceListService(apiClient: _apiClient);
|
||||
_currencyService = CurrencyService(_apiClient);
|
||||
|
|
@ -61,7 +57,6 @@ class ProductFormController extends ChangeNotifier {
|
|||
String? get errorMessage => _errorMessage;
|
||||
List<Map<String, dynamic>> get categories => _categories;
|
||||
List<Map<String, dynamic>> get attributes => _attributes;
|
||||
List<Map<String, dynamic>> get units => _units;
|
||||
List<Map<String, dynamic>> get taxTypes => _taxTypes;
|
||||
List<Map<String, dynamic>> get taxUnits => _taxUnits;
|
||||
List<Map<String, dynamic>> get priceLists => _priceLists;
|
||||
|
|
@ -70,23 +65,13 @@ class ProductFormController extends ChangeNotifier {
|
|||
|
||||
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
|
||||
final String key = (
|
||||
(item['price_list_id']?.toString() ?? '') + '|' +
|
||||
(item['product_id']?.toString() ?? '') + '|' +
|
||||
(item['unit_id']?.toString() ?? 'null') + '|' +
|
||||
(item['currency_id']?.toString() ?? '') + '|' +
|
||||
(item['tier_name']?.toString() ?? '') + '|' +
|
||||
(item['min_qty']?.toString() ?? '0')
|
||||
'${item['price_list_id']?.toString() ?? ''}|${item['product_id']?.toString() ?? ''}|${item['unit_id']?.toString() ?? 'null'}|${item['currency_id']?.toString() ?? ''}|${item['tier_name']?.toString() ?? ''}|${item['min_qty']?.toString() ?? '0'}'
|
||||
);
|
||||
int existingIndex = -1;
|
||||
for (int i = 0; i < _draftPriceItems.length; i++) {
|
||||
final it = _draftPriceItems[i];
|
||||
final itKey = (
|
||||
(it['price_list_id']?.toString() ?? '') + '|' +
|
||||
(it['product_id']?.toString() ?? '') + '|' +
|
||||
(it['unit_id']?.toString() ?? 'null') + '|' +
|
||||
(it['currency_id']?.toString() ?? '') + '|' +
|
||||
(it['tier_name']?.toString() ?? '') + '|' +
|
||||
(it['min_qty']?.toString() ?? '0')
|
||||
'${it['price_list_id']?.toString() ?? ''}|${it['product_id']?.toString() ?? ''}|${it['unit_id']?.toString() ?? 'null'}|${it['currency_id']?.toString() ?? ''}|${it['tier_name']?.toString() ?? ''}|${it['min_qty']?.toString() ?? '0'}'
|
||||
);
|
||||
if (itKey == key) {
|
||||
existingIndex = i;
|
||||
|
|
@ -139,20 +124,8 @@ class ProductFormController extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Default main unit id: prefer unit titled "عدد", then first available, else 1
|
||||
if (_formData.mainUnitId == null) {
|
||||
int? unitId;
|
||||
try {
|
||||
final numberUnit = _units.firstWhere(
|
||||
(e) => ((e['title'] ?? e['name'])?.toString().trim() ?? '') == 'عدد',
|
||||
);
|
||||
unitId = (numberUnit['id'] as num?)?.toInt();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
unitId ??= _units.isNotEmpty ? (_units.first['id'] as num).toInt() : 1;
|
||||
_formData = _formData.copyWith(mainUnitId: unitId);
|
||||
}
|
||||
// دیگر واحد اصلی را بهصورت خودکار مقداردهی نکن؛
|
||||
// کاربر میتواند عنوان واحد را در فرم وارد کند و در صورت تطبیق با لیست، آیدی ست میشود
|
||||
|
||||
_clearError();
|
||||
notifyListeners();
|
||||
|
|
@ -178,23 +151,17 @@ class ProductFormController extends ChangeNotifier {
|
|||
_attributes = [];
|
||||
}
|
||||
|
||||
// Load units
|
||||
try {
|
||||
_units = await _unitService.getUnits(businessId: businessId);
|
||||
} catch (_) {
|
||||
_units = [];
|
||||
}
|
||||
|
||||
// Load tax types
|
||||
try {
|
||||
_taxTypes = await _taxService.getTaxTypes(businessId: businessId);
|
||||
_taxTypes = await _taxService.getTaxTypes();
|
||||
} catch (_) {
|
||||
_taxTypes = [];
|
||||
}
|
||||
|
||||
// Load tax units
|
||||
try {
|
||||
_taxUnits = await _taxService.getTaxUnits(businessId: businessId);
|
||||
_taxUnits = await _taxService.getTaxUnits();
|
||||
} catch (_) {
|
||||
_taxUnits = [];
|
||||
}
|
||||
|
|
@ -394,8 +361,4 @@ class ProductFormController extends ChangeNotifier {
|
|||
_errorMessage = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ApiClient {
|
|||
static Locale? _currentLocale;
|
||||
static AuthStore? _authStore;
|
||||
static CalendarController? _calendarController;
|
||||
static ValueNotifier<int?>? _fiscalYearId;
|
||||
|
||||
static void setCurrentLocale(Locale locale) {
|
||||
_currentLocale = locale;
|
||||
|
|
@ -36,6 +37,11 @@ class ApiClient {
|
|||
_calendarController = controller;
|
||||
}
|
||||
|
||||
// Fiscal Year binding (allows UI to update selected fiscal year globally)
|
||||
static void bindFiscalYear(ValueNotifier<int?> fiscalYearId) {
|
||||
_fiscalYearId = fiscalYearId;
|
||||
}
|
||||
|
||||
ApiClient._(this._dio);
|
||||
|
||||
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
|
||||
|
|
@ -71,6 +77,11 @@ class ApiClient {
|
|||
if (calendarType != null && calendarType.isNotEmpty) {
|
||||
options.headers['X-Calendar-Type'] = calendarType;
|
||||
}
|
||||
// Inject Fiscal Year header if provided
|
||||
final fyId = _fiscalYearId?.value;
|
||||
if (fyId != null && fyId > 0) {
|
||||
options.headers['X-Fiscal-Year-ID'] = fyId.toString();
|
||||
}
|
||||
// Inject X-Business-ID header when request targets a specific business
|
||||
try {
|
||||
final uri = options.uri;
|
||||
|
|
|
|||
|
|
@ -479,6 +479,7 @@ class AuthStore with ChangeNotifier {
|
|||
Future<void> _ensureCurrencyForBusiness() async {
|
||||
final business = _currentBusiness;
|
||||
if (business == null) return;
|
||||
|
||||
// اگر ارزی انتخاب نشده، یا کد/شناسه فعلی جزو ارزهای کسبوکار نیست
|
||||
final allowedCodes = business.currencies.map((c) => c.code).toSet();
|
||||
final allowedIds = business.currencies.map((c) => c.id).toSet();
|
||||
|
|
|
|||
33
hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart
Normal file
33
hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class FiscalYearController extends ChangeNotifier {
|
||||
static const String _prefsKey = 'selected_fiscal_year_id';
|
||||
|
||||
int? _fiscalYearId;
|
||||
int? get fiscalYearId => _fiscalYearId;
|
||||
|
||||
FiscalYearController._(this._fiscalYearId);
|
||||
|
||||
static Future<FiscalYearController> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt(_prefsKey);
|
||||
return FiscalYearController._(id);
|
||||
}
|
||||
|
||||
Future<void> setFiscalYearId(int? id) async {
|
||||
if (_fiscalYearId == id) return;
|
||||
_fiscalYearId = id;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (id == null) {
|
||||
await prefs.remove(_prefsKey);
|
||||
} else {
|
||||
await prefs.setInt(_prefsKey, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1091,5 +1091,14 @@
|
|||
"pettyCashExportExcel": "Export petty cash to Excel",
|
||||
"pettyCashExportPdf": "Export petty cash to PDF",
|
||||
"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": "جزئیات تنخواه گردان",
|
||||
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
||||
"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:
|
||||
/// **'Petty Cash Report'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2897,4 +2897,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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 => 'سند حسابداری';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import 'pages/business/wallet_page.dart';
|
|||
import 'pages/business/invoice_page.dart';
|
||||
import 'pages/business/new_invoice_page.dart';
|
||||
import 'pages/business/settings_page.dart';
|
||||
import 'pages/business/reports_page.dart';
|
||||
import 'pages/business/persons_page.dart';
|
||||
import 'pages/business/product_attributes_page.dart';
|
||||
import 'pages/business/products_page.dart';
|
||||
|
|
@ -35,6 +36,9 @@ import 'pages/business/price_lists_page.dart';
|
|||
import 'pages/business/price_list_items_page.dart';
|
||||
import 'pages/business/cash_registers_page.dart';
|
||||
import 'pages/business/petty_cash_page.dart';
|
||||
import 'pages/business/checks_page.dart';
|
||||
import 'pages/business/check_form_page.dart';
|
||||
import 'pages/business/receipts_payments_list_page.dart';
|
||||
import 'pages/error_404_page.dart';
|
||||
import 'core/locale_controller.dart';
|
||||
import 'core/calendar_controller.dart';
|
||||
|
|
@ -652,6 +656,25 @@ class _MyAppState extends State<MyApp> {
|
|||
child: NewInvoicePage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
calendarController: _calendarController!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'reports',
|
||||
name: 'business_reports',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: ReportsPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -661,6 +684,10 @@ class _MyAppState extends State<MyApp> {
|
|||
name: 'business_settings',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند
|
||||
if (!_authStore!.hasBusinessPermission('settings', 'join')) {
|
||||
return PermissionGuard.buildAccessDeniedPage();
|
||||
}
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
|
|
@ -768,6 +795,85 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
// Receipts & Payments: list with data table
|
||||
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: ReceiptsPaymentsListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
authStore: _authStore!,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks',
|
||||
name: 'business_checks',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: ChecksPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks/new',
|
||||
name: 'business_new_check',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: CheckFormPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
calendarController: _calendarController!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: 'checks/:check_id/edit',
|
||||
name: 'business_edit_check',
|
||||
builder: (context, state) {
|
||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||
final checkId = int.tryParse(state.pathParameters['check_id'] ?? '0');
|
||||
return BusinessShell(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: CheckFormPage(
|
||||
businessId: businessId,
|
||||
authStore: _authStore!,
|
||||
checkId: checkId,
|
||||
calendarController: _calendarController!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// TODO: Add other business routes (sales, accounting, etc.)
|
||||
],
|
||||
),
|
||||
|
|
|
|||
97
hesabixUI/hesabix_ui/lib/models/account_tree_node.dart
Normal file
97
hesabixUI/hesabix_ui/lib/models/account_tree_node.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
class AccountTreeNode {
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String? accountType;
|
||||
final int? parentId;
|
||||
final int? level;
|
||||
final List<AccountTreeNode> children;
|
||||
|
||||
const AccountTreeNode({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.accountType,
|
||||
this.parentId,
|
||||
this.level,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
factory AccountTreeNode.fromJson(Map<String, dynamic> json) {
|
||||
return AccountTreeNode(
|
||||
id: json['id'] as int,
|
||||
code: json['code'] as String,
|
||||
name: json['name'] as String,
|
||||
accountType: json['account_type'] as String?,
|
||||
parentId: json['parent_id'] as int?,
|
||||
level: json['level'] as int?,
|
||||
children: (json['children'] as List<dynamic>?)
|
||||
?.map((child) => AccountTreeNode.fromJson(child as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'code': code,
|
||||
'name': name,
|
||||
'account_type': accountType,
|
||||
'parent_id': parentId,
|
||||
'level': level,
|
||||
'children': children.map((child) => child.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// بررسی میکند که آیا این حساب فرزند دارد یا نه
|
||||
bool get hasChildren => children.isNotEmpty;
|
||||
|
||||
/// دریافت تمام حسابهای قابل انتخاب (بدون فرزند) به صورت تخت
|
||||
List<AccountTreeNode> getSelectableAccounts() {
|
||||
List<AccountTreeNode> selectable = [];
|
||||
|
||||
if (!hasChildren) {
|
||||
selectable.add(this);
|
||||
} else {
|
||||
for (final child in children) {
|
||||
selectable.addAll(child.getSelectableAccounts());
|
||||
}
|
||||
}
|
||||
|
||||
return selectable;
|
||||
}
|
||||
|
||||
/// دریافت تمام حسابها به صورت تخت (شامل همه سطوح)
|
||||
List<AccountTreeNode> getAllAccounts() {
|
||||
List<AccountTreeNode> all = [this];
|
||||
|
||||
for (final child in children) {
|
||||
all.addAll(child.getAllAccounts());
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/// جستجو در درخت حسابها بر اساس نام یا کد
|
||||
List<AccountTreeNode> searchAccounts(String query) {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
return getAllAccounts().where((account) {
|
||||
return account.name.toLowerCase().contains(lowerQuery) ||
|
||||
account.code.toLowerCase().contains(lowerQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$code - $name';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AccountTreeNode && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
61
hesabixUI/hesabix_ui/lib/models/customer_model.dart
Normal file
61
hesabixUI/hesabix_ui/lib/models/customer_model.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
class Customer {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? code;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
final String? address;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const Customer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.code,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.address,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory Customer.fromJson(Map<String, dynamic> json) {
|
||||
return Customer(
|
||||
id: json['id'] as int,
|
||||
name: json['name'] as String,
|
||||
code: json['code'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
email: json['email'] as String?,
|
||||
address: json['address'] as String?,
|
||||
isActive: json['is_active'] ?? true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.tryParse(json['created_at'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'phone': phone,
|
||||
'email': email,
|
||||
'address': address,
|
||||
'is_active': isActive,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is Customer && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
120
hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
Normal file
120
hesabixUI/hesabix_ui/lib/models/invoice_line_item.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
class InvoiceLineItem {
|
||||
final int? productId;
|
||||
final String? productCode;
|
||||
final String? productName;
|
||||
|
||||
final String? mainUnit;
|
||||
final String? secondaryUnit;
|
||||
final num? unitConversionFactor; // 1 main = factor * secondary
|
||||
|
||||
String? selectedUnit;
|
||||
|
||||
num quantity;
|
||||
|
||||
// unit price handling
|
||||
String unitPriceSource; // manual | base | priceList
|
||||
num unitPrice; // price per selected unit
|
||||
|
||||
// base prices on main unit (as provided by product)
|
||||
final num? baseSalesPriceMainUnit;
|
||||
final num? basePurchasePriceMainUnit;
|
||||
|
||||
// discount
|
||||
String discountType; // percent | amount
|
||||
num discountValue; // either percentage (0-100) or absolute amount
|
||||
|
||||
// tax
|
||||
num taxRate; // percent, editable by user
|
||||
|
||||
// inventory/constraints
|
||||
final int? minOrderQty;
|
||||
final bool trackInventory;
|
||||
|
||||
// presentation
|
||||
String? description;
|
||||
|
||||
InvoiceLineItem({
|
||||
this.productId,
|
||||
this.productCode,
|
||||
this.productName,
|
||||
this.mainUnit,
|
||||
this.secondaryUnit,
|
||||
this.unitConversionFactor,
|
||||
this.selectedUnit,
|
||||
this.description,
|
||||
this.unitPriceSource = 'base',
|
||||
this.unitPrice = 0,
|
||||
this.quantity = 1,
|
||||
this.discountType = 'amount',
|
||||
this.discountValue = 0,
|
||||
this.taxRate = 0,
|
||||
this.baseSalesPriceMainUnit,
|
||||
this.basePurchasePriceMainUnit,
|
||||
this.minOrderQty,
|
||||
this.trackInventory = false,
|
||||
});
|
||||
|
||||
InvoiceLineItem copyWith({
|
||||
int? productId,
|
||||
String? productCode,
|
||||
String? productName,
|
||||
String? mainUnit,
|
||||
String? secondaryUnit,
|
||||
num? unitConversionFactor,
|
||||
String? selectedUnit,
|
||||
num? quantity,
|
||||
String? unitPriceSource,
|
||||
num? unitPrice,
|
||||
String? discountType,
|
||||
num? discountValue,
|
||||
num? taxRate,
|
||||
String? description,
|
||||
num? baseSalesPriceMainUnit,
|
||||
num? basePurchasePriceMainUnit,
|
||||
int? minOrderQty,
|
||||
bool? trackInventory,
|
||||
}) {
|
||||
return InvoiceLineItem(
|
||||
productId: productId ?? this.productId,
|
||||
productCode: productCode ?? this.productCode,
|
||||
productName: productName ?? this.productName,
|
||||
mainUnit: mainUnit ?? this.mainUnit,
|
||||
secondaryUnit: secondaryUnit ?? this.secondaryUnit,
|
||||
unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor,
|
||||
selectedUnit: selectedUnit ?? this.selectedUnit,
|
||||
quantity: quantity ?? this.quantity,
|
||||
unitPriceSource: unitPriceSource ?? this.unitPriceSource,
|
||||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
discountType: discountType ?? this.discountType,
|
||||
discountValue: discountValue ?? this.discountValue,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
description: description ?? this.description,
|
||||
baseSalesPriceMainUnit: baseSalesPriceMainUnit ?? this.baseSalesPriceMainUnit,
|
||||
basePurchasePriceMainUnit: basePurchasePriceMainUnit ?? this.basePurchasePriceMainUnit,
|
||||
minOrderQty: minOrderQty ?? this.minOrderQty,
|
||||
trackInventory: trackInventory ?? this.trackInventory,
|
||||
);
|
||||
}
|
||||
|
||||
num get subtotal => quantity * unitPrice;
|
||||
|
||||
num get discountAmount {
|
||||
if (discountType == 'percent') {
|
||||
final p = discountValue;
|
||||
if (p <= 0) return 0;
|
||||
return subtotal * (p / 100);
|
||||
}
|
||||
return discountValue;
|
||||
}
|
||||
|
||||
num get taxableAmount {
|
||||
final base = subtotal - discountAmount;
|
||||
return base < 0 ? 0 : base;
|
||||
}
|
||||
|
||||
num get taxAmount => taxableAmount * (taxRate / 100);
|
||||
|
||||
num get total => taxableAmount + taxAmount;
|
||||
}
|
||||
|
||||
|
||||
155
hesabixUI/hesabix_ui/lib/models/invoice_transaction.dart
Normal file
155
hesabixUI/hesabix_ui/lib/models/invoice_transaction.dart
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
enum TransactionType {
|
||||
bank('bank', 'بانک'),
|
||||
cashRegister('cash_register', 'صندوق'),
|
||||
pettyCash('petty_cash', 'تنخواهگردان'),
|
||||
check('check', 'چک'),
|
||||
checkExpense('check_expense', 'خرج چک'),
|
||||
person('person', 'شخص'),
|
||||
account('account', 'حساب');
|
||||
|
||||
const TransactionType(this.value, this.label);
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
static TransactionType? fromValue(String value) {
|
||||
for (final type in TransactionType.values) {
|
||||
if (type.value == value) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<TransactionType> get allTypes => TransactionType.values;
|
||||
}
|
||||
|
||||
class InvoiceTransaction {
|
||||
final String id;
|
||||
final TransactionType type;
|
||||
final String? bankId;
|
||||
final String? bankName;
|
||||
final String? cashRegisterId;
|
||||
final String? cashRegisterName;
|
||||
final String? pettyCashId;
|
||||
final String? pettyCashName;
|
||||
final String? checkId;
|
||||
final String? checkNumber;
|
||||
final String? personId;
|
||||
final String? personName;
|
||||
final String? accountId;
|
||||
final String? accountName;
|
||||
final DateTime transactionDate;
|
||||
final num amount;
|
||||
final num? commission;
|
||||
final String? description;
|
||||
|
||||
const InvoiceTransaction({
|
||||
required this.id,
|
||||
required this.type,
|
||||
this.bankId,
|
||||
this.bankName,
|
||||
this.cashRegisterId,
|
||||
this.cashRegisterName,
|
||||
this.pettyCashId,
|
||||
this.pettyCashName,
|
||||
this.checkId,
|
||||
this.checkNumber,
|
||||
this.personId,
|
||||
this.personName,
|
||||
this.accountId,
|
||||
this.accountName,
|
||||
required this.transactionDate,
|
||||
required this.amount,
|
||||
this.commission,
|
||||
this.description,
|
||||
});
|
||||
|
||||
InvoiceTransaction copyWith({
|
||||
String? id,
|
||||
TransactionType? type,
|
||||
String? bankId,
|
||||
String? bankName,
|
||||
String? cashRegisterId,
|
||||
String? cashRegisterName,
|
||||
String? pettyCashId,
|
||||
String? pettyCashName,
|
||||
String? checkId,
|
||||
String? checkNumber,
|
||||
String? personId,
|
||||
String? personName,
|
||||
String? accountId,
|
||||
String? accountName,
|
||||
DateTime? transactionDate,
|
||||
num? amount,
|
||||
num? commission,
|
||||
String? description,
|
||||
}) {
|
||||
return InvoiceTransaction(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
bankId: bankId ?? this.bankId,
|
||||
bankName: bankName ?? this.bankName,
|
||||
cashRegisterId: cashRegisterId ?? this.cashRegisterId,
|
||||
cashRegisterName: cashRegisterName ?? this.cashRegisterName,
|
||||
pettyCashId: pettyCashId ?? this.pettyCashId,
|
||||
pettyCashName: pettyCashName ?? this.pettyCashName,
|
||||
checkId: checkId ?? this.checkId,
|
||||
checkNumber: checkNumber ?? this.checkNumber,
|
||||
personId: personId ?? this.personId,
|
||||
personName: personName ?? this.personName,
|
||||
accountId: accountId ?? this.accountId,
|
||||
accountName: accountName ?? this.accountName,
|
||||
transactionDate: transactionDate ?? this.transactionDate,
|
||||
amount: amount ?? this.amount,
|
||||
commission: commission ?? this.commission,
|
||||
description: description ?? this.description,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.value,
|
||||
'bank_id': bankId,
|
||||
'bank_name': bankName,
|
||||
'cash_register_id': cashRegisterId,
|
||||
'cash_register_name': cashRegisterName,
|
||||
'petty_cash_id': pettyCashId,
|
||||
'petty_cash_name': pettyCashName,
|
||||
'check_id': checkId,
|
||||
'check_number': checkNumber,
|
||||
'person_id': personId,
|
||||
'person_name': personName,
|
||||
'account_id': accountId,
|
||||
'account_name': accountName,
|
||||
'transaction_date': transactionDate.toIso8601String(),
|
||||
'amount': amount,
|
||||
'commission': commission,
|
||||
'description': description,
|
||||
};
|
||||
}
|
||||
|
||||
factory InvoiceTransaction.fromJson(Map<String, dynamic> json) {
|
||||
return InvoiceTransaction(
|
||||
id: json['id'] as String,
|
||||
type: TransactionType.fromValue(json['type'] as String) ?? TransactionType.person,
|
||||
bankId: json['bank_id'] as String?,
|
||||
bankName: json['bank_name'] as String?,
|
||||
cashRegisterId: json['cash_register_id'] as String?,
|
||||
cashRegisterName: json['cash_register_name'] as String?,
|
||||
pettyCashId: json['petty_cash_id'] as String?,
|
||||
pettyCashName: json['petty_cash_name'] as String?,
|
||||
checkId: json['check_id'] as String?,
|
||||
checkNumber: json['check_number'] as String?,
|
||||
personId: json['person_id'] as String?,
|
||||
personName: json['person_name'] as String?,
|
||||
accountId: json['account_id'] as String?,
|
||||
accountName: json['account_name'] as String?,
|
||||
transactionDate: DateTime.parse(json['transaction_date'] as String),
|
||||
amount: json['amount'] as num,
|
||||
commission: json['commission'] as num?,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
25
hesabixUI/hesabix_ui/lib/models/invoice_type_model.dart
Normal file
25
hesabixUI/hesabix_ui/lib/models/invoice_type_model.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
enum InvoiceType {
|
||||
sales('sales', 'فروش'),
|
||||
salesReturn('sales_return', 'برگشت از فروش'),
|
||||
purchase('purchase', 'خرید'),
|
||||
purchaseReturn('purchase_return', 'برگشت از خرید'),
|
||||
waste('waste', 'ضایعات'),
|
||||
directConsumption('direct_consumption', 'مصرف مستقیم'),
|
||||
production('production', 'تولید');
|
||||
|
||||
const InvoiceType(this.value, this.label);
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
static InvoiceType? fromValue(String value) {
|
||||
for (final type in InvoiceType.values) {
|
||||
if (type.value == value) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<InvoiceType> get allTypes => InvoiceType.values;
|
||||
}
|
||||
|
|
@ -102,7 +102,6 @@ class Person {
|
|||
final String aliasName;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final PersonType personType;
|
||||
final List<PersonType> personTypes;
|
||||
final String? companyName;
|
||||
final String? paymentId;
|
||||
|
|
@ -140,8 +139,7 @@ class Person {
|
|||
required this.aliasName,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
required this.personType,
|
||||
this.personTypes = const [],
|
||||
required this.personTypes,
|
||||
this.companyName,
|
||||
this.paymentId,
|
||||
this.nationalId,
|
||||
|
|
@ -176,9 +174,6 @@ class Person {
|
|||
?.map((e) => PersonType.fromString(e.toString()))
|
||||
.toList() ??
|
||||
[];
|
||||
final PersonType primaryType = types.isNotEmpty
|
||||
? types.first
|
||||
: PersonType.fromString(json['person_type']);
|
||||
return Person(
|
||||
id: json['id'],
|
||||
businessId: json['business_id'],
|
||||
|
|
@ -186,7 +181,6 @@ class Person {
|
|||
aliasName: json['alias_name'],
|
||||
firstName: json['first_name'],
|
||||
lastName: json['last_name'],
|
||||
personType: primaryType,
|
||||
personTypes: types,
|
||||
companyName: json['company_name'],
|
||||
paymentId: json['payment_id'],
|
||||
|
|
@ -228,7 +222,6 @@ class Person {
|
|||
'alias_name': aliasName,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'person_type': personType.persianName,
|
||||
'person_types': personTypes.map((t) => t.persianName).toList(),
|
||||
'company_name': companyName,
|
||||
'payment_id': paymentId,
|
||||
|
|
@ -266,7 +259,7 @@ class Person {
|
|||
String? aliasName,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
PersonType? personType,
|
||||
List<PersonType>? personTypes,
|
||||
String? companyName,
|
||||
String? paymentId,
|
||||
String? nationalId,
|
||||
|
|
@ -293,7 +286,7 @@ class Person {
|
|||
aliasName: aliasName ?? this.aliasName,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
personType: personType ?? this.personType,
|
||||
personTypes: personTypes ?? this.personTypes,
|
||||
companyName: companyName ?? this.companyName,
|
||||
paymentId: paymentId ?? this.paymentId,
|
||||
nationalId: nationalId ?? this.nationalId,
|
||||
|
|
@ -444,7 +437,6 @@ class PersonUpdateRequest {
|
|||
final String? aliasName;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final PersonType? personType;
|
||||
final List<PersonType>? personTypes;
|
||||
final String? companyName;
|
||||
final String? paymentId;
|
||||
|
|
@ -476,7 +468,6 @@ class PersonUpdateRequest {
|
|||
this.aliasName,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.personType,
|
||||
this.personTypes,
|
||||
this.companyName,
|
||||
this.paymentId,
|
||||
|
|
@ -511,7 +502,6 @@ class PersonUpdateRequest {
|
|||
if (aliasName != null) json['alias_name'] = aliasName;
|
||||
if (firstName != null) json['first_name'] = firstName;
|
||||
if (lastName != null) json['last_name'] = lastName;
|
||||
if (personType != null) json['person_type'] = personType!.persianName;
|
||||
if (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList();
|
||||
if (companyName != null) json['company_name'] = companyName;
|
||||
if (paymentId != null) json['payment_id'] = paymentId;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ class ProductFormData {
|
|||
String? basePurchaseNote;
|
||||
|
||||
// Units
|
||||
int? mainUnitId;
|
||||
int? secondaryUnitId;
|
||||
String? mainUnit;
|
||||
String? secondaryUnit;
|
||||
num unitConversionFactor;
|
||||
|
||||
// Taxes
|
||||
|
|
@ -49,8 +49,8 @@ class ProductFormData {
|
|||
this.basePurchasePrice,
|
||||
this.baseSalesNote,
|
||||
this.basePurchaseNote,
|
||||
this.mainUnitId,
|
||||
this.secondaryUnitId,
|
||||
this.mainUnit = 'عدد',
|
||||
this.secondaryUnit,
|
||||
this.unitConversionFactor = 1,
|
||||
this.isSalesTaxable = false,
|
||||
this.isPurchaseTaxable = false,
|
||||
|
|
@ -76,8 +76,8 @@ class ProductFormData {
|
|||
num? basePurchasePrice,
|
||||
String? baseSalesNote,
|
||||
String? basePurchaseNote,
|
||||
int? mainUnitId,
|
||||
int? secondaryUnitId,
|
||||
String? mainUnit,
|
||||
String? secondaryUnit,
|
||||
num? unitConversionFactor,
|
||||
bool? isSalesTaxable,
|
||||
bool? isPurchaseTaxable,
|
||||
|
|
@ -102,8 +102,8 @@ class ProductFormData {
|
|||
basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice,
|
||||
baseSalesNote: baseSalesNote ?? this.baseSalesNote,
|
||||
basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote,
|
||||
mainUnitId: mainUnitId ?? this.mainUnitId,
|
||||
secondaryUnitId: secondaryUnitId ?? this.secondaryUnitId,
|
||||
mainUnit: mainUnit ?? this.mainUnit,
|
||||
secondaryUnit: secondaryUnit ?? this.secondaryUnit,
|
||||
unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor,
|
||||
isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable,
|
||||
isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable,
|
||||
|
|
@ -134,9 +134,9 @@ class ProductFormData {
|
|||
'is_purchase_taxable': isPurchaseTaxable,
|
||||
'sales_tax_rate': salesTaxRate ?? 0,
|
||||
'purchase_tax_rate': purchaseTaxRate ?? 0,
|
||||
// Keep optional IDs and factor as-is (do not force zero)
|
||||
'main_unit_id': mainUnitId,
|
||||
'secondary_unit_id': secondaryUnitId,
|
||||
// Units as strings
|
||||
'main_unit': mainUnit,
|
||||
'secondary_unit': secondaryUnit,
|
||||
'unit_conversion_factor': unitConversionFactor,
|
||||
'base_sales_note': baseSalesNote,
|
||||
'base_purchase_note': basePurchaseNote,
|
||||
|
|
@ -160,8 +160,8 @@ class ProductFormData {
|
|||
trackInventory: (product['track_inventory'] == true),
|
||||
baseSalesPrice: _parseNumeric(product['base_sales_price']),
|
||||
basePurchasePrice: _parseNumeric(product['base_purchase_price']),
|
||||
mainUnitId: product['main_unit_id'] as int?,
|
||||
secondaryUnitId: product['secondary_unit_id'] as int?,
|
||||
mainUnit: product['main_unit']?.toString(),
|
||||
secondaryUnit: product['secondary_unit']?.toString(),
|
||||
unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1,
|
||||
baseSalesNote: product['base_sales_note']?.toString(),
|
||||
basePurchaseNote: product['base_purchase_note']?.toString(),
|
||||
|
|
|
|||
222
hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart
Normal file
222
hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
|
||||
/// مدل خط شخص در سند دریافت/پرداخت
|
||||
class PersonLine {
|
||||
final int id;
|
||||
final int? personId;
|
||||
final String? personName;
|
||||
final double amount;
|
||||
final String? description;
|
||||
final Map<String, dynamic>? extraInfo;
|
||||
|
||||
const PersonLine({
|
||||
required this.id,
|
||||
this.personId,
|
||||
this.personName,
|
||||
required this.amount,
|
||||
this.description,
|
||||
this.extraInfo,
|
||||
});
|
||||
|
||||
factory PersonLine.fromJson(Map<String, dynamic> json) {
|
||||
return PersonLine(
|
||||
id: json['id'] ?? 0,
|
||||
personId: json['person_id'],
|
||||
personName: json['person_name'],
|
||||
amount: (json['amount'] ?? 0).toDouble(),
|
||||
description: json['description'],
|
||||
extraInfo: json['extra_info'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'person_id': personId,
|
||||
'person_name': personName,
|
||||
'amount': amount,
|
||||
'description': description,
|
||||
'extra_info': extraInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// مدل خط حساب در سند دریافت/پرداخت
|
||||
class AccountLine {
|
||||
final int id;
|
||||
final int accountId;
|
||||
final String accountName;
|
||||
final String accountCode;
|
||||
final String? accountType;
|
||||
final double amount;
|
||||
final String? description;
|
||||
final String? transactionType;
|
||||
final DateTime? transactionDate;
|
||||
final double? commission;
|
||||
final Map<String, dynamic>? extraInfo;
|
||||
|
||||
const AccountLine({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.accountName,
|
||||
required this.accountCode,
|
||||
this.accountType,
|
||||
required this.amount,
|
||||
this.description,
|
||||
this.transactionType,
|
||||
this.transactionDate,
|
||||
this.commission,
|
||||
this.extraInfo,
|
||||
});
|
||||
|
||||
factory AccountLine.fromJson(Map<String, dynamic> json) {
|
||||
return AccountLine(
|
||||
id: json['id'] ?? 0,
|
||||
accountId: json['account_id'] ?? 0,
|
||||
accountName: json['account_name'] ?? '',
|
||||
accountCode: json['account_code'] ?? '',
|
||||
accountType: json['account_type'],
|
||||
amount: (json['amount'] ?? 0).toDouble(),
|
||||
description: json['description'],
|
||||
transactionType: json['transaction_type'],
|
||||
transactionDate: json['transaction_date'] != null
|
||||
? DateTime.tryParse(json['transaction_date'])
|
||||
: null,
|
||||
commission: json['commission']?.toDouble(),
|
||||
extraInfo: json['extra_info'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'account_name': accountName,
|
||||
'account_code': accountCode,
|
||||
'account_type': accountType,
|
||||
'amount': amount,
|
||||
'description': description,
|
||||
'transaction_type': transactionType,
|
||||
'transaction_date': transactionDate?.toIso8601String(),
|
||||
'commission': commission,
|
||||
'extra_info': extraInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// مدل سند دریافت/پرداخت
|
||||
class ReceiptPaymentDocument {
|
||||
final int id;
|
||||
final String code;
|
||||
final int businessId;
|
||||
final String documentType; // 'receipt' or 'payment'
|
||||
final DateTime documentDate;
|
||||
final DateTime registeredAt;
|
||||
final int currencyId;
|
||||
final String? currencyCode;
|
||||
final int createdByUserId;
|
||||
final String? createdByName;
|
||||
final bool isProforma;
|
||||
final Map<String, dynamic>? extraInfo;
|
||||
final List<PersonLine> personLines;
|
||||
final List<AccountLine> accountLines;
|
||||
final String? personNames;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const ReceiptPaymentDocument({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.businessId,
|
||||
required this.documentType,
|
||||
required this.documentDate,
|
||||
required this.registeredAt,
|
||||
required this.currencyId,
|
||||
this.currencyCode,
|
||||
required this.createdByUserId,
|
||||
this.createdByName,
|
||||
required this.isProforma,
|
||||
this.extraInfo,
|
||||
required this.personLines,
|
||||
required this.accountLines,
|
||||
this.personNames,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory ReceiptPaymentDocument.fromJson(Map<String, dynamic> json) {
|
||||
return ReceiptPaymentDocument(
|
||||
id: json['id'] ?? 0,
|
||||
code: json['code'] ?? '',
|
||||
businessId: json['business_id'] ?? 0,
|
||||
documentType: json['document_type'] ?? '',
|
||||
documentDate: DateTime.tryParse(json['document_date'] ?? '') ?? DateTime.now(),
|
||||
registeredAt: DateTime.tryParse(json['registered_at'] ?? '') ?? DateTime.now(),
|
||||
currencyId: json['currency_id'] ?? 0,
|
||||
currencyCode: json['currency_code'],
|
||||
createdByUserId: json['created_by_user_id'] ?? 0,
|
||||
createdByName: json['created_by_name'],
|
||||
isProforma: json['is_proforma'] ?? false,
|
||||
extraInfo: json['extra_info'],
|
||||
personLines: (json['person_lines'] as List<dynamic>?)
|
||||
?.map((item) => PersonLine.fromJson(item))
|
||||
.toList() ?? [],
|
||||
accountLines: (json['account_lines'] as List<dynamic>?)
|
||||
?.map((item) => AccountLine.fromJson(item))
|
||||
.toList() ?? [],
|
||||
personNames: json['person_names'],
|
||||
createdAt: DateTime.tryParse(json['created_at'] ?? '') ?? DateTime.now(),
|
||||
updatedAt: DateTime.tryParse(json['updated_at'] ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'code': code,
|
||||
'business_id': businessId,
|
||||
'document_type': documentType,
|
||||
'document_date': documentDate.toIso8601String(),
|
||||
'registered_at': registeredAt.toIso8601String(),
|
||||
'currency_id': currencyId,
|
||||
'currency_code': currencyCode,
|
||||
'created_by_user_id': createdByUserId,
|
||||
'created_by_name': createdByName,
|
||||
'is_proforma': isProforma,
|
||||
'extra_info': extraInfo,
|
||||
'person_lines': personLines.map((item) => item.toJson()).toList(),
|
||||
'account_lines': accountLines.map((item) => item.toJson()).toList(),
|
||||
'person_names': personNames,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// محاسبه مجموع مبلغ کل
|
||||
double get totalAmount {
|
||||
return personLines.fold(0.0, (sum, line) => sum + line.amount);
|
||||
}
|
||||
|
||||
/// تعداد خطوط اشخاص
|
||||
int get personLinesCount => personLines.length;
|
||||
|
||||
/// تعداد خطوط حسابها
|
||||
int get accountLinesCount => accountLines.length;
|
||||
|
||||
/// آیا سند دریافت است؟
|
||||
bool get isReceipt => documentType == 'receipt';
|
||||
|
||||
/// آیا سند پرداخت است؟
|
||||
bool get isPayment => documentType == 'payment';
|
||||
|
||||
/// دریافت نام نوع سند
|
||||
String get documentTypeName {
|
||||
switch (documentType) {
|
||||
case 'receipt':
|
||||
return 'دریافت';
|
||||
case 'payment':
|
||||
return 'پرداخت';
|
||||
default:
|
||||
return documentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,45 @@ import 'package:flutter/material.dart';
|
|||
import 'package:hesabix_ui/l10n/app_localizations.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 {
|
||||
final int businessId;
|
||||
const AccountsPage({super.key, required this.businessId});
|
||||
|
|
@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget {
|
|||
class _AccountsPageState extends State<AccountsPage> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<dynamic> _tree = const [];
|
||||
List<AccountNode> _roots = const [];
|
||||
final Set<String> _expandedIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -26,7 +66,11 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
try {
|
||||
final api = ApiClient();
|
||||
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) {
|
||||
setState(() { _error = e.toString(); });
|
||||
} finally {
|
||||
|
|
@ -34,17 +78,86 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _buildNode(Map<String, dynamic> node) {
|
||||
final children = (node['children'] as List?) ?? const [];
|
||||
if (children.isEmpty) {
|
||||
return ListTile(
|
||||
title: Text('${node['code']} - ${node['name']}'),
|
||||
);
|
||||
List<_VisibleNode> _buildVisibleNodes() {
|
||||
final List<_VisibleNode> result = <_VisibleNode>[];
|
||||
void dfs(AccountNode node, int level) {
|
||||
result.add(_VisibleNode(node, level));
|
||||
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
|
||||
|
|
@ -52,13 +165,65 @@ class _AccountsPageState extends State<AccountsPage> {
|
|||
final t = AppLocalizations.of(context);
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) return Center(child: Text(_error!));
|
||||
final visible = _buildVisibleNodes();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _fetch,
|
||||
child: ListView(
|
||||
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
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))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import '../../widgets/category/category_tree_dialog.dart';
|
|||
import '../../services/business_dashboard_service.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
|
||||
|
||||
class BusinessShell extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -68,7 +69,25 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _refreshCurrentPage() {
|
||||
Future<void> showAddReceiptPaymentDialog() async {
|
||||
final calendarController = widget.calendarController ?? await CalendarController.load();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => BulkSettlementDialog(
|
||||
businessId: widget.businessId,
|
||||
calendarController: calendarController,
|
||||
isReceipt: true, // پیشفرض دریافت
|
||||
businessInfo: widget.authStore.currentBusiness,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
);
|
||||
if (result == true) {
|
||||
// Refresh the receipts payments page if it's currently open
|
||||
_refreshCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshCurrentPage() {
|
||||
// Force a rebuild of the current page
|
||||
setState(() {
|
||||
// This will cause the current page to rebuild
|
||||
|
|
@ -809,6 +828,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (child.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (child.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (child.label == t.expenseAndIncome) {
|
||||
// Navigate to add expense/income
|
||||
} else if (child.label == t.warehouses) {
|
||||
|
|
@ -948,6 +970,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
showAddBankAccountDialog();
|
||||
} else if (item.label == t.cashBox) {
|
||||
showAddCashBoxDialog();
|
||||
} else if (item.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (item.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (item.label == t.checks) {
|
||||
// Navigate to add check
|
||||
context.go('/business/${widget.businessId}/checks/new');
|
||||
}
|
||||
// سایر مسیرهای افزودن در آینده متصل میشوند
|
||||
},
|
||||
|
|
@ -1044,6 +1075,12 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
// در حال حاضر فقط اشخاص پشتیبانی میشود
|
||||
if (item.label == t.people) {
|
||||
showAddPersonDialog();
|
||||
} else if (item.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (item.label == t.checks) {
|
||||
// Navigate to add check
|
||||
context.go('/business/${widget.businessId}/checks/new');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
|
|
@ -1110,6 +1147,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (child.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (child.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (child.label == t.expenseAndIncome) {
|
||||
// Navigate to add expense/income
|
||||
} else if (child.label == t.warehouses) {
|
||||
|
|
@ -1220,7 +1260,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
return true;
|
||||
}
|
||||
|
||||
// برای کاربران عضو، بررسی دسترسی view
|
||||
// برای کاربران عضو، بررسی دسترسی
|
||||
// تنظیمات: نیازمند دسترسی join
|
||||
if (section == 'settings' && item.label == AppLocalizations.of(context).settings) {
|
||||
final hasJoin = widget.authStore.hasBusinessPermission('settings', 'join');
|
||||
print(' Settings item requires join permission: $hasJoin');
|
||||
return hasJoin;
|
||||
}
|
||||
|
||||
// سایر سکشنها: بررسی دسترسی view
|
||||
final hasAccess = widget.authStore.canReadSection(section);
|
||||
print(' Checking view permission for section "$section": $hasAccess');
|
||||
|
||||
|
|
@ -1270,6 +1318,7 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
if (label == t.documents) return 'accounting_documents';
|
||||
if (label == t.chartOfAccounts) return 'chart_of_accounts';
|
||||
if (label == t.openingBalance) return 'opening_balance';
|
||||
if (label == t.reports) return 'reports';
|
||||
if (label == t.warehouses) return 'warehouses';
|
||||
if (label == t.shipments) return 'warehouse_transfers';
|
||||
if (label == t.inquiries) return 'reports';
|
||||
|
|
|
|||
350
hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart
Normal file
350
hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.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 StatefulWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
final int? checkId; // null => new, not null => edit
|
||||
final CalendarController? calendarController;
|
||||
|
||||
const CheckFormPage({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
170
hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart
Normal file
170
hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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 '../../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 StatefulWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
|
||||
const ChecksPage({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
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');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3,6 +3,8 @@ import 'package:hesabix_ui/l10n/app_localizations.dart';
|
|||
import '../../../services/business_dashboard_service.dart';
|
||||
import '../../../core/api_client.dart';
|
||||
import '../../../models/business_dashboard_models.dart';
|
||||
import '../../../core/fiscal_year_controller.dart';
|
||||
import '../../../widgets/fiscal_year_switcher.dart';
|
||||
|
||||
class BusinessDashboardPage extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -14,7 +16,8 @@ class BusinessDashboardPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||
final BusinessDashboardService _service = BusinessDashboardService(ApiClient());
|
||||
late final FiscalYearController _fiscalController;
|
||||
late final BusinessDashboardService _service;
|
||||
BusinessDashboardResponse? _dashboardData;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
|
@ -22,7 +25,20 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDashboard();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
_fiscalController = await FiscalYearController.load();
|
||||
_service = BusinessDashboardService(ApiClient(), fiscalYearController: _fiscalController);
|
||||
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
|
||||
_fiscalController.addListener(() {
|
||||
// بهروزرسانی هدر سراسری
|
||||
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
|
||||
// رفرش داشبورد
|
||||
_loadDashboard();
|
||||
});
|
||||
await _loadDashboard();
|
||||
}
|
||||
|
||||
Future<void> _loadDashboard() async {
|
||||
|
|
@ -85,9 +101,45 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.businessDashboard,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.businessDashboard,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
),
|
||||
FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _service.listFiscalYears(widget.businessId),
|
||||
builder: (context, snapshot) {
|
||||
final items = snapshot.data ?? const <Map<String, dynamic>>[];
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2));
|
||||
}
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.timeline, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
FiscalYearSwitcher(
|
||||
controller: _fiscalController,
|
||||
fiscalYears: items,
|
||||
onChanged: () => _loadDashboard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_dashboardData != null) ...[
|
||||
|
|
|
|||
|
|
@ -34,20 +34,20 @@ class _InvoicePageState extends State<InvoicePage> {
|
|||
Icon(
|
||||
Icons.receipt,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.6),
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
t.invoice,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'صفحه فاکتور در حال توسعه است',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,903 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import '../../core/auth_store.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
import '../../widgets/permission/access_denied_page.dart';
|
||||
import '../../widgets/invoice/invoice_type_combobox.dart';
|
||||
import '../../widgets/invoice/code_field_widget.dart';
|
||||
import '../../widgets/invoice/customer_combobox_widget.dart';
|
||||
import '../../widgets/invoice/seller_picker_widget.dart';
|
||||
import '../../widgets/invoice/commission_percentage_field.dart';
|
||||
import '../../widgets/invoice/commission_type_selector.dart';
|
||||
import '../../widgets/invoice/commission_amount_field.dart';
|
||||
import '../../widgets/date_input_field.dart';
|
||||
import '../../widgets/banking/currency_picker_widget.dart';
|
||||
import '../../core/date_utils.dart';
|
||||
import '../../models/invoice_type_model.dart';
|
||||
import '../../models/customer_model.dart';
|
||||
import '../../models/person_model.dart';
|
||||
|
||||
class NewInvoicePage extends StatefulWidget {
|
||||
final int businessId;
|
||||
final AuthStore authStore;
|
||||
final CalendarController calendarController;
|
||||
|
||||
const NewInvoicePage({
|
||||
super.key,
|
||||
required this.businessId,
|
||||
required this.authStore,
|
||||
required this.calendarController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NewInvoicePage> createState() => _NewInvoicePageState();
|
||||
}
|
||||
|
||||
class _NewInvoicePageState extends State<NewInvoicePage> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
InvoiceType? _selectedInvoiceType;
|
||||
bool _isDraft = false;
|
||||
String? _invoiceNumber;
|
||||
final bool _autoGenerateInvoiceNumber = true;
|
||||
Customer? _selectedCustomer;
|
||||
Person? _selectedSeller;
|
||||
double? _commissionPercentage;
|
||||
double? _commissionAmount;
|
||||
CommissionType? _commissionType;
|
||||
DateTime? _invoiceDate;
|
||||
DateTime? _dueDate;
|
||||
int? _selectedCurrencyId;
|
||||
String? _invoiceTitle;
|
||||
String? _invoiceReference;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
if (!widget.authStore.canWriteSection('invoices')) {
|
||||
return AccessDeniedPage(message: t.accessDenied);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(t.addInvoice),
|
||||
toolbarHeight: 56,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.info_outline),
|
||||
text: 'اطلاعات',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.inventory_2_outlined),
|
||||
text: 'کالا و خدمات',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.receipt_long_outlined),
|
||||
text: 'تراکنشها',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
text: 'تنظیمات',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// تب اطلاعات
|
||||
_buildInfoTab(),
|
||||
// تب کالا و خدمات
|
||||
_buildProductsTab(),
|
||||
// تب تراکنشها
|
||||
_buildTransactionsTab(),
|
||||
// تب تنظیمات
|
||||
_buildSettingsTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1600),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// فیلدهای اصلی - responsive layout
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// اگر عرض صفحه کمتر از 768 پیکسل باشد، تک ستونه
|
||||
if (constraints.maxWidth < 768) {
|
||||
return Column(
|
||||
children: [
|
||||
// نوع فاکتور
|
||||
InvoiceTypeCombobox(
|
||||
selectedType: _selectedInvoiceType,
|
||||
onTypeChanged: (type) {
|
||||
setState(() {
|
||||
_selectedInvoiceType = type;
|
||||
});
|
||||
},
|
||||
isDraft: _isDraft,
|
||||
onDraftChanged: (isDraft) {
|
||||
setState(() {
|
||||
_isDraft = isDraft;
|
||||
});
|
||||
},
|
||||
isRequired: true,
|
||||
label: 'نوع فاکتور',
|
||||
hintText: 'انتخاب نوع فاکتور',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// شماره فاکتور
|
||||
CodeFieldWidget(
|
||||
initialValue: _invoiceNumber,
|
||||
onChanged: (number) {
|
||||
setState(() {
|
||||
_invoiceNumber = number;
|
||||
});
|
||||
},
|
||||
isRequired: true,
|
||||
label: 'شماره فاکتور',
|
||||
hintText: 'مثال: INV-2024-001',
|
||||
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// تاریخ فاکتور
|
||||
DateInputField(
|
||||
value: _invoiceDate,
|
||||
labelText: 'تاریخ فاکتور *',
|
||||
hintText: 'انتخاب تاریخ فاکتور',
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_invoiceDate = date;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// تاریخ سررسید
|
||||
DateInputField(
|
||||
value: _dueDate,
|
||||
labelText: 'تاریخ سررسید',
|
||||
hintText: 'انتخاب تاریخ سررسید',
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_dueDate = date;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// مشتری
|
||||
CustomerComboboxWidget(
|
||||
selectedCustomer: _selectedCustomer,
|
||||
onCustomerChanged: (customer) {
|
||||
setState(() {
|
||||
_selectedCustomer = customer;
|
||||
});
|
||||
},
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
isRequired: false,
|
||||
label: 'مشتری',
|
||||
hintText: 'انتخاب مشتری',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ارز فاکتور
|
||||
CurrencyPickerWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedCurrencyId: _selectedCurrencyId,
|
||||
onChanged: (currencyId) {
|
||||
setState(() {
|
||||
_selectedCurrencyId = currencyId;
|
||||
});
|
||||
},
|
||||
label: 'ارز فاکتور',
|
||||
hintText: 'انتخاب ارز فاکتور',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SellerPickerWidget(
|
||||
selectedSeller: _selectedSeller,
|
||||
onSellerChanged: (seller) {
|
||||
setState(() {
|
||||
_selectedSeller = seller;
|
||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||
if (seller != null) {
|
||||
if (seller.commissionSalePercent != null) {
|
||||
_commissionType = CommissionType.percentage;
|
||||
_commissionPercentage = seller.commissionSalePercent;
|
||||
_commissionAmount = null;
|
||||
} else if (seller.commissionSalesAmount != null) {
|
||||
_commissionType = CommissionType.amount;
|
||||
_commissionAmount = seller.commissionSalesAmount;
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
} else {
|
||||
_commissionType = null;
|
||||
_commissionPercentage = null;
|
||||
_commissionAmount = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
isRequired: false,
|
||||
label: 'فروشنده/بازاریاب',
|
||||
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||
if (_selectedSeller != null) ...[
|
||||
Expanded(
|
||||
child: CommissionTypeSelector(
|
||||
selectedType: _commissionType,
|
||||
onTypeChanged: (type) {
|
||||
setState(() {
|
||||
_commissionType = type;
|
||||
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||
if (type == CommissionType.percentage) {
|
||||
_commissionAmount = null;
|
||||
} else if (type == CommissionType.amount) {
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'نوع کارمزد',
|
||||
hintText: 'انتخاب نوع کارمزد',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||
if (_commissionType == CommissionType.percentage)
|
||||
Expanded(
|
||||
child: CommissionPercentageField(
|
||||
initialValue: _commissionPercentage,
|
||||
onChanged: (percentage) {
|
||||
setState(() {
|
||||
_commissionPercentage = percentage;
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'درصد کارمزد',
|
||||
hintText: 'مثال: 5.5',
|
||||
),
|
||||
)
|
||||
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||
else if (_commissionType == CommissionType.amount)
|
||||
Expanded(
|
||||
child: CommissionAmountField(
|
||||
initialValue: _commissionAmount,
|
||||
onChanged: (amount) {
|
||||
setState(() {
|
||||
_commissionAmount = amount;
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'مبلغ کارمزد',
|
||||
hintText: 'مثال: 100000',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// عنوان فاکتور
|
||||
TextFormField(
|
||||
initialValue: _invoiceTitle,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'عنوان فاکتور',
|
||||
hintText: 'مثال: فروش محصولات',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ارجاع
|
||||
TextFormField(
|
||||
initialValue: _invoiceReference,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ارجاع',
|
||||
hintText: 'مثال: PO-2024-001',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// برای دسکتاپ - چند ستونه
|
||||
return Column(
|
||||
children: [
|
||||
// ردیف اول: 5 فیلد اصلی
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: InvoiceTypeCombobox(
|
||||
selectedType: _selectedInvoiceType,
|
||||
onTypeChanged: (type) {
|
||||
setState(() {
|
||||
_selectedInvoiceType = type;
|
||||
});
|
||||
},
|
||||
isDraft: _isDraft,
|
||||
onDraftChanged: (isDraft) {
|
||||
setState(() {
|
||||
_isDraft = isDraft;
|
||||
});
|
||||
},
|
||||
isRequired: true,
|
||||
label: 'نوع فاکتور',
|
||||
hintText: 'انتخاب نوع فاکتور',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CodeFieldWidget(
|
||||
initialValue: _invoiceNumber,
|
||||
onChanged: (number) {
|
||||
setState(() {
|
||||
_invoiceNumber = number;
|
||||
});
|
||||
},
|
||||
isRequired: true,
|
||||
label: 'شماره فاکتور',
|
||||
hintText: 'مثال: INV-2024-001',
|
||||
autoGenerateCode: _autoGenerateInvoiceNumber,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: _invoiceDate,
|
||||
labelText: 'تاریخ فاکتور *',
|
||||
hintText: 'انتخاب تاریخ فاکتور',
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_invoiceDate = date;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DateInputField(
|
||||
value: _dueDate,
|
||||
labelText: 'تاریخ سررسید',
|
||||
hintText: 'انتخاب تاریخ سررسید',
|
||||
calendarController: widget.calendarController,
|
||||
onChanged: (date) {
|
||||
setState(() {
|
||||
_dueDate = date;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: CustomerComboboxWidget(
|
||||
selectedCustomer: _selectedCustomer,
|
||||
onCustomerChanged: (customer) {
|
||||
setState(() {
|
||||
_selectedCustomer = customer;
|
||||
});
|
||||
},
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
isRequired: false,
|
||||
label: 'مشتری',
|
||||
hintText: 'انتخاب مشتری',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ردیف دوم: ارز، عنوان فاکتور، ارجاع
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CurrencyPickerWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedCurrencyId: _selectedCurrencyId,
|
||||
onChanged: (currencyId) {
|
||||
setState(() {
|
||||
_selectedCurrencyId = currencyId;
|
||||
});
|
||||
},
|
||||
label: 'ارز فاکتور',
|
||||
hintText: 'انتخاب ارز فاکتور',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _invoiceTitle,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_invoiceTitle = value.trim().isEmpty ? null : value.trim();
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'عنوان فاکتور',
|
||||
hintText: 'مثال: فروش محصولات',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _invoiceReference,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_invoiceReference = value.trim().isEmpty ? null : value.trim();
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'ارجاع',
|
||||
hintText: 'مثال: PO-2024-001',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: SizedBox()), // جای خالی
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: SizedBox()), // جای خالی
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ردیف سوم: فروشنده و کارمزد (فقط برای فروش و برگشت فروش)
|
||||
if (_selectedInvoiceType == InvoiceType.sales || _selectedInvoiceType == InvoiceType.salesReturn) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SellerPickerWidget(
|
||||
selectedSeller: _selectedSeller,
|
||||
onSellerChanged: (seller) {
|
||||
setState(() {
|
||||
_selectedSeller = seller;
|
||||
// تنظیم خودکار نوع کارمزد و مقادیر بر اساس فروشنده
|
||||
if (seller != null) {
|
||||
if (seller.commissionSalePercent != null) {
|
||||
_commissionType = CommissionType.percentage;
|
||||
_commissionPercentage = seller.commissionSalePercent;
|
||||
_commissionAmount = null;
|
||||
} else if (seller.commissionSalesAmount != null) {
|
||||
_commissionType = CommissionType.amount;
|
||||
_commissionAmount = seller.commissionSalesAmount;
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
} else {
|
||||
_commissionType = null;
|
||||
_commissionPercentage = null;
|
||||
_commissionAmount = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
businessId: widget.businessId,
|
||||
authStore: widget.authStore,
|
||||
isRequired: false,
|
||||
label: 'فروشنده/بازاریاب',
|
||||
hintText: 'جستوجو و انتخاب فروشنده یا بازاریاب',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// فیلدهای کارمزد (فقط اگر فروشنده انتخاب شده باشد)
|
||||
if (_selectedSeller != null) ...[
|
||||
Expanded(
|
||||
child: CommissionTypeSelector(
|
||||
selectedType: _commissionType,
|
||||
onTypeChanged: (type) {
|
||||
setState(() {
|
||||
_commissionType = type;
|
||||
// پاک کردن مقادیر قبلی هنگام تغییر نوع
|
||||
if (type == CommissionType.percentage) {
|
||||
_commissionAmount = null;
|
||||
} else if (type == CommissionType.amount) {
|
||||
_commissionPercentage = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'نوع کارمزد',
|
||||
hintText: 'انتخاب نوع کارمزد',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// فیلد درصد کارمزد (فقط اگر نوع درصدی انتخاب شده)
|
||||
if (_commissionType == CommissionType.percentage)
|
||||
Expanded(
|
||||
child: CommissionPercentageField(
|
||||
initialValue: _commissionPercentage,
|
||||
onChanged: (percentage) {
|
||||
setState(() {
|
||||
_commissionPercentage = percentage;
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'درصد کارمزد',
|
||||
hintText: 'مثال: 5.5',
|
||||
),
|
||||
)
|
||||
// فیلد مبلغ کارمزد (فقط اگر نوع مبلغی انتخاب شده)
|
||||
else if (_commissionType == CommissionType.amount)
|
||||
Expanded(
|
||||
child: CommissionAmountField(
|
||||
initialValue: _commissionAmount,
|
||||
onChanged: (amount) {
|
||||
setState(() {
|
||||
_commissionAmount = amount;
|
||||
});
|
||||
},
|
||||
isRequired: false,
|
||||
label: 'مبلغ کارمزد',
|
||||
hintText: 'مثال: 100000',
|
||||
),
|
||||
)
|
||||
else
|
||||
const Expanded(child: SizedBox()),
|
||||
const SizedBox(width: 12),
|
||||
] else ...[
|
||||
const Expanded(child: SizedBox()),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: SizedBox()),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
const Expanded(child: SizedBox()), // جای خالی
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// دکمه ادامه
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: (_selectedInvoiceType != null && _invoiceDate != null) ? _continueToInvoiceForm : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text('ادامه ایجاد فاکتور'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
minimumSize: const Size(200, 48),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// نمایش اطلاعات انتخاب شده
|
||||
if (_selectedInvoiceType != null || _invoiceDate != null || _dueDate != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'اطلاعات انتخاب شده:',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// نمایش اطلاعات در دو ستون
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_selectedInvoiceType != null)
|
||||
_buildInfoItem('نوع فاکتور', _selectedInvoiceType!.label),
|
||||
if (_invoiceDate != null)
|
||||
_buildInfoItem('تاریخ فاکتور', HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)),
|
||||
if (_dueDate != null)
|
||||
_buildInfoItem('تاریخ سررسید', HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)),
|
||||
if (_selectedCurrencyId != null)
|
||||
_buildInfoItem('ارز فاکتور', 'انتخاب شده'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_selectedSeller != null)
|
||||
_buildInfoItem('فروشنده/بازاریاب', '${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})'),
|
||||
if (_commissionType != null)
|
||||
_buildInfoItem('نوع کارمزد', _commissionType!.label),
|
||||
if (_commissionPercentage != null)
|
||||
_buildInfoItem('درصد کارمزد', '${_commissionPercentage!.toStringAsFixed(1)}%'),
|
||||
if (_commissionAmount != null)
|
||||
_buildInfoItem('مبلغ کارمزد', '${_commissionAmount!.toStringAsFixed(0)} ریال'),
|
||||
if (_invoiceTitle != null)
|
||||
_buildInfoItem('عنوان فاکتور', _invoiceTitle!),
|
||||
if (_invoiceReference != null)
|
||||
_buildInfoItem('ارجاع', _invoiceReference!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _continueToInvoiceForm() {
|
||||
if (_selectedInvoiceType == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('لطفا نوع فاکتور را انتخاب کنید'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final invoiceNumberText = _autoGenerateInvoiceNumber
|
||||
? 'شماره فاکتور: اتوماتیک\n'
|
||||
: (_invoiceNumber != null
|
||||
? 'شماره فاکتور: $_invoiceNumber\n'
|
||||
: 'شماره فاکتور: انتخاب نشده\n');
|
||||
|
||||
final customerText = _selectedCustomer != null
|
||||
? 'مشتری: ${_selectedCustomer!.name}\n'
|
||||
: 'مشتری: خویشتنفروش\n';
|
||||
|
||||
final sellerText = _selectedSeller != null
|
||||
? 'فروشنده/بازاریاب: ${_selectedSeller!.displayName} (${_selectedSeller!.personTypes.isNotEmpty ? _selectedSeller!.personTypes.first.persianName : 'نامشخص'})\n'
|
||||
: '';
|
||||
|
||||
final commissionText = _commissionPercentage != null
|
||||
? 'درصد کارمزد: ${_commissionPercentage!.toStringAsFixed(1)}%\n'
|
||||
: '';
|
||||
|
||||
final invoiceDateText = _invoiceDate != null
|
||||
? 'تاریخ فاکتور: ${HesabixDateUtils.formatForDisplay(_invoiceDate, widget.calendarController.isJalali == true)}\n'
|
||||
: 'تاریخ فاکتور: انتخاب نشده\n';
|
||||
|
||||
final dueDateText = _dueDate != null
|
||||
? 'تاریخ سررسید: ${HesabixDateUtils.formatForDisplay(_dueDate, widget.calendarController.isJalali == true)}\n'
|
||||
: 'تاریخ سررسید: انتخاب نشده\n';
|
||||
|
||||
final currencyText = _selectedCurrencyId != null
|
||||
? 'ارز فاکتور: انتخاب شده\n'
|
||||
: 'ارز فاکتور: انتخاب نشده\n';
|
||||
|
||||
final titleText = _invoiceTitle != null
|
||||
? 'عنوان فاکتور: $_invoiceTitle\n'
|
||||
: 'عنوان فاکتور: انتخاب نشده\n';
|
||||
|
||||
final referenceText = _invoiceReference != null
|
||||
? 'ارجاع: $_invoiceReference\n'
|
||||
: 'ارجاع: انتخاب نشده\n';
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('نوع فاکتور: ${_selectedInvoiceType!.label}\n$invoiceNumberText$customerText$sellerText$commissionText$invoiceDateText$dueDateText$currencyText$titleText$referenceText\nفرم کامل فاکتور به زودی اضافه خواهد شد'),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: در آینده میتوانید به صفحه فرم کامل فاکتور بروید
|
||||
// Navigator.push(
|
||||
// context,
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => InvoiceFormPage(
|
||||
// businessId: widget.businessId,
|
||||
// authStore: widget.authStore,
|
||||
// invoiceType: _selectedInvoiceType!,
|
||||
// invoiceNumber: _invoiceNumber,
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
}
|
||||
|
||||
Widget _buildProductsTab() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'کالا و خدمات',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'این بخش در آینده پیادهسازی خواهد شد',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTransactionsTab() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'تراکنشها',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'این بخش در آینده پیادهسازی خواهد شد',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsTab() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'تنظیمات',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'این بخش در آینده پیادهسازی خواهد شد',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -103,9 +103,7 @@ class _PersonsPageState extends State<PersonsPage> {
|
|||
FilterOption(value: 'فروشنده', label: t.personTypeSeller),
|
||||
FilterOption(value: 'سهامدار', label: 'سهامدار'),
|
||||
],
|
||||
formatter: (person) => (person.personTypes.isNotEmpty
|
||||
? person.personTypes.map((e) => e.persianName).join('، ')
|
||||
: person.personType.persianName),
|
||||
formatter: (person) => person.personTypes.map((e) => e.persianName).join('، '),
|
||||
),
|
||||
TextColumn(
|
||||
'company_name',
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
|||
onChanged: (v) => productId = int.tryParse(v),
|
||||
),
|
||||
DropdownButtonFormField<int>(
|
||||
value: currencyId,
|
||||
initialValue: currencyId,
|
||||
items: _fallbackCurrencies
|
||||
.map((c) => DropdownMenuItem<int>(
|
||||
value: c['id'] as int,
|
||||
|
|
@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
|||
onChanged: (v) => tierName = v,
|
||||
),
|
||||
DropdownButtonFormField<int>(
|
||||
value: unitId,
|
||||
initialValue: unitId,
|
||||
items: _fallbackUnits
|
||||
.map((u) => DropdownMenuItem<int>(
|
||||
value: u['id'] as int,
|
||||
|
|
|
|||
|
|
@ -169,8 +169,8 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
|||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isActive
|
||||
? Theme.of(context).primaryColor.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
? Theme.of(context).primaryColor.withValues(alpha: 0.1)
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
child: Icon(
|
||||
Icons.price_change,
|
||||
color: isActive
|
||||
|
|
@ -193,7 +193,7 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
|||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
|
|
@ -392,9 +392,9 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
|||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue