Compare commits

...

10 commits

Author SHA1 Message Date
Hesabix 4d7a31409c progress in recipies 2025-10-16 13:02:03 +03:30
Hesabix 4c9283ab98 progress in recipies 2025-10-15 21:21:11 +03:30
Hesabix 37f4e0b6b4 progress in application 2025-10-14 23:16:28 +03:30
Hesabix 76ab27aa24 progress in cheques 2025-10-11 02:32:13 +03:30
Hesabix ff968aed7a progress in invoices 2025-10-11 02:13:18 +03:30
Hesabix 09c17b580d transactions 2025-10-07 02:14:58 +03:30
Hesabix 14d9024e8e progree in invoces 2025-10-07 01:59:25 +03:30
Hesabix 2591e9a7a9 progress in invoices 2025-10-07 01:32:30 +03:30
Hesabix 7f6a78f642 progress in invoice and products 2025-10-07 00:46:29 +03:30
Hesabix 0edff7d020 progress in invoice 2025-10-05 02:33:08 +03:30
159 changed files with 20847 additions and 1375 deletions

View 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 ریال ✅

View 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)

View 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

View file

@ -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

View 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")

View 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": "دسترسی مجاز است"}

View 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")

View 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")

View file

@ -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", "ایجاد"),
] ]

View 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",
},
)

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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 = [
"دارو",
"دخانیات",
"موبایل",
"لوازم خانگی برقی",
"قطعات مصرفی و یدکی وسایل نقلیه",
"فراورده ها و مشتقات نفتی و گازی و پتروشیمیایی",
"طلا اعم از شمش، مسکوکات و مصنوعات زینتی",
"منسوجات و پوشاک",
"اسباب بازی",
"دام زنده، گوشت سفید و قرمز",
"محصولات اساسی کشاورزی",
"سایر کالا ها",
]
return [{"id": idx + 1, "title": t} for idx, t in enumerate(titles)]
@router.get(
"/business/{business_id}",
summary="لیست نوع‌های مالیات", summary="لیست نوع‌های مالیات",
description="دریافت لیست نوع‌های مالیات (ثابت)", 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()
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) return success_response(items, request)

View file

@ -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)

View file

@ -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

View 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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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="شناسه پرداخت")
# سهام # سهام

View file

@ -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)
# قیمت‌های پایه (نمایشی) # قیمت‌های پایه (نمایشی)

View 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)

View file

@ -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)

View file

@ -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()

View file

@ -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,8 +125,13 @@ 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):
if k in nullable_overrides:
setattr(obj, k, v)
elif v is not None:
setattr(obj, k, v) setattr(obj, k, v)
self.db.commit() self.db.commit()
self.db.refresh(obj) self.db.refresh(obj)

View file

@ -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

View file

@ -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:

View file

@ -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")

View 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(),
}

View file

@ -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()

View file

@ -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)

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"

View file

@ -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 "سند دریافت/پرداخت با موفقیت ویرایش شد"

View file

@ -17,7 +17,16 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # Check if table already exists
connection = op.get_bind()
result = connection.execute(sa.text("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'petty_cash'
""")).fetchone()
if result[0] == 0:
op.create_table('petty_cash', 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),

View file

@ -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)

View file

@ -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='وضعیت فعال/غیرفعال'))

View file

@ -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

View file

@ -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)},
)

View file

@ -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

View file

@ -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

View file

@ -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',)

View file

@ -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')

View file

@ -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')

View file

@ -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

View file

@ -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')

View 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')

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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})

View file

@ -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},
)

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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,6 +19,12 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
bind = op.get_bind()
inspector = inspect(bind)
tables = set(inspector.get_table_names())
# Only create tables if they don't exist
if 'support_categories' not in tables:
op.create_table('support_categories', op.create_table('support_categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False), sa.Column('name', sa.String(length=100), nullable=False),
@ -28,6 +35,8 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
if 'support_priorities' not in tables:
op.create_table('support_priorities', op.create_table('support_priorities',
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('name', sa.String(length=50), nullable=False),
@ -39,6 +48,8 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
if 'support_statuses' not in tables:
op.create_table('support_statuses', op.create_table('support_statuses',
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('name', sa.String(length=50), nullable=False),
@ -50,6 +61,8 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
if 'support_tickets' not in tables:
op.create_table('support_tickets', 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('title', sa.String(length=255), nullable=False), sa.Column('title', sa.String(length=255), nullable=False),
@ -76,6 +89,8 @@ def upgrade() -> None:
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) op.create_index(op.f('ix_support_tickets_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_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
if 'support_messages' not in tables:
op.create_table('support_messages', op.create_table('support_messages',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('ticket_id', sa.Integer(), nullable=False), sa.Column('ticket_id', sa.Integer(), nullable=False),
@ -91,6 +106,9 @@ def upgrade() -> None:
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_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_sender_type'), 'support_messages', ['sender_type'], unique=False)
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) op.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', op.alter_column('businesses', 'business_type',
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),

View file

@ -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

View 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

View file

@ -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 ###

View file

@ -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')

View file

@ -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='نوع شخص'
)
)

View file

@ -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! ###
bind = op.get_bind()
inspector = inspect(bind)
# Check if persons table exists and has the code column
if 'persons' in inspector.get_table_names():
cols = {c['name'] for c in inspector.get_columns('persons')}
# Only alter code column if it exists
if 'code' in cols:
op.alter_column('persons', 'code', op.alter_column('persons', 'code',
existing_type=mysql.INTEGER(), existing_type=mysql.INTEGER(),
comment='کد یکتا در هر کسب و کار', comment='کد یکتا در هر کسب و کار',
existing_nullable=True) existing_nullable=True)
# Only alter person_type column if it exists
if 'person_type' in cols:
op.alter_column('persons', 'person_type', op.alter_column('persons', 'person_type',
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
comment='نوع شخص', comment='نوع شخص',
existing_nullable=False) existing_nullable=False)
# Only alter person_types column if it exists
if 'person_types' in cols:
op.alter_column('persons', 'person_types', op.alter_column('persons', 'person_types',
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
comment='لیست انواع شخص به صورت JSON', comment='لیست انواع شخص به صورت JSON',
existing_nullable=True) existing_nullable=True)
# Only alter commission columns if they exist
if 'commission_sale_percent' in cols:
op.alter_column('persons', 'commission_sale_percent', op.alter_column('persons', 'commission_sale_percent',
existing_type=mysql.DECIMAL(precision=5, scale=2), existing_type=mysql.DECIMAL(precision=5, scale=2),
comment='درصد پورسانت از فروش', comment='درصد پورسانت از فروش',
existing_nullable=True) existing_nullable=True)
if 'commission_sales_return_percent' in cols:
op.alter_column('persons', 'commission_sales_return_percent', op.alter_column('persons', 'commission_sales_return_percent',
existing_type=mysql.DECIMAL(precision=5, scale=2), existing_type=mysql.DECIMAL(precision=5, scale=2),
comment='درصد پورسانت از برگشت از فروش', comment='درصد پورسانت از برگشت از فروش',
existing_nullable=True) existing_nullable=True)
if 'commission_sales_amount' in cols:
op.alter_column('persons', 'commission_sales_amount', op.alter_column('persons', 'commission_sales_amount',
existing_type=mysql.DECIMAL(precision=12, scale=2), existing_type=mysql.DECIMAL(precision=12, scale=2),
comment='مبلغ فروش مبنا برای پورسانت', comment='مبلغ فروش مبنا برای پورسانت',
existing_nullable=True) existing_nullable=True)
if 'commission_sales_return_amount' in cols:
op.alter_column('persons', 'commission_sales_return_amount', op.alter_column('persons', 'commission_sales_return_amount',
existing_type=mysql.DECIMAL(precision=12, scale=2), existing_type=mysql.DECIMAL(precision=12, scale=2),
comment='مبلغ برگشت از فروش مبنا برای پورسانت', comment='مبلغ برگشت از فروش مبنا برای پورسانت',
existing_nullable=True) existing_nullable=True)
if 'commission_exclude_discounts' in cols:
op.alter_column('persons', 'commission_exclude_discounts', op.alter_column('persons', 'commission_exclude_discounts',
existing_type=mysql.TINYINT(display_width=1), existing_type=mysql.TINYINT(display_width=1),
comment='عدم محاسبه تخفیف در پورسانت', comment='عدم محاسبه تخفیف در پورسانت',
existing_nullable=False, existing_nullable=False,
existing_server_default=sa.text("'0'")) existing_server_default=sa.text("'0'"))
if 'commission_exclude_additions_deductions' in cols:
op.alter_column('persons', 'commission_exclude_additions_deductions', op.alter_column('persons', 'commission_exclude_additions_deductions',
existing_type=mysql.TINYINT(display_width=1), existing_type=mysql.TINYINT(display_width=1),
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
existing_nullable=False, existing_nullable=False,
existing_server_default=sa.text("'0'")) existing_server_default=sa.text("'0'"))
if 'commission_post_in_invoice_document' in cols:
op.alter_column('persons', 'commission_post_in_invoice_document', op.alter_column('persons', 'commission_post_in_invoice_document',
existing_type=mysql.TINYINT(display_width=1), existing_type=mysql.TINYINT(display_width=1),
comment='ثبت پورسانت در سند حسابداری فاکتور', comment='ثبت پورسانت در سند حسابداری فاکتور',
existing_nullable=False, existing_nullable=False,
existing_server_default=sa.text("'0'")) 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='نام پله قیمت (تکی/عمده/همکار/...)',

View 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()

View file

@ -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();
}
} }

View file

@ -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;

View file

@ -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();

View 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);
}
}
}

View file

@ -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"
} }

View file

@ -1073,6 +1073,14 @@
"pettyCashDetails": "جزئیات تنخواه گردان", "pettyCashDetails": "جزئیات تنخواه گردان",
"pettyCashExportExcel": "خروجی Excel تنخواه گردان‌ها", "pettyCashExportExcel": "خروجی Excel تنخواه گردان‌ها",
"pettyCashExportPdf": "خروجی PDF تنخواه گردان‌ها", "pettyCashExportPdf": "خروجی PDF تنخواه گردان‌ها",
"pettyCashReport": "گزارش تنخواه گردان‌ها" "pettyCashReport": "گزارش تنخواه گردان‌ها",
"accountTypeBank": "بانک",
"accountTypeCashRegister": "صندوق",
"accountTypePettyCash": "تنخواه گردان",
"accountTypeCheck": "چک",
"accountTypePerson": "شخص",
"accountTypeProduct": "کالا",
"accountTypeService": "خدمات",
"accountTypeAccountingDocument": "سند حسابداری"
} }

View file

@ -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

View file

@ -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';
} }

View file

@ -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 => 'سند حسابداری';
} }

View file

@ -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.)
], ],
), ),

View 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;
}

View 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;
}

View 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;
}

View 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?,
);
}
}

View 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;
}

View file

@ -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;

View file

@ -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(),

View 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;
}
}
}

View file

@ -2,6 +2,45 @@ import 'package:flutter/material.dart';
import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart';
import 'package:hesabix_ui/core/api_client.dart'; import 'package:hesabix_ui/core/api_client.dart';
class AccountNode {
final String id;
final String code;
final String name;
final String? accountType;
final List<AccountNode> children;
final bool hasChildren;
const AccountNode({
required this.id,
required this.code,
required this.name,
this.accountType,
this.children = const [],
this.hasChildren = false,
});
factory AccountNode.fromJson(Map<String, dynamic> json) {
final rawChildren = (json['children'] as List?) ?? const [];
final parsedChildren = rawChildren
.map((c) => AccountNode.fromJson(Map<String, dynamic>.from(c as Map)))
.toList();
return AccountNode(
id: (json['id']?.toString() ?? json['code']?.toString() ?? UniqueKey().toString()),
code: json['code']?.toString() ?? '',
name: json['name']?.toString() ?? '',
accountType: json['account_type']?.toString(),
children: parsedChildren,
hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty,
);
}
}
class _VisibleNode {
final AccountNode node;
final int level;
const _VisibleNode(this.node, this.level);
}
class AccountsPage extends StatefulWidget { class AccountsPage extends StatefulWidget {
final int businessId; final int businessId;
const AccountsPage({super.key, required this.businessId}); const AccountsPage({super.key, required this.businessId});
@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget {
class _AccountsPageState extends State<AccountsPage> { class _AccountsPageState extends State<AccountsPage> {
bool _loading = true; bool _loading = true;
String? _error; String? _error;
List<dynamic> _tree = const []; List<AccountNode> _roots = const [];
final Set<String> _expandedIds = <String>{};
@override @override
void initState() { void initState() {
@ -26,7 +66,11 @@ class _AccountsPageState extends State<AccountsPage> {
try { try {
final api = ApiClient(); final api = ApiClient();
final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree'); final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree');
setState(() { _tree = res.data['data']['items'] ?? []; }); final items = (res.data['data']['items'] as List?) ?? const [];
final parsed = items
.map((n) => AccountNode.fromJson(Map<String, dynamic>.from(n as Map)))
.toList();
setState(() { _roots = parsed; });
} catch (e) { } catch (e) {
setState(() { _error = e.toString(); }); setState(() { _error = e.toString(); });
} finally { } finally {
@ -34,17 +78,86 @@ class _AccountsPageState extends State<AccountsPage> {
} }
} }
Widget _buildNode(Map<String, dynamic> node) { List<_VisibleNode> _buildVisibleNodes() {
final children = (node['children'] as List?) ?? const []; final List<_VisibleNode> result = <_VisibleNode>[];
if (children.isEmpty) { void dfs(AccountNode node, int level) {
return ListTile( result.add(_VisibleNode(node, level));
title: Text('${node['code']} - ${node['name']}'), if (_expandedIds.contains(node.id)) {
); for (final child in node.children) {
dfs(child, level + 1);
}
}
}
for (final r in _roots) {
dfs(r, 0);
}
return result;
}
void _toggleExpand(AccountNode node) {
setState(() {
if (_expandedIds.contains(node.id)) {
_expandedIds.remove(node.id);
} else {
if (node.hasChildren) {
_expandedIds.add(node.id);
}
}
});
}
String _localizedAccountType(AppLocalizations t, String? value) {
if (value == null || value.isEmpty) return '-';
final ln = t.localeName;
if (ln.startsWith('fa')) {
switch (value) {
case 'bank':
return t.accountTypeBank;
case 'cash_register':
return t.accountTypeCashRegister;
case 'petty_cash':
return t.accountTypePettyCash;
case 'check':
return t.accountTypeCheck;
case 'person':
return t.accountTypePerson;
case 'product':
return t.accountTypeProduct;
case 'service':
return t.accountTypeService;
case 'accounting_document':
return t.accountTypeAccountingDocument;
default:
return value;
}
}
// English and other locales: humanize
String humanize(String v) {
return v
.split('_')
.map((p) => p.isEmpty ? p : (p[0].toUpperCase() + p.substring(1)))
.join(' ');
}
switch (value) {
case 'bank':
return t.accountTypeBank;
case 'cash_register':
return t.accountTypeCashRegister;
case 'petty_cash':
return t.accountTypePettyCash;
case 'check':
return t.accountTypeCheck;
case 'person':
return t.accountTypePerson;
case 'product':
return t.accountTypeProduct;
case 'service':
return t.accountTypeService;
case 'accounting_document':
return t.accountTypeAccountingDocument;
default:
return humanize(value);
} }
return ExpansionTile(
title: Text('${node['code']} - ${node['name']}'),
children: children.map<Widget>((c) => _buildNode(Map<String, dynamic>.from(c))).toList(),
);
} }
@override @override
@ -52,14 +165,66 @@ class _AccountsPageState extends State<AccountsPage> {
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
if (_loading) return const Center(child: CircularProgressIndicator()); if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) return Center(child: Text(_error!)); if (_error != null) return Center(child: Text(_error!));
final visible = _buildVisibleNodes();
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(t.chartOfAccounts)), appBar: AppBar(title: Text(t.chartOfAccounts)),
body: RefreshIndicator( body: Column(
onRefresh: _fetch, children: [
child: ListView( Container(
children: _tree.map<Widget>((n) => _buildNode(Map<String, dynamic>.from(n))).toList(), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
const SizedBox(width: 28), // expander space
Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(flex: 5, child: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(flex: 3, child: Text(t.type, style: const TextStyle(fontWeight: FontWeight.w600))),
],
), ),
), ),
Expanded(
child: RefreshIndicator(
onRefresh: _fetch,
child: ListView.builder(
itemCount: visible.length,
itemBuilder: (context, index) {
final item = visible[index];
final node = item.node;
final level = item.level;
final isExpanded = _expandedIds.contains(node.id);
final canExpand = node.hasChildren;
return InkWell(
onTap: canExpand ? () => _toggleExpand(node) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
SizedBox(width: 12.0 * level),
SizedBox(
width: 28,
child: canExpand
? IconButton(
padding: EdgeInsets.zero,
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right),
onPressed: () => _toggleExpand(node),
)
: const SizedBox.shrink(),
),
Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))),
Expanded(flex: 5, child: Text(node.name)),
Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))),
],
),
),
);
},
),
),
),
],
),
); );
} }
} }

View file

@ -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,6 +69,24 @@ class _BusinessShellState extends State<BusinessShell> {
super.dispose(); super.dispose();
} }
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() { void _refreshCurrentPage() {
// Force a rebuild of the current page // Force a rebuild of the current page
setState(() { setState(() {
@ -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';

View 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),
),
],
),
],
),
),
),
),
),
),
);
}
}

View 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');
}
},
);
}
}

View file

@ -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();
_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(); _loadDashboard();
});
await _loadDashboard();
} }
Future<void> _loadDashboard() async { Future<void> _loadDashboard() async {
@ -85,10 +101,46 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Expanded(
child: Text(
t.businessDashboard, t.businessDashboard,
style: Theme.of(context).textTheme.headlineMedium, 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) ...[
_buildBusinessInfo(_dashboardData!.businessInfo), _buildBusinessInfo(_dashboardData!.businessInfo),

View file

@ -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

View file

@ -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,
),
),
],
),
);
}
}

View file

@ -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',

View file

@ -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,

View file

@ -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