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 .categories import router as categories # noqa: F401
|
||||||
from .products import router as products # noqa: F401
|
from .products import router as products # noqa: F401
|
||||||
from .price_lists import router as price_lists # 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", "دسته"),
|
("category_id", "دسته"),
|
||||||
("base_sales_price", "قیمت فروش"),
|
("base_sales_price", "قیمت فروش"),
|
||||||
("base_purchase_price", "قیمت خرید"),
|
("base_purchase_price", "قیمت خرید"),
|
||||||
("main_unit_id", "واحد اصلی"),
|
("main_unit", "واحد اصلی"),
|
||||||
("secondary_unit_id", "واحد فرعی"),
|
("secondary_unit", "واحد فرعی"),
|
||||||
("track_inventory", "کنترل موجودی"),
|
("track_inventory", "کنترل موجودی"),
|
||||||
("created_at_formatted", "ایجاد"),
|
("created_at_formatted", "ایجاد"),
|
||||||
]
|
]
|
||||||
|
|
@ -358,7 +358,7 @@ async def download_products_import_template(
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"code","name","item_type","description","category_id",
|
"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",
|
"base_sales_price","base_purchase_price","track_inventory",
|
||||||
"reorder_point","min_order_qty","lead_time_days",
|
"reorder_point","min_order_qty","lead_time_days",
|
||||||
"is_sales_taxable","is_purchase_taxable","sales_tax_rate","purchase_tax_rate",
|
"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']:
|
for k in ['base_sales_price','base_purchase_price','sales_tax_rate','purchase_tax_rate','unit_conversion_factor']:
|
||||||
if k in item:
|
if k in item:
|
||||||
item[k] = _parse_decimal(item.get(k))
|
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:
|
if k in item:
|
||||||
item[k] = _parse_int(item.get(k))
|
item[k] = _parse_int(item.get(k))
|
||||||
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
|
for k in ['track_inventory','is_sales_taxable','is_purchase_taxable']:
|
||||||
|
|
@ -673,8 +673,8 @@ async def export_products_pdf(
|
||||||
("category_id", "دسته"),
|
("category_id", "دسته"),
|
||||||
("base_sales_price", "قیمت فروش"),
|
("base_sales_price", "قیمت فروش"),
|
||||||
("base_purchase_price", "قیمت خرید"),
|
("base_purchase_price", "قیمت خرید"),
|
||||||
("main_unit_id", "واحد اصلی"),
|
("main_unit", "واحد اصلی"),
|
||||||
("secondary_unit_id", "واحد فرعی"),
|
("secondary_unit", "واحد فرعی"),
|
||||||
("track_inventory", "کنترل موجودی"),
|
("track_inventory", "کنترل موجودی"),
|
||||||
("created_at_formatted", "ایجاد"),
|
("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
|
# Import from file_storage module
|
||||||
from .file_storage import *
|
from .file_storage import *
|
||||||
|
|
||||||
|
# Import document line schemas
|
||||||
|
from .document_line import *
|
||||||
|
|
||||||
# Re-export from parent schemas module
|
# Re-export from parent schemas module
|
||||||
import sys
|
import sys
|
||||||
import os
|
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)
|
description: Optional[str] = Field(default=None, max_length=2000)
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
|
|
||||||
main_unit_id: Optional[int] = None
|
main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
|
||||||
secondary_unit_id: Optional[int] = None
|
secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
|
||||||
unit_conversion_factor: Optional[Decimal] = None
|
unit_conversion_factor: Optional[Decimal] = None
|
||||||
|
|
||||||
base_sales_price: 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)
|
description: Optional[str] = Field(default=None, max_length=2000)
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
|
|
||||||
main_unit_id: Optional[int] = None
|
main_unit: Optional[str] = Field(default=None, max_length=32, description="واحد اصلی شمارش")
|
||||||
secondary_unit_id: Optional[int] = None
|
secondary_unit: Optional[str] = Field(default=None, max_length=32, description="واحد فرعی شمارش")
|
||||||
unit_conversion_factor: Optional[Decimal] = None
|
unit_conversion_factor: Optional[Decimal] = None
|
||||||
|
|
||||||
base_sales_price: Optional[Decimal] = None
|
base_sales_price: Optional[Decimal] = None
|
||||||
|
|
@ -83,8 +83,8 @@ class ProductResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
main_unit_id: Optional[int] = None
|
main_unit: Optional[str] = None
|
||||||
secondary_unit_id: Optional[int] = None
|
secondary_unit: Optional[str] = None
|
||||||
unit_conversion_factor: Optional[Decimal] = None
|
unit_conversion_factor: Optional[Decimal] = None
|
||||||
base_sales_price: Optional[Decimal] = None
|
base_sales_price: Optional[Decimal] = None
|
||||||
base_sales_note: Optional[str] = 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 fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
from adapters.api.v1.schemas import SuccessResponse
|
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.responses import success_response
|
||||||
from app.core.auth_dependency import get_current_user, AuthContext
|
from sqlalchemy.orm import Session
|
||||||
from app.core.permissions import require_business_access
|
from adapters.db.models.tax_type import TaxType
|
||||||
from sqlalchemy.orm import Session # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tax-types", tags=["tax-types"])
|
router = APIRouter(prefix="/tax-types", tags=["tax-types"])
|
||||||
|
|
||||||
|
|
||||||
def _static_tax_types() -> List[Dict[str, Any]]:
|
@router.get("/",
|
||||||
titles = [
|
summary="لیست نوعهای مالیات",
|
||||||
"دارو",
|
description="دریافت لیست تمام نوعهای مالیات استاندارد",
|
||||||
"دخانیات",
|
|
||||||
"موبایل",
|
|
||||||
"لوازم خانگی برقی",
|
|
||||||
"قطعات مصرفی و یدکی وسایل نقلیه",
|
|
||||||
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
|
|
||||||
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
|
|
||||||
"منسوجات و پوشاک",
|
|
||||||
"اسباب بازی",
|
|
||||||
"دام زنده، گوشت سفید و قرمز",
|
|
||||||
"محصولات اساسی کشاورزی",
|
|
||||||
"سایر کالا ها",
|
|
||||||
]
|
|
||||||
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/business/{business_id}",
|
|
||||||
summary="لیست نوعهای مالیات",
|
|
||||||
description="دریافت لیست نوعهای مالیات (ثابت)",
|
|
||||||
response_model=SuccessResponse,
|
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(
|
def list_tax_types(
|
||||||
request: Request,
|
request: Request,
|
||||||
business_id: int,
|
db: Session = Depends(get_db),
|
||||||
ctx: AuthContext = Depends(get_current_user),
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
# Currently returns a static list; later can be sourced from DB if needed
|
"""دریافت لیست تمام نوعهای مالیات استاندارد"""
|
||||||
items = _static_tax_types()
|
|
||||||
return success_response(items, request)
|
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 sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import Dict, Any
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from adapters.db.session import get_db
|
from adapters.db.session import get_db
|
||||||
from adapters.db.models.tax_unit import TaxUnit
|
from adapters.db.models.tax_unit import TaxUnit
|
||||||
from adapters.api.v1.schemas import SuccessResponse
|
from adapters.api.v1.schemas import SuccessResponse
|
||||||
from app.core.responses import success_response, format_datetime_fields
|
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 pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tax-units", tags=["tax-units"])
|
router = APIRouter(prefix="/tax-units", tags=["tax-units"])
|
||||||
alias_router = APIRouter(prefix="/units", tags=["units"])
|
|
||||||
|
|
||||||
|
|
||||||
class TaxUnitCreateRequest(BaseModel):
|
@router.get("/",
|
||||||
name: str = Field(..., min_length=1, max_length=255, description="نام واحد مالیاتی")
|
summary="لیست واحدهای مالیاتی",
|
||||||
code: str = Field(..., min_length=1, max_length=64, description="کد واحد مالیاتی")
|
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="دریافت لیست واحدهای مالیاتی یک کسبوکار",
|
|
||||||
response_model=SuccessResponse,
|
response_model=SuccessResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
|
|
@ -62,12 +26,9 @@ class TaxUnitResponse(BaseModel):
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"business_id": 1,
|
"name": "کیلوگرم",
|
||||||
"name": "مالیات بر ارزش افزوده",
|
"code": "کیلوگرم",
|
||||||
"code": "VAT",
|
"description": None,
|
||||||
"description": "مالیات بر ارزش افزوده 9 درصد",
|
|
||||||
"tax_rate": 9.0,
|
|
||||||
"is_active": True,
|
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
"updated_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}")
|
def list_tax_units(
|
||||||
@require_business_access()
|
|
||||||
def get_tax_units(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
business_id: int,
|
|
||||||
ctx: AuthContext = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> Dict[str, Any]:
|
||||||
"""دریافت لیست واحدهای مالیاتی یک کسبوکار"""
|
"""دریافت لیست تمام واحدهای مالیاتی استاندارد"""
|
||||||
|
|
||||||
# Query tax units for the business
|
# Query all tax units (they are global now)
|
||||||
tax_units = db.query(TaxUnit).filter(
|
tax_units = db.query(TaxUnit).order_by(TaxUnit.name).all()
|
||||||
TaxUnit.business_id == business_id
|
|
||||||
).order_by(TaxUnit.name).all()
|
|
||||||
|
|
||||||
# Convert to response format
|
# Convert to response format
|
||||||
tax_unit_dicts = []
|
tax_unit_dicts = []
|
||||||
for tax_unit in tax_units:
|
for tax_unit in tax_units:
|
||||||
tax_unit_dict = {
|
tax_unit_dict = {
|
||||||
"id": tax_unit.id,
|
"id": tax_unit.id,
|
||||||
"business_id": tax_unit.business_id,
|
|
||||||
"name": tax_unit.name,
|
"name": tax_unit.name,
|
||||||
"code": tax_unit.code,
|
"code": tax_unit.code,
|
||||||
"description": tax_unit.description,
|
"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(),
|
"created_at": tax_unit.created_at.isoformat(),
|
||||||
"updated_at": tax_unit.updated_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)
|
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 .price_list import PriceList, PriceItem # noqa: F401
|
||||||
from .product_attribute_link import ProductAttributeLink # noqa: F401
|
from .product_attribute_link import ProductAttributeLink # noqa: F401
|
||||||
from .tax_unit import TaxUnit # noqa: F401
|
from .tax_unit import TaxUnit # noqa: F401
|
||||||
|
from .tax_type import TaxType # noqa: F401
|
||||||
from .bank_account import BankAccount # noqa: F401
|
from .bank_account import BankAccount # noqa: F401
|
||||||
|
from .cash_register import CashRegister # noqa: F401
|
||||||
from .petty_cash import PettyCash # noqa: F401
|
from .petty_cash import PettyCash # noqa: F401
|
||||||
|
from .check import Check # noqa: F401
|
||||||
|
|
|
||||||
65
hesabixAPI/adapters/db/models/check.py
Normal file
65
hesabixAPI/adapters/db/models/check.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
Numeric,
|
||||||
|
Enum as SQLEnum,
|
||||||
|
Index,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from adapters.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CheckType(str, Enum):
|
||||||
|
RECEIVED = "received"
|
||||||
|
TRANSFERRED = "transferred"
|
||||||
|
|
||||||
|
|
||||||
|
class Check(Base):
|
||||||
|
__tablename__ = "checks"
|
||||||
|
__table_args__ = (
|
||||||
|
# پیشنهاد: یکتا بودن شماره چک در سطح کسبوکار
|
||||||
|
UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'),
|
||||||
|
# پیشنهاد: یکتا بودن شناسه صیاد در سطح کسبوکار (چند NULL مجاز است)
|
||||||
|
UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'),
|
||||||
|
Index('ix_checks_business_type', 'business_id', 'type'),
|
||||||
|
Index('ix_checks_business_person', 'business_id', 'person_id'),
|
||||||
|
Index('ix_checks_business_issue_date', 'business_id', 'issue_date'),
|
||||||
|
Index('ix_checks_business_due_date', 'business_id', 'due_date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
type: Mapped[CheckType] = mapped_column(SQLEnum(CheckType, name="check_type"), nullable=False, index=True)
|
||||||
|
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
issue_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
due_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
|
||||||
|
check_number: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
sayad_code: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
|
|
||||||
|
bank_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||||
|
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
# روابط
|
||||||
|
business = relationship("Business", backref="checks")
|
||||||
|
person = relationship("Person", lazy="joined")
|
||||||
|
currency = relationship("Currency")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,6 +17,7 @@ class Document(Base):
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=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)
|
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)
|
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)
|
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)
|
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
@ -30,6 +31,7 @@ class Document(Base):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
business = relationship("Business", back_populates="documents")
|
business = relationship("Business", back_populates="documents")
|
||||||
|
fiscal_year = relationship("FiscalYear", back_populates="documents")
|
||||||
currency = relationship("Currency", back_populates="documents")
|
currency = relationship("Currency", back_populates="documents")
|
||||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||||
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
|
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
|
account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True)
|
||||||
|
person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
bank_account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
cash_register_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("cash_registers.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
petty_cash_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("petty_cash.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
check_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("checks.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True, default=0)
|
||||||
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||||
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
@ -26,5 +33,11 @@ class DocumentLine(Base):
|
||||||
# Relationships
|
# Relationships
|
||||||
document = relationship("Document", back_populates="lines")
|
document = relationship("Document", back_populates="lines")
|
||||||
account = relationship("Account", back_populates="document_lines")
|
account = relationship("Account", back_populates="document_lines")
|
||||||
|
person = relationship("Person")
|
||||||
|
product = relationship("Product")
|
||||||
|
bank_account = relationship("BankAccount")
|
||||||
|
cash_register = relationship("CashRegister")
|
||||||
|
petty_cash = relationship("PettyCash")
|
||||||
|
check = relationship("Check")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,6 @@ class FiscalYear(Base):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
business = relationship("Business", back_populates="fiscal_years")
|
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="نام مستعار (الزامی)")
|
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="نام")
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="نام")
|
||||||
last_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(
|
person_types: Mapped[str] = mapped_column(Text, nullable=False, comment="لیست انواع شخص به صورت JSON")
|
||||||
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")
|
|
||||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
|
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="نام شرکت")
|
||||||
payment_id: Mapped[str | None] = mapped_column(String(100), 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)
|
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)
|
main_unit: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="واحد اصلی شمارش")
|
||||||
secondary_unit_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
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)
|
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 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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from adapters.db.session import Base
|
from adapters.db.session import Base
|
||||||
|
|
||||||
|
|
@ -14,11 +14,8 @@ class TaxUnit(Base):
|
||||||
__tablename__ = "tax_units"
|
__tablename__ = "tax_units"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
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="نام واحد مالیاتی")
|
name: Mapped[str] = mapped_column(String(255), nullable=False, comment="نام واحد مالیاتی")
|
||||||
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
|
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="کد واحد مالیاتی")
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, 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)
|
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)
|
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)
|
self.db.refresh(fiscal_year)
|
||||||
return 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,
|
"name": p.name,
|
||||||
"description": p.description,
|
"description": p.description,
|
||||||
"category_id": p.category_id,
|
"category_id": p.category_id,
|
||||||
"main_unit_id": p.main_unit_id,
|
"main_unit": p.main_unit,
|
||||||
"secondary_unit_id": p.secondary_unit_id,
|
"secondary_unit": p.secondary_unit,
|
||||||
"unit_conversion_factor": p.unit_conversion_factor,
|
"unit_conversion_factor": p.unit_conversion_factor,
|
||||||
"base_sales_price": p.base_sales_price,
|
"base_sales_price": p.base_sales_price,
|
||||||
"base_sales_note": p.base_sales_note,
|
"base_sales_note": p.base_sales_note,
|
||||||
|
|
@ -125,9 +125,14 @@ class ProductRepository(BaseRepository[Product]):
|
||||||
obj = self.db.get(Product, product_id)
|
obj = self.db.get(Product, product_id)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None
|
return None
|
||||||
|
# اجازه بده فیلدهای خاص حتی اگر None باشند هم ست شوند
|
||||||
|
nullable_overrides = {"main_unit_id", "secondary_unit_id", "unit_conversion_factor"}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if hasattr(obj, k) and v is not None:
|
if hasattr(obj, k):
|
||||||
setattr(obj, k, v)
|
if k in nullable_overrides:
|
||||||
|
setattr(obj, k, v)
|
||||||
|
elif v is not None:
|
||||||
|
setattr(obj, k, v)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(obj)
|
self.db.refresh(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
|
|
@ -212,3 +212,10 @@ def require_business_management_dep(auth_context: AuthContext = Depends(get_curr
|
||||||
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
|
"""FastAPI dependency برای بررسی مجوز مدیریت کسب و کارها."""
|
||||||
if not auth_context.has_app_permission("business_management"):
|
if not auth_context.has_app_permission("business_management"):
|
||||||
raise ApiError("FORBIDDEN", "Missing app permission: business_management", http_status=403)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
|
||||||
from fastapi import HTTPException, status, Request
|
from fastapi import HTTPException, status, Request
|
||||||
from .calendar import CalendarConverter, CalendarType
|
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"]
|
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
|
||||||
else:
|
else:
|
||||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
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)):
|
elif isinstance(value, (dict, list)):
|
||||||
formatted_data[key] = format_datetime_fields(value, request)
|
formatted_data[key] = format_datetime_fields(value, request)
|
||||||
else:
|
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.product_attributes import router as product_attributes_router
|
||||||
from adapters.api.v1.products import router as products_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.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.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.bank_accounts import router as bank_accounts_router
|
||||||
from adapters.api.v1.cash_registers import router as cash_registers_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.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 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.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.tickets import router as support_tickets_router
|
||||||
from adapters.api.v1.support.operator import router as support_operator_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.support.statuses import router as support_statuses_router
|
||||||
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
|
from adapters.api.v1.admin.file_storage import router as admin_file_storage_router
|
||||||
from adapters.api.v1.admin.email_config import router as admin_email_config_router
|
from adapters.api.v1.admin.email_config import router as admin_email_config_router
|
||||||
|
from adapters.api.v1.receipts_payments import router as receipts_payments_router
|
||||||
|
from adapters.api.v1.fiscal_years import router as fiscal_years_router
|
||||||
from app.core.i18n import negotiate_locale, Translator
|
from app.core.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
||||||
|
|
@ -294,13 +297,18 @@ def create_app() -> FastAPI:
|
||||||
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
application.include_router(product_attributes_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(products_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(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(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)
|
application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix)
|
||||||
|
from adapters.api.v1.checks import router as checks_router
|
||||||
|
application.include_router(checks_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
application.include_router(cash_registers_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
application.include_router(petty_cash_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
|
application.include_router(tax_units_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(units_alias_router, prefix=settings.api_v1_prefix)
|
|
||||||
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
|
application.include_router(tax_types_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
# Support endpoints
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
|
||||||
286
hesabixAPI/app/services/check_service.py
Normal file
286
hesabixAPI/app/services/check_service.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
||||||
|
from adapters.db.models.check import Check, CheckType
|
||||||
|
from adapters.db.models.person import Person
|
||||||
|
from adapters.db.models.currency import Currency
|
||||||
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(dt: str) -> datetime:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
ctype = str(data.get('type', '')).lower()
|
||||||
|
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||||
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
|
||||||
|
person_id = data.get('person_id')
|
||||||
|
if ctype == CheckType.RECEIVED.value and not person_id:
|
||||||
|
raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400)
|
||||||
|
|
||||||
|
issue_date = _parse_iso(str(data.get('issue_date')))
|
||||||
|
due_date = _parse_iso(str(data.get('due_date')))
|
||||||
|
if due_date < issue_date:
|
||||||
|
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
|
||||||
|
|
||||||
|
sayad = data.get('sayad_code')
|
||||||
|
if sayad is not None:
|
||||||
|
s = str(sayad).strip()
|
||||||
|
if s and (len(s) != 16 or not s.isdigit()):
|
||||||
|
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
|
||||||
|
|
||||||
|
amount = data.get('amount')
|
||||||
|
try:
|
||||||
|
amount_val = float(amount)
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
|
||||||
|
if amount_val <= 0:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
|
||||||
|
|
||||||
|
check_number = str(data.get('check_number', '')).strip()
|
||||||
|
if not check_number:
|
||||||
|
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
|
||||||
|
|
||||||
|
# یونیک بودن در سطح کسبوکار
|
||||||
|
exists = db.query(Check).filter(and_(Check.business_id == business_id, Check.check_number == check_number)).first()
|
||||||
|
if exists is not None:
|
||||||
|
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
|
||||||
|
|
||||||
|
if sayad:
|
||||||
|
exists_sayad = db.query(Check).filter(and_(Check.business_id == business_id, Check.sayad_code == sayad)).first()
|
||||||
|
if exists_sayad is not None:
|
||||||
|
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
|
||||||
|
|
||||||
|
obj = Check(
|
||||||
|
business_id=business_id,
|
||||||
|
type=CheckType(ctype),
|
||||||
|
person_id=int(person_id) if person_id else None,
|
||||||
|
issue_date=issue_date,
|
||||||
|
due_date=due_date,
|
||||||
|
check_number=check_number,
|
||||||
|
sayad_code=str(sayad).strip() if sayad else None,
|
||||||
|
bank_name=(str(data.get('bank_name')).strip() if data.get('bank_name') else None),
|
||||||
|
branch_name=(str(data.get('branch_name')).strip() if data.get('branch_name') else None),
|
||||||
|
amount=amount_val,
|
||||||
|
currency_id=int(data.get('currency_id')),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return check_to_dict(db, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
return check_to_dict(db, obj) if obj else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if 'type' in data and data['type'] is not None:
|
||||||
|
ctype = str(data['type']).lower()
|
||||||
|
if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value):
|
||||||
|
raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400)
|
||||||
|
obj.type = CheckType(ctype)
|
||||||
|
|
||||||
|
if 'person_id' in data:
|
||||||
|
obj.person_id = int(data['person_id']) if data['person_id'] is not None else None
|
||||||
|
|
||||||
|
if 'issue_date' in data and data['issue_date'] is not None:
|
||||||
|
obj.issue_date = _parse_iso(str(data['issue_date']))
|
||||||
|
if 'due_date' in data and data['due_date'] is not None:
|
||||||
|
obj.due_date = _parse_iso(str(data['due_date']))
|
||||||
|
if obj.due_date < obj.issue_date:
|
||||||
|
raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400)
|
||||||
|
|
||||||
|
if 'check_number' in data and data['check_number'] is not None:
|
||||||
|
new_num = str(data['check_number']).strip()
|
||||||
|
if not new_num:
|
||||||
|
raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400)
|
||||||
|
exists = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.check_number == new_num, Check.id != obj.id)).first()
|
||||||
|
if exists is not None:
|
||||||
|
raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400)
|
||||||
|
obj.check_number = new_num
|
||||||
|
|
||||||
|
if 'sayad_code' in data:
|
||||||
|
s = data['sayad_code']
|
||||||
|
if s is not None:
|
||||||
|
s = str(s).strip()
|
||||||
|
if s and (len(s) != 16 or not s.isdigit()):
|
||||||
|
raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400)
|
||||||
|
if s:
|
||||||
|
exists_sayad = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.sayad_code == s, Check.id != obj.id)).first()
|
||||||
|
if exists_sayad is not None:
|
||||||
|
raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400)
|
||||||
|
obj.sayad_code = s if s else None
|
||||||
|
|
||||||
|
for field in ["bank_name", "branch_name"]:
|
||||||
|
if field in data:
|
||||||
|
setattr(obj, field, (str(data[field]).strip() if data[field] is not None else None))
|
||||||
|
|
||||||
|
if 'amount' in data and data['amount'] is not None:
|
||||||
|
try:
|
||||||
|
amount_val = float(data['amount'])
|
||||||
|
except Exception:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400)
|
||||||
|
if amount_val <= 0:
|
||||||
|
raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400)
|
||||||
|
obj.amount = amount_val
|
||||||
|
|
||||||
|
if 'currency_id' in data and data['currency_id'] is not None:
|
||||||
|
obj.currency_id = int(data['currency_id'])
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return check_to_dict(db, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_check(db: Session, check_id: int) -> bool:
|
||||||
|
obj = db.query(Check).filter(Check.id == check_id).first()
|
||||||
|
if obj is None:
|
||||||
|
return False
|
||||||
|
db.delete(obj)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
q = db.query(Check).filter(Check.business_id == business_id)
|
||||||
|
|
||||||
|
# جستجو
|
||||||
|
if query.get("search") and query.get("search_fields"):
|
||||||
|
term = f"%{query['search']}%"
|
||||||
|
conditions = []
|
||||||
|
for f in query["search_fields"]:
|
||||||
|
if f == "check_number":
|
||||||
|
conditions.append(Check.check_number.ilike(term))
|
||||||
|
elif f == "sayad_code":
|
||||||
|
conditions.append(Check.sayad_code.ilike(term))
|
||||||
|
elif f == "bank_name":
|
||||||
|
conditions.append(Check.bank_name.ilike(term))
|
||||||
|
elif f == "branch_name":
|
||||||
|
conditions.append(Check.branch_name.ilike(term))
|
||||||
|
elif f == "person_name":
|
||||||
|
# join به persons
|
||||||
|
q = q.join(Person, Check.person_id == Person.id, isouter=True)
|
||||||
|
conditions.append(Person.alias_name.ilike(term))
|
||||||
|
if conditions:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
q = q.filter(or_(*conditions))
|
||||||
|
|
||||||
|
# فیلترها
|
||||||
|
if query.get("filters"):
|
||||||
|
from app.core.calendar import CalendarConverter
|
||||||
|
for flt in query["filters"]:
|
||||||
|
prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property')
|
||||||
|
op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator')
|
||||||
|
val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value')
|
||||||
|
if not prop or not op:
|
||||||
|
continue
|
||||||
|
if prop == 'type' and op == '=':
|
||||||
|
q = q.filter(Check.type == val)
|
||||||
|
elif prop == 'currency' and op == '=':
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.currency_id == int(val))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif prop == 'person_id' and op == '=':
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.person_id == int(val))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif prop in ('issue_date', 'due_date'):
|
||||||
|
# انتظار: فیلترهای بازه با اپراتورهای ">=" و "<=" از DataTable
|
||||||
|
try:
|
||||||
|
if isinstance(val, str) and val:
|
||||||
|
# ورودی تاریخ ممکن است بر اساس هدر تقویم باشد؛ در این لایه فرض بر ISO است (از فرانت ارسال میشود)
|
||||||
|
dt = _parse_iso(val)
|
||||||
|
col = getattr(Check, prop)
|
||||||
|
if op == ">=":
|
||||||
|
q = q.filter(col >= dt)
|
||||||
|
elif op == "<=":
|
||||||
|
q = q.filter(col <= dt)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# additional params: person_id
|
||||||
|
person_param = query.get('person_id')
|
||||||
|
if person_param:
|
||||||
|
try:
|
||||||
|
q = q.filter(Check.person_id == int(person_param))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# مرتبسازی
|
||||||
|
sort_by = query.get("sort_by") or "created_at"
|
||||||
|
sort_desc = bool(query.get("sort_desc", True))
|
||||||
|
col = getattr(Check, sort_by, Check.created_at)
|
||||||
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
|
|
||||||
|
# صفحهبندی
|
||||||
|
skip = int(query.get("skip", 0))
|
||||||
|
take = int(query.get("take", 20))
|
||||||
|
total = q.count()
|
||||||
|
items = q.offset(skip).limit(take).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [check_to_dict(db, i) for i in items],
|
||||||
|
"pagination": {
|
||||||
|
"total": total,
|
||||||
|
"page": (skip // take) + 1,
|
||||||
|
"per_page": take,
|
||||||
|
"total_pages": (total + take - 1) // take,
|
||||||
|
"has_next": skip + take < total,
|
||||||
|
"has_prev": skip > 0,
|
||||||
|
},
|
||||||
|
"query_info": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]:
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
person_name = None
|
||||||
|
if obj.person_id:
|
||||||
|
p = db.query(Person).filter(Person.id == obj.person_id).first()
|
||||||
|
person_name = getattr(p, 'alias_name', None)
|
||||||
|
currency_title = None
|
||||||
|
try:
|
||||||
|
c = db.query(Currency).filter(Currency.id == obj.currency_id).first()
|
||||||
|
currency_title = c.title or c.code if c else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"id": obj.id,
|
||||||
|
"business_id": obj.business_id,
|
||||||
|
"type": obj.type.value,
|
||||||
|
"person_id": obj.person_id,
|
||||||
|
"person_name": person_name,
|
||||||
|
"issue_date": obj.issue_date.isoformat(),
|
||||||
|
"due_date": obj.due_date.isoformat(),
|
||||||
|
"check_number": obj.check_number,
|
||||||
|
"sayad_code": obj.sayad_code,
|
||||||
|
"bank_name": obj.bank_name,
|
||||||
|
"branch_name": obj.branch_name,
|
||||||
|
"amount": float(obj.amount),
|
||||||
|
"currency_id": obj.currency_id,
|
||||||
|
"currency": currency_title,
|
||||||
|
"created_at": obj.created_at.isoformat(),
|
||||||
|
"updated_at": obj.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,7 +68,6 @@ def create_person(db: Session, business_id: int, person_data: PersonCreateReques
|
||||||
first_name=person_data.first_name,
|
first_name=person_data.first_name,
|
||||||
last_name=person_data.last_name,
|
last_name=person_data.last_name,
|
||||||
# ذخیره مقدار Enum با مقدار فارسی (values_callable در مدل مقادیر فارسی را مینویسد)
|
# ذخیره مقدار 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,
|
person_types=json.dumps(types_list, ensure_ascii=False) if types_list else None,
|
||||||
company_name=person_data.company_name,
|
company_name=person_data.company_name,
|
||||||
payment_id=person_data.payment_id,
|
payment_id=person_data.payment_id,
|
||||||
|
|
@ -198,14 +197,6 @@ def get_persons_by_business(
|
||||||
query = query.filter(Person.code.in_(value))
|
query = query.filter(Person.code.in_(value))
|
||||||
continue
|
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)
|
# انواع شخص چندانتخابی (رشته JSON)
|
||||||
if field == 'person_types':
|
if field == 'person_types':
|
||||||
if operator == '=' and isinstance(value, str):
|
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())
|
query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc())
|
||||||
elif sort_by == 'last_name':
|
elif sort_by == 'last_name':
|
||||||
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
|
query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc())
|
||||||
elif sort_by == 'person_type':
|
# person_type sorting removed - use person_types instead
|
||||||
query = query.order_by(Person.person_type.desc() if sort_desc else Person.person_type.asc())
|
|
||||||
elif sort_by == 'created_at':
|
elif sort_by == 'created_at':
|
||||||
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc())
|
||||||
elif sort_by == 'updated_at':
|
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]
|
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.person_types = json.dumps(types_list, ensure_ascii=False) if types_list else None
|
||||||
# همگام کردن person_type تکی برای سازگاری
|
# همگام کردن person_type تکی برای سازگاری
|
||||||
if types_list:
|
# person_type handling removed - only person_types is used now
|
||||||
# مقدار 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)
|
|
||||||
|
|
||||||
# اگر شخص سهامدار شد، share_count معتبر باشد
|
# اگر شخص سهامدار شد، share_count معتبر باشد
|
||||||
resulting_types: List[str] = []
|
resulting_types: List[str] = []
|
||||||
|
|
@ -394,7 +368,7 @@ def update_person(
|
||||||
resulting_types = [str(x) for x in tmp]
|
resulting_types = [str(x) for x in tmp]
|
||||||
except Exception:
|
except Exception:
|
||||||
resulting_types = []
|
resulting_types = []
|
||||||
if (person.person_type == 'سهامدار') or ('سهامدار' in resulting_types):
|
if 'سهامدار' in resulting_types:
|
||||||
sc_val2 = update_data.get('share_count', person.share_count)
|
sc_val2 = update_data.get('share_count', person.share_count)
|
||||||
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
if sc_val2 is None or (isinstance(sc_val2, int) and sc_val2 <= 0):
|
||||||
raise ApiError("INVALID_SHARE_COUNT", "برای سهامدار، تعداد سهام الزامی و باید بزرگتر از صفر باشد", http_status=400)
|
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 = {}
|
by_type = {}
|
||||||
for person_type in PersonType:
|
for person_type in PersonType:
|
||||||
count = db.query(Person).filter(
|
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()
|
).count()
|
||||||
by_type[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,
|
'alias_name': person.alias_name,
|
||||||
'first_name': person.first_name,
|
'first_name': person.first_name,
|
||||||
'last_name': person.last_name,
|
'last_name': person.last_name,
|
||||||
'person_type': person.person_type.value,
|
|
||||||
'person_types': types_list,
|
'person_types': types_list,
|
||||||
'company_name': person.company_name,
|
'company_name': person.company_name,
|
||||||
'payment_id': person.payment_id,
|
'payment_id': person.payment_id,
|
||||||
|
|
@ -514,3 +487,51 @@ def _person_to_dict(person: Person) -> Dict[str, Any]:
|
||||||
for ba in person.bank_accounts
|
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:
|
if not pr or pr.business_id != business_id:
|
||||||
raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
|
raise ApiError("NOT_FOUND", "کالا/خدمت یافت نشد", http_status=404)
|
||||||
# اگر unit_id داده شده و با واحدهای محصول سازگار نباشد، خطا بده
|
# اگر 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)
|
raise ApiError("INVALID_UNIT", "واحد انتخابی با واحدهای محصول همخوانی ندارد", http_status=400)
|
||||||
|
|
||||||
repo = PriceItemRepository(db)
|
repo = PriceItemRepository(db)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
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.orm import Session
|
||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, func
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -39,9 +39,20 @@ def _validate_tax(payload: ProductCreateRequest | ProductUpdateRequest) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _validate_units(main_unit_id: Optional[int], secondary_unit_id: Optional[int], factor: Optional[Decimal]) -> None:
|
def _validate_units(main_unit: Optional[str], secondary_unit: Optional[str], factor: Optional[Decimal]) -> None:
|
||||||
if secondary_unit_id and not factor:
|
if secondary_unit and not factor:
|
||||||
raise ApiError("INVALID_UNIT_FACTOR", "برای واحد فرعی تعیین ضریب تبدیل الزامی است", http_status=400)
|
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:
|
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]:
|
def create_product(db: Session, business_id: int, payload: ProductCreateRequest) -> Dict[str, Any]:
|
||||||
repo = ProductRepository(db)
|
repo = ProductRepository(db)
|
||||||
_validate_tax(payload)
|
_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
|
code = payload.code.strip() if isinstance(payload.code, str) and payload.code.strip() else None
|
||||||
if code:
|
if code:
|
||||||
|
|
@ -81,8 +95,8 @@ def create_product(db: Session, business_id: int, payload: ProductCreateRequest)
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
category_id=payload.category_id,
|
category_id=payload.category_id,
|
||||||
main_unit_id=payload.main_unit_id,
|
main_unit=main_unit,
|
||||||
secondary_unit_id=payload.secondary_unit_id,
|
secondary_unit=secondary_unit,
|
||||||
unit_conversion_factor=payload.unit_conversion_factor,
|
unit_conversion_factor=payload.unit_conversion_factor,
|
||||||
base_sales_price=payload.base_sales_price,
|
base_sales_price=payload.base_sales_price,
|
||||||
base_sales_note=payload.base_sales_note,
|
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)
|
_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]:
|
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)
|
raise ApiError("DUPLICATE_PRODUCT_CODE", "کد کالا/خدمت تکراری است", http_status=400)
|
||||||
|
|
||||||
_validate_tax(payload)
|
_validate_tax(payload)
|
||||||
_validate_units(payload.main_unit_id if payload.main_unit_id is not None else obj.main_unit_id,
|
# از فیلدهای explicitly-set برای تشخیص پاکسازی (None) استفاده کن
|
||||||
payload.secondary_unit_id if payload.secondary_unit_id is not None else obj.secondary_unit_id,
|
fields_set = getattr(payload, 'model_fields_set', getattr(payload, '__fields_set__', set()))
|
||||||
payload.unit_conversion_factor if payload.unit_conversion_factor is not None else obj.unit_conversion_factor)
|
# 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(
|
updated = repo.update(
|
||||||
product_id,
|
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,
|
name=payload.name.strip() if isinstance(payload.name, str) else None,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
category_id=payload.category_id,
|
category_id=payload.category_id,
|
||||||
main_unit_id=payload.main_unit_id,
|
main_unit=main_unit_val if 'main_unit' in fields_set else None,
|
||||||
secondary_unit_id=payload.secondary_unit_id,
|
secondary_unit=secondary_unit_val if 'secondary_unit' in fields_set else None,
|
||||||
unit_conversion_factor=payload.unit_conversion_factor,
|
unit_conversion_factor=payload.unit_conversion_factor,
|
||||||
base_sales_price=payload.base_sales_price,
|
base_sales_price=payload.base_sales_price,
|
||||||
base_sales_note=payload.base_sales_note,
|
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
|
return None
|
||||||
|
|
||||||
_upsert_attributes(db, product_id, business_id, payload.attribute_ids)
|
_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:
|
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,
|
"name": obj.name,
|
||||||
"description": obj.description,
|
"description": obj.description,
|
||||||
"category_id": obj.category_id,
|
"category_id": obj.category_id,
|
||||||
"main_unit_id": obj.main_unit_id,
|
"main_unit": obj.main_unit,
|
||||||
"secondary_unit_id": obj.secondary_unit_id,
|
"secondary_unit": obj.secondary_unit,
|
||||||
"unit_conversion_factor": obj.unit_conversion_factor,
|
"unit_conversion_factor": obj.unit_conversion_factor,
|
||||||
"base_sales_price": obj.base_sales_price,
|
"base_sales_price": obj.base_sales_price,
|
||||||
"base_sales_note": obj.base_sales_note,
|
"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/businesses.py
|
||||||
adapters/api/v1/cash_registers.py
|
adapters/api/v1/cash_registers.py
|
||||||
adapters/api/v1/categories.py
|
adapters/api/v1/categories.py
|
||||||
|
adapters/api/v1/checks.py
|
||||||
adapters/api/v1/currencies.py
|
adapters/api/v1/currencies.py
|
||||||
|
adapters/api/v1/customers.py
|
||||||
adapters/api/v1/health.py
|
adapters/api/v1/health.py
|
||||||
|
adapters/api/v1/invoices.py
|
||||||
adapters/api/v1/persons.py
|
adapters/api/v1/persons.py
|
||||||
adapters/api/v1/petty_cash.py
|
adapters/api/v1/petty_cash.py
|
||||||
adapters/api/v1/price_lists.py
|
adapters/api/v1/price_lists.py
|
||||||
adapters/api/v1/product_attributes.py
|
adapters/api/v1/product_attributes.py
|
||||||
adapters/api/v1/products.py
|
adapters/api/v1/products.py
|
||||||
|
adapters/api/v1/receipts_payments.py
|
||||||
adapters/api/v1/schemas.py
|
adapters/api/v1/schemas.py
|
||||||
adapters/api/v1/tax_types.py
|
adapters/api/v1/tax_types.py
|
||||||
adapters/api/v1/tax_units.py
|
adapters/api/v1/tax_units.py
|
||||||
|
|
@ -27,6 +31,8 @@ adapters/api/v1/admin/file_storage.py
|
||||||
adapters/api/v1/schema_models/__init__.py
|
adapters/api/v1/schema_models/__init__.py
|
||||||
adapters/api/v1/schema_models/account.py
|
adapters/api/v1/schema_models/account.py
|
||||||
adapters/api/v1/schema_models/bank_account.py
|
adapters/api/v1/schema_models/bank_account.py
|
||||||
|
adapters/api/v1/schema_models/check.py
|
||||||
|
adapters/api/v1/schema_models/document_line.py
|
||||||
adapters/api/v1/schema_models/email.py
|
adapters/api/v1/schema_models/email.py
|
||||||
adapters/api/v1/schema_models/file_storage.py
|
adapters/api/v1/schema_models/file_storage.py
|
||||||
adapters/api/v1/schema_models/person.py
|
adapters/api/v1/schema_models/person.py
|
||||||
|
|
@ -51,6 +57,7 @@ adapters/db/models/business_permission.py
|
||||||
adapters/db/models/captcha.py
|
adapters/db/models/captcha.py
|
||||||
adapters/db/models/cash_register.py
|
adapters/db/models/cash_register.py
|
||||||
adapters/db/models/category.py
|
adapters/db/models/category.py
|
||||||
|
adapters/db/models/check.py
|
||||||
adapters/db/models/currency.py
|
adapters/db/models/currency.py
|
||||||
adapters/db/models/document.py
|
adapters/db/models/document.py
|
||||||
adapters/db/models/document_line.py
|
adapters/db/models/document_line.py
|
||||||
|
|
@ -64,6 +71,7 @@ adapters/db/models/price_list.py
|
||||||
adapters/db/models/product.py
|
adapters/db/models/product.py
|
||||||
adapters/db/models/product_attribute.py
|
adapters/db/models/product_attribute.py
|
||||||
adapters/db/models/product_attribute_link.py
|
adapters/db/models/product_attribute_link.py
|
||||||
|
adapters/db/models/tax_type.py
|
||||||
adapters/db/models/tax_unit.py
|
adapters/db/models/tax_unit.py
|
||||||
adapters/db/models/user.py
|
adapters/db/models/user.py
|
||||||
adapters/db/models/support/__init__.py
|
adapters/db/models/support/__init__.py
|
||||||
|
|
@ -116,6 +124,7 @@ app/services/business_dashboard_service.py
|
||||||
app/services/business_service.py
|
app/services/business_service.py
|
||||||
app/services/captcha_service.py
|
app/services/captcha_service.py
|
||||||
app/services/cash_register_service.py
|
app/services/cash_register_service.py
|
||||||
|
app/services/check_service.py
|
||||||
app/services/email_service.py
|
app/services/email_service.py
|
||||||
app/services/file_storage_service.py
|
app/services/file_storage_service.py
|
||||||
app/services/person_service.py
|
app/services/person_service.py
|
||||||
|
|
@ -124,6 +133,7 @@ app/services/price_list_service.py
|
||||||
app/services/product_attribute_service.py
|
app/services/product_attribute_service.py
|
||||||
app/services/product_service.py
|
app/services/product_service.py
|
||||||
app/services/query_service.py
|
app/services/query_service.py
|
||||||
|
app/services/receipt_payment_service.py
|
||||||
app/services/pdf/__init__.py
|
app/services/pdf/__init__.py
|
||||||
app/services/pdf/base_pdf_service.py
|
app/services/pdf/base_pdf_service.py
|
||||||
app/services/pdf/modules/__init__.py
|
app/services/pdf/modules/__init__.py
|
||||||
|
|
@ -137,6 +147,10 @@ hesabix_api.egg-info/top_level.txt
|
||||||
migrations/env.py
|
migrations/env.py
|
||||||
migrations/versions/1f0abcdd7300_add_petty_cash_table.py
|
migrations/versions/1f0abcdd7300_add_petty_cash_table.py
|
||||||
migrations/versions/20250102_000001_seed_support_data.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_000003_add_business_table.py
|
||||||
migrations/versions/20250117_000004_add_business_contact_fields.py
|
migrations/versions/20250117_000004_add_business_contact_fields.py
|
||||||
migrations/versions/20250117_000005_add_business_geographic_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/20250120_000002_add_join_permission.py
|
||||||
migrations/versions/20250915_000001_init_auth_tables.py
|
migrations/versions/20250915_000001_init_auth_tables.py
|
||||||
migrations/versions/20250916_000002_add_referral_fields.py
|
migrations/versions/20250916_000002_add_referral_fields.py
|
||||||
migrations/versions/20250926_000010_add_person_code_and_types.py
|
migrations/versions/20250926_000010_add_person_code.py
|
||||||
migrations/versions/20250926_000011_drop_person_is_active.py
|
migrations/versions/20250926_000011_drop_active.py
|
||||||
migrations/versions/20250927_000012_add_fiscal_years_table.py
|
migrations/versions/20250927_000012_add_fiscal_years.py
|
||||||
migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
|
migrations/versions/20250927_000013_add_currencies.py
|
||||||
migrations/versions/20250927_000014_add_documents_table.py
|
migrations/versions/20250927_000014_add_documents.py
|
||||||
migrations/versions/20250927_000015_add_document_lines_table.py
|
migrations/versions/20250927_000015_add_lines.py
|
||||||
migrations/versions/20250927_000016_add_accounts_table.py
|
migrations/versions/20250927_000016_add_accounts_table.py
|
||||||
migrations/versions/20250927_000017_add_account_id_to_document_lines.py
|
migrations/versions/20250927_000017_add_account_id_to_document_lines.py
|
||||||
migrations/versions/20250927_000018_seed_currencies.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/20251002_000101_add_bank_accounts_table.py
|
||||||
migrations/versions/20251003_000201_add_cash_registers_table.py
|
migrations/versions/20251003_000201_add_cash_registers_table.py
|
||||||
migrations/versions/20251003_010501_add_name_to_cash_registers.py
|
migrations/versions/20251003_010501_add_name_to_cash_registers.py
|
||||||
|
migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
|
||||||
|
migrations/versions/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/4b2ea782bcb3_merge_heads.py
|
||||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||||
|
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||||
|
migrations/versions/7ecb63029764_merge_heads.py
|
||||||
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
||||||
migrations/versions/a1443c153b47_merge_heads.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/caf3f4ef4b76_add_tax_units_table.py
|
||||||
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
migrations/versions/d3e84892c1c2_sync_person_type_enum_values_callable_.py
|
||||||
migrations/versions/f876bfa36805_merge_multiple_heads.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"
|
msgid "TEST_STORAGE_CONFIG_ERROR"
|
||||||
msgstr "Error testing storage connection"
|
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 "خطا در تست اتصال"
|
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:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# Check if table already exists
|
||||||
op.create_table('petty_cash',
|
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('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||||
sa.Column('name', sa.String(length=255), nullable=False, comment='نام تنخواه گردان'),
|
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.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='RESTRICT'),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.UniqueConstraint('business_id', 'code', name='uq_petty_cash_business_code')
|
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_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_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_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_name'), 'petty_cash', ['name'], unique=False)
|
||||||
# ### end Alembic commands ###
|
# ### 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
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250926_000010_add_person_code_and_types'
|
revision = '20250926_000010_add_person_code'
|
||||||
down_revision = '20250916_000002'
|
down_revision = '20250916_000002'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
@ -3,8 +3,8 @@ import sqlalchemy as sa
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250926_000011_drop_person_is_active'
|
revision = '20250926_000011_drop_active'
|
||||||
down_revision = '20250926_000010_add_person_code_and_types'
|
down_revision = '20250926_000010_add_person_code'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
@ -6,8 +6,8 @@ from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250927_000012_add_fiscal_years_table'
|
revision = '20250927_000012_add_fiscal_years'
|
||||||
down_revision = '20250926_000011_drop_person_is_active'
|
down_revision = '20250926_000011_drop_active'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = ('20250117_000003',)
|
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 identifiers, used by Alembic.
|
||||||
revision = '20250927_000014_add_documents_table'
|
revision = '20250927_000014_add_documents'
|
||||||
down_revision = '20250927_000013_add_currencies_and_business_currencies'
|
down_revision = '20250927_000013_add_currencies'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = 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 identifiers, used by Alembic.
|
||||||
revision = '20250927_000016_add_accounts_table'
|
revision = '20250927_000016_add_accounts_table'
|
||||||
down_revision = '20250927_000015_add_document_lines_table'
|
down_revision = '20250927_000015_add_lines'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = 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
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '5553f8745c6e'
|
revision = '5553f8745c6e'
|
||||||
|
|
@ -18,87 +19,104 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('support_categories',
|
bind = op.get_bind()
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
inspector = inspect(bind)
|
||||||
sa.Column('name', sa.String(length=100), nullable=False),
|
tables = set(inspector.get_table_names())
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
# Only create tables if they don't exist
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
if 'support_categories' not in tables:
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_table('support_categories',
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
)
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
op.create_table('support_priorities',
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('name', sa.String(length=50), nullable=False),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('color', sa.String(length=7), nullable=True),
|
)
|
||||||
sa.Column('order', sa.Integer(), nullable=False),
|
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
if 'support_priorities' not in tables:
|
||||||
sa.PrimaryKeyConstraint('id')
|
op.create_table('support_priorities',
|
||||||
)
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
op.create_table('support_statuses',
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
sa.Column('name', sa.String(length=50), nullable=False),
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('color', sa.String(length=7), nullable=True),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('is_final', sa.Boolean(), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
)
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
if 'support_statuses' not in tables:
|
||||||
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
op.create_table('support_statuses',
|
||||||
op.create_table('support_tickets',
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
sa.Column('description', sa.Text(), nullable=False),
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
sa.Column('is_final', sa.Boolean(), nullable=False),
|
||||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('priority_id', sa.Integer(), nullable=False),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('status_id', sa.Integer(), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
)
|
||||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
||||||
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
if 'support_tickets' not in tables:
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_table('support_tickets',
|
||||||
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column('priority_id', sa.Integer(), nullable=False),
|
||||||
)
|
sa.Column('status_id', sa.Integer(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
||||||
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||||
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
||||||
op.create_table('support_messages',
|
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('sender_id', sa.Integer(), nullable=False),
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
)
|
||||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
||||||
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
|
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
||||||
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
||||||
sa.PrimaryKeyConstraint('id')
|
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_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)
|
if 'support_messages' not in tables:
|
||||||
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
|
op.create_table('support_messages',
|
||||||
op.alter_column('businesses', 'business_type',
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
|
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
||||||
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
|
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||||
existing_nullable=False)
|
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
||||||
op.alter_column('businesses', 'business_field',
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
|
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||||
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
existing_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 ###
|
# ### 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
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'caf3f4ef4b76'
|
revision = 'caf3f4ef4b76'
|
||||||
|
|
@ -18,49 +19,81 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.alter_column('persons', 'code',
|
bind = op.get_bind()
|
||||||
existing_type=mysql.INTEGER(),
|
inspector = inspect(bind)
|
||||||
comment='کد یکتا در هر کسب و کار',
|
|
||||||
existing_nullable=True)
|
# Check if persons table exists and has the code column
|
||||||
op.alter_column('persons', 'person_type',
|
if 'persons' in inspector.get_table_names():
|
||||||
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
cols = {c['name'] for c in inspector.get_columns('persons')}
|
||||||
comment='نوع شخص',
|
|
||||||
existing_nullable=False)
|
# Only alter code column if it exists
|
||||||
op.alter_column('persons', 'person_types',
|
if 'code' in cols:
|
||||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
op.alter_column('persons', 'code',
|
||||||
comment='لیست انواع شخص به صورت JSON',
|
existing_type=mysql.INTEGER(),
|
||||||
existing_nullable=True)
|
comment='کد یکتا در هر کسب و کار',
|
||||||
op.alter_column('persons', 'commission_sale_percent',
|
existing_nullable=True)
|
||||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
|
||||||
comment='درصد پورسانت از فروش',
|
# Only alter person_type column if it exists
|
||||||
existing_nullable=True)
|
if 'person_type' in cols:
|
||||||
op.alter_column('persons', 'commission_sales_return_percent',
|
op.alter_column('persons', 'person_type',
|
||||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
||||||
comment='درصد پورسانت از برگشت از فروش',
|
comment='نوع شخص',
|
||||||
existing_nullable=True)
|
existing_nullable=False)
|
||||||
op.alter_column('persons', 'commission_sales_amount',
|
|
||||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
# Only alter person_types column if it exists
|
||||||
comment='مبلغ فروش مبنا برای پورسانت',
|
if 'person_types' in cols:
|
||||||
existing_nullable=True)
|
op.alter_column('persons', 'person_types',
|
||||||
op.alter_column('persons', 'commission_sales_return_amount',
|
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
comment='لیست انواع شخص به صورت JSON',
|
||||||
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
existing_nullable=True)
|
||||||
existing_nullable=True)
|
|
||||||
op.alter_column('persons', 'commission_exclude_discounts',
|
# Only alter commission columns if they exist
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
if 'commission_sale_percent' in cols:
|
||||||
comment='عدم محاسبه تخفیف در پورسانت',
|
op.alter_column('persons', 'commission_sale_percent',
|
||||||
existing_nullable=False,
|
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||||
existing_server_default=sa.text("'0'"))
|
comment='درصد پورسانت از فروش',
|
||||||
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
existing_nullable=True)
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
|
||||||
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
if 'commission_sales_return_percent' in cols:
|
||||||
existing_nullable=False,
|
op.alter_column('persons', 'commission_sales_return_percent',
|
||||||
existing_server_default=sa.text("'0'"))
|
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||||
op.alter_column('persons', 'commission_post_in_invoice_document',
|
comment='درصد پورسانت از برگشت از فروش',
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
existing_nullable=True)
|
||||||
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
|
||||||
existing_nullable=False,
|
if 'commission_sales_amount' in cols:
|
||||||
existing_server_default=sa.text("'0'"))
|
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',
|
op.alter_column('price_items', 'tier_name',
|
||||||
existing_type=mysql.VARCHAR(length=64),
|
existing_type=mysql.VARCHAR(length=64),
|
||||||
comment='نام پله قیمت (تکی/عمده/همکار/...)',
|
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/product_service.dart';
|
||||||
import '../services/category_service.dart';
|
import '../services/category_service.dart';
|
||||||
import '../services/product_attribute_service.dart';
|
import '../services/product_attribute_service.dart';
|
||||||
import '../services/unit_service.dart';
|
|
||||||
import '../services/tax_service.dart';
|
import '../services/tax_service.dart';
|
||||||
import '../services/price_list_service.dart';
|
import '../services/price_list_service.dart';
|
||||||
import '../services/currency_service.dart';
|
import '../services/currency_service.dart';
|
||||||
|
|
@ -16,7 +15,6 @@ class ProductFormController extends ChangeNotifier {
|
||||||
late final ProductService _productService;
|
late final ProductService _productService;
|
||||||
late final CategoryService _categoryService;
|
late final CategoryService _categoryService;
|
||||||
late final ProductAttributeService _attributeService;
|
late final ProductAttributeService _attributeService;
|
||||||
late final UnitService _unitService;
|
|
||||||
late final TaxService _taxService;
|
late final TaxService _taxService;
|
||||||
late final PriceListService _priceListService;
|
late final PriceListService _priceListService;
|
||||||
late final CurrencyService _currencyService;
|
late final CurrencyService _currencyService;
|
||||||
|
|
@ -29,7 +27,6 @@ class ProductFormController extends ChangeNotifier {
|
||||||
// Reference data
|
// Reference data
|
||||||
List<Map<String, dynamic>> _categories = [];
|
List<Map<String, dynamic>> _categories = [];
|
||||||
List<Map<String, dynamic>> _attributes = [];
|
List<Map<String, dynamic>> _attributes = [];
|
||||||
List<Map<String, dynamic>> _units = [];
|
|
||||||
List<Map<String, dynamic>> _taxTypes = [];
|
List<Map<String, dynamic>> _taxTypes = [];
|
||||||
List<Map<String, dynamic>> _taxUnits = [];
|
List<Map<String, dynamic>> _taxUnits = [];
|
||||||
List<Map<String, dynamic>> _priceLists = [];
|
List<Map<String, dynamic>> _priceLists = [];
|
||||||
|
|
@ -49,7 +46,6 @@ class ProductFormController extends ChangeNotifier {
|
||||||
_productService = ProductService(apiClient: _apiClient);
|
_productService = ProductService(apiClient: _apiClient);
|
||||||
_categoryService = CategoryService(_apiClient);
|
_categoryService = CategoryService(_apiClient);
|
||||||
_attributeService = ProductAttributeService(apiClient: _apiClient);
|
_attributeService = ProductAttributeService(apiClient: _apiClient);
|
||||||
_unitService = UnitService(apiClient: _apiClient);
|
|
||||||
_taxService = TaxService(apiClient: _apiClient);
|
_taxService = TaxService(apiClient: _apiClient);
|
||||||
_priceListService = PriceListService(apiClient: _apiClient);
|
_priceListService = PriceListService(apiClient: _apiClient);
|
||||||
_currencyService = CurrencyService(_apiClient);
|
_currencyService = CurrencyService(_apiClient);
|
||||||
|
|
@ -61,7 +57,6 @@ class ProductFormController extends ChangeNotifier {
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
List<Map<String, dynamic>> get categories => _categories;
|
List<Map<String, dynamic>> get categories => _categories;
|
||||||
List<Map<String, dynamic>> get attributes => _attributes;
|
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 taxTypes => _taxTypes;
|
||||||
List<Map<String, dynamic>> get taxUnits => _taxUnits;
|
List<Map<String, dynamic>> get taxUnits => _taxUnits;
|
||||||
List<Map<String, dynamic>> get priceLists => _priceLists;
|
List<Map<String, dynamic>> get priceLists => _priceLists;
|
||||||
|
|
@ -70,23 +65,13 @@ class ProductFormController extends ChangeNotifier {
|
||||||
|
|
||||||
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
|
void addOrUpdateDraftPriceItem(Map<String, dynamic> item) {
|
||||||
final String key = (
|
final String key = (
|
||||||
(item['price_list_id']?.toString() ?? '') + '|' +
|
'${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['product_id']?.toString() ?? '') + '|' +
|
|
||||||
(item['unit_id']?.toString() ?? 'null') + '|' +
|
|
||||||
(item['currency_id']?.toString() ?? '') + '|' +
|
|
||||||
(item['tier_name']?.toString() ?? '') + '|' +
|
|
||||||
(item['min_qty']?.toString() ?? '0')
|
|
||||||
);
|
);
|
||||||
int existingIndex = -1;
|
int existingIndex = -1;
|
||||||
for (int i = 0; i < _draftPriceItems.length; i++) {
|
for (int i = 0; i < _draftPriceItems.length; i++) {
|
||||||
final it = _draftPriceItems[i];
|
final it = _draftPriceItems[i];
|
||||||
final itKey = (
|
final itKey = (
|
||||||
(it['price_list_id']?.toString() ?? '') + '|' +
|
'${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['product_id']?.toString() ?? '') + '|' +
|
|
||||||
(it['unit_id']?.toString() ?? 'null') + '|' +
|
|
||||||
(it['currency_id']?.toString() ?? '') + '|' +
|
|
||||||
(it['tier_name']?.toString() ?? '') + '|' +
|
|
||||||
(it['min_qty']?.toString() ?? '0')
|
|
||||||
);
|
);
|
||||||
if (itKey == key) {
|
if (itKey == key) {
|
||||||
existingIndex = i;
|
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();
|
_clearError();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -178,23 +151,17 @@ class ProductFormController extends ChangeNotifier {
|
||||||
_attributes = [];
|
_attributes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load units
|
|
||||||
try {
|
|
||||||
_units = await _unitService.getUnits(businessId: businessId);
|
|
||||||
} catch (_) {
|
|
||||||
_units = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load tax types
|
// Load tax types
|
||||||
try {
|
try {
|
||||||
_taxTypes = await _taxService.getTaxTypes(businessId: businessId);
|
_taxTypes = await _taxService.getTaxTypes();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_taxTypes = [];
|
_taxTypes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tax units
|
// Load tax units
|
||||||
try {
|
try {
|
||||||
_taxUnits = await _taxService.getTaxUnits(businessId: businessId);
|
_taxUnits = await _taxService.getTaxUnits();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_taxUnits = [];
|
_taxUnits = [];
|
||||||
}
|
}
|
||||||
|
|
@ -394,8 +361,4 @@ class ProductFormController extends ChangeNotifier {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class ApiClient {
|
||||||
static Locale? _currentLocale;
|
static Locale? _currentLocale;
|
||||||
static AuthStore? _authStore;
|
static AuthStore? _authStore;
|
||||||
static CalendarController? _calendarController;
|
static CalendarController? _calendarController;
|
||||||
|
static ValueNotifier<int?>? _fiscalYearId;
|
||||||
|
|
||||||
static void setCurrentLocale(Locale locale) {
|
static void setCurrentLocale(Locale locale) {
|
||||||
_currentLocale = locale;
|
_currentLocale = locale;
|
||||||
|
|
@ -36,6 +37,11 @@ class ApiClient {
|
||||||
_calendarController = controller;
|
_calendarController = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fiscal Year binding (allows UI to update selected fiscal year globally)
|
||||||
|
static void bindFiscalYear(ValueNotifier<int?> fiscalYearId) {
|
||||||
|
_fiscalYearId = fiscalYearId;
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient._(this._dio);
|
ApiClient._(this._dio);
|
||||||
|
|
||||||
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
|
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
|
||||||
|
|
@ -71,6 +77,11 @@ class ApiClient {
|
||||||
if (calendarType != null && calendarType.isNotEmpty) {
|
if (calendarType != null && calendarType.isNotEmpty) {
|
||||||
options.headers['X-Calendar-Type'] = calendarType;
|
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
|
// Inject X-Business-ID header when request targets a specific business
|
||||||
try {
|
try {
|
||||||
final uri = options.uri;
|
final uri = options.uri;
|
||||||
|
|
|
||||||
|
|
@ -479,6 +479,7 @@ class AuthStore with ChangeNotifier {
|
||||||
Future<void> _ensureCurrencyForBusiness() async {
|
Future<void> _ensureCurrencyForBusiness() async {
|
||||||
final business = _currentBusiness;
|
final business = _currentBusiness;
|
||||||
if (business == null) return;
|
if (business == null) return;
|
||||||
|
|
||||||
// اگر ارزی انتخاب نشده، یا کد/شناسه فعلی جزو ارزهای کسبوکار نیست
|
// اگر ارزی انتخاب نشده، یا کد/شناسه فعلی جزو ارزهای کسبوکار نیست
|
||||||
final allowedCodes = business.currencies.map((c) => c.code).toSet();
|
final allowedCodes = business.currencies.map((c) => c.code).toSet();
|
||||||
final allowedIds = business.currencies.map((c) => c.id).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",
|
"pettyCashExportExcel": "Export petty cash to Excel",
|
||||||
"pettyCashExportPdf": "Export petty cash to PDF",
|
"pettyCashExportPdf": "Export petty cash to PDF",
|
||||||
"pettyCashReport": "Petty Cash Report"
|
"pettyCashReport": "Petty Cash Report"
|
||||||
|
,
|
||||||
|
"accountTypeBank": "Bank",
|
||||||
|
"accountTypeCashRegister": "Cash Register",
|
||||||
|
"accountTypePettyCash": "Petty Cash",
|
||||||
|
"accountTypeCheck": "Check",
|
||||||
|
"accountTypePerson": "Person",
|
||||||
|
"accountTypeProduct": "Product",
|
||||||
|
"accountTypeService": "Service",
|
||||||
|
"accountTypeAccountingDocument": "Accounting Document"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1073,6 +1073,14 @@
|
||||||
"pettyCashDetails": "جزئیات تنخواه گردان",
|
"pettyCashDetails": "جزئیات تنخواه گردان",
|
||||||
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
"pettyCashExportExcel": "خروجی Excel تنخواه گردانها",
|
||||||
"pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
|
"pettyCashExportPdf": "خروجی PDF تنخواه گردانها",
|
||||||
"pettyCashReport": "گزارش تنخواه گردانها"
|
"pettyCashReport": "گزارش تنخواه گردانها",
|
||||||
|
"accountTypeBank": "بانک",
|
||||||
|
"accountTypeCashRegister": "صندوق",
|
||||||
|
"accountTypePettyCash": "تنخواه گردان",
|
||||||
|
"accountTypeCheck": "چک",
|
||||||
|
"accountTypePerson": "شخص",
|
||||||
|
"accountTypeProduct": "کالا",
|
||||||
|
"accountTypeService": "خدمات",
|
||||||
|
"accountTypeAccountingDocument": "سند حسابداری"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5719,6 +5719,54 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Petty Cash Report'**
|
/// **'Petty Cash Report'**
|
||||||
String get pettyCashReport;
|
String get pettyCashReport;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeBank.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bank'**
|
||||||
|
String get accountTypeBank;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeCashRegister.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cash Register'**
|
||||||
|
String get accountTypeCashRegister;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypePettyCash.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Petty Cash'**
|
||||||
|
String get accountTypePettyCash;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeCheck.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check'**
|
||||||
|
String get accountTypeCheck;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypePerson.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Person'**
|
||||||
|
String get accountTypePerson;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeProduct.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Product'**
|
||||||
|
String get accountTypeProduct;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeService.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Service'**
|
||||||
|
String get accountTypeService;
|
||||||
|
|
||||||
|
/// No description provided for @accountTypeAccountingDocument.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Accounting Document'**
|
||||||
|
String get accountTypeAccountingDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2897,4 +2897,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pettyCashReport => 'Petty Cash Report';
|
String get pettyCashReport => 'Petty Cash Report';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeBank => 'Bank';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCashRegister => 'Cash Register';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePettyCash => 'Petty Cash';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCheck => 'Check';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePerson => 'Person';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeProduct => 'Product';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeService => 'Service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeAccountingDocument => 'Accounting Document';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2876,4 +2876,28 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pettyCashReport => 'گزارش تنخواه گردانها';
|
String get pettyCashReport => 'گزارش تنخواه گردانها';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeBank => 'بانک';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCashRegister => 'صندوق';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePettyCash => 'تنخواه گردان';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeCheck => 'چک';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypePerson => 'شخص';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeProduct => 'کالا';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeService => 'خدمات';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get accountTypeAccountingDocument => 'سند حسابداری';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import 'pages/business/wallet_page.dart';
|
||||||
import 'pages/business/invoice_page.dart';
|
import 'pages/business/invoice_page.dart';
|
||||||
import 'pages/business/new_invoice_page.dart';
|
import 'pages/business/new_invoice_page.dart';
|
||||||
import 'pages/business/settings_page.dart';
|
import 'pages/business/settings_page.dart';
|
||||||
|
import 'pages/business/reports_page.dart';
|
||||||
import 'pages/business/persons_page.dart';
|
import 'pages/business/persons_page.dart';
|
||||||
import 'pages/business/product_attributes_page.dart';
|
import 'pages/business/product_attributes_page.dart';
|
||||||
import 'pages/business/products_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/price_list_items_page.dart';
|
||||||
import 'pages/business/cash_registers_page.dart';
|
import 'pages/business/cash_registers_page.dart';
|
||||||
import 'pages/business/petty_cash_page.dart';
|
import 'pages/business/petty_cash_page.dart';
|
||||||
|
import 'pages/business/checks_page.dart';
|
||||||
|
import 'pages/business/check_form_page.dart';
|
||||||
|
import 'pages/business/receipts_payments_list_page.dart';
|
||||||
import 'pages/error_404_page.dart';
|
import 'pages/error_404_page.dart';
|
||||||
import 'core/locale_controller.dart';
|
import 'core/locale_controller.dart';
|
||||||
import 'core/calendar_controller.dart';
|
import 'core/calendar_controller.dart';
|
||||||
|
|
@ -652,6 +656,25 @@ class _MyAppState extends State<MyApp> {
|
||||||
child: NewInvoicePage(
|
child: NewInvoicePage(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
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',
|
name: 'business_settings',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final businessId = int.parse(state.pathParameters['business_id']!);
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
// گارد دسترسی: فقط کاربرانی که دسترسی join دارند
|
||||||
|
if (!_authStore!.hasBusinessPermission('settings', 'join')) {
|
||||||
|
return PermissionGuard.buildAccessDeniedPage();
|
||||||
|
}
|
||||||
return BusinessShell(
|
return BusinessShell(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
authStore: _authStore!,
|
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.)
|
// 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 aliasName;
|
||||||
final String? firstName;
|
final String? firstName;
|
||||||
final String? lastName;
|
final String? lastName;
|
||||||
final PersonType personType;
|
|
||||||
final List<PersonType> personTypes;
|
final List<PersonType> personTypes;
|
||||||
final String? companyName;
|
final String? companyName;
|
||||||
final String? paymentId;
|
final String? paymentId;
|
||||||
|
|
@ -140,8 +139,7 @@ class Person {
|
||||||
required this.aliasName,
|
required this.aliasName,
|
||||||
this.firstName,
|
this.firstName,
|
||||||
this.lastName,
|
this.lastName,
|
||||||
required this.personType,
|
required this.personTypes,
|
||||||
this.personTypes = const [],
|
|
||||||
this.companyName,
|
this.companyName,
|
||||||
this.paymentId,
|
this.paymentId,
|
||||||
this.nationalId,
|
this.nationalId,
|
||||||
|
|
@ -176,9 +174,6 @@ class Person {
|
||||||
?.map((e) => PersonType.fromString(e.toString()))
|
?.map((e) => PersonType.fromString(e.toString()))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[];
|
[];
|
||||||
final PersonType primaryType = types.isNotEmpty
|
|
||||||
? types.first
|
|
||||||
: PersonType.fromString(json['person_type']);
|
|
||||||
return Person(
|
return Person(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
businessId: json['business_id'],
|
businessId: json['business_id'],
|
||||||
|
|
@ -186,7 +181,6 @@ class Person {
|
||||||
aliasName: json['alias_name'],
|
aliasName: json['alias_name'],
|
||||||
firstName: json['first_name'],
|
firstName: json['first_name'],
|
||||||
lastName: json['last_name'],
|
lastName: json['last_name'],
|
||||||
personType: primaryType,
|
|
||||||
personTypes: types,
|
personTypes: types,
|
||||||
companyName: json['company_name'],
|
companyName: json['company_name'],
|
||||||
paymentId: json['payment_id'],
|
paymentId: json['payment_id'],
|
||||||
|
|
@ -228,7 +222,6 @@ class Person {
|
||||||
'alias_name': aliasName,
|
'alias_name': aliasName,
|
||||||
'first_name': firstName,
|
'first_name': firstName,
|
||||||
'last_name': lastName,
|
'last_name': lastName,
|
||||||
'person_type': personType.persianName,
|
|
||||||
'person_types': personTypes.map((t) => t.persianName).toList(),
|
'person_types': personTypes.map((t) => t.persianName).toList(),
|
||||||
'company_name': companyName,
|
'company_name': companyName,
|
||||||
'payment_id': paymentId,
|
'payment_id': paymentId,
|
||||||
|
|
@ -266,7 +259,7 @@ class Person {
|
||||||
String? aliasName,
|
String? aliasName,
|
||||||
String? firstName,
|
String? firstName,
|
||||||
String? lastName,
|
String? lastName,
|
||||||
PersonType? personType,
|
List<PersonType>? personTypes,
|
||||||
String? companyName,
|
String? companyName,
|
||||||
String? paymentId,
|
String? paymentId,
|
||||||
String? nationalId,
|
String? nationalId,
|
||||||
|
|
@ -293,7 +286,7 @@ class Person {
|
||||||
aliasName: aliasName ?? this.aliasName,
|
aliasName: aliasName ?? this.aliasName,
|
||||||
firstName: firstName ?? this.firstName,
|
firstName: firstName ?? this.firstName,
|
||||||
lastName: lastName ?? this.lastName,
|
lastName: lastName ?? this.lastName,
|
||||||
personType: personType ?? this.personType,
|
personTypes: personTypes ?? this.personTypes,
|
||||||
companyName: companyName ?? this.companyName,
|
companyName: companyName ?? this.companyName,
|
||||||
paymentId: paymentId ?? this.paymentId,
|
paymentId: paymentId ?? this.paymentId,
|
||||||
nationalId: nationalId ?? this.nationalId,
|
nationalId: nationalId ?? this.nationalId,
|
||||||
|
|
@ -444,7 +437,6 @@ class PersonUpdateRequest {
|
||||||
final String? aliasName;
|
final String? aliasName;
|
||||||
final String? firstName;
|
final String? firstName;
|
||||||
final String? lastName;
|
final String? lastName;
|
||||||
final PersonType? personType;
|
|
||||||
final List<PersonType>? personTypes;
|
final List<PersonType>? personTypes;
|
||||||
final String? companyName;
|
final String? companyName;
|
||||||
final String? paymentId;
|
final String? paymentId;
|
||||||
|
|
@ -476,7 +468,6 @@ class PersonUpdateRequest {
|
||||||
this.aliasName,
|
this.aliasName,
|
||||||
this.firstName,
|
this.firstName,
|
||||||
this.lastName,
|
this.lastName,
|
||||||
this.personType,
|
|
||||||
this.personTypes,
|
this.personTypes,
|
||||||
this.companyName,
|
this.companyName,
|
||||||
this.paymentId,
|
this.paymentId,
|
||||||
|
|
@ -511,7 +502,6 @@ class PersonUpdateRequest {
|
||||||
if (aliasName != null) json['alias_name'] = aliasName;
|
if (aliasName != null) json['alias_name'] = aliasName;
|
||||||
if (firstName != null) json['first_name'] = firstName;
|
if (firstName != null) json['first_name'] = firstName;
|
||||||
if (lastName != null) json['last_name'] = lastName;
|
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 (personTypes != null) json['person_types'] = personTypes!.map((t) => t.persianName).toList();
|
||||||
if (companyName != null) json['company_name'] = companyName;
|
if (companyName != null) json['company_name'] = companyName;
|
||||||
if (paymentId != null) json['payment_id'] = paymentId;
|
if (paymentId != null) json['payment_id'] = paymentId;
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ class ProductFormData {
|
||||||
String? basePurchaseNote;
|
String? basePurchaseNote;
|
||||||
|
|
||||||
// Units
|
// Units
|
||||||
int? mainUnitId;
|
String? mainUnit;
|
||||||
int? secondaryUnitId;
|
String? secondaryUnit;
|
||||||
num unitConversionFactor;
|
num unitConversionFactor;
|
||||||
|
|
||||||
// Taxes
|
// Taxes
|
||||||
|
|
@ -49,8 +49,8 @@ class ProductFormData {
|
||||||
this.basePurchasePrice,
|
this.basePurchasePrice,
|
||||||
this.baseSalesNote,
|
this.baseSalesNote,
|
||||||
this.basePurchaseNote,
|
this.basePurchaseNote,
|
||||||
this.mainUnitId,
|
this.mainUnit = 'عدد',
|
||||||
this.secondaryUnitId,
|
this.secondaryUnit,
|
||||||
this.unitConversionFactor = 1,
|
this.unitConversionFactor = 1,
|
||||||
this.isSalesTaxable = false,
|
this.isSalesTaxable = false,
|
||||||
this.isPurchaseTaxable = false,
|
this.isPurchaseTaxable = false,
|
||||||
|
|
@ -76,8 +76,8 @@ class ProductFormData {
|
||||||
num? basePurchasePrice,
|
num? basePurchasePrice,
|
||||||
String? baseSalesNote,
|
String? baseSalesNote,
|
||||||
String? basePurchaseNote,
|
String? basePurchaseNote,
|
||||||
int? mainUnitId,
|
String? mainUnit,
|
||||||
int? secondaryUnitId,
|
String? secondaryUnit,
|
||||||
num? unitConversionFactor,
|
num? unitConversionFactor,
|
||||||
bool? isSalesTaxable,
|
bool? isSalesTaxable,
|
||||||
bool? isPurchaseTaxable,
|
bool? isPurchaseTaxable,
|
||||||
|
|
@ -102,8 +102,8 @@ class ProductFormData {
|
||||||
basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice,
|
basePurchasePrice: basePurchasePrice ?? this.basePurchasePrice,
|
||||||
baseSalesNote: baseSalesNote ?? this.baseSalesNote,
|
baseSalesNote: baseSalesNote ?? this.baseSalesNote,
|
||||||
basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote,
|
basePurchaseNote: basePurchaseNote ?? this.basePurchaseNote,
|
||||||
mainUnitId: mainUnitId ?? this.mainUnitId,
|
mainUnit: mainUnit ?? this.mainUnit,
|
||||||
secondaryUnitId: secondaryUnitId ?? this.secondaryUnitId,
|
secondaryUnit: secondaryUnit ?? this.secondaryUnit,
|
||||||
unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor,
|
unitConversionFactor: unitConversionFactor ?? this.unitConversionFactor,
|
||||||
isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable,
|
isSalesTaxable: isSalesTaxable ?? this.isSalesTaxable,
|
||||||
isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable,
|
isPurchaseTaxable: isPurchaseTaxable ?? this.isPurchaseTaxable,
|
||||||
|
|
@ -134,9 +134,9 @@ class ProductFormData {
|
||||||
'is_purchase_taxable': isPurchaseTaxable,
|
'is_purchase_taxable': isPurchaseTaxable,
|
||||||
'sales_tax_rate': salesTaxRate ?? 0,
|
'sales_tax_rate': salesTaxRate ?? 0,
|
||||||
'purchase_tax_rate': purchaseTaxRate ?? 0,
|
'purchase_tax_rate': purchaseTaxRate ?? 0,
|
||||||
// Keep optional IDs and factor as-is (do not force zero)
|
// Units as strings
|
||||||
'main_unit_id': mainUnitId,
|
'main_unit': mainUnit,
|
||||||
'secondary_unit_id': secondaryUnitId,
|
'secondary_unit': secondaryUnit,
|
||||||
'unit_conversion_factor': unitConversionFactor,
|
'unit_conversion_factor': unitConversionFactor,
|
||||||
'base_sales_note': baseSalesNote,
|
'base_sales_note': baseSalesNote,
|
||||||
'base_purchase_note': basePurchaseNote,
|
'base_purchase_note': basePurchaseNote,
|
||||||
|
|
@ -160,8 +160,8 @@ class ProductFormData {
|
||||||
trackInventory: (product['track_inventory'] == true),
|
trackInventory: (product['track_inventory'] == true),
|
||||||
baseSalesPrice: _parseNumeric(product['base_sales_price']),
|
baseSalesPrice: _parseNumeric(product['base_sales_price']),
|
||||||
basePurchasePrice: _parseNumeric(product['base_purchase_price']),
|
basePurchasePrice: _parseNumeric(product['base_purchase_price']),
|
||||||
mainUnitId: product['main_unit_id'] as int?,
|
mainUnit: product['main_unit']?.toString(),
|
||||||
secondaryUnitId: product['secondary_unit_id'] as int?,
|
secondaryUnit: product['secondary_unit']?.toString(),
|
||||||
unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1,
|
unitConversionFactor: _parseNumeric(product['unit_conversion_factor']) ?? 1,
|
||||||
baseSalesNote: product['base_sales_note']?.toString(),
|
baseSalesNote: product['base_sales_note']?.toString(),
|
||||||
basePurchaseNote: product['base_purchase_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/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/api_client.dart';
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
|
||||||
|
class AccountNode {
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String? accountType;
|
||||||
|
final List<AccountNode> children;
|
||||||
|
final bool hasChildren;
|
||||||
|
|
||||||
|
const AccountNode({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
this.accountType,
|
||||||
|
this.children = const [],
|
||||||
|
this.hasChildren = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AccountNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawChildren = (json['children'] as List?) ?? const [];
|
||||||
|
final parsedChildren = rawChildren
|
||||||
|
.map((c) => AccountNode.fromJson(Map<String, dynamic>.from(c as Map)))
|
||||||
|
.toList();
|
||||||
|
return AccountNode(
|
||||||
|
id: (json['id']?.toString() ?? json['code']?.toString() ?? UniqueKey().toString()),
|
||||||
|
code: json['code']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '',
|
||||||
|
accountType: json['account_type']?.toString(),
|
||||||
|
children: parsedChildren,
|
||||||
|
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VisibleNode {
|
||||||
|
final AccountNode node;
|
||||||
|
final int level;
|
||||||
|
const _VisibleNode(this.node, this.level);
|
||||||
|
}
|
||||||
|
|
||||||
class AccountsPage extends StatefulWidget {
|
class AccountsPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
const AccountsPage({super.key, required this.businessId});
|
const AccountsPage({super.key, required this.businessId});
|
||||||
|
|
@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget {
|
||||||
class _AccountsPageState extends State<AccountsPage> {
|
class _AccountsPageState extends State<AccountsPage> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<dynamic> _tree = const [];
|
List<AccountNode> _roots = const [];
|
||||||
|
final Set<String> _expandedIds = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -26,7 +66,11 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
try {
|
try {
|
||||||
final api = ApiClient();
|
final api = ApiClient();
|
||||||
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
|
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
|
||||||
setState(() { _tree = res.data['data']['items'] ?? []; });
|
final items = (res.data['data']['items'] as List?) ?? const [];
|
||||||
|
final parsed = items
|
||||||
|
.map((n) => AccountNode.fromJson(Map<String, dynamic>.from(n as Map)))
|
||||||
|
.toList();
|
||||||
|
setState(() { _roots = parsed; });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() { _error = e.toString(); });
|
setState(() { _error = e.toString(); });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -34,17 +78,86 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNode(Map<String, dynamic> node) {
|
List<_VisibleNode> _buildVisibleNodes() {
|
||||||
final children = (node['children'] as List?) ?? const [];
|
final List<_VisibleNode> result = <_VisibleNode>[];
|
||||||
if (children.isEmpty) {
|
void dfs(AccountNode node, int level) {
|
||||||
return ListTile(
|
result.add(_VisibleNode(node, level));
|
||||||
title: Text('${node['code']} - ${node['name']}'),
|
if (_expandedIds.contains(node.id)) {
|
||||||
);
|
for (final child in node.children) {
|
||||||
|
dfs(child, level + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final r in _roots) {
|
||||||
|
dfs(r, 0);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleExpand(AccountNode node) {
|
||||||
|
setState(() {
|
||||||
|
if (_expandedIds.contains(node.id)) {
|
||||||
|
_expandedIds.remove(node.id);
|
||||||
|
} else {
|
||||||
|
if (node.hasChildren) {
|
||||||
|
_expandedIds.add(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _localizedAccountType(AppLocalizations t, String? value) {
|
||||||
|
if (value == null || value.isEmpty) return '-';
|
||||||
|
final ln = t.localeName;
|
||||||
|
if (ln.startsWith('fa')) {
|
||||||
|
switch (value) {
|
||||||
|
case 'bank':
|
||||||
|
return t.accountTypeBank;
|
||||||
|
case 'cash_register':
|
||||||
|
return t.accountTypeCashRegister;
|
||||||
|
case 'petty_cash':
|
||||||
|
return t.accountTypePettyCash;
|
||||||
|
case 'check':
|
||||||
|
return t.accountTypeCheck;
|
||||||
|
case 'person':
|
||||||
|
return t.accountTypePerson;
|
||||||
|
case 'product':
|
||||||
|
return t.accountTypeProduct;
|
||||||
|
case 'service':
|
||||||
|
return t.accountTypeService;
|
||||||
|
case 'accounting_document':
|
||||||
|
return t.accountTypeAccountingDocument;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// English and other locales: humanize
|
||||||
|
String humanize(String v) {
|
||||||
|
return v
|
||||||
|
.split('_')
|
||||||
|
.map((p) => p.isEmpty ? p : (p[0].toUpperCase() + p.substring(1)))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
switch (value) {
|
||||||
|
case 'bank':
|
||||||
|
return t.accountTypeBank;
|
||||||
|
case 'cash_register':
|
||||||
|
return t.accountTypeCashRegister;
|
||||||
|
case 'petty_cash':
|
||||||
|
return t.accountTypePettyCash;
|
||||||
|
case 'check':
|
||||||
|
return t.accountTypeCheck;
|
||||||
|
case 'person':
|
||||||
|
return t.accountTypePerson;
|
||||||
|
case 'product':
|
||||||
|
return t.accountTypeProduct;
|
||||||
|
case 'service':
|
||||||
|
return t.accountTypeService;
|
||||||
|
case 'accounting_document':
|
||||||
|
return t.accountTypeAccountingDocument;
|
||||||
|
default:
|
||||||
|
return humanize(value);
|
||||||
}
|
}
|
||||||
return ExpansionTile(
|
|
||||||
title: Text('${node['code']} - ${node['name']}'),
|
|
||||||
children: children.map<Widget>((c) => _buildNode(Map<String, dynamic>.from(c))).toList(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -52,13 +165,65 @@ class _AccountsPageState extends State<AccountsPage> {
|
||||||
final t = AppLocalizations.of(context);
|
final t = AppLocalizations.of(context);
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) return Center(child: Text(_error!));
|
if (_error != null) return Center(child: Text(_error!));
|
||||||
|
final visible = _buildVisibleNodes();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
appBar: AppBar(title: Text(t.chartOfAccounts)),
|
||||||
body: RefreshIndicator(
|
body: Column(
|
||||||
onRefresh: _fetch,
|
children: [
|
||||||
child: ListView(
|
Container(
|
||||||
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
),
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 28), // expander space
|
||||||
|
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
Expanded(flex: 5, child: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
Expanded(flex: 3, child: Text(t.type, style: const TextStyle(fontWeight: FontWeight.w600))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _fetch,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: visible.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = visible[index];
|
||||||
|
final node = item.node;
|
||||||
|
final level = item.level;
|
||||||
|
final isExpanded = _expandedIds.contains(node.id);
|
||||||
|
final canExpand = node.hasChildren;
|
||||||
|
return InkWell(
|
||||||
|
onTap: canExpand ? () => _toggleExpand(node) : null,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 12.0 * level),
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: canExpand
|
||||||
|
? IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 20,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
|
||||||
|
onPressed: () => _toggleExpand(node),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
|
||||||
|
Expanded(flex: 5, child: Text(node.name)),
|
||||||
|
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import '../../widgets/category/category_tree_dialog.dart';
|
||||||
import '../../services/business_dashboard_service.dart';
|
import '../../services/business_dashboard_service.dart';
|
||||||
import '../../core/api_client.dart';
|
import '../../core/api_client.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
|
||||||
|
|
||||||
class BusinessShell extends StatefulWidget {
|
class BusinessShell extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -68,7 +69,25 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
super.dispose();
|
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
|
// Force a rebuild of the current page
|
||||||
setState(() {
|
setState(() {
|
||||||
// This will cause the current page to rebuild
|
// This will cause the current page to rebuild
|
||||||
|
|
@ -809,6 +828,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
} else if (child.label == t.invoice) {
|
} else if (child.label == t.invoice) {
|
||||||
// Navigate to add invoice
|
// Navigate to add invoice
|
||||||
context.go('/business/${widget.businessId}/invoice/new');
|
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) {
|
} else if (child.label == t.expenseAndIncome) {
|
||||||
// Navigate to add expense/income
|
// Navigate to add expense/income
|
||||||
} else if (child.label == t.warehouses) {
|
} else if (child.label == t.warehouses) {
|
||||||
|
|
@ -948,6 +970,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
showAddBankAccountDialog();
|
showAddBankAccountDialog();
|
||||||
} else if (item.label == t.cashBox) {
|
} else if (item.label == t.cashBox) {
|
||||||
showAddCashBoxDialog();
|
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) {
|
if (item.label == t.people) {
|
||||||
showAddPersonDialog();
|
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(
|
child: Container(
|
||||||
|
|
@ -1110,6 +1147,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
} else if (child.label == t.invoice) {
|
} else if (child.label == t.invoice) {
|
||||||
// Navigate to add invoice
|
// Navigate to add invoice
|
||||||
context.go('/business/${widget.businessId}/invoice/new');
|
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) {
|
} else if (child.label == t.expenseAndIncome) {
|
||||||
// Navigate to add expense/income
|
// Navigate to add expense/income
|
||||||
} else if (child.label == t.warehouses) {
|
} else if (child.label == t.warehouses) {
|
||||||
|
|
@ -1220,7 +1260,15 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
return true;
|
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);
|
final hasAccess = widget.authStore.canReadSection(section);
|
||||||
print(' Checking view permission for section "$section": $hasAccess');
|
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.documents) return 'accounting_documents';
|
||||||
if (label == t.chartOfAccounts) return 'chart_of_accounts';
|
if (label == t.chartOfAccounts) return 'chart_of_accounts';
|
||||||
if (label == t.openingBalance) return 'opening_balance';
|
if (label == t.openingBalance) return 'opening_balance';
|
||||||
|
if (label == t.reports) return 'reports';
|
||||||
if (label == t.warehouses) return 'warehouses';
|
if (label == t.warehouses) return 'warehouses';
|
||||||
if (label == t.shipments) return 'warehouse_transfers';
|
if (label == t.shipments) return 'warehouse_transfers';
|
||||||
if (label == t.inquiries) return 'reports';
|
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 '../../../services/business_dashboard_service.dart';
|
||||||
import '../../../core/api_client.dart';
|
import '../../../core/api_client.dart';
|
||||||
import '../../../models/business_dashboard_models.dart';
|
import '../../../models/business_dashboard_models.dart';
|
||||||
|
import '../../../core/fiscal_year_controller.dart';
|
||||||
|
import '../../../widgets/fiscal_year_switcher.dart';
|
||||||
|
|
||||||
class BusinessDashboardPage extends StatefulWidget {
|
class BusinessDashboardPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -14,7 +16,8 @@ class BusinessDashboardPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
final BusinessDashboardService _service = BusinessDashboardService(ApiClient());
|
late final FiscalYearController _fiscalController;
|
||||||
|
late final BusinessDashboardService _service;
|
||||||
BusinessDashboardResponse? _dashboardData;
|
BusinessDashboardResponse? _dashboardData;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
@ -22,7 +25,20 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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 {
|
Future<void> _loadDashboard() async {
|
||||||
|
|
@ -85,9 +101,45 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
t.businessDashboard,
|
children: [
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
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),
|
const SizedBox(height: 16),
|
||||||
if (_dashboardData != null) ...[
|
if (_dashboardData != null) ...[
|
||||||
|
|
|
||||||
|
|
@ -34,20 +34,20 @@ class _InvoicePageState extends State<InvoicePage> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.receipt,
|
Icons.receipt,
|
||||||
size: 80,
|
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),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
t.invoice,
|
t.invoice,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'صفحه فاکتور در حال توسعه است',
|
'صفحه فاکتور در حال توسعه است',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
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: t.personTypeSeller),
|
||||||
FilterOption(value: 'سهامدار', label: 'سهامدار'),
|
FilterOption(value: 'سهامدار', label: 'سهامدار'),
|
||||||
],
|
],
|
||||||
formatter: (person) => (person.personTypes.isNotEmpty
|
formatter: (person) => person.personTypes.map((e) => e.persianName).join('، '),
|
||||||
? person.personTypes.map((e) => e.persianName).join('، ')
|
|
||||||
: person.personType.persianName),
|
|
||||||
),
|
),
|
||||||
TextColumn(
|
TextColumn(
|
||||||
'company_name',
|
'company_name',
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
||||||
onChanged: (v) => productId = int.tryParse(v),
|
onChanged: (v) => productId = int.tryParse(v),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: currencyId,
|
initialValue: currencyId,
|
||||||
items: _fallbackCurrencies
|
items: _fallbackCurrencies
|
||||||
.map((c) => DropdownMenuItem<int>(
|
.map((c) => DropdownMenuItem<int>(
|
||||||
value: c['id'] as int,
|
value: c['id'] as int,
|
||||||
|
|
@ -143,7 +143,7 @@ class _PriceListItemsPageState extends State<PriceListItemsPage> {
|
||||||
onChanged: (v) => tierName = v,
|
onChanged: (v) => tierName = v,
|
||||||
),
|
),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
value: unitId,
|
initialValue: unitId,
|
||||||
items: _fallbackUnits
|
items: _fallbackUnits
|
||||||
.map((u) => DropdownMenuItem<int>(
|
.map((u) => DropdownMenuItem<int>(
|
||||||
value: u['id'] as int,
|
value: u['id'] as int,
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: isActive
|
backgroundColor: isActive
|
||||||
? Theme.of(context).primaryColor.withOpacity(0.1)
|
? Theme.of(context).primaryColor.withValues(alpha: 0.1)
|
||||||
: Colors.grey.withOpacity(0.1),
|
: Colors.grey.withValues(alpha: 0.1),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.price_change,
|
Icons.price_change,
|
||||||
color: isActive
|
color: isActive
|
||||||
|
|
@ -193,7 +193,7 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -392,9 +392,9 @@ class _PriceListsPageState extends State<PriceListsPage> {
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.withOpacity(0.1),
|
color: Colors.red.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
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(
|
child: Row(
|
||||||
children: [
|
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