progress in recipies
This commit is contained in:
parent
37f4e0b6b4
commit
4c9283ab98
79
docs/COMMISSION_IMPLEMENTATION.md
Normal file
79
docs/COMMISSION_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# پیادهسازی کارمزد در بخش دریافت و پرداخت
|
||||||
|
|
||||||
|
## تغییرات اعمال شده
|
||||||
|
|
||||||
|
### 1. سرویس دریافت و پرداخت (`receipt_payment_service.py`)
|
||||||
|
|
||||||
|
#### تغییرات اصلی:
|
||||||
|
- **محاسبه مجموع کارمزدها**: مجموع کارمزدهای همه تراکنشها محاسبه میشود
|
||||||
|
- **ایجاد خطوط کارمزد جداگانه**: برای هر کارمزد، دو خط جداگانه ایجاد میشود:
|
||||||
|
- خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
|
||||||
|
- خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
|
||||||
|
|
||||||
|
#### منطق کارمزد:
|
||||||
|
|
||||||
|
**در دریافت (Receipt):**
|
||||||
|
- کارمزد از حساب بانک/صندوق/تنخواهگردان کم میشود (Credit)
|
||||||
|
- کارمزد به حساب کارمزد خدمات بانکی اضافه میشود (Debit)
|
||||||
|
|
||||||
|
**در پرداخت (Payment):**
|
||||||
|
- کارمزد به حساب بانک/صندوق/تنخواهگردان اضافه میشود (Debit)
|
||||||
|
- کارمزد از حساب کارمزد خدمات بانکی کم میشود (Credit)
|
||||||
|
|
||||||
|
#### کدهای حساب:
|
||||||
|
- بانک: `10203`
|
||||||
|
- صندوق: `10202`
|
||||||
|
- تنخواهگردان: `10201`
|
||||||
|
- چک دریافتی: `10403`
|
||||||
|
- چک پرداختی: `20202`
|
||||||
|
- **کارمزد خدمات بانکی: `70902`**
|
||||||
|
|
||||||
|
### 2. نمایش خطوط کارمزد
|
||||||
|
|
||||||
|
در تابع `document_to_dict`:
|
||||||
|
- خطوط کارمزد با فلگ `is_commission_line: true` تشخیص داده میشوند
|
||||||
|
- خطوط کارمزد همیشه در `account_lines` نمایش داده میشوند
|
||||||
|
|
||||||
|
## نحوه استفاده
|
||||||
|
|
||||||
|
### فرانتاند:
|
||||||
|
```dart
|
||||||
|
// در InvoiceTransaction
|
||||||
|
final transaction = InvoiceTransaction(
|
||||||
|
// ... سایر فیلدها
|
||||||
|
commission: 5000, // کارمزد به ریال
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account_lines": [
|
||||||
|
{
|
||||||
|
"transaction_type": "bank",
|
||||||
|
"amount": 1000000,
|
||||||
|
"commission": 5000,
|
||||||
|
"bank_id": "123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نتیجه
|
||||||
|
|
||||||
|
✅ کارمزد از فرانت به سرور ارسال میشود
|
||||||
|
✅ کارمزد به عنوان سطر جداگانه در `document_lines` ثبت میشود
|
||||||
|
✅ کارمزد برای بانک، صندوق و تنخواهگردان به صورت جداگانه ثبت میشود
|
||||||
|
✅ کارمزد در حساب کارمزد خدمات بانکی (کد 70902) ثبت میشود
|
||||||
|
✅ تعادل حسابداری حفظ میشود
|
||||||
|
|
||||||
|
## مثال عملی
|
||||||
|
|
||||||
|
**دریافت 1,000,000 ریال از شخص با کارمزد 5,000 ریال:**
|
||||||
|
|
||||||
|
1. خط اصلی: شخص بستانکار 1,000,000 ریال
|
||||||
|
2. خط اصلی: بانک بدهکار 1,000,000 ریال
|
||||||
|
3. خط کارمزد: بانک بستانکار 5,000 ریال (کم شدن کارمزد)
|
||||||
|
4. خط کارمزد: کارمزد خدمات بانکی بدهکار 5,000 ریال (اضافه شدن کارمزد)
|
||||||
|
|
||||||
|
**مجموع:** شخص = 1,000,000 ریال، بانک = 995,000 ریال، کارمزد خدمات بانکی = 5,000 ریال ✅
|
||||||
236
docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md
Normal file
236
docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
# پیادهسازی صفحه لیست دریافت و پرداخت با ویجت جدول
|
||||||
|
|
||||||
|
## 📋 خلاصه
|
||||||
|
این سند توضیح میدهد که چگونه بخش لیست دریافت و پرداخت از یک ListView ساده به یک ویجت جدول پیشرفته تبدیل شده است.
|
||||||
|
|
||||||
|
## 🎯 اهداف
|
||||||
|
- جایگزینی ListView ساده با DataTableWidget پیشرفته
|
||||||
|
- افزودن قابلیتهای جستجو، فیلتر و صفحهبندی
|
||||||
|
- بهبود تجربه کاربری و عملکرد
|
||||||
|
- استفاده مجدد از ویجت جدول در بخشهای دیگر
|
||||||
|
|
||||||
|
## 📁 فایلهای ایجاد شده
|
||||||
|
|
||||||
|
### 1. مدل داده
|
||||||
|
**مسیر:** `lib/models/receipt_payment_document.dart`
|
||||||
|
|
||||||
|
#### کلاسهای اصلی:
|
||||||
|
- `ReceiptPaymentDocument`: مدل اصلی سند دریافت/پرداخت
|
||||||
|
- `PersonLine`: مدل خط شخص در سند
|
||||||
|
- `AccountLine`: مدل خط حساب در سند
|
||||||
|
|
||||||
|
#### ویژگیهای کلیدی:
|
||||||
|
- پشتیبانی از JSON serialization
|
||||||
|
- محاسبه خودکار مجموع مبالغ
|
||||||
|
- تشخیص نوع سند (دریافت/پرداخت)
|
||||||
|
- فرمتبندی مناسب برای نمایش
|
||||||
|
|
||||||
|
### 2. سرویس
|
||||||
|
**مسیر:** `lib/services/receipt_payment_list_service.dart`
|
||||||
|
|
||||||
|
#### کلاس اصلی:
|
||||||
|
- `ReceiptPaymentListService`: مدیریت API calls
|
||||||
|
|
||||||
|
#### متدهای اصلی:
|
||||||
|
- `getList()`: دریافت لیست اسناد با فیلتر
|
||||||
|
- `getById()`: دریافت جزئیات یک سند
|
||||||
|
- `delete()`: حذف یک سند
|
||||||
|
- `deleteMultiple()`: حذف چندین سند
|
||||||
|
- `getStats()`: دریافت آمار کلی
|
||||||
|
|
||||||
|
### 3. صفحه جدید
|
||||||
|
**مسیر:** `lib/pages/business/receipts_payments_list_page.dart`
|
||||||
|
|
||||||
|
#### ویژگیهای صفحه:
|
||||||
|
- استفاده از DataTableWidget
|
||||||
|
- فیلتر نوع سند (دریافت/پرداخت/همه)
|
||||||
|
- فیلتر بازه زمانی
|
||||||
|
- جستجوی پیشرفته
|
||||||
|
- عملیات CRUD کامل
|
||||||
|
|
||||||
|
## 🔧 تنظیمات جدول
|
||||||
|
|
||||||
|
### ستونهای تعریف شده:
|
||||||
|
1. **کد سند** (TextColumn): نمایش کد سند
|
||||||
|
2. **نوع سند** (TextColumn): دریافت/پرداخت
|
||||||
|
3. **تاریخ سند** (DateColumn): تاریخ با فرمت جلالی
|
||||||
|
4. **مبلغ کل** (NumberColumn): مجموع مبالغ
|
||||||
|
5. **تعداد اشخاص** (NumberColumn): تعداد خطوط اشخاص
|
||||||
|
6. **تعداد حسابها** (NumberColumn): تعداد خطوط حسابها
|
||||||
|
7. **ایجادکننده** (TextColumn): نام کاربر
|
||||||
|
8. **تاریخ ثبت** (DateColumn): زمان ثبت
|
||||||
|
9. **عملیات** (ActionColumn): دکمههای عملیات
|
||||||
|
|
||||||
|
### قابلیتهای فعال:
|
||||||
|
- ✅ جستجوی کلی
|
||||||
|
- ✅ فیلتر ستونی
|
||||||
|
- ✅ فیلتر بازه زمانی
|
||||||
|
- ✅ مرتبسازی
|
||||||
|
- ✅ صفحهبندی
|
||||||
|
- ✅ انتخاب چندتایی
|
||||||
|
- ✅ دکمه refresh
|
||||||
|
- ✅ دکمه clear filters
|
||||||
|
|
||||||
|
## 🚀 نحوه استفاده
|
||||||
|
|
||||||
|
### 1. Navigation
|
||||||
|
```dart
|
||||||
|
// در routing موجود
|
||||||
|
GoRoute(
|
||||||
|
path: 'receipts-payments',
|
||||||
|
name: 'business_receipts_payments',
|
||||||
|
builder: (context, state) {
|
||||||
|
final businessId = int.parse(state.pathParameters['business_id']!);
|
||||||
|
return BusinessShell(
|
||||||
|
businessId: businessId,
|
||||||
|
authStore: _authStore!,
|
||||||
|
localeController: controller,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
themeController: themeController,
|
||||||
|
child: ReceiptsPaymentsListPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
authStore: _authStore!,
|
||||||
|
apiClient: ApiClient(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. استفاده مستقیم
|
||||||
|
```dart
|
||||||
|
ReceiptsPaymentsListPage(
|
||||||
|
businessId: 123,
|
||||||
|
calendarController: calendarController,
|
||||||
|
authStore: authStore,
|
||||||
|
apiClient: apiClient,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 تغییرات در Routing
|
||||||
|
|
||||||
|
### قبل:
|
||||||
|
```dart
|
||||||
|
child: ReceiptsPaymentsPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
authStore: _authStore!,
|
||||||
|
apiClient: ApiClient(),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
### بعد:
|
||||||
|
```dart
|
||||||
|
child: ReceiptsPaymentsListPage(
|
||||||
|
businessId: businessId,
|
||||||
|
calendarController: _calendarController!,
|
||||||
|
authStore: _authStore!,
|
||||||
|
apiClient: ApiClient(),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 API Integration
|
||||||
|
|
||||||
|
### Endpoint استفاده شده:
|
||||||
|
```
|
||||||
|
POST /businesses/{business_id}/receipts-payments
|
||||||
|
```
|
||||||
|
|
||||||
|
### پارامترهای پشتیبانی شده:
|
||||||
|
- `search`: جستجوی کلی
|
||||||
|
- `document_type`: نوع سند (receipt/payment)
|
||||||
|
- `from_date`: تاریخ شروع
|
||||||
|
- `to_date`: تاریخ پایان
|
||||||
|
- `sort_by`: فیلد مرتبسازی
|
||||||
|
- `sort_desc`: جهت مرتبسازی
|
||||||
|
- `take`: تعداد رکورد در صفحه
|
||||||
|
- `skip`: تعداد رکورد رد شده
|
||||||
|
|
||||||
|
## 🎨 UI/UX بهبودها
|
||||||
|
|
||||||
|
### قبل:
|
||||||
|
- ListView ساده
|
||||||
|
- فقط نمایش draft های محلی
|
||||||
|
- عدم وجود جستجو و فیلتر
|
||||||
|
- UI محدود
|
||||||
|
|
||||||
|
### بعد:
|
||||||
|
- DataTableWidget پیشرفته
|
||||||
|
- اتصال مستقیم به API
|
||||||
|
- جستجو و فیلتر کامل
|
||||||
|
- UI مدرن و responsive
|
||||||
|
- عملیات CRUD کامل
|
||||||
|
|
||||||
|
## 🔧 تنظیمات پیشرفته
|
||||||
|
|
||||||
|
### فیلترهای اضافی:
|
||||||
|
```dart
|
||||||
|
additionalParams: {
|
||||||
|
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||||
|
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
||||||
|
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### تنظیمات جدول:
|
||||||
|
```dart
|
||||||
|
DataTableConfig<ReceiptPaymentDocument>(
|
||||||
|
endpoint: '/businesses/${widget.businessId}/receipts-payments',
|
||||||
|
searchFields: ['code', 'created_by_name'],
|
||||||
|
filterFields: ['document_type'],
|
||||||
|
dateRangeField: 'document_date',
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
defaultPageSize: 20,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚧 TODO های آینده
|
||||||
|
|
||||||
|
1. **صفحه افزودن سند جدید**
|
||||||
|
- استفاده از dialog موجود
|
||||||
|
- یکپارچهسازی با API
|
||||||
|
|
||||||
|
2. **صفحه جزئیات سند**
|
||||||
|
- نمایش کامل خطوط اشخاص و حسابها
|
||||||
|
- امکان ویرایش
|
||||||
|
|
||||||
|
3. **عملیات گروهی**
|
||||||
|
- حذف چندتایی
|
||||||
|
- خروجی اکسل
|
||||||
|
- چاپ اسناد
|
||||||
|
|
||||||
|
4. **بهبودهای UX**
|
||||||
|
- انیمیشنهای بهتر
|
||||||
|
- حالتهای loading پیشرفته
|
||||||
|
- پیامهای خطای بهتر
|
||||||
|
|
||||||
|
## 📝 نکات مهم
|
||||||
|
|
||||||
|
1. **سازگاری**: صفحه قدیمی `ReceiptsPaymentsPage` همچنان موجود است
|
||||||
|
2. **API**: از همان API موجود استفاده میکند
|
||||||
|
3. **مدلها**: مدلهای جدید با ساختار API سازگار هستند
|
||||||
|
4. **Performance**: صفحهبندی و lazy loading برای عملکرد بهتر
|
||||||
|
|
||||||
|
## 🔍 تست
|
||||||
|
|
||||||
|
### بررسی syntax:
|
||||||
|
```bash
|
||||||
|
flutter analyze lib/pages/business/receipts_payments_list_page.dart
|
||||||
|
flutter analyze lib/models/receipt_payment_document.dart
|
||||||
|
flutter analyze lib/services/receipt_payment_list_service.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
### تست runtime:
|
||||||
|
1. اجرای اپلیکیشن
|
||||||
|
2. رفتن به بخش دریافت و پرداخت
|
||||||
|
3. تست فیلترها و جستجو
|
||||||
|
4. تست عملیات CRUD
|
||||||
|
|
||||||
|
## 📚 منابع
|
||||||
|
|
||||||
|
- [DataTableWidget Documentation](../hesabixUI/hesabix_ui/lib/widgets/data_table/README.md)
|
||||||
|
- [API Documentation](../hesabixAPI/adapters/api/v1/receipts_payments.py)
|
||||||
|
- [Service Implementation](../hesabixAPI/app/services/receipt_payment_service.py)
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
API endpoints برای دریافت و پرداخت (Receipt & Payment)
|
API endpoints برای دریافت و پرداخت (Receipt & Payment)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List
|
||||||
from fastapi import APIRouter, Depends, Request, Body
|
from fastapi import APIRouter, Depends, Request, Body
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
from adapters.db.session import get_db
|
from adapters.db.session import get_db
|
||||||
from app.core.auth_dependency import get_current_user, AuthContext
|
from app.core.auth_dependency import get_current_user, AuthContext
|
||||||
|
|
@ -17,6 +22,7 @@ from app.services.receipt_payment_service import (
|
||||||
list_receipts_payments,
|
list_receipts_payments,
|
||||||
delete_receipt_payment,
|
delete_receipt_payment,
|
||||||
)
|
)
|
||||||
|
from adapters.db.models.business import Business
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["receipts-payments"])
|
router = APIRouter(tags=["receipts-payments"])
|
||||||
|
|
@ -201,3 +207,439 @@ async def delete_receipt_payment_endpoint(
|
||||||
message="RECEIPT_PAYMENT_DELETED"
|
message="RECEIPT_PAYMENT_DELETED"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/receipts-payments/export/excel",
|
||||||
|
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
|
||||||
|
description="خروجی Excel لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_receipts_payments_excel(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
auth_context: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""خروجی Excel لیست اسناد دریافت و پرداخت"""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
|
||||||
|
# Build query dict from flat body
|
||||||
|
# For export, we limit to reasonable number to prevent memory issues
|
||||||
|
max_export_records = 10000
|
||||||
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": take_value,
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
"document_type": body.get("document_type"),
|
||||||
|
"from_date": body.get("from_date"),
|
||||||
|
"to_date": body.get("to_date"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_receipts_payments(db, business_id, query_dict)
|
||||||
|
items = result.get('items', [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Check if we hit the limit
|
||||||
|
if len(items) >= max_export_records:
|
||||||
|
# Add a warning row to indicate data was truncated
|
||||||
|
warning_item = {
|
||||||
|
"code": "⚠️ هشدار",
|
||||||
|
"document_type": "حداکثر ۱۰,۰۰۰ رکورد قابل export است",
|
||||||
|
"document_date": "",
|
||||||
|
"total_amount": "",
|
||||||
|
"person_lines_count": "",
|
||||||
|
"account_lines_count": "",
|
||||||
|
"created_by_name": "",
|
||||||
|
"registered_at": "",
|
||||||
|
}
|
||||||
|
items.append(warning_item)
|
||||||
|
|
||||||
|
# Handle selected rows
|
||||||
|
selected_only = bool(body.get('selected_only', False))
|
||||||
|
selected_indices = body.get('selected_indices')
|
||||||
|
if selected_only and selected_indices is not None:
|
||||||
|
indices = None
|
||||||
|
if isinstance(selected_indices, str):
|
||||||
|
try:
|
||||||
|
indices = json.loads(selected_indices)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
indices = None
|
||||||
|
elif isinstance(selected_indices, list):
|
||||||
|
indices = selected_indices
|
||||||
|
if isinstance(indices, list):
|
||||||
|
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||||
|
|
||||||
|
# Prepare headers based on export_columns (order + visibility)
|
||||||
|
headers: List[str] = []
|
||||||
|
keys: List[str] = []
|
||||||
|
export_columns = body.get('export_columns')
|
||||||
|
if export_columns:
|
||||||
|
for col in export_columns:
|
||||||
|
key = col.get('key')
|
||||||
|
label = col.get('label', key)
|
||||||
|
if key:
|
||||||
|
keys.append(str(key))
|
||||||
|
headers.append(str(label))
|
||||||
|
else:
|
||||||
|
# Default columns for receipts/payments
|
||||||
|
default_columns = [
|
||||||
|
('code', 'کد سند'),
|
||||||
|
('document_type_name', 'نوع سند'),
|
||||||
|
('document_date', 'تاریخ سند'),
|
||||||
|
('total_amount', 'مبلغ کل'),
|
||||||
|
('person_names', 'اشخاص'),
|
||||||
|
('account_lines_count', 'تعداد حسابها'),
|
||||||
|
('created_by_name', 'ایجادکننده'),
|
||||||
|
('registered_at', 'تاریخ ثبت'),
|
||||||
|
]
|
||||||
|
for key, label in default_columns:
|
||||||
|
if items and key in items[0]:
|
||||||
|
keys.append(key)
|
||||||
|
headers.append(label)
|
||||||
|
|
||||||
|
# Create workbook
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Receipts & Payments"
|
||||||
|
|
||||||
|
# Locale and RTL/LTR handling
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
if locale == 'fa':
|
||||||
|
try:
|
||||||
|
ws.sheet_view.rightToLeft = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
header_font = Font(bold=True, color="FFFFFF")
|
||||||
|
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
|
||||||
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
||||||
|
|
||||||
|
# Write header row
|
||||||
|
for col_idx, header in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
cell.border = border
|
||||||
|
|
||||||
|
# Write data rows
|
||||||
|
for row_idx, item in enumerate(items, 2):
|
||||||
|
for col_idx, key in enumerate(keys, 1):
|
||||||
|
value = item.get(key, "")
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
value = str(value)
|
||||||
|
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||||
|
cell.border = border
|
||||||
|
|
||||||
|
# RTL alignment for Persian text
|
||||||
|
if locale == 'fa' and isinstance(value, str) and any('\u0600' <= c <= '\u06FF' for c in value):
|
||||||
|
cell.alignment = Alignment(horizontal="right")
|
||||||
|
|
||||||
|
# Auto-width columns
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = column[0].column_letter
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if len(str(cell.value)) > max_length:
|
||||||
|
max_length = len(str(cell.value))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
|
# Save to bytes
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
wb.save(buffer)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
# Build meaningful filename
|
||||||
|
biz_name = ""
|
||||||
|
try:
|
||||||
|
b = db.query(Business).filter(Business.id == business_id).first()
|
||||||
|
if b is not None:
|
||||||
|
biz_name = b.name or ""
|
||||||
|
except Exception:
|
||||||
|
biz_name = ""
|
||||||
|
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||||||
|
|
||||||
|
base = "receipts_payments"
|
||||||
|
if biz_name:
|
||||||
|
base += f"_{slugify(biz_name)}"
|
||||||
|
if selected_only:
|
||||||
|
base += "_selected"
|
||||||
|
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
content = buffer.getvalue()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"Content-Length": str(len(content)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/businesses/{business_id}/receipts-payments/export/pdf",
|
||||||
|
summary="خروجی PDF لیست اسناد دریافت و پرداخت",
|
||||||
|
description="خروجی PDF لیست اسناد دریافت و پرداخت با قابلیت فیلتر، انتخاب سطرها و رعایت ترتیب/نمایش ستونها",
|
||||||
|
)
|
||||||
|
@require_business_access("business_id")
|
||||||
|
async def export_receipts_payments_pdf(
|
||||||
|
business_id: int,
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
auth_context: AuthContext = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""خروجی PDF لیست اسناد دریافت و پرداخت"""
|
||||||
|
from weasyprint import HTML, CSS
|
||||||
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
|
from app.core.i18n import negotiate_locale
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
# Build query dict from flat body
|
||||||
|
# For export, we limit to reasonable number to prevent memory issues
|
||||||
|
max_export_records = 10000
|
||||||
|
take_value = min(int(body.get("take", 1000)), max_export_records)
|
||||||
|
|
||||||
|
query_dict = {
|
||||||
|
"take": take_value,
|
||||||
|
"skip": int(body.get("skip", 0)),
|
||||||
|
"sort_by": body.get("sort_by"),
|
||||||
|
"sort_desc": bool(body.get("sort_desc", False)),
|
||||||
|
"search": body.get("search"),
|
||||||
|
"search_fields": body.get("search_fields"),
|
||||||
|
"filters": body.get("filters"),
|
||||||
|
"document_type": body.get("document_type"),
|
||||||
|
"from_date": body.get("from_date"),
|
||||||
|
"to_date": body.get("to_date"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = list_receipts_payments(db, business_id, query_dict)
|
||||||
|
items = result.get('items', [])
|
||||||
|
items = [format_datetime_fields(item, request) for item in items]
|
||||||
|
|
||||||
|
# Handle selected rows
|
||||||
|
selected_only = bool(body.get('selected_only', False))
|
||||||
|
selected_indices = body.get('selected_indices')
|
||||||
|
if selected_only and selected_indices is not None:
|
||||||
|
indices = None
|
||||||
|
if isinstance(selected_indices, str):
|
||||||
|
try:
|
||||||
|
indices = json.loads(selected_indices)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
indices = None
|
||||||
|
elif isinstance(selected_indices, list):
|
||||||
|
indices = selected_indices
|
||||||
|
if isinstance(indices, list):
|
||||||
|
items = [items[i] for i in indices if isinstance(i, int) and 0 <= i < len(items)]
|
||||||
|
|
||||||
|
# Prepare headers and data
|
||||||
|
headers: List[str] = []
|
||||||
|
keys: List[str] = []
|
||||||
|
export_columns = body.get('export_columns')
|
||||||
|
if export_columns:
|
||||||
|
for col in export_columns:
|
||||||
|
key = col.get('key')
|
||||||
|
label = col.get('label', key)
|
||||||
|
if key:
|
||||||
|
keys.append(str(key))
|
||||||
|
headers.append(str(label))
|
||||||
|
else:
|
||||||
|
# Default columns for receipts/payments
|
||||||
|
default_columns = [
|
||||||
|
('code', 'کد سند'),
|
||||||
|
('document_type_name', 'نوع سند'),
|
||||||
|
('document_date', 'تاریخ سند'),
|
||||||
|
('total_amount', 'مبلغ کل'),
|
||||||
|
('person_names', 'اشخاص'),
|
||||||
|
('account_lines_count', 'تعداد حسابها'),
|
||||||
|
('created_by_name', 'ایجادکننده'),
|
||||||
|
('registered_at', 'تاریخ ثبت'),
|
||||||
|
]
|
||||||
|
for key, label in default_columns:
|
||||||
|
if items and key in items[0]:
|
||||||
|
keys.append(key)
|
||||||
|
headers.append(label)
|
||||||
|
|
||||||
|
# Get business name
|
||||||
|
business_name = ""
|
||||||
|
try:
|
||||||
|
b = db.query(Business).filter(Business.id == business_id).first()
|
||||||
|
if b is not None:
|
||||||
|
business_name = b.name or ""
|
||||||
|
except Exception:
|
||||||
|
business_name = ""
|
||||||
|
|
||||||
|
# Locale handling
|
||||||
|
locale = negotiate_locale(request.headers.get("Accept-Language"))
|
||||||
|
is_fa = locale == 'fa'
|
||||||
|
|
||||||
|
# Prepare data for HTML
|
||||||
|
now = datetime.datetime.now().strftime('%Y/%m/%d %H:%M')
|
||||||
|
title_text = "لیست اسناد دریافت و پرداخت" if is_fa else "Receipts & Payments List"
|
||||||
|
label_biz = "کسب و کار" if is_fa else "Business"
|
||||||
|
label_date = "تاریخ تولید" if is_fa else "Generated Date"
|
||||||
|
footer_text = f"تولید شده در {now}" if is_fa else f"Generated at {now}"
|
||||||
|
|
||||||
|
# Create headers HTML
|
||||||
|
headers_html = ''.join(f'<th>{escape(header)}</th>' for header in headers)
|
||||||
|
|
||||||
|
# Create rows HTML
|
||||||
|
rows_html = []
|
||||||
|
for item in items:
|
||||||
|
row_cells = []
|
||||||
|
for key in keys:
|
||||||
|
value = item.get(key, "")
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ", ".join(str(v) for v in value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
value = str(value)
|
||||||
|
row_cells.append(f'<td>{escape(str(value))}</td>')
|
||||||
|
rows_html.append(f'<tr>{"".join(row_cells)}</tr>')
|
||||||
|
|
||||||
|
# Create HTML table
|
||||||
|
table_html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="{'rtl' if is_fa else 'ltr'}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{title_text}</title>
|
||||||
|
<style>
|
||||||
|
@page {{
|
||||||
|
margin: 1cm;
|
||||||
|
size: A4;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: {'Tahoma, Arial' if is_fa else 'Arial, sans-serif'};
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #333;
|
||||||
|
direction: {'rtl' if is_fa else 'ltr'};
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #366092;
|
||||||
|
}}
|
||||||
|
.title {{
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #366092;
|
||||||
|
}}
|
||||||
|
.meta {{
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}}
|
||||||
|
.table-wrapper {{
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.report-table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}}
|
||||||
|
.report-table thead {{
|
||||||
|
background-color: #366092;
|
||||||
|
color: white;
|
||||||
|
}}
|
||||||
|
.report-table th {{
|
||||||
|
border: 1px solid #d7dde6;
|
||||||
|
padding: 8px 6px;
|
||||||
|
text-align: {'right' if is_fa else 'left'};
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
.report-table tbody tr:nth-child(even) {{
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}}
|
||||||
|
.report-table tbody tr:hover {{
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}}
|
||||||
|
tbody td {{
|
||||||
|
border: 1px solid #d7dde6;
|
||||||
|
padding: 5px 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: {'right' if is_fa else 'left'};
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
position: running(footer);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: {'left' if is_fa else 'right'};
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<div class="title">{title_text}</div>
|
||||||
|
<div class="meta">{label_biz}: {escape(business_name)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">{label_date}: {escape(now)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>{headers_html}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{''.join(rows_html)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">{footer_text}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
font_config = FontConfiguration()
|
||||||
|
pdf_bytes = HTML(string=table_html).write_pdf(font_config=font_config)
|
||||||
|
|
||||||
|
# Build meaningful filename
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9_-]+", "_", text).strip("_")
|
||||||
|
|
||||||
|
base = "receipts_payments"
|
||||||
|
if business_name:
|
||||||
|
base += f"_{slugify(business_name)}"
|
||||||
|
if selected_only:
|
||||||
|
base += "_selected"
|
||||||
|
filename = f"{base}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"Content-Length": str(len(pdf_bytes)),
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
# Import from file_storage module
|
# Import from file_storage module
|
||||||
from .file_storage import *
|
from .file_storage import *
|
||||||
|
|
||||||
|
# Import document line schemas
|
||||||
|
from .document_line import *
|
||||||
|
|
||||||
# Re-export from parent schemas module
|
# Re-export from parent schemas module
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
69
hesabixAPI/adapters/api/v1/schema_models/document_line.py
Normal file
69
hesabixAPI/adapters/api/v1/schema_models/document_line.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLineCreateRequest(BaseModel):
|
||||||
|
"""درخواست ایجاد خط سند جدید"""
|
||||||
|
account_id: Optional[int] = Field(default=None, description="شناسه حساب")
|
||||||
|
person_id: Optional[int] = Field(default=None, description="شناسه شخص")
|
||||||
|
product_id: Optional[int] = Field(default=None, description="شناسه محصول")
|
||||||
|
bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی")
|
||||||
|
cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق")
|
||||||
|
petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه گردان")
|
||||||
|
check_id: Optional[int] = Field(default=None, description="شناسه چک")
|
||||||
|
quantity: Optional[Decimal] = Field(default=0, description="تعداد کالا")
|
||||||
|
debit: Decimal = Field(default=0, description="مبلغ بدهکار")
|
||||||
|
credit: Decimal = Field(default=0, description="مبلغ بستانکار")
|
||||||
|
description: Optional[str] = Field(default=None, description="توضیحات")
|
||||||
|
extra_info: Optional[dict] = Field(default=None, description="اطلاعات اضافی")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLineUpdateRequest(BaseModel):
|
||||||
|
"""درخواست بهروزرسانی خط سند"""
|
||||||
|
account_id: Optional[int] = None
|
||||||
|
person_id: Optional[int] = None
|
||||||
|
product_id: Optional[int] = None
|
||||||
|
bank_account_id: Optional[int] = None
|
||||||
|
cash_register_id: Optional[int] = None
|
||||||
|
petty_cash_id: Optional[int] = None
|
||||||
|
check_id: Optional[int] = None
|
||||||
|
quantity: Optional[Decimal] = None
|
||||||
|
debit: Optional[Decimal] = None
|
||||||
|
credit: Optional[Decimal] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
extra_info: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLineResponse(BaseModel):
|
||||||
|
"""پاسخ خط سند"""
|
||||||
|
id: int
|
||||||
|
document_id: int
|
||||||
|
account_id: Optional[int]
|
||||||
|
person_id: Optional[int]
|
||||||
|
product_id: Optional[int]
|
||||||
|
bank_account_id: Optional[int]
|
||||||
|
cash_register_id: Optional[int]
|
||||||
|
petty_cash_id: Optional[int]
|
||||||
|
check_id: Optional[int]
|
||||||
|
quantity: Optional[Decimal]
|
||||||
|
debit: Decimal
|
||||||
|
credit: Decimal
|
||||||
|
description: Optional[str]
|
||||||
|
extra_info: Optional[dict]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
# اطلاعات مرتبط
|
||||||
|
account_name: Optional[str] = None
|
||||||
|
person_name: Optional[str] = None
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
bank_account_name: Optional[str] = None
|
||||||
|
cash_register_name: Optional[str] = None
|
||||||
|
petty_cash_name: Optional[str] = None
|
||||||
|
check_number: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
@ -38,5 +38,6 @@ 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 .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
|
from .check import Check # noqa: F401
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
import logging
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_, func
|
from sqlalchemy import and_, or_, func
|
||||||
|
|
@ -21,8 +22,12 @@ from adapters.db.models.account import Account
|
||||||
from adapters.db.models.person import Person
|
from adapters.db.models.person import Person
|
||||||
from adapters.db.models.currency import Currency
|
from adapters.db.models.currency import Currency
|
||||||
from adapters.db.models.user import User
|
from adapters.db.models.user import User
|
||||||
|
from adapters.db.models.fiscal_year import FiscalYear
|
||||||
from app.core.responses import ApiError
|
from app.core.responses import ApiError
|
||||||
|
|
||||||
|
# تنظیم لاگر
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# نوعهای سند
|
# نوعهای سند
|
||||||
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
|
DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت
|
||||||
|
|
@ -50,14 +55,57 @@ def _parse_iso_date(dt: str | datetime | date) -> date:
|
||||||
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400)
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_person_account(
|
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
|
||||||
|
"""دریافت سال مالی فعلی برای کسبوکار"""
|
||||||
|
fiscal_year = db.query(FiscalYear).filter(
|
||||||
|
and_(
|
||||||
|
FiscalYear.business_id == business_id,
|
||||||
|
FiscalYear.is_last == True
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not fiscal_year:
|
||||||
|
raise ApiError("NO_FISCAL_YEAR", "No active fiscal year found for this business", http_status=400)
|
||||||
|
|
||||||
|
return fiscal_year
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
|
||||||
|
"""
|
||||||
|
دریافت حساب ثابت بر اساس کد
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Session پایگاه داده
|
||||||
|
account_code: کد حساب (مثل 10201, 10202, 10203)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Account: حساب ثابت
|
||||||
|
"""
|
||||||
|
account = db.query(Account).filter(
|
||||||
|
and_(
|
||||||
|
Account.business_id == None, # حسابهای عمومی
|
||||||
|
Account.code == account_code
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
raise ApiError(
|
||||||
|
"ACCOUNT_NOT_FOUND",
|
||||||
|
f"Account with code {account_code} not found",
|
||||||
|
http_status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def _get_person_account(
|
||||||
db: Session,
|
db: Session,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
person_id: int,
|
person_id: int,
|
||||||
is_receivable: bool
|
is_receivable: bool
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""
|
"""
|
||||||
ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی)
|
دریافت حساب شخص (حساب دریافتنی یا پرداختنی عمومی)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
business_id: شناسه کسبوکار
|
business_id: شناسه کسبوکار
|
||||||
|
|
@ -65,7 +113,7 @@ def _get_or_create_person_account(
|
||||||
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
|
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Account: حساب شخص
|
Account: حساب شخص عمومی
|
||||||
"""
|
"""
|
||||||
person = db.query(Person).filter(
|
person = db.query(Person).filter(
|
||||||
and_(Person.id == person_id, Person.business_id == business_id)
|
and_(Person.id == person_id, Person.business_id == business_id)
|
||||||
|
|
@ -74,53 +122,11 @@ def _get_or_create_person_account(
|
||||||
if not person:
|
if not person:
|
||||||
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
|
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
|
||||||
|
|
||||||
# کد حساب والد
|
# کد حساب عمومی (بدون ایجاد حساب جداگانه)
|
||||||
parent_code = "10401" if is_receivable else "20201"
|
account_code = "10401" if is_receivable else "20201"
|
||||||
account_type = ACCOUNT_TYPE_RECEIVABLE if is_receivable else ACCOUNT_TYPE_PAYABLE
|
|
||||||
|
|
||||||
# پیدا کردن حساب والد
|
# استفاده از تابع کمکی
|
||||||
parent_account = db.query(Account).filter(
|
return _get_fixed_account_by_code(db, account_code)
|
||||||
and_(
|
|
||||||
Account.business_id == None, # حسابهای عمومی
|
|
||||||
Account.code == parent_code
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not parent_account:
|
|
||||||
raise ApiError(
|
|
||||||
"PARENT_ACCOUNT_NOT_FOUND",
|
|
||||||
f"Parent account with code {parent_code} not found",
|
|
||||||
http_status=500
|
|
||||||
)
|
|
||||||
|
|
||||||
# بررسی وجود حساب شخص
|
|
||||||
person_account_code = f"{parent_code}-{person_id}"
|
|
||||||
person_account = db.query(Account).filter(
|
|
||||||
and_(
|
|
||||||
Account.business_id == business_id,
|
|
||||||
Account.code == person_account_code
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not person_account:
|
|
||||||
# ایجاد حساب جدید برای شخص
|
|
||||||
account_name = f"{person.alias_name}"
|
|
||||||
if is_receivable:
|
|
||||||
account_name = f"طلب از {account_name}"
|
|
||||||
else:
|
|
||||||
account_name = f"بدهی به {account_name}"
|
|
||||||
|
|
||||||
person_account = Account(
|
|
||||||
business_id=business_id,
|
|
||||||
code=person_account_code,
|
|
||||||
name=account_name,
|
|
||||||
account_type=account_type,
|
|
||||||
parent_id=parent_account.id,
|
|
||||||
)
|
|
||||||
db.add(person_account)
|
|
||||||
db.flush() # برای دریافت ID
|
|
||||||
|
|
||||||
return person_account
|
|
||||||
|
|
||||||
|
|
||||||
def create_receipt_payment(
|
def create_receipt_payment(
|
||||||
|
|
@ -146,12 +152,17 @@ def create_receipt_payment(
|
||||||
Returns:
|
Returns:
|
||||||
Dict: اطلاعات سند ایجاد شده
|
Dict: اطلاعات سند ایجاد شده
|
||||||
"""
|
"""
|
||||||
|
logger.info(f"=== شروع ایجاد سند دریافت/پرداخت ===")
|
||||||
|
logger.info(f"business_id: {business_id}, user_id: {user_id}")
|
||||||
|
logger.info(f"دادههای ورودی: {data}")
|
||||||
# اعتبارسنجی نوع سند
|
# اعتبارسنجی نوع سند
|
||||||
document_type = str(data.get("document_type", "")).lower()
|
document_type = str(data.get("document_type", "")).lower()
|
||||||
|
logger.info(f"نوع سند: {document_type}")
|
||||||
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||||
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
|
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
|
||||||
|
|
||||||
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
|
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
|
||||||
|
logger.info(f"آیا دریافت است: {is_receipt}")
|
||||||
|
|
||||||
# اعتبارسنجی تاریخ
|
# اعتبارسنجی تاریخ
|
||||||
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
|
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
|
||||||
|
|
@ -165,13 +176,22 @@ def create_receipt_payment(
|
||||||
if not currency:
|
if not currency:
|
||||||
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
|
raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404)
|
||||||
|
|
||||||
|
# دریافت سال مالی فعلی
|
||||||
|
logger.info(f"دریافت سال مالی فعلی برای business_id={business_id}")
|
||||||
|
fiscal_year = _get_current_fiscal_year(db, business_id)
|
||||||
|
logger.info(f"سال مالی فعلی: id={fiscal_year.id}, title={fiscal_year.title}")
|
||||||
|
|
||||||
# اعتبارسنجی خطوط اشخاص
|
# اعتبارسنجی خطوط اشخاص
|
||||||
person_lines = data.get("person_lines", [])
|
person_lines = data.get("person_lines", [])
|
||||||
|
logger.info(f"تعداد خطوط اشخاص: {len(person_lines)}")
|
||||||
|
logger.info(f"خطوط اشخاص: {person_lines}")
|
||||||
if not person_lines or not isinstance(person_lines, list):
|
if not person_lines or not isinstance(person_lines, list):
|
||||||
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
|
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
|
||||||
|
|
||||||
# اعتبارسنجی خطوط حسابها
|
# اعتبارسنجی خطوط حسابها
|
||||||
account_lines = data.get("account_lines", [])
|
account_lines = data.get("account_lines", [])
|
||||||
|
logger.info(f"تعداد خطوط حسابها: {len(account_lines)}")
|
||||||
|
logger.info(f"خطوط حسابها: {account_lines}")
|
||||||
if not account_lines or not isinstance(account_lines, list):
|
if not account_lines or not isinstance(account_lines, list):
|
||||||
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
|
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
|
||||||
|
|
||||||
|
|
@ -213,6 +233,7 @@ def create_receipt_payment(
|
||||||
# ایجاد سند
|
# ایجاد سند
|
||||||
document = Document(
|
document = Document(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
|
fiscal_year_id=fiscal_year.id,
|
||||||
code=doc_code,
|
code=doc_code,
|
||||||
document_type=document_type,
|
document_type=document_type,
|
||||||
document_date=document_date,
|
document_date=document_date,
|
||||||
|
|
@ -226,51 +247,74 @@ def create_receipt_payment(
|
||||||
db.flush() # برای دریافت document.id
|
db.flush() # برای دریافت document.id
|
||||||
|
|
||||||
# ایجاد خطوط سند برای اشخاص
|
# ایجاد خطوط سند برای اشخاص
|
||||||
for person_line in person_lines:
|
logger.info(f"=== شروع ایجاد خطوط اشخاص ===")
|
||||||
|
for i, person_line in enumerate(person_lines):
|
||||||
|
logger.info(f"پردازش خط شخص {i+1}: {person_line}")
|
||||||
person_id = person_line.get("person_id")
|
person_id = person_line.get("person_id")
|
||||||
|
logger.info(f"person_id: {person_id}")
|
||||||
if not person_id:
|
if not person_id:
|
||||||
|
logger.warning(f"خط شخص {i+1}: person_id موجود نیست، رد میشود")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
amount = Decimal(str(person_line.get("amount", 0)))
|
amount = Decimal(str(person_line.get("amount", 0)))
|
||||||
|
logger.info(f"مبلغ: {amount}")
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
|
logger.warning(f"خط شخص {i+1}: مبلغ صفر یا منفی، رد میشود")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
description = person_line.get("description", "").strip() or None
|
description = person_line.get("description", "").strip() or None
|
||||||
|
logger.info(f"توضیحات: {description}")
|
||||||
|
|
||||||
# دریافت یا ایجاد حساب شخص
|
# دریافت حساب شخص عمومی
|
||||||
# در دریافت: حساب دریافتنی (receivable)
|
# در دریافت: حساب دریافتنی (receivable) - کد 10401
|
||||||
# در پرداخت: حساب پرداختنی (payable)
|
# در پرداخت: حساب پرداختنی (payable) - کد 20201
|
||||||
person_account = _get_or_create_person_account(
|
logger.info(f"دریافت حساب شخص برای person_id={person_id}, is_receivable={is_receipt}")
|
||||||
|
person_account = _get_person_account(
|
||||||
db,
|
db,
|
||||||
business_id,
|
business_id,
|
||||||
int(person_id),
|
int(person_id),
|
||||||
is_receivable=is_receipt
|
is_receivable=is_receipt
|
||||||
)
|
)
|
||||||
|
logger.info(f"حساب شخص پیدا شد: id={person_account.id}, code={person_account.code}, name={person_account.name}")
|
||||||
|
|
||||||
# ایجاد خط سند برای شخص
|
# ایجاد خط سند برای شخص
|
||||||
# در دریافت: شخص بستانکار (credit)
|
# در دریافت: شخص بستانکار (credit)
|
||||||
# در پرداخت: شخص بدهکار (debit)
|
# در پرداخت: شخص بدهکار (debit)
|
||||||
|
debit_amount = amount if not is_receipt else Decimal(0)
|
||||||
|
credit_amount = amount if is_receipt else Decimal(0)
|
||||||
|
logger.info(f"مقادیر بدهکار/بستانکار: debit={debit_amount}, credit={credit_amount}")
|
||||||
|
|
||||||
line = DocumentLine(
|
line = DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
account_id=person_account.id,
|
account_id=person_account.id,
|
||||||
debit=amount if not is_receipt else Decimal(0),
|
person_id=int(person_id),
|
||||||
credit=amount if is_receipt else Decimal(0),
|
quantity=person_line.get("quantity"),
|
||||||
|
debit=debit_amount,
|
||||||
|
credit=credit_amount,
|
||||||
description=description,
|
description=description,
|
||||||
extra_info={
|
extra_info={
|
||||||
"person_id": int(person_id),
|
"person_id": int(person_id),
|
||||||
"person_name": person_line.get("person_name"),
|
"person_name": person_line.get("person_name"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
logger.info(f"خط سند شخص ایجاد شد: {line}")
|
||||||
db.add(line)
|
db.add(line)
|
||||||
|
|
||||||
# ایجاد خطوط سند برای حسابها
|
# ایجاد خطوط سند برای حسابها
|
||||||
for account_line in account_lines:
|
logger.info(f"=== شروع ایجاد خطوط حسابها ===")
|
||||||
|
total_commission = Decimal(0) # مجموع کارمزدها
|
||||||
|
|
||||||
|
for i, account_line in enumerate(account_lines):
|
||||||
|
logger.info(f"پردازش خط حساب {i+1}: {account_line}")
|
||||||
account_id = account_line.get("account_id")
|
account_id = account_line.get("account_id")
|
||||||
|
logger.info(f"account_id: {account_id}")
|
||||||
if not account_id:
|
if not account_id:
|
||||||
continue
|
logger.info(f"خط حساب {i+1}: account_id موجود نیست، ادامه میدهد")
|
||||||
|
|
||||||
amount = Decimal(str(account_line.get("amount", 0)))
|
amount = Decimal(str(account_line.get("amount", 0)))
|
||||||
|
logger.info(f"مبلغ: {amount}")
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
|
logger.warning(f"خط حساب {i+1}: مبلغ صفر یا منفی، رد میشود")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
description = account_line.get("description", "").strip() or None
|
description = account_line.get("description", "").strip() or None
|
||||||
|
|
@ -278,24 +322,70 @@ def create_receipt_payment(
|
||||||
transaction_date = account_line.get("transaction_date")
|
transaction_date = account_line.get("transaction_date")
|
||||||
commission = account_line.get("commission")
|
commission = account_line.get("commission")
|
||||||
|
|
||||||
# بررسی وجود حساب
|
logger.info(f"نوع تراکنش: {transaction_type}")
|
||||||
account = db.query(Account).filter(
|
logger.info(f"تاریخ تراکنش: {transaction_date}")
|
||||||
and_(
|
logger.info(f"کمیسیون: {commission}")
|
||||||
Account.id == int(account_id),
|
|
||||||
or_(
|
# اضافه کردن کارمزد به مجموع
|
||||||
Account.business_id == business_id,
|
if commission:
|
||||||
Account.business_id == None # حسابهای عمومی
|
commission_amount = Decimal(str(commission))
|
||||||
|
total_commission += commission_amount
|
||||||
|
logger.info(f"کارمزد اضافه شد: {commission_amount}, مجموع: {total_commission}")
|
||||||
|
|
||||||
|
# تعیین حساب بر اساس transaction_type
|
||||||
|
account = None
|
||||||
|
|
||||||
|
if transaction_type == "bank":
|
||||||
|
# برای بانک، از حساب بانک استفاده کن
|
||||||
|
account_code = "10203" # بانک
|
||||||
|
logger.info(f"انتخاب حساب بانک با کد: {account_code}")
|
||||||
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
|
elif transaction_type == "cash_register":
|
||||||
|
# برای صندوق، از حساب صندوق استفاده کن
|
||||||
|
account_code = "10202" # صندوق
|
||||||
|
logger.info(f"انتخاب حساب صندوق با کد: {account_code}")
|
||||||
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
|
elif transaction_type == "petty_cash":
|
||||||
|
# برای تنخواهگردان، از حساب تنخواهگردان استفاده کن
|
||||||
|
account_code = "10201" # تنخواه گردان
|
||||||
|
logger.info(f"انتخاب حساب تنخواهگردان با کد: {account_code}")
|
||||||
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
|
elif transaction_type == "check":
|
||||||
|
# برای چک، بر اساس نوع سند از کد مناسب استفاده کن
|
||||||
|
if is_receipt:
|
||||||
|
account_code = "10403" # اسناد دریافتنی (چک دریافتی)
|
||||||
|
else:
|
||||||
|
account_code = "20202" # اسناد پرداختنی (چک پرداختی)
|
||||||
|
logger.info(f"انتخاب حساب چک با کد: {account_code}")
|
||||||
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
|
elif transaction_type == "person":
|
||||||
|
# برای شخص، از حساب شخص عمومی استفاده کن
|
||||||
|
account_code = "20201" # حسابهای پرداختنی
|
||||||
|
logger.info(f"انتخاب حساب شخص با کد: {account_code}")
|
||||||
|
account = _get_fixed_account_by_code(db, account_code)
|
||||||
|
elif account_id:
|
||||||
|
# اگر account_id مشخص باشد، از آن استفاده کن
|
||||||
|
logger.info(f"استفاده از account_id مشخص: {account_id}")
|
||||||
|
account = db.query(Account).filter(
|
||||||
|
and_(
|
||||||
|
Account.id == int(account_id),
|
||||||
|
or_(
|
||||||
|
Account.business_id == business_id,
|
||||||
|
Account.business_id == None # حسابهای عمومی
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
).first()
|
||||||
).first()
|
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
|
logger.error(f"خط حساب {i+1}: حساب پیدا نشد برای transaction_type: {transaction_type}")
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
"ACCOUNT_NOT_FOUND",
|
"ACCOUNT_NOT_FOUND",
|
||||||
f"Account with id {account_id} not found",
|
f"Account not found for transaction_type: {transaction_type}",
|
||||||
http_status=404
|
http_status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"حساب پیدا شد: id={account.id}, code={account.code}, name={account.name}")
|
||||||
|
|
||||||
# ایجاد اطلاعات اضافی برای خط سند
|
# ایجاد اطلاعات اضافی برای خط سند
|
||||||
extra_info = {}
|
extra_info = {}
|
||||||
if transaction_type:
|
if transaction_type:
|
||||||
|
|
@ -330,19 +420,141 @@ def create_receipt_payment(
|
||||||
# ایجاد خط سند برای حساب
|
# ایجاد خط سند برای حساب
|
||||||
# در دریافت: حساب بدهکار (debit) - دارایی افزایش مییابد
|
# در دریافت: حساب بدهکار (debit) - دارایی افزایش مییابد
|
||||||
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش مییابد
|
# در پرداخت: حساب بستانکار (credit) - دارایی کاهش مییابد
|
||||||
|
debit_amount = amount if is_receipt else Decimal(0)
|
||||||
|
credit_amount = amount if not is_receipt else Decimal(0)
|
||||||
|
logger.info(f"مقادیر بدهکار/بستانکار برای حساب: debit={debit_amount}, credit={credit_amount}")
|
||||||
|
|
||||||
|
# تنظیم bank_account_id بر اساس bank_id ارسالی
|
||||||
|
bank_account_id = None
|
||||||
|
if transaction_type == "bank" and account_line.get("bank_id"):
|
||||||
|
try:
|
||||||
|
bank_account_id = int(account_line.get("bank_id"))
|
||||||
|
logger.info(f"bank_account_id تنظیم شد: {bank_account_id}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"خطا در تبدیل bank_id: {account_line.get('bank_id')}")
|
||||||
|
|
||||||
|
# تنظیم person_id برای transaction_type="person"
|
||||||
|
person_id_for_line = None
|
||||||
|
if transaction_type == "person" and account_line.get("person_id"):
|
||||||
|
try:
|
||||||
|
person_id_for_line = int(account_line.get("person_id"))
|
||||||
|
logger.info(f"person_id تنظیم شد: {person_id_for_line}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning(f"خطا در تبدیل person_id: {account_line.get('person_id')}")
|
||||||
|
|
||||||
line = DocumentLine(
|
line = DocumentLine(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
debit=amount if is_receipt else Decimal(0),
|
person_id=person_id_for_line,
|
||||||
credit=amount if not is_receipt else Decimal(0),
|
bank_account_id=bank_account_id,
|
||||||
|
cash_register_id=account_line.get("cash_register_id"),
|
||||||
|
petty_cash_id=account_line.get("petty_cash_id"),
|
||||||
|
check_id=account_line.get("check_id"),
|
||||||
|
quantity=account_line.get("quantity"),
|
||||||
|
debit=debit_amount,
|
||||||
|
credit=credit_amount,
|
||||||
description=description,
|
description=description,
|
||||||
extra_info=extra_info if extra_info else None,
|
extra_info=extra_info if extra_info else None,
|
||||||
)
|
)
|
||||||
|
logger.info(f"خط سند حساب ایجاد شد: {line}")
|
||||||
db.add(line)
|
db.add(line)
|
||||||
|
|
||||||
|
# ایجاد خطوط کارمزد اگر کارمزدی وجود دارد
|
||||||
|
if total_commission > 0:
|
||||||
|
logger.info(f"=== ایجاد خطوط کارمزد ===")
|
||||||
|
logger.info(f"مجموع کارمزد: {total_commission}")
|
||||||
|
|
||||||
|
# ایجاد خط کارمزد برای هر تراکنش که کارمزد دارد
|
||||||
|
for i, account_line in enumerate(account_lines):
|
||||||
|
commission = account_line.get("commission")
|
||||||
|
if not commission or Decimal(str(commission)) <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
commission_amount = Decimal(str(commission))
|
||||||
|
transaction_type = account_line.get("transaction_type")
|
||||||
|
logger.info(f"ایجاد خط کارمزد برای تراکنش {i+1}: مبلغ={commission_amount}, نوع={transaction_type}")
|
||||||
|
|
||||||
|
# تعیین حساب کارمزد بر اساس نوع تراکنش
|
||||||
|
commission_account = None
|
||||||
|
commission_account_code = None
|
||||||
|
|
||||||
|
if transaction_type == "bank":
|
||||||
|
commission_account_code = "10203" # بانک
|
||||||
|
elif transaction_type == "cash_register":
|
||||||
|
commission_account_code = "10202" # صندوق
|
||||||
|
elif transaction_type == "petty_cash":
|
||||||
|
commission_account_code = "10201" # تنخواه گردان
|
||||||
|
elif transaction_type == "check":
|
||||||
|
if is_receipt:
|
||||||
|
commission_account_code = "10403" # اسناد دریافتنی
|
||||||
|
else:
|
||||||
|
commission_account_code = "20202" # اسناد پرداختنی
|
||||||
|
elif transaction_type == "person":
|
||||||
|
commission_account_code = "20201" # حسابهای پرداختنی
|
||||||
|
|
||||||
|
if commission_account_code:
|
||||||
|
commission_account = _get_fixed_account_by_code(db, commission_account_code)
|
||||||
|
logger.info(f"حساب کارمزد پیدا شد: id={commission_account.id}, code={commission_account.code}, name={commission_account.name}")
|
||||||
|
|
||||||
|
# ایجاد خط کارمزد برای حساب (بانک/صندوق/تنخواهگردان)
|
||||||
|
# در دریافت: کارمزد از حساب کم میشود (credit)
|
||||||
|
# در پرداخت: کارمزد به حساب اضافه میشود (debit)
|
||||||
|
commission_debit = commission_amount if not is_receipt else Decimal(0)
|
||||||
|
commission_credit = commission_amount if is_receipt else Decimal(0)
|
||||||
|
|
||||||
|
commission_line = DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_account.id,
|
||||||
|
bank_account_id=account_line.get("bank_id"),
|
||||||
|
cash_register_id=account_line.get("cash_register_id"),
|
||||||
|
petty_cash_id=account_line.get("petty_cash_id"),
|
||||||
|
check_id=account_line.get("check_id"),
|
||||||
|
debit=commission_debit,
|
||||||
|
credit=commission_credit,
|
||||||
|
description=f"کارمزد تراکنش {transaction_type}",
|
||||||
|
extra_info={
|
||||||
|
"transaction_type": transaction_type,
|
||||||
|
"commission": float(commission_amount),
|
||||||
|
"is_commission_line": True,
|
||||||
|
"original_transaction_index": i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f"خط کارمزد حساب ایجاد شد: {commission_line}")
|
||||||
|
db.add(commission_line)
|
||||||
|
|
||||||
|
# ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی (کد 70902)
|
||||||
|
# در دریافت: کارمزد به حساب کارمزد اضافه میشود (debit)
|
||||||
|
# در پرداخت: کارمزد از حساب کارمزد کم میشود (credit)
|
||||||
|
logger.info(f"ایجاد خط کارمزد برای حساب کارمزد خدمات بانکی")
|
||||||
|
|
||||||
|
# دریافت حساب کارمزد خدمات بانکی
|
||||||
|
commission_service_account = _get_fixed_account_by_code(db, "70902")
|
||||||
|
logger.info(f"حساب کارمزد خدمات بانکی پیدا شد: id={commission_service_account.id}, code={commission_service_account.code}, name={commission_service_account.name}")
|
||||||
|
|
||||||
|
commission_service_debit = commission_amount if is_receipt else Decimal(0)
|
||||||
|
commission_service_credit = commission_amount if not is_receipt else Decimal(0)
|
||||||
|
|
||||||
|
commission_service_line = DocumentLine(
|
||||||
|
document_id=document.id,
|
||||||
|
account_id=commission_service_account.id,
|
||||||
|
debit=commission_service_debit,
|
||||||
|
credit=commission_service_credit,
|
||||||
|
description=f"کارمزد خدمات بانکی",
|
||||||
|
extra_info={
|
||||||
|
"commission": float(commission_amount),
|
||||||
|
"is_commission_line": True,
|
||||||
|
"original_transaction_index": i,
|
||||||
|
"commission_type": "banking_service",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f"خط کارمزد خدمات بانکی ایجاد شد: {commission_service_line}")
|
||||||
|
db.add(commission_service_line)
|
||||||
|
|
||||||
# ذخیره تغییرات
|
# ذخیره تغییرات
|
||||||
|
logger.info(f"=== ذخیره تغییرات ===")
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(document)
|
db.refresh(document)
|
||||||
|
logger.info(f"سند با موفقیت ایجاد شد: id={document.id}, code={document.code}")
|
||||||
|
|
||||||
return document_to_dict(db, document)
|
return document_to_dict(db, document)
|
||||||
|
|
||||||
|
|
@ -404,7 +616,8 @@ def list_receipts_payments(
|
||||||
sort_by = query.get("sort_by", "document_date")
|
sort_by = query.get("sort_by", "document_date")
|
||||||
sort_desc = query.get("sort_desc", True)
|
sort_desc = query.get("sort_desc", True)
|
||||||
|
|
||||||
if hasattr(Document, sort_by):
|
# بررسی اینکه sort_by معتبر است
|
||||||
|
if sort_by and isinstance(sort_by, str) and hasattr(Document, sort_by):
|
||||||
col = getattr(Document, sort_by)
|
col = getattr(Document, sort_by)
|
||||||
q = q.order_by(col.desc() if sort_desc else col.asc())
|
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||||
else:
|
else:
|
||||||
|
|
@ -464,6 +677,13 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
line_dict = {
|
line_dict = {
|
||||||
"id": line.id,
|
"id": line.id,
|
||||||
"account_id": line.account_id,
|
"account_id": line.account_id,
|
||||||
|
"person_id": line.person_id,
|
||||||
|
"product_id": line.product_id,
|
||||||
|
"bank_account_id": line.bank_account_id,
|
||||||
|
"cash_register_id": line.cash_register_id,
|
||||||
|
"petty_cash_id": line.petty_cash_id,
|
||||||
|
"check_id": line.check_id,
|
||||||
|
"quantity": float(line.quantity) if line.quantity else None,
|
||||||
"account_name": account.name,
|
"account_name": account.name,
|
||||||
"account_code": account.code,
|
"account_code": account.code,
|
||||||
"account_type": account.account_type,
|
"account_type": account.account_type,
|
||||||
|
|
@ -498,9 +718,25 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
line_dict["check_id"] = line.extra_info["check_id"]
|
line_dict["check_id"] = line.extra_info["check_id"]
|
||||||
if "check_number" in line.extra_info:
|
if "check_number" in line.extra_info:
|
||||||
line_dict["check_number"] = line.extra_info["check_number"]
|
line_dict["check_number"] = line.extra_info["check_number"]
|
||||||
|
if "person_name" in line.extra_info:
|
||||||
|
line_dict["person_name"] = line.extra_info["person_name"]
|
||||||
|
|
||||||
|
# اگر person_id موجود است، نام شخص را از دیتابیس دریافت کن
|
||||||
|
if line.person_id and "person_name" not in line_dict:
|
||||||
|
person = db.query(Person).filter(Person.id == line.person_id).first()
|
||||||
|
if person:
|
||||||
|
line_dict["person_name"] = person.alias_name or f"{person.first_name} {person.last_name}".strip()
|
||||||
|
else:
|
||||||
|
line_dict["person_name"] = "نامشخص"
|
||||||
|
|
||||||
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
|
# تشخیص اینکه آیا این خط مربوط به شخص است یا حساب
|
||||||
if line.extra_info and line.extra_info.get("person_id"):
|
# خطوط کارمزد را جداگانه تشخیص میدهیم
|
||||||
|
is_commission_line = line.extra_info and line.extra_info.get("is_commission_line", False)
|
||||||
|
|
||||||
|
if is_commission_line:
|
||||||
|
# خط کارمزد - همیشه در account_lines قرار میگیرد
|
||||||
|
account_lines.append(line_dict)
|
||||||
|
elif line.extra_info and line.extra_info.get("person_id"):
|
||||||
person_lines.append(line_dict)
|
person_lines.append(line_dict)
|
||||||
else:
|
else:
|
||||||
account_lines.append(line_dict)
|
account_lines.append(line_dict)
|
||||||
|
|
@ -513,11 +749,28 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
|
currency = db.query(Currency).filter(Currency.id == document.currency_id).first()
|
||||||
currency_code = currency.code if currency else None
|
currency_code = currency.code if currency else None
|
||||||
|
|
||||||
|
# محاسبه مبلغ کل و تعداد خطوط
|
||||||
|
total_amount = sum(line.get("amount", 0) for line in person_lines)
|
||||||
|
person_lines_count = len(person_lines)
|
||||||
|
account_lines_count = len(account_lines)
|
||||||
|
|
||||||
|
# ایجاد لیست نام اشخاص برای نمایش
|
||||||
|
person_names = []
|
||||||
|
for line in person_lines:
|
||||||
|
person_name = line.get("person_name")
|
||||||
|
if person_name and person_name not in person_names:
|
||||||
|
person_names.append(person_name)
|
||||||
|
person_names_str = ", ".join(person_names) if person_names else "نامشخص"
|
||||||
|
|
||||||
|
# تعیین نام نوع سند
|
||||||
|
document_type_name = "دریافت" if document.document_type == DOCUMENT_TYPE_RECEIPT else "پرداخت"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": document.id,
|
"id": document.id,
|
||||||
"code": document.code,
|
"code": document.code,
|
||||||
"business_id": document.business_id,
|
"business_id": document.business_id,
|
||||||
"document_type": document.document_type,
|
"document_type": document.document_type,
|
||||||
|
"document_type_name": document_type_name,
|
||||||
"document_date": document.document_date.isoformat(),
|
"document_date": document.document_date.isoformat(),
|
||||||
"registered_at": document.registered_at.isoformat(),
|
"registered_at": document.registered_at.isoformat(),
|
||||||
"currency_id": document.currency_id,
|
"currency_id": document.currency_id,
|
||||||
|
|
@ -528,6 +781,10 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
"extra_info": document.extra_info,
|
"extra_info": document.extra_info,
|
||||||
"person_lines": person_lines,
|
"person_lines": person_lines,
|
||||||
"account_lines": account_lines,
|
"account_lines": account_lines,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"person_lines_count": person_lines_count,
|
||||||
|
"account_lines_count": account_lines_count,
|
||||||
|
"person_names": person_names_str,
|
||||||
"created_at": document.created_at.isoformat(),
|
"created_at": document.created_at.isoformat(),
|
||||||
"updated_at": document.updated_at.isoformat(),
|
"updated_at": document.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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/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
|
||||||
|
|
@ -161,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
|
||||||
|
|
@ -190,11 +191,17 @@ migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py
|
||||||
migrations/versions/20251011_000901_add_checks_table.py
|
migrations/versions/20251011_000901_add_checks_table.py
|
||||||
migrations/versions/20251011_010001_replace_accounts_chart_seed.py
|
migrations/versions/20251011_010001_replace_accounts_chart_seed.py
|
||||||
migrations/versions/20251012_000101_update_accounts_account_type_to_english.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/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/b2b68cf299a3_convert_unit_fields_to_string.py
|
||||||
migrations/versions/c302bc2f2cb8_remove_person_type_column.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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import sqlalchemy as sa
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250926_000010_add_person_code_and_types'
|
revision = '20250926_000010_add_person_code'
|
||||||
down_revision = '20250916_000002'
|
down_revision = '20250916_000002'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
@ -3,8 +3,8 @@ import sqlalchemy as sa
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250926_000011_drop_person_is_active'
|
revision = '20250926_000011_drop_active'
|
||||||
down_revision = '20250926_000010_add_person_code_and_types'
|
down_revision = '20250926_000010_add_person_code'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
@ -6,8 +6,8 @@ from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250927_000012_add_fiscal_years_table'
|
revision = '20250927_000012_add_fiscal_years'
|
||||||
down_revision = '20250926_000011_drop_person_is_active'
|
down_revision = '20250926_000011_drop_active'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = ('20250117_000003',)
|
depends_on = ('20250117_000003',)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20250927_000013_add_currencies'
|
||||||
|
down_revision = '20250927_000012_add_fiscal_years'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
# Create currencies table if it doesn't exist
|
||||||
|
if 'currencies' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'currencies',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_charset='utf8mb4'
|
||||||
|
)
|
||||||
|
# Unique constraints and indexes
|
||||||
|
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||||
|
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||||
|
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||||
|
|
||||||
|
# Create business_currencies association table if it doesn't exist
|
||||||
|
if 'business_currencies' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'business_currencies',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_charset='utf8mb4'
|
||||||
|
)
|
||||||
|
# Unique and indexes for association
|
||||||
|
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||||
|
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||||
|
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||||
|
|
||||||
|
# Add default_currency_id to businesses if not exists
|
||||||
|
if 'businesses' in tables:
|
||||||
|
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
||||||
|
if 'default_currency_id' not in cols:
|
||||||
|
with op.batch_alter_table('businesses') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
||||||
|
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index/foreign key/column default_currency_id if exists
|
||||||
|
with op.batch_alter_table('businesses') as batch_op:
|
||||||
|
try:
|
||||||
|
batch_op.drop_index('ix_businesses_default_currency_id')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
batch_op.drop_column('default_currency_id')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||||
|
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||||
|
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||||
|
op.drop_table('business_currencies')
|
||||||
|
|
||||||
|
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||||
|
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||||
|
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||||
|
op.drop_table('currencies')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '20250927_000013_add_currencies_and_business_currencies'
|
|
||||||
down_revision = '20250927_000012_add_fiscal_years_table'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create currencies table
|
|
||||||
op.create_table(
|
|
||||||
'currencies',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('name', sa.String(length=100), nullable=False),
|
|
||||||
sa.Column('title', sa.String(length=100), nullable=False),
|
|
||||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('code', sa.String(length=16), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
mysql_charset='utf8mb4'
|
|
||||||
)
|
|
||||||
# Unique constraints and indexes
|
|
||||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
|
||||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
|
||||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
|
||||||
|
|
||||||
# Create business_currencies association table
|
|
||||||
op.create_table(
|
|
||||||
'business_currencies',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
|
||||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
mysql_charset='utf8mb4'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add default_currency_id to businesses if not exists
|
|
||||||
bind = op.get_bind()
|
|
||||||
inspector = sa.inspect(bind)
|
|
||||||
if 'businesses' in inspector.get_table_names():
|
|
||||||
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
|
||||||
if 'default_currency_id' not in cols:
|
|
||||||
with op.batch_alter_table('businesses') as batch_op:
|
|
||||||
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
|
||||||
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
|
||||||
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
|
||||||
# Unique and indexes for association
|
|
||||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
|
||||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
|
||||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop index/foreign key/column default_currency_id if exists
|
|
||||||
with op.batch_alter_table('businesses') as batch_op:
|
|
||||||
try:
|
|
||||||
batch_op.drop_index('ix_businesses_default_currency_id')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
batch_op.drop_column('default_currency_id')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
|
||||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
|
||||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
|
||||||
op.drop_table('business_currencies')
|
|
||||||
|
|
||||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
|
||||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
|
||||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
|
||||||
op.drop_table('currencies')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5,8 +5,8 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250927_000014_add_documents_table'
|
revision = '20250927_000014_add_documents'
|
||||||
down_revision = '20250927_000013_add_currencies_and_business_currencies'
|
down_revision = '20250927_000013_add_currencies'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '20250927_000015_add_document_lines_table'
|
|
||||||
down_revision = '20250927_000014_add_documents_table'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'document_lines',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
mysql_charset='utf8mb4'
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
|
||||||
op.drop_table('document_lines')
|
|
||||||
|
|
||||||
|
|
||||||
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '20250927_000015_add_lines'
|
||||||
|
down_revision = '20250927_000014_add_documents'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
# Create document_lines table if it doesn't exist
|
||||||
|
if 'document_lines' not in tables:
|
||||||
|
op.create_table(
|
||||||
|
'document_lines',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||||
|
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
mysql_charset='utf8mb4'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||||
|
op.drop_table('document_lines')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '20250927_000016_add_accounts_table'
|
revision = '20250927_000016_add_accounts_table'
|
||||||
down_revision = '20250927_000015_add_document_lines_table'
|
down_revision = '20250927_000015_add_lines'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
@ -12,21 +13,47 @@ depends_on = None
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> 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:
|
with op.batch_alter_table('document_lines') as batch_op:
|
||||||
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
|
# Only add columns if they don't exist
|
||||||
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
|
if 'bank_account_id' not in cols:
|
||||||
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
|
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
|
||||||
batch_op.add_column(sa.Column('check_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))
|
||||||
|
|
||||||
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
|
# Only create foreign keys if the referenced tables exist
|
||||||
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
|
if 'bank_accounts' in tables and 'bank_account_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')
|
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
|
||||||
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_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')
|
||||||
|
|
||||||
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
|
# Only create indexes if columns were added
|
||||||
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
|
if 'bank_account_id' not in cols:
|
||||||
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
|
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
|
||||||
batch_op.create_index('ix_document_lines_check_id', ['check_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:
|
def downgrade() -> None:
|
||||||
|
|
@ -36,10 +63,23 @@ def downgrade() -> None:
|
||||||
batch_op.drop_index('ix_document_lines_cash_register_id')
|
batch_op.drop_index('ix_document_lines_cash_register_id')
|
||||||
batch_op.drop_index('ix_document_lines_bank_account_id')
|
batch_op.drop_index('ix_document_lines_bank_account_id')
|
||||||
|
|
||||||
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
|
# Try to drop foreign keys, ignore if they don't exist
|
||||||
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
|
try:
|
||||||
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
|
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
|
||||||
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', 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('check_id')
|
||||||
batch_op.drop_column('petty_cash_id')
|
batch_op.drop_column('petty_cash_id')
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Create Date: 2025-09-20 14:02:19.543853
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '5553f8745c6e'
|
revision = '5553f8745c6e'
|
||||||
|
|
@ -18,87 +19,104 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.create_table('support_categories',
|
bind = op.get_bind()
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
inspector = inspect(bind)
|
||||||
sa.Column('name', sa.String(length=100), nullable=False),
|
tables = set(inspector.get_table_names())
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
# Only create tables if they don't exist
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
if 'support_categories' not in tables:
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_table('support_categories',
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
)
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
op.create_table('support_priorities',
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('name', sa.String(length=50), nullable=False),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('color', sa.String(length=7), nullable=True),
|
)
|
||||||
sa.Column('order', sa.Integer(), nullable=False),
|
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
if 'support_priorities' not in tables:
|
||||||
sa.PrimaryKeyConstraint('id')
|
op.create_table('support_priorities',
|
||||||
)
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
op.create_table('support_statuses',
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
sa.Column('name', sa.String(length=50), nullable=False),
|
sa.Column('order', sa.Integer(), nullable=False),
|
||||||
sa.Column('description', sa.Text(), nullable=True),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('color', sa.String(length=7), nullable=True),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('is_final', sa.Boolean(), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
)
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
if 'support_statuses' not in tables:
|
||||||
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
op.create_table('support_statuses',
|
||||||
op.create_table('support_tickets',
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
sa.Column('title', sa.String(length=255), nullable=False),
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
sa.Column('description', sa.Text(), nullable=False),
|
sa.Column('color', sa.String(length=7), nullable=True),
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
sa.Column('is_final', sa.Boolean(), nullable=False),
|
||||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('priority_id', sa.Integer(), nullable=False),
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
sa.Column('status_id', sa.Integer(), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
)
|
||||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
||||||
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
if 'support_tickets' not in tables:
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
op.create_table('support_tickets',
|
||||||
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.Column('priority_id', sa.Integer(), nullable=False),
|
||||||
)
|
sa.Column('status_id', sa.Integer(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
sa.Column('assigned_operator_id', sa.Integer(), nullable=True),
|
||||||
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
sa.Column('closed_at', sa.DateTime(), nullable=True),
|
||||||
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'),
|
||||||
op.create_table('support_messages',
|
sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'),
|
||||||
sa.Column('sender_id', sa.Integer(), nullable=False),
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
sa.PrimaryKeyConstraint('id')
|
||||||
sa.Column('content', sa.Text(), nullable=False),
|
)
|
||||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False)
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False)
|
||||||
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
|
op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False)
|
||||||
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
||||||
sa.PrimaryKeyConstraint('id')
|
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
||||||
)
|
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
||||||
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
|
|
||||||
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
|
if 'support_messages' not in tables:
|
||||||
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
|
op.create_table('support_messages',
|
||||||
op.alter_column('businesses', 'business_type',
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
|
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
||||||
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
|
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||||
existing_nullable=False)
|
sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False),
|
||||||
op.alter_column('businesses', 'business_field',
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
|
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||||
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
existing_nullable=False)
|
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False)
|
||||||
|
|
||||||
|
# Only alter columns if businesses table exists
|
||||||
|
if 'businesses' in tables:
|
||||||
|
op.alter_column('businesses', 'business_type',
|
||||||
|
existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'),
|
||||||
|
type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'),
|
||||||
|
existing_nullable=False)
|
||||||
|
op.alter_column('businesses', 'business_field',
|
||||||
|
existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'),
|
||||||
|
type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'),
|
||||||
|
existing_nullable=False)
|
||||||
# ### end Alembic commands ###
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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 ###
|
||||||
|
|
@ -8,6 +8,7 @@ Create Date: 2025-09-30 14:46:58.614162
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'caf3f4ef4b76'
|
revision = 'caf3f4ef4b76'
|
||||||
|
|
@ -18,49 +19,81 @@ depends_on = None
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.alter_column('persons', 'code',
|
bind = op.get_bind()
|
||||||
existing_type=mysql.INTEGER(),
|
inspector = inspect(bind)
|
||||||
comment='کد یکتا در هر کسب و کار',
|
|
||||||
existing_nullable=True)
|
# Check if persons table exists and has the code column
|
||||||
op.alter_column('persons', 'person_type',
|
if 'persons' in inspector.get_table_names():
|
||||||
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
cols = {c['name'] for c in inspector.get_columns('persons')}
|
||||||
comment='نوع شخص',
|
|
||||||
existing_nullable=False)
|
# Only alter code column if it exists
|
||||||
op.alter_column('persons', 'person_types',
|
if 'code' in cols:
|
||||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
op.alter_column('persons', 'code',
|
||||||
comment='لیست انواع شخص به صورت JSON',
|
existing_type=mysql.INTEGER(),
|
||||||
existing_nullable=True)
|
comment='کد یکتا در هر کسب و کار',
|
||||||
op.alter_column('persons', 'commission_sale_percent',
|
existing_nullable=True)
|
||||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
|
||||||
comment='درصد پورسانت از فروش',
|
# Only alter person_type column if it exists
|
||||||
existing_nullable=True)
|
if 'person_type' in cols:
|
||||||
op.alter_column('persons', 'commission_sales_return_percent',
|
op.alter_column('persons', 'person_type',
|
||||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
||||||
comment='درصد پورسانت از برگشت از فروش',
|
comment='نوع شخص',
|
||||||
existing_nullable=True)
|
existing_nullable=False)
|
||||||
op.alter_column('persons', 'commission_sales_amount',
|
|
||||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
# Only alter person_types column if it exists
|
||||||
comment='مبلغ فروش مبنا برای پورسانت',
|
if 'person_types' in cols:
|
||||||
existing_nullable=True)
|
op.alter_column('persons', 'person_types',
|
||||||
op.alter_column('persons', 'commission_sales_return_amount',
|
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
comment='لیست انواع شخص به صورت JSON',
|
||||||
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
existing_nullable=True)
|
||||||
existing_nullable=True)
|
|
||||||
op.alter_column('persons', 'commission_exclude_discounts',
|
# Only alter commission columns if they exist
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
if 'commission_sale_percent' in cols:
|
||||||
comment='عدم محاسبه تخفیف در پورسانت',
|
op.alter_column('persons', 'commission_sale_percent',
|
||||||
existing_nullable=False,
|
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||||
existing_server_default=sa.text("'0'"))
|
comment='درصد پورسانت از فروش',
|
||||||
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
existing_nullable=True)
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
|
||||||
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
if 'commission_sales_return_percent' in cols:
|
||||||
existing_nullable=False,
|
op.alter_column('persons', 'commission_sales_return_percent',
|
||||||
existing_server_default=sa.text("'0'"))
|
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||||
op.alter_column('persons', 'commission_post_in_invoice_document',
|
comment='درصد پورسانت از برگشت از فروش',
|
||||||
existing_type=mysql.TINYINT(display_width=1),
|
existing_nullable=True)
|
||||||
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
|
||||||
existing_nullable=False,
|
if 'commission_sales_amount' in cols:
|
||||||
existing_server_default=sa.text("'0'"))
|
op.alter_column('persons', 'commission_sales_amount',
|
||||||
|
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||||
|
comment='مبلغ فروش مبنا برای پورسانت',
|
||||||
|
existing_nullable=True)
|
||||||
|
|
||||||
|
if 'commission_sales_return_amount' in cols:
|
||||||
|
op.alter_column('persons', 'commission_sales_return_amount',
|
||||||
|
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||||
|
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
||||||
|
existing_nullable=True)
|
||||||
|
|
||||||
|
if 'commission_exclude_discounts' in cols:
|
||||||
|
op.alter_column('persons', 'commission_exclude_discounts',
|
||||||
|
existing_type=mysql.TINYINT(display_width=1),
|
||||||
|
comment='عدم محاسبه تخفیف در پورسانت',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'0'"))
|
||||||
|
|
||||||
|
if 'commission_exclude_additions_deductions' in cols:
|
||||||
|
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
||||||
|
existing_type=mysql.TINYINT(display_width=1),
|
||||||
|
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'0'"))
|
||||||
|
|
||||||
|
if 'commission_post_in_invoice_document' in cols:
|
||||||
|
op.alter_column('persons', 'commission_post_in_invoice_document',
|
||||||
|
existing_type=mysql.TINYINT(display_width=1),
|
||||||
|
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'0'"))
|
||||||
|
|
||||||
|
# Continue with other operations
|
||||||
op.alter_column('price_items', 'tier_name',
|
op.alter_column('price_items', 'tier_name',
|
||||||
existing_type=mysql.VARCHAR(length=64),
|
existing_type=mysql.VARCHAR(length=64),
|
||||||
comment='نام پله قیمت (تکی/عمده/همکار/...)',
|
comment='نام پله قیمت (تکی/عمده/همکار/...)',
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ import 'pages/business/cash_registers_page.dart';
|
||||||
import 'pages/business/petty_cash_page.dart';
|
import 'pages/business/petty_cash_page.dart';
|
||||||
import 'pages/business/checks_page.dart';
|
import 'pages/business/checks_page.dart';
|
||||||
import 'pages/business/check_form_page.dart';
|
import 'pages/business/check_form_page.dart';
|
||||||
import 'pages/business/receipts_payments_page.dart';
|
import 'pages/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';
|
||||||
|
|
@ -795,7 +795,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Checks: list, new, edit
|
// Receipts & Payments: list with data table
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'receipts-payments',
|
path: 'receipts-payments',
|
||||||
name: 'business_receipts_payments',
|
name: 'business_receipts_payments',
|
||||||
|
|
@ -807,7 +807,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
localeController: controller,
|
localeController: controller,
|
||||||
calendarController: _calendarController!,
|
calendarController: _calendarController!,
|
||||||
themeController: themeController,
|
themeController: themeController,
|
||||||
child: ReceiptsPaymentsPage(
|
child: ReceiptsPaymentsListPage(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
calendarController: _calendarController!,
|
calendarController: _calendarController!,
|
||||||
authStore: _authStore!,
|
authStore: _authStore!,
|
||||||
|
|
|
||||||
97
hesabixUI/hesabix_ui/lib/models/account_tree_node.dart
Normal file
97
hesabixUI/hesabix_ui/lib/models/account_tree_node.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
class AccountTreeNode {
|
||||||
|
final int id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final String? accountType;
|
||||||
|
final int? parentId;
|
||||||
|
final int? level;
|
||||||
|
final List<AccountTreeNode> children;
|
||||||
|
|
||||||
|
const AccountTreeNode({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
this.accountType,
|
||||||
|
this.parentId,
|
||||||
|
this.level,
|
||||||
|
this.children = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AccountTreeNode.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AccountTreeNode(
|
||||||
|
id: json['id'] as int,
|
||||||
|
code: json['code'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
accountType: json['account_type'] as String?,
|
||||||
|
parentId: json['parent_id'] as int?,
|
||||||
|
level: json['level'] as int?,
|
||||||
|
children: (json['children'] as List<dynamic>?)
|
||||||
|
?.map((child) => AccountTreeNode.fromJson(child as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'code': code,
|
||||||
|
'name': name,
|
||||||
|
'account_type': accountType,
|
||||||
|
'parent_id': parentId,
|
||||||
|
'level': level,
|
||||||
|
'children': children.map((child) => child.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// بررسی میکند که آیا این حساب فرزند دارد یا نه
|
||||||
|
bool get hasChildren => children.isNotEmpty;
|
||||||
|
|
||||||
|
/// دریافت تمام حسابهای قابل انتخاب (بدون فرزند) به صورت تخت
|
||||||
|
List<AccountTreeNode> getSelectableAccounts() {
|
||||||
|
List<AccountTreeNode> selectable = [];
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
selectable.add(this);
|
||||||
|
} else {
|
||||||
|
for (final child in children) {
|
||||||
|
selectable.addAll(child.getSelectableAccounts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت تمام حسابها به صورت تخت (شامل همه سطوح)
|
||||||
|
List<AccountTreeNode> getAllAccounts() {
|
||||||
|
List<AccountTreeNode> all = [this];
|
||||||
|
|
||||||
|
for (final child in children) {
|
||||||
|
all.addAll(child.getAllAccounts());
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// جستجو در درخت حسابها بر اساس نام یا کد
|
||||||
|
List<AccountTreeNode> searchAccounts(String query) {
|
||||||
|
final lowerQuery = query.toLowerCase();
|
||||||
|
return getAllAccounts().where((account) {
|
||||||
|
return account.name.toLowerCase().contains(lowerQuery) ||
|
||||||
|
account.code.toLowerCase().contains(lowerQuery);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return '$code - $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is AccountTreeNode && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode;
|
||||||
|
}
|
||||||
222
hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart
Normal file
222
hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
|
||||||
|
/// مدل خط شخص در سند دریافت/پرداخت
|
||||||
|
class PersonLine {
|
||||||
|
final int id;
|
||||||
|
final int? personId;
|
||||||
|
final String? personName;
|
||||||
|
final double amount;
|
||||||
|
final String? description;
|
||||||
|
final Map<String, dynamic>? extraInfo;
|
||||||
|
|
||||||
|
const PersonLine({
|
||||||
|
required this.id,
|
||||||
|
this.personId,
|
||||||
|
this.personName,
|
||||||
|
required this.amount,
|
||||||
|
this.description,
|
||||||
|
this.extraInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PersonLine.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PersonLine(
|
||||||
|
id: json['id'] ?? 0,
|
||||||
|
personId: json['person_id'],
|
||||||
|
personName: json['person_name'],
|
||||||
|
amount: (json['amount'] ?? 0).toDouble(),
|
||||||
|
description: json['description'],
|
||||||
|
extraInfo: json['extra_info'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'person_id': personId,
|
||||||
|
'person_name': personName,
|
||||||
|
'amount': amount,
|
||||||
|
'description': description,
|
||||||
|
'extra_info': extraInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// مدل خط حساب در سند دریافت/پرداخت
|
||||||
|
class AccountLine {
|
||||||
|
final int id;
|
||||||
|
final int accountId;
|
||||||
|
final String accountName;
|
||||||
|
final String accountCode;
|
||||||
|
final String? accountType;
|
||||||
|
final double amount;
|
||||||
|
final String? description;
|
||||||
|
final String? transactionType;
|
||||||
|
final DateTime? transactionDate;
|
||||||
|
final double? commission;
|
||||||
|
final Map<String, dynamic>? extraInfo;
|
||||||
|
|
||||||
|
const AccountLine({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.accountName,
|
||||||
|
required this.accountCode,
|
||||||
|
this.accountType,
|
||||||
|
required this.amount,
|
||||||
|
this.description,
|
||||||
|
this.transactionType,
|
||||||
|
this.transactionDate,
|
||||||
|
this.commission,
|
||||||
|
this.extraInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AccountLine.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AccountLine(
|
||||||
|
id: json['id'] ?? 0,
|
||||||
|
accountId: json['account_id'] ?? 0,
|
||||||
|
accountName: json['account_name'] ?? '',
|
||||||
|
accountCode: json['account_code'] ?? '',
|
||||||
|
accountType: json['account_type'],
|
||||||
|
amount: (json['amount'] ?? 0).toDouble(),
|
||||||
|
description: json['description'],
|
||||||
|
transactionType: json['transaction_type'],
|
||||||
|
transactionDate: json['transaction_date'] != null
|
||||||
|
? DateTime.tryParse(json['transaction_date'])
|
||||||
|
: null,
|
||||||
|
commission: json['commission']?.toDouble(),
|
||||||
|
extraInfo: json['extra_info'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'account_id': accountId,
|
||||||
|
'account_name': accountName,
|
||||||
|
'account_code': accountCode,
|
||||||
|
'account_type': accountType,
|
||||||
|
'amount': amount,
|
||||||
|
'description': description,
|
||||||
|
'transaction_type': transactionType,
|
||||||
|
'transaction_date': transactionDate?.toIso8601String(),
|
||||||
|
'commission': commission,
|
||||||
|
'extra_info': extraInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// مدل سند دریافت/پرداخت
|
||||||
|
class ReceiptPaymentDocument {
|
||||||
|
final int id;
|
||||||
|
final String code;
|
||||||
|
final int businessId;
|
||||||
|
final String documentType; // 'receipt' or 'payment'
|
||||||
|
final DateTime documentDate;
|
||||||
|
final DateTime registeredAt;
|
||||||
|
final int currencyId;
|
||||||
|
final String? currencyCode;
|
||||||
|
final int createdByUserId;
|
||||||
|
final String? createdByName;
|
||||||
|
final bool isProforma;
|
||||||
|
final Map<String, dynamic>? extraInfo;
|
||||||
|
final List<PersonLine> personLines;
|
||||||
|
final List<AccountLine> accountLines;
|
||||||
|
final String? personNames;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const ReceiptPaymentDocument({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.businessId,
|
||||||
|
required this.documentType,
|
||||||
|
required this.documentDate,
|
||||||
|
required this.registeredAt,
|
||||||
|
required this.currencyId,
|
||||||
|
this.currencyCode,
|
||||||
|
required this.createdByUserId,
|
||||||
|
this.createdByName,
|
||||||
|
required this.isProforma,
|
||||||
|
this.extraInfo,
|
||||||
|
required this.personLines,
|
||||||
|
required this.accountLines,
|
||||||
|
this.personNames,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReceiptPaymentDocument.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ReceiptPaymentDocument(
|
||||||
|
id: json['id'] ?? 0,
|
||||||
|
code: json['code'] ?? '',
|
||||||
|
businessId: json['business_id'] ?? 0,
|
||||||
|
documentType: json['document_type'] ?? '',
|
||||||
|
documentDate: DateTime.tryParse(json['document_date'] ?? '') ?? DateTime.now(),
|
||||||
|
registeredAt: DateTime.tryParse(json['registered_at'] ?? '') ?? DateTime.now(),
|
||||||
|
currencyId: json['currency_id'] ?? 0,
|
||||||
|
currencyCode: json['currency_code'],
|
||||||
|
createdByUserId: json['created_by_user_id'] ?? 0,
|
||||||
|
createdByName: json['created_by_name'],
|
||||||
|
isProforma: json['is_proforma'] ?? false,
|
||||||
|
extraInfo: json['extra_info'],
|
||||||
|
personLines: (json['person_lines'] as List<dynamic>?)
|
||||||
|
?.map((item) => PersonLine.fromJson(item))
|
||||||
|
.toList() ?? [],
|
||||||
|
accountLines: (json['account_lines'] as List<dynamic>?)
|
||||||
|
?.map((item) => AccountLine.fromJson(item))
|
||||||
|
.toList() ?? [],
|
||||||
|
personNames: json['person_names'],
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] ?? '') ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.tryParse(json['updated_at'] ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'code': code,
|
||||||
|
'business_id': businessId,
|
||||||
|
'document_type': documentType,
|
||||||
|
'document_date': documentDate.toIso8601String(),
|
||||||
|
'registered_at': registeredAt.toIso8601String(),
|
||||||
|
'currency_id': currencyId,
|
||||||
|
'currency_code': currencyCode,
|
||||||
|
'created_by_user_id': createdByUserId,
|
||||||
|
'created_by_name': createdByName,
|
||||||
|
'is_proforma': isProforma,
|
||||||
|
'extra_info': extraInfo,
|
||||||
|
'person_lines': personLines.map((item) => item.toJson()).toList(),
|
||||||
|
'account_lines': accountLines.map((item) => item.toJson()).toList(),
|
||||||
|
'person_names': personNames,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// محاسبه مجموع مبلغ کل
|
||||||
|
double get totalAmount {
|
||||||
|
return personLines.fold(0.0, (sum, line) => sum + line.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// تعداد خطوط اشخاص
|
||||||
|
int get personLinesCount => personLines.length;
|
||||||
|
|
||||||
|
/// تعداد خطوط حسابها
|
||||||
|
int get accountLinesCount => accountLines.length;
|
||||||
|
|
||||||
|
/// آیا سند دریافت است؟
|
||||||
|
bool get isReceipt => documentType == 'receipt';
|
||||||
|
|
||||||
|
/// آیا سند پرداخت است؟
|
||||||
|
bool get isPayment => documentType == 'payment';
|
||||||
|
|
||||||
|
/// دریافت نام نوع سند
|
||||||
|
String get documentTypeName {
|
||||||
|
switch (documentType) {
|
||||||
|
case 'receipt':
|
||||||
|
return 'دریافت';
|
||||||
|
case 'payment':
|
||||||
|
return 'پرداخت';
|
||||||
|
default:
|
||||||
|
return documentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import '../../widgets/category/category_tree_dialog.dart';
|
||||||
import '../../services/business_dashboard_service.dart';
|
import '../../services/business_dashboard_service.dart';
|
||||||
import '../../core/api_client.dart';
|
import '../../core/api_client.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
|
||||||
|
|
||||||
class BusinessShell extends StatefulWidget {
|
class BusinessShell extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -68,7 +69,25 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refreshCurrentPage() {
|
Future<void> showAddReceiptPaymentDialog() async {
|
||||||
|
final calendarController = widget.calendarController ?? await CalendarController.load();
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => BulkSettlementDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: calendarController,
|
||||||
|
isReceipt: true, // پیشفرض دریافت
|
||||||
|
businessInfo: widget.authStore.currentBusiness,
|
||||||
|
apiClient: ApiClient(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
// Refresh the receipts payments page if it's currently open
|
||||||
|
_refreshCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshCurrentPage() {
|
||||||
// Force a rebuild of the current page
|
// Force a rebuild of the current page
|
||||||
setState(() {
|
setState(() {
|
||||||
// This will cause the current page to rebuild
|
// This will cause the current page to rebuild
|
||||||
|
|
@ -809,6 +828,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
} else if (child.label == t.invoice) {
|
} else if (child.label == t.invoice) {
|
||||||
// Navigate to add invoice
|
// Navigate to add invoice
|
||||||
context.go('/business/${widget.businessId}/invoice/new');
|
context.go('/business/${widget.businessId}/invoice/new');
|
||||||
|
} else if (child.label == t.receiptsAndPayments) {
|
||||||
|
// Show add receipt payment dialog
|
||||||
|
showAddReceiptPaymentDialog();
|
||||||
} else if (child.label == t.expenseAndIncome) {
|
} else if (child.label == t.expenseAndIncome) {
|
||||||
// Navigate to add expense/income
|
// Navigate to add expense/income
|
||||||
} else if (child.label == t.warehouses) {
|
} else if (child.label == t.warehouses) {
|
||||||
|
|
@ -951,6 +973,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
||||||
} else if (item.label == t.invoice) {
|
} else if (item.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 (item.label == t.receiptsAndPayments) {
|
||||||
|
// Show add receipt payment dialog
|
||||||
|
showAddReceiptPaymentDialog();
|
||||||
} else if (item.label == t.checks) {
|
} else if (item.label == t.checks) {
|
||||||
// Navigate to add check
|
// Navigate to add check
|
||||||
context.go('/business/${widget.businessId}/checks/new');
|
context.go('/business/${widget.businessId}/checks/new');
|
||||||
|
|
@ -1122,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) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,926 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||||
|
import 'package:hesabix_ui/core/auth_store.dart';
|
||||||
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
import 'package:hesabix_ui/models/receipt_payment_document.dart';
|
||||||
|
import 'package:hesabix_ui/services/receipt_payment_list_service.dart';
|
||||||
|
import 'package:hesabix_ui/services/receipt_payment_service.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/data_table/data_table_config.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/date_input_field.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/invoice/invoice_transactions_widget.dart';
|
||||||
|
import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart';
|
||||||
|
import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands;
|
||||||
|
import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils;
|
||||||
|
import 'package:hesabix_ui/models/invoice_transaction.dart';
|
||||||
|
import 'package:hesabix_ui/models/invoice_type_model.dart';
|
||||||
|
import 'package:hesabix_ui/models/person_model.dart';
|
||||||
|
import 'package:hesabix_ui/models/business_dashboard_models.dart';
|
||||||
|
|
||||||
|
/// صفحه لیست اسناد دریافت و پرداخت با ویجت جدول
|
||||||
|
class ReceiptsPaymentsListPage extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
final AuthStore authStore;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
const ReceiptsPaymentsListPage({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.calendarController,
|
||||||
|
required this.authStore,
|
||||||
|
required this.apiClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReceiptsPaymentsListPage> createState() => _ReceiptsPaymentsListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
|
late ReceiptPaymentListService _service;
|
||||||
|
String? _selectedDocumentType;
|
||||||
|
DateTime? _fromDate;
|
||||||
|
DateTime? _toDate;
|
||||||
|
int _refreshKey = 0; // کلید برای تازهسازی جدول
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_service = ReceiptPaymentListService(widget.apiClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// تازهسازی دادههای جدول
|
||||||
|
void _refreshData() {
|
||||||
|
setState(() {
|
||||||
|
_refreshKey++; // تغییر کلید باعث rebuild شدن جدول میشود
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// هدر صفحه
|
||||||
|
_buildHeader(t),
|
||||||
|
|
||||||
|
// فیلترها
|
||||||
|
_buildFilters(t),
|
||||||
|
|
||||||
|
// جدول دادهها
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: DataTableWidget<ReceiptPaymentDocument>(
|
||||||
|
key: ValueKey(_refreshKey),
|
||||||
|
config: _buildTableConfig(t),
|
||||||
|
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ساخت هدر صفحه
|
||||||
|
Widget _buildHeader(AppLocalizations t) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.receiptsAndPayments,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'مدیریت اسناد دریافت و پرداخت',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _onAddNew,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(t.add),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ساخت بخش فیلترها
|
||||||
|
Widget _buildFilters(AppLocalizations t) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// فیلتر نوع سند
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: SegmentedButton<String?>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<String?>(
|
||||||
|
value: null,
|
||||||
|
label: Text('همه'),
|
||||||
|
icon: const Icon(Icons.all_inclusive),
|
||||||
|
),
|
||||||
|
ButtonSegment<String?>(
|
||||||
|
value: 'receipt',
|
||||||
|
label: Text(t.receipts),
|
||||||
|
icon: const Icon(Icons.download_done_outlined),
|
||||||
|
),
|
||||||
|
ButtonSegment<String?>(
|
||||||
|
value: 'payment',
|
||||||
|
label: Text(t.payments),
|
||||||
|
icon: const Icon(Icons.upload_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_selectedDocumentType},
|
||||||
|
onSelectionChanged: (set) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDocumentType = set.first;
|
||||||
|
});
|
||||||
|
// refresh data when filter changes
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// فیلتر تاریخ
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _fromDate,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() => _fromDate = date);
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
labelText: 'از تاریخ',
|
||||||
|
hintText: 'انتخاب تاریخ شروع',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: DateInputField(
|
||||||
|
value: _toDate,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (date) {
|
||||||
|
setState(() => _toDate = date);
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
labelText: 'تا تاریخ',
|
||||||
|
hintText: 'انتخاب تاریخ پایان',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_fromDate = null;
|
||||||
|
_toDate = null;
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
tooltip: 'پاک کردن فیلتر تاریخ',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ساخت تنظیمات جدول
|
||||||
|
DataTableConfig<ReceiptPaymentDocument> _buildTableConfig(AppLocalizations t) {
|
||||||
|
return DataTableConfig<ReceiptPaymentDocument>(
|
||||||
|
endpoint: '/businesses/${widget.businessId}/receipts-payments',
|
||||||
|
title: t.receiptsAndPayments,
|
||||||
|
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
||||||
|
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
|
||||||
|
getExportParams: () => {
|
||||||
|
'business_id': widget.businessId,
|
||||||
|
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||||
|
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
||||||
|
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
// کد سند
|
||||||
|
TextColumn(
|
||||||
|
'code',
|
||||||
|
'کد سند',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (item) => item.code,
|
||||||
|
),
|
||||||
|
|
||||||
|
// نوع سند
|
||||||
|
TextColumn(
|
||||||
|
'document_type',
|
||||||
|
'نوع',
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
formatter: (item) => item.documentTypeName,
|
||||||
|
),
|
||||||
|
|
||||||
|
// تاریخ سند
|
||||||
|
DateColumn(
|
||||||
|
'document_date',
|
||||||
|
'تاریخ سند',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali),
|
||||||
|
),
|
||||||
|
|
||||||
|
// مبلغ کل
|
||||||
|
NumberColumn(
|
||||||
|
'total_amount',
|
||||||
|
'مبلغ کل',
|
||||||
|
width: ColumnWidth.large,
|
||||||
|
formatter: (item) => formatWithThousands(item.totalAmount),
|
||||||
|
suffix: ' ریال',
|
||||||
|
),
|
||||||
|
|
||||||
|
// نام اشخاص
|
||||||
|
TextColumn(
|
||||||
|
'person_names',
|
||||||
|
'اشخاص',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (item) => item.personNames ?? 'نامشخص',
|
||||||
|
),
|
||||||
|
|
||||||
|
// تعداد حسابها
|
||||||
|
NumberColumn(
|
||||||
|
'account_lines_count',
|
||||||
|
'حسابها',
|
||||||
|
width: ColumnWidth.small,
|
||||||
|
formatter: (item) => item.accountLinesCount.toString(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ایجادکننده
|
||||||
|
TextColumn(
|
||||||
|
'created_by_name',
|
||||||
|
'ایجادکننده',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (item) => item.createdByName ?? 'نامشخص',
|
||||||
|
),
|
||||||
|
|
||||||
|
// تاریخ ثبت
|
||||||
|
DateColumn(
|
||||||
|
'registered_at',
|
||||||
|
'تاریخ ثبت',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
formatter: (item) => HesabixDateUtils.formatForDisplay(item.registeredAt, widget.calendarController.isJalali),
|
||||||
|
),
|
||||||
|
|
||||||
|
// عملیات
|
||||||
|
ActionColumn(
|
||||||
|
'actions',
|
||||||
|
'عملیات',
|
||||||
|
width: ColumnWidth.medium,
|
||||||
|
actions: [
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.visibility,
|
||||||
|
label: 'مشاهده',
|
||||||
|
onTap: (item) => _onView(item),
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.edit,
|
||||||
|
label: 'ویرایش',
|
||||||
|
onTap: (item) => _onEdit(item),
|
||||||
|
),
|
||||||
|
DataTableAction(
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'حذف',
|
||||||
|
onTap: (item) => _onDelete(item),
|
||||||
|
isDestructive: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
searchFields: ['code', 'created_by_name'],
|
||||||
|
filterFields: ['document_type'],
|
||||||
|
dateRangeField: 'document_date',
|
||||||
|
showSearch: true,
|
||||||
|
showFilters: true,
|
||||||
|
showPagination: true,
|
||||||
|
showColumnSearch: true,
|
||||||
|
showRefreshButton: true,
|
||||||
|
showClearFiltersButton: true,
|
||||||
|
enableRowSelection: true,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
showExportButtons: true,
|
||||||
|
showExcelExport: true,
|
||||||
|
showPdfExport: true,
|
||||||
|
defaultPageSize: 20,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
additionalParams: {
|
||||||
|
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||||
|
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
||||||
|
if (_toDate != null) 'to_date': _toDate!.toIso8601String(),
|
||||||
|
},
|
||||||
|
onRowTap: (item) => _onView(item),
|
||||||
|
onRowDoubleTap: (item) => _onEdit(item),
|
||||||
|
emptyStateMessage: 'هیچ سند دریافت یا پرداختی یافت نشد',
|
||||||
|
loadingMessage: 'در حال بارگذاری اسناد...',
|
||||||
|
errorMessage: 'خطا در بارگذاری اسناد',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// افزودن سند جدید
|
||||||
|
void _onAddNew() async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => BulkSettlementDialog(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
isReceipt: true, // پیشفرض دریافت
|
||||||
|
businessInfo: widget.authStore.currentBusiness,
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// اگر سند با موفقیت ثبت شد، جدول را تازهسازی کن
|
||||||
|
if (result == true) {
|
||||||
|
_refreshData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// مشاهده جزئیات سند
|
||||||
|
void _onView(ReceiptPaymentDocument document) {
|
||||||
|
// TODO: باز کردن صفحه جزئیات سند
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('مشاهده سند ${document.code}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ویرایش سند
|
||||||
|
void _onEdit(ReceiptPaymentDocument document) {
|
||||||
|
// TODO: باز کردن صفحه ویرایش سند
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('ویرایش سند ${document.code}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// حذف سند
|
||||||
|
void _onDelete(ReceiptPaymentDocument document) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('تأیید حذف'),
|
||||||
|
content: Text('آیا از حذف سند ${document.code} اطمینان دارید؟'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('انصراف'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _performDelete(document);
|
||||||
|
},
|
||||||
|
child: const Text('حذف'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// انجام عملیات حذف
|
||||||
|
Future<void> _performDelete(ReceiptPaymentDocument document) async {
|
||||||
|
try {
|
||||||
|
final success = await _service.delete(document.id);
|
||||||
|
if (success) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('سند ${document.code} با موفقیت حذف شد'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Exception('خطا در حذف سند');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('خطا در حذف سند: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BulkSettlementDialog extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final CalendarController calendarController;
|
||||||
|
final bool isReceipt;
|
||||||
|
final BusinessWithPermission? businessInfo;
|
||||||
|
final ApiClient apiClient;
|
||||||
|
const BulkSettlementDialog({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
required this.calendarController,
|
||||||
|
required this.isReceipt,
|
||||||
|
this.businessInfo,
|
||||||
|
required this.apiClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BulkSettlementDialog> createState() => _BulkSettlementDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
late DateTime _docDate;
|
||||||
|
late bool _isReceipt;
|
||||||
|
int? _selectedCurrencyId;
|
||||||
|
final List<_PersonLine> _personLines = <_PersonLine>[];
|
||||||
|
final List<InvoiceTransaction> _centerTransactions = <InvoiceTransaction>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_docDate = DateTime.now();
|
||||||
|
_isReceipt = widget.isReceipt;
|
||||||
|
// اگر ارز پیشفرض موجود است، آن را انتخاب کن، در غیر این صورت null بگذار تا CurrencyPickerWidget خودکار انتخاب کند
|
||||||
|
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final sumPersons = _personLines.fold<double>(0, (p, e) => p + e.amount);
|
||||||
|
final sumCenters = _centerTransactions.fold<double>(0, (p, e) => p + (e.amount.toDouble()));
|
||||||
|
final diff = (_isReceipt ? sumCenters - sumPersons : sumPersons - sumCenters).toDouble();
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1100, maxHeight: 720),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.receiptsAndPayments,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
|
||||||
|
ButtonSegment<bool>(value: false, label: Text(t.payments)),
|
||||||
|
],
|
||||||
|
selected: {_isReceipt},
|
||||||
|
onSelectionChanged: (s) => setState(() => _isReceipt = s.first),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DateInputField(
|
||||||
|
value: _docDate,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
onChanged: (d) => setState(() => _docDate = d ?? DateTime.now()),
|
||||||
|
labelText: 'تاریخ سند',
|
||||||
|
hintText: 'انتخاب تاریخ',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: CurrencyPickerWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedCurrencyId: _selectedCurrencyId,
|
||||||
|
onChanged: (currencyId) => setState(() => _selectedCurrencyId = currencyId),
|
||||||
|
label: 'ارز',
|
||||||
|
hintText: 'انتخاب ارز',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _PersonsPanel(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
lines: _personLines,
|
||||||
|
onChanged: (ls) => setState(() {
|
||||||
|
_personLines.clear();
|
||||||
|
_personLines.addAll(ls);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: InvoiceTransactionsWidget(
|
||||||
|
transactions: _centerTransactions,
|
||||||
|
onChanged: (txs) => setState(() {
|
||||||
|
_centerTransactions.clear();
|
||||||
|
_centerTransactions.addAll(txs);
|
||||||
|
}),
|
||||||
|
businessId: widget.businessId,
|
||||||
|
calendarController: widget.calendarController,
|
||||||
|
invoiceType: InvoiceType.sales,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_TotalChip(label: t.people, value: sumPersons),
|
||||||
|
_TotalChip(label: t.accounts, value: sumCenters),
|
||||||
|
_TotalChip(label: 'اختلاف', value: diff, isError: diff != 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(t.cancel),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: diff == 0 && _personLines.isNotEmpty && _centerTransactions.isNotEmpty
|
||||||
|
? _onSave
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: Text(t.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSave() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// نمایش loading
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final service = ReceiptPaymentService(widget.apiClient);
|
||||||
|
|
||||||
|
// تبدیل personLines به فرمت مورد نیاز API
|
||||||
|
final personLinesData = _personLines.map((line) => {
|
||||||
|
'person_id': int.parse(line.personId!),
|
||||||
|
'person_name': line.personName,
|
||||||
|
'amount': line.amount,
|
||||||
|
if (line.description != null && line.description!.isNotEmpty)
|
||||||
|
'description': line.description,
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// تبدیل centerTransactions به فرمت مورد نیاز API
|
||||||
|
final accountLinesData = _centerTransactions.map((tx) => {
|
||||||
|
'account_id': tx.accountId,
|
||||||
|
'amount': tx.amount.toDouble(),
|
||||||
|
'transaction_type': tx.type.value,
|
||||||
|
'transaction_date': tx.transactionDate.toIso8601String(),
|
||||||
|
if (tx.commission != null && tx.commission! > 0)
|
||||||
|
'commission': tx.commission!.toDouble(),
|
||||||
|
if (tx.description != null && tx.description!.isNotEmpty)
|
||||||
|
'description': tx.description,
|
||||||
|
// اطلاعات اضافی بر اساس نوع تراکنش
|
||||||
|
if (tx.type == TransactionType.bank) ...{
|
||||||
|
'bank_id': tx.bankId,
|
||||||
|
'bank_name': tx.bankName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.cashRegister) ...{
|
||||||
|
'cash_register_id': tx.cashRegisterId,
|
||||||
|
'cash_register_name': tx.cashRegisterName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.pettyCash) ...{
|
||||||
|
'petty_cash_id': tx.pettyCashId,
|
||||||
|
'petty_cash_name': tx.pettyCashName,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.check) ...{
|
||||||
|
'check_id': tx.checkId,
|
||||||
|
'check_number': tx.checkNumber,
|
||||||
|
},
|
||||||
|
if (tx.type == TransactionType.person) ...{
|
||||||
|
'person_id': tx.personId,
|
||||||
|
'person_name': tx.personName,
|
||||||
|
},
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// ارسال به سرور
|
||||||
|
await service.createReceiptPayment(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
documentType: _isReceipt ? 'receipt' : 'payment',
|
||||||
|
documentDate: _docDate,
|
||||||
|
currencyId: _selectedCurrencyId!,
|
||||||
|
personLines: personLinesData,
|
||||||
|
accountLines: accountLinesData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// بستن dialog loading
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// بستن dialog اصلی با موفقیت
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
|
||||||
|
// نمایش پیام موفقیت
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isReceipt
|
||||||
|
? 'سند دریافت با موفقیت ثبت شد'
|
||||||
|
: 'سند پرداخت با موفقیت ثبت شد',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// بستن dialog loading
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// نمایش خطا
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('خطا: ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonsPanel extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final List<_PersonLine> lines;
|
||||||
|
final ValueChanged<List<_PersonLine>> onChanged;
|
||||||
|
const _PersonsPanel({
|
||||||
|
required this.businessId,
|
||||||
|
required this.lines,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PersonsPanel> createState() => _PersonsPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonsPanelState extends State<_PersonsPanel> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(t.people, style: Theme.of(context).textTheme.titleMedium)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines.add(_PersonLine.empty());
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: t.add,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: widget.lines.isEmpty
|
||||||
|
? Center(child: Text(t.noDataFound))
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: widget.lines.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final line = widget.lines[i];
|
||||||
|
return _PersonLineTile(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
line: line,
|
||||||
|
onChanged: (l) {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines[i] = l;
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
final newLines = List<_PersonLine>.from(widget.lines);
|
||||||
|
newLines.removeAt(i);
|
||||||
|
widget.onChanged(newLines);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLineTile extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final _PersonLine line;
|
||||||
|
final ValueChanged<_PersonLine> onChanged;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
const _PersonLineTile({
|
||||||
|
required this.businessId,
|
||||||
|
required this.line,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PersonLineTile> createState() => _PersonLineTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLineTileState extends State<_PersonLineTile> {
|
||||||
|
final _amountController = TextEditingController();
|
||||||
|
final _descController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_amountController.text = widget.line.amount == 0 ? '' : widget.line.amount.toStringAsFixed(0);
|
||||||
|
_descController.text = widget.line.description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amountController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PersonComboboxWidget(
|
||||||
|
businessId: widget.businessId,
|
||||||
|
selectedPerson: widget.line.personId != null
|
||||||
|
? Person(
|
||||||
|
id: int.tryParse(widget.line.personId!),
|
||||||
|
businessId: widget.businessId,
|
||||||
|
aliasName: widget.line.personName ?? '',
|
||||||
|
personTypes: const [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onChanged: (opt) {
|
||||||
|
widget.onChanged(widget.line.copyWith(personId: opt?.id?.toString(), personName: opt?.displayName));
|
||||||
|
},
|
||||||
|
label: t.people,
|
||||||
|
hintText: t.search,
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _amountController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.amount,
|
||||||
|
hintText: '1,000,000',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
validator: (v) {
|
||||||
|
final val = double.tryParse((v ?? '').replaceAll(',', ''));
|
||||||
|
if (val == null || val <= 0) return t.mustBePositiveNumber;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onChanged: (v) {
|
||||||
|
final val = double.tryParse(v.replaceAll(',', '')) ?? 0;
|
||||||
|
widget.onChanged(widget.line.copyWith(amount: val));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.description,
|
||||||
|
),
|
||||||
|
onChanged: (v) => widget.onChanged(widget.line.copyWith(description: v.trim().isEmpty ? null : v.trim())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TotalChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final bool isError;
|
||||||
|
const _TotalChip({required this.label, required this.value, this.isError = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ColorScheme scheme = Theme.of(context).colorScheme;
|
||||||
|
return Chip(
|
||||||
|
label: Text('$label: ${formatWithThousands(value)}'),
|
||||||
|
backgroundColor: isError ? scheme.errorContainer : scheme.surfaceContainerHighest,
|
||||||
|
labelStyle: TextStyle(color: isError ? scheme.onErrorContainer : scheme.onSurfaceVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonLine {
|
||||||
|
final String? personId;
|
||||||
|
final String? personName;
|
||||||
|
final double amount;
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
const _PersonLine({this.personId, this.personName, required this.amount, this.description});
|
||||||
|
|
||||||
|
factory _PersonLine.empty() => const _PersonLine(amount: 0);
|
||||||
|
|
||||||
|
_PersonLine copyWith({String? personId, String? personName, double? amount, String? description}) {
|
||||||
|
return _PersonLine(
|
||||||
|
personId: personId ?? this.personId,
|
||||||
|
personName: personName ?? this.personName,
|
||||||
|
amount: amount ?? this.amount,
|
||||||
|
description: description ?? this.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +411,10 @@ class _BulkSettlementDialogState extends State<_BulkSettlementDialog> {
|
||||||
'check_id': tx.checkId,
|
'check_id': tx.checkId,
|
||||||
'check_number': tx.checkNumber,
|
'check_number': tx.checkNumber,
|
||||||
},
|
},
|
||||||
|
if (tx.type == TransactionType.person) ...{
|
||||||
|
'person_id': tx.personId,
|
||||||
|
'person_name': tx.personName,
|
||||||
|
},
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// ارسال به سرور
|
// ارسال به سرور
|
||||||
|
|
|
||||||
22
hesabixUI/hesabix_ui/lib/services/account_service.dart
Normal file
22
hesabixUI/hesabix_ui/lib/services/account_service.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
class AccountService {
|
||||||
|
final ApiClient _client;
|
||||||
|
AccountService({ApiClient? client}) : _client = client ?? ApiClient();
|
||||||
|
|
||||||
|
/// دریافت درخت حسابها برای یک کسب و کار
|
||||||
|
Future<Map<String, dynamic>> getAccountsTree({required int businessId}) async {
|
||||||
|
try {
|
||||||
|
final res = await _client.get<Map<String, dynamic>>(
|
||||||
|
'/api/v1/accounts/business/$businessId/tree',
|
||||||
|
);
|
||||||
|
|
||||||
|
// API پاسخ را در فیلد 'data' برمیگرداند
|
||||||
|
final responseData = res.data?['data'] as Map<String, dynamic>?;
|
||||||
|
return responseData ?? <String, dynamic>{'items': <dynamic>[]};
|
||||||
|
} catch (e) {
|
||||||
|
print('خطا در دریافت درخت حسابها: $e');
|
||||||
|
return <String, dynamic>{'items': <dynamic>[]};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import 'package:hesabix_ui/core/api_client.dart';
|
||||||
|
import 'package:hesabix_ui/models/receipt_payment_document.dart';
|
||||||
|
|
||||||
|
/// پاسخ API برای لیست اسناد
|
||||||
|
class ReceiptPaymentListResponse {
|
||||||
|
final List<ReceiptPaymentDocument> items;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int perPage;
|
||||||
|
final int totalPages;
|
||||||
|
final bool hasNext;
|
||||||
|
final bool hasPrev;
|
||||||
|
|
||||||
|
const ReceiptPaymentListResponse({
|
||||||
|
required this.items,
|
||||||
|
required this.total,
|
||||||
|
required this.page,
|
||||||
|
required this.perPage,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.hasNext,
|
||||||
|
required this.hasPrev,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReceiptPaymentListResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
final pagination = json['pagination'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
return ReceiptPaymentListResponse(
|
||||||
|
items: (json['items'] as List<dynamic>?)
|
||||||
|
?.map((item) => ReceiptPaymentDocument.fromJson(item))
|
||||||
|
.toList() ?? [],
|
||||||
|
total: pagination['total'] ?? 0,
|
||||||
|
page: pagination['page'] ?? 1,
|
||||||
|
perPage: pagination['per_page'] ?? 20,
|
||||||
|
totalPages: pagination['total_pages'] ?? 0,
|
||||||
|
hasNext: pagination['has_next'] ?? false,
|
||||||
|
hasPrev: pagination['has_prev'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// سرویس برای مدیریت لیست اسناد دریافت و پرداخت
|
||||||
|
class ReceiptPaymentListService {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
ReceiptPaymentListService(this._apiClient);
|
||||||
|
|
||||||
|
/// دریافت لیست اسناد دریافت و پرداخت
|
||||||
|
Future<ReceiptPaymentListResponse> getList({
|
||||||
|
required int businessId,
|
||||||
|
String? search,
|
||||||
|
String? documentType,
|
||||||
|
DateTime? fromDate,
|
||||||
|
DateTime? toDate,
|
||||||
|
String? sortBy,
|
||||||
|
bool? sortDesc,
|
||||||
|
int page = 1,
|
||||||
|
int limit = 20,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'take': limit,
|
||||||
|
'skip': (page - 1) * limit,
|
||||||
|
'sort_by': sortBy ?? 'document_date',
|
||||||
|
'sort_desc': sortDesc ?? true,
|
||||||
|
'search': search,
|
||||||
|
'document_type': documentType,
|
||||||
|
'from_date': fromDate?.toIso8601String(),
|
||||||
|
'to_date': toDate?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// حذف پارامترهای null
|
||||||
|
queryParams.removeWhere((key, value) => value == null);
|
||||||
|
|
||||||
|
final response = await _apiClient.post(
|
||||||
|
'/businesses/$businessId/receipts-payments',
|
||||||
|
data: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
final data = response.data['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
return ReceiptPaymentListResponse.fromJson(data);
|
||||||
|
} else {
|
||||||
|
throw Exception('خطا در دریافت لیست اسناد: ${response.statusMessage}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در دریافت لیست اسناد: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت جزئیات یک سند
|
||||||
|
Future<ReceiptPaymentDocument?> getById(int documentId) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.get('/receipts-payments/$documentId');
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
final data = response.data['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
return ReceiptPaymentDocument.fromJson(data);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در دریافت جزئیات سند: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// حذف یک سند
|
||||||
|
Future<bool> delete(int documentId) async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.delete('/receipts-payments/$documentId');
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در حذف سند: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// حذف چندین سند
|
||||||
|
Future<bool> deleteMultiple(List<int> documentIds) async {
|
||||||
|
try {
|
||||||
|
// حذف تکتک اسناد
|
||||||
|
for (final id in documentIds) {
|
||||||
|
await delete(id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در حذف اسناد: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// دریافت آمار کلی
|
||||||
|
Future<Map<String, dynamic>> getStats({
|
||||||
|
required int businessId,
|
||||||
|
DateTime? fromDate,
|
||||||
|
DateTime? toDate,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final queryParams = <String, dynamic>{
|
||||||
|
'take': 1, // فقط برای آمار
|
||||||
|
'skip': 0,
|
||||||
|
'from_date': fromDate?.toIso8601String(),
|
||||||
|
'to_date': toDate?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
queryParams.removeWhere((key, value) => value == null);
|
||||||
|
|
||||||
|
// دریافت آمار دریافتها
|
||||||
|
final receiptsResponse = await _apiClient.post(
|
||||||
|
'/businesses/$businessId/receipts-payments',
|
||||||
|
data: {
|
||||||
|
...queryParams,
|
||||||
|
'document_type': 'receipt',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// دریافت آمار پرداختها
|
||||||
|
final paymentsResponse = await _apiClient.post(
|
||||||
|
'/businesses/$businessId/receipts-payments',
|
||||||
|
data: {
|
||||||
|
...queryParams,
|
||||||
|
'document_type': 'payment',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
int receiptsCount = 0;
|
||||||
|
int paymentsCount = 0;
|
||||||
|
double receiptsTotal = 0.0;
|
||||||
|
double paymentsTotal = 0.0;
|
||||||
|
|
||||||
|
if (receiptsResponse.statusCode == 200 && receiptsResponse.data != null) {
|
||||||
|
final receiptsData = receiptsResponse.data['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
receiptsCount = receiptsData['pagination']?['total'] ?? 0;
|
||||||
|
|
||||||
|
final receipts = (receiptsData['items'] as List<dynamic>?)
|
||||||
|
?.map((item) => ReceiptPaymentDocument.fromJson(item))
|
||||||
|
.toList() ?? [];
|
||||||
|
receiptsTotal = receipts.fold(0.0, (sum, doc) => sum + doc.totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentsResponse.statusCode == 200 && paymentsResponse.data != null) {
|
||||||
|
final paymentsData = paymentsResponse.data['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
paymentsCount = paymentsData['pagination']?['total'] ?? 0;
|
||||||
|
|
||||||
|
final payments = (paymentsData['items'] as List<dynamic>?)
|
||||||
|
?.map((item) => ReceiptPaymentDocument.fromJson(item))
|
||||||
|
.toList() ?? [];
|
||||||
|
paymentsTotal = payments.fold(0.0, (sum, doc) => sum + doc.totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'receipts_count': receiptsCount,
|
||||||
|
'payments_count': paymentsCount,
|
||||||
|
'receipts_total': receiptsTotal,
|
||||||
|
'payments_total': paymentsTotal,
|
||||||
|
'total_count': receiptsCount + paymentsCount,
|
||||||
|
'net_amount': receiptsTotal - paymentsTotal,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('خطا در دریافت آمار: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,16 @@ class _CurrencyPickerWidgetState extends State<CurrencyPickerWidget> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currencies = currencies;
|
_currencies = currencies;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|
||||||
|
// اگر ارزی انتخاب نشده و ارز پیشفرض موجود است، آن را انتخاب کن
|
||||||
|
if (_selectedValue == null && currencies.isNotEmpty) {
|
||||||
|
final defaultCurrency = currencies.firstWhere(
|
||||||
|
(currency) => currency['is_default'] == true,
|
||||||
|
orElse: () => currencies.first,
|
||||||
|
);
|
||||||
|
_selectedValue = defaultCurrency['id'] as int;
|
||||||
|
widget.onChanged(_selectedValue);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,9 @@ class DataTableConfig<T> {
|
||||||
final String? excelEndpoint;
|
final String? excelEndpoint;
|
||||||
final String? pdfEndpoint;
|
final String? pdfEndpoint;
|
||||||
final Map<String, dynamic> Function()? getExportParams;
|
final Map<String, dynamic> Function()? getExportParams;
|
||||||
|
final bool showExportButtons;
|
||||||
|
final bool showExcelExport;
|
||||||
|
final bool showPdfExport;
|
||||||
|
|
||||||
// Column settings configuration
|
// Column settings configuration
|
||||||
final String? tableId;
|
final String? tableId;
|
||||||
|
|
@ -321,6 +324,9 @@ class DataTableConfig<T> {
|
||||||
this.excelEndpoint,
|
this.excelEndpoint,
|
||||||
this.pdfEndpoint,
|
this.pdfEndpoint,
|
||||||
this.getExportParams,
|
this.getExportParams,
|
||||||
|
this.showExportButtons = false,
|
||||||
|
this.showExcelExport = true,
|
||||||
|
this.showPdfExport = true,
|
||||||
this.tableId,
|
this.tableId,
|
||||||
this.enableColumnSettings = true,
|
this.enableColumnSettings = true,
|
||||||
this.showColumnSettingsButton = true,
|
this.showColumnSettingsButton = true,
|
||||||
|
|
@ -452,6 +458,7 @@ class QueryInfo {
|
||||||
'take': take,
|
'take': take,
|
||||||
'skip': skip,
|
'skip': skip,
|
||||||
'sort_desc': sortDesc,
|
'sort_desc': sortDesc,
|
||||||
|
'sort_by': sortBy ?? 'document_date', // مقدار پیشفرض برای sort_by
|
||||||
};
|
};
|
||||||
|
|
||||||
if (search != null && search!.isNotEmpty) {
|
if (search != null && search!.isNotEmpty) {
|
||||||
|
|
@ -461,10 +468,6 @@ class QueryInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortBy != null && sortBy!.isNotEmpty) {
|
|
||||||
json['sort_by'] = sortBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters != null && filters!.isNotEmpty) {
|
if (filters != null && filters!.isNotEmpty) {
|
||||||
json['filters'] = filters!.map((f) => f.toJson()).toList();
|
json['filters'] = filters!.map((f) => f.toJson()).toList();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'helpers/file_saver.dart';
|
import 'package:file_saver/file_saver.dart';
|
||||||
// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:data_table_2/data_table_2.dart';
|
import 'package:data_table_2/data_table_2.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
@ -687,29 +687,49 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
||||||
|
|
||||||
// Cross-platform save using conditional FileSaver
|
// Cross-platform save using conditional FileSaver
|
||||||
Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
|
Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
|
||||||
List<int> bytes;
|
Uint8List bytes;
|
||||||
if (data is List<int>) {
|
if (data is List<int>) {
|
||||||
bytes = data;
|
bytes = Uint8List.fromList(data);
|
||||||
} else if (data is Uint8List) {
|
} else if (data is Uint8List) {
|
||||||
bytes = data.toList();
|
bytes = data;
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Unsupported binary data type: ${data.runtimeType}');
|
throw Exception('Unsupported binary data type: ${data.runtimeType}');
|
||||||
}
|
}
|
||||||
await FileSaver.saveBytes(bytes, filename);
|
|
||||||
|
// Use file_saver package for cross-platform file saving
|
||||||
|
try {
|
||||||
|
final fileSaver = FileSaver.instance;
|
||||||
|
final extension = filename.split('.').last;
|
||||||
|
await fileSaver.saveFile(
|
||||||
|
name: filename,
|
||||||
|
bytes: bytes,
|
||||||
|
ext: extension,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
print('Error saving file: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform-specific download functions for Linux
|
|
||||||
// Platform-specific download functions for Linux
|
// Platform-specific download functions for Linux
|
||||||
Future<void> _downloadPdf(dynamic data, String filename) async {
|
Future<void> _downloadPdf(dynamic data, String filename) async {
|
||||||
// For Linux desktop, we'll save to Downloads folder
|
try {
|
||||||
print('Download PDF: $filename (Linux desktop - save to Downloads folder)');
|
await _saveBytesToDownloads(data, filename);
|
||||||
// TODO: Implement proper file saving for Linux
|
print('✅ PDF downloaded successfully: $filename');
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error downloading PDF: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _downloadExcel(dynamic data, String filename) async {
|
Future<void> _downloadExcel(dynamic data, String filename) async {
|
||||||
// For Linux desktop, we'll save to Downloads folder
|
try {
|
||||||
print('Download Excel: $filename (Linux desktop - save to Downloads folder)');
|
await _saveBytesToDownloads(data, filename);
|
||||||
// TODO: Implement proper file saving for Linux
|
print('✅ Excel downloaded successfully: $filename');
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ Error downloading Excel: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,488 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/account_tree_node.dart';
|
||||||
|
import '../../services/account_service.dart';
|
||||||
|
|
||||||
|
class AccountTreeComboboxWidget extends StatefulWidget {
|
||||||
|
final int businessId;
|
||||||
|
final AccountTreeNode? selectedAccount;
|
||||||
|
final ValueChanged<AccountTreeNode?> onChanged;
|
||||||
|
final String label;
|
||||||
|
final String hintText;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
const AccountTreeComboboxWidget({
|
||||||
|
super.key,
|
||||||
|
required this.businessId,
|
||||||
|
this.selectedAccount,
|
||||||
|
required this.onChanged,
|
||||||
|
this.label = 'حساب',
|
||||||
|
this.hintText = 'انتخاب حساب',
|
||||||
|
this.isRequired = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountTreeComboboxWidget> createState() => _AccountTreeComboboxWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountTreeComboboxWidgetState extends State<AccountTreeComboboxWidget> {
|
||||||
|
final AccountService _accountService = AccountService();
|
||||||
|
List<AccountTreeNode> _accounts = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAccounts() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _accountService.getAccountsTree(businessId: widget.businessId);
|
||||||
|
final items = (response['items'] as List<dynamic>?)
|
||||||
|
?.map((item) => AccountTreeNode.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_accounts = items;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
print('خطا در لود کردن حسابها: $e');
|
||||||
|
setState(() {
|
||||||
|
_accounts = [];
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// لیبل
|
||||||
|
if (widget.label.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isRequired)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// فیلد انتخاب
|
||||||
|
InkWell(
|
||||||
|
onTap: _isLoading ? null : _showAccountDialog,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: theme.colorScheme.outline),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.selectedAccount?.toString() ?? widget.hintText,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: widget.selectedAccount != null
|
||||||
|
? theme.colorScheme.onSurface
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAccountDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AccountSelectionDialog(
|
||||||
|
accounts: _accounts,
|
||||||
|
selectedAccount: widget.selectedAccount,
|
||||||
|
onAccountSelected: (account) {
|
||||||
|
widget.onChanged(account);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountSelectionDialog extends StatefulWidget {
|
||||||
|
final List<AccountTreeNode> accounts;
|
||||||
|
final AccountTreeNode? selectedAccount;
|
||||||
|
final ValueChanged<AccountTreeNode?> onAccountSelected;
|
||||||
|
|
||||||
|
const AccountSelectionDialog({
|
||||||
|
super.key,
|
||||||
|
required this.accounts,
|
||||||
|
this.selectedAccount,
|
||||||
|
required this.onAccountSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountSelectionDialog> createState() => _AccountSelectionDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountSelectionDialogState extends State<AccountSelectionDialog> {
|
||||||
|
String _searchQuery = '';
|
||||||
|
List<AccountTreeNode> _filteredAccounts = [];
|
||||||
|
final Set<int> _expandedNodes = <int>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filteredAccounts = widget.accounts;
|
||||||
|
// همه گرههای سطح اول را به صورت پیشفرض باز کن
|
||||||
|
_expandedNodes.addAll(widget.accounts.map((account) => account.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterAccounts(String query) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = query;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_filteredAccounts = widget.accounts;
|
||||||
|
} else {
|
||||||
|
_filteredAccounts = widget.accounts
|
||||||
|
.expand((account) => account.searchAccounts(query))
|
||||||
|
.where((account) => !account.hasChildren) // فقط حسابهای بدون فرزند
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expandAll() {
|
||||||
|
setState(() {
|
||||||
|
_expandedNodes.clear();
|
||||||
|
// همه گرههایی که فرزند دارند را باز کن
|
||||||
|
for (final account in widget.accounts) {
|
||||||
|
_addAllExpandableNodes(account);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _collapseAll() {
|
||||||
|
setState(() {
|
||||||
|
_expandedNodes.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addAllExpandableNodes(AccountTreeNode account) {
|
||||||
|
if (account.hasChildren) {
|
||||||
|
_expandedNodes.add(account.id);
|
||||||
|
for (final child in account.children) {
|
||||||
|
_addAllExpandableNodes(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: Container(
|
||||||
|
width: 600,
|
||||||
|
height: 500,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// هدر
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(12),
|
||||||
|
topRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'انتخاب حساب',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// جستجو و دکمههای کنترل
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'جستجو در حسابها...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: _filterAccounts,
|
||||||
|
),
|
||||||
|
if (_searchQuery.isEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _expandAll,
|
||||||
|
icon: const Icon(Icons.expand_more),
|
||||||
|
label: const Text('همه را باز کن'),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _collapseAll,
|
||||||
|
icon: const Icon(Icons.expand_less),
|
||||||
|
label: const Text('همه را ببند'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// لیست حسابها
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: _searchQuery.isEmpty
|
||||||
|
? _buildTreeView()
|
||||||
|
: _buildSearchResults(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// دکمهها
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(12),
|
||||||
|
bottomRight: Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('انصراف'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (widget.selectedAccount != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
widget.onAccountSelected(null);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('حذف انتخاب'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTreeView() {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: widget.accounts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildAccountNode(widget.accounts[index], 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchResults() {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: _filteredAccounts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final account = _filteredAccounts[index];
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
tileColor: account.id == widget.selectedAccount?.id
|
||||||
|
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||||
|
: null,
|
||||||
|
leading: Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
color: account.id == widget.selectedAccount?.id
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: Text(account.name),
|
||||||
|
subtitle: Text('کد: ${account.code}'),
|
||||||
|
trailing: account.id == widget.selectedAccount?.id
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => widget.onAccountSelected(account),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAccountNode(AccountTreeNode account, int level) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isSelected = account.id == widget.selectedAccount?.id;
|
||||||
|
final canSelect = !account.hasChildren;
|
||||||
|
final isExpanded = _expandedNodes.contains(account.id);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16 + (level * 24),
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
tileColor: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||||
|
: null,
|
||||||
|
leading: account.hasChildren
|
||||||
|
? IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (isExpanded) {
|
||||||
|
_expandedNodes.remove(account.id);
|
||||||
|
} else {
|
||||||
|
_expandedNodes.add(account.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 24,
|
||||||
|
minHeight: 24,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
color: canSelect
|
||||||
|
? (isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant)
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
account.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: canSelect
|
||||||
|
? theme.colorScheme.onSurface
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
fontWeight: account.hasChildren ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'کد: ${account.code}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: canSelect
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: canSelect ? () => widget.onAccountSelected(account) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// نمایش فرزندان فقط اگر گره باز باشد
|
||||||
|
if (account.hasChildren && isExpanded)
|
||||||
|
...account.children.map((child) => _buildAccountNode(child, level + 1)),
|
||||||
|
// خط جداکننده بین حسابهای مختلف (فقط برای سطح اول)
|
||||||
|
if (level == 0 && account != widget.accounts.last)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Divider(
|
||||||
|
height: 1,
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../services/bank_account_service.dart';
|
import '../../services/bank_account_service.dart';
|
||||||
|
|
||||||
|
|
@ -99,7 +100,10 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
||||||
: res['items'];
|
: res['items'];
|
||||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||||
final m = Map<String, dynamic>.from(e as Map);
|
final m = Map<String, dynamic>.from(e as Map);
|
||||||
return BankAccountOption('${m['id']}', (m['name']?.toString() ?? 'نامشخص'));
|
final id = m['id']?.toString();
|
||||||
|
final name = m['name']?.toString() ?? 'نامشخص';
|
||||||
|
log('Bank account item: id=$id, name=$name');
|
||||||
|
return BankAccountOption(id ?? '', name);
|
||||||
}).toList();
|
}).toList();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../../models/invoice_transaction.dart';
|
import '../../models/invoice_transaction.dart';
|
||||||
import '../../models/person_model.dart';
|
import '../../models/person_model.dart';
|
||||||
|
import '../../models/account_tree_node.dart';
|
||||||
import '../../core/date_utils.dart';
|
import '../../core/date_utils.dart';
|
||||||
import '../../core/calendar_controller.dart';
|
import '../../core/calendar_controller.dart';
|
||||||
import '../../utils/number_formatters.dart';
|
import '../../utils/number_formatters.dart';
|
||||||
|
|
@ -9,10 +10,12 @@ import '../../services/bank_account_service.dart';
|
||||||
import '../../services/cash_register_service.dart';
|
import '../../services/cash_register_service.dart';
|
||||||
import '../../services/petty_cash_service.dart';
|
import '../../services/petty_cash_service.dart';
|
||||||
import '../../services/person_service.dart';
|
import '../../services/person_service.dart';
|
||||||
|
import '../../services/account_service.dart';
|
||||||
import 'person_combobox_widget.dart';
|
import 'person_combobox_widget.dart';
|
||||||
import 'bank_account_combobox_widget.dart';
|
import 'bank_account_combobox_widget.dart';
|
||||||
import 'cash_register_combobox_widget.dart';
|
import 'cash_register_combobox_widget.dart';
|
||||||
import 'petty_cash_combobox_widget.dart';
|
import 'petty_cash_combobox_widget.dart';
|
||||||
|
import 'account_tree_combobox_widget.dart';
|
||||||
import '../../models/invoice_type_model.dart';
|
import '../../models/invoice_type_model.dart';
|
||||||
|
|
||||||
class InvoiceTransactionsWidget extends StatefulWidget {
|
class InvoiceTransactionsWidget extends StatefulWidget {
|
||||||
|
|
@ -194,7 +197,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// تاریخ و مبلغ
|
// تاریخ، مبلغ و کارمزد
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -206,6 +209,12 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildDetailRow(
|
||||||
|
'مبلغ:',
|
||||||
|
formatWithThousands(transaction.amount, decimalPlaces: 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (transaction.commission != null)
|
if (transaction.commission != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDetailRow(
|
child: _buildDetailRow(
|
||||||
|
|
@ -371,6 +380,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
final CashRegisterService _cashRegisterService = CashRegisterService();
|
final CashRegisterService _cashRegisterService = CashRegisterService();
|
||||||
final PettyCashService _pettyCashService = PettyCashService();
|
final PettyCashService _pettyCashService = PettyCashService();
|
||||||
final PersonService _personService = PersonService();
|
final PersonService _personService = PersonService();
|
||||||
|
final AccountService _accountService = AccountService();
|
||||||
|
|
||||||
// فیلدهای خاص هر نوع تراکنش
|
// فیلدهای خاص هر نوع تراکنش
|
||||||
String? _selectedBankId;
|
String? _selectedBankId;
|
||||||
|
|
@ -378,7 +388,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
String? _selectedPettyCashId;
|
String? _selectedPettyCashId;
|
||||||
String? _selectedCheckId;
|
String? _selectedCheckId;
|
||||||
String? _selectedPersonId;
|
String? _selectedPersonId;
|
||||||
String? _selectedAccountId;
|
AccountTreeNode? _selectedAccount;
|
||||||
|
|
||||||
// لیستهای داده
|
// لیستهای داده
|
||||||
List<Map<String, dynamic>> _banks = [];
|
List<Map<String, dynamic>> _banks = [];
|
||||||
|
|
@ -403,12 +413,44 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
_selectedPettyCashId = widget.transaction?.pettyCashId;
|
_selectedPettyCashId = widget.transaction?.pettyCashId;
|
||||||
_selectedCheckId = widget.transaction?.checkId;
|
_selectedCheckId = widget.transaction?.checkId;
|
||||||
_selectedPersonId = widget.transaction?.personId;
|
_selectedPersonId = widget.transaction?.personId;
|
||||||
_selectedAccountId = widget.transaction?.accountId;
|
|
||||||
|
// اگر حساب انتخاب شده است، باید آن را از API دریافت کنیم
|
||||||
|
if (widget.transaction?.accountId != null) {
|
||||||
|
_loadSelectedAccount();
|
||||||
|
}
|
||||||
|
|
||||||
// لود کردن دادهها از دیتابیس
|
// لود کردن دادهها از دیتابیس
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSelectedAccount() async {
|
||||||
|
try {
|
||||||
|
final response = await _accountService.getAccountsTree(businessId: widget.businessId);
|
||||||
|
final items = (response['items'] as List<dynamic>?)
|
||||||
|
?.map((item) => AccountTreeNode.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
// جستجو برای پیدا کردن حساب انتخاب شده
|
||||||
|
final accountId = int.tryParse(widget.transaction?.accountId ?? '');
|
||||||
|
if (accountId != null) {
|
||||||
|
for (final account in items) {
|
||||||
|
final foundAccount = account.getAllAccounts().firstWhere(
|
||||||
|
(acc) => acc.id == accountId,
|
||||||
|
orElse: () => throw StateError('Account not found'),
|
||||||
|
);
|
||||||
|
if (foundAccount.id == accountId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedAccount = foundAccount;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('خطا در لود کردن حساب انتخاب شده: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
|
@ -794,21 +836,17 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAccountFields() {
|
Widget _buildAccountFields() {
|
||||||
return DropdownButtonFormField<String>(
|
return AccountTreeComboboxWidget(
|
||||||
initialValue: _selectedAccountId,
|
businessId: widget.businessId,
|
||||||
decoration: const InputDecoration(
|
selectedAccount: _selectedAccount,
|
||||||
labelText: 'حساب *',
|
onChanged: (account) {
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: 'account1', child: Text('حساب جاری')),
|
|
||||||
DropdownMenuItem(value: 'account2', child: Text('حساب پسانداز')),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAccountId = value;
|
_selectedAccount = account;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
label: 'حساب *',
|
||||||
|
hintText: 'انتخاب حساب',
|
||||||
|
isRequired: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -848,8 +886,8 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
checkNumber: _getCheckNumber(_selectedCheckId),
|
checkNumber: _getCheckNumber(_selectedCheckId),
|
||||||
personId: _selectedPersonId,
|
personId: _selectedPersonId,
|
||||||
personName: _getPersonName(_selectedPersonId),
|
personName: _getPersonName(_selectedPersonId),
|
||||||
accountId: _selectedAccountId,
|
accountId: _selectedAccount?.id.toString(),
|
||||||
accountName: _getAccountName(_selectedAccountId),
|
accountName: _selectedAccount?.name,
|
||||||
transactionDate: _transactionDate,
|
transactionDate: _transactionDate,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
commission: commission,
|
commission: commission,
|
||||||
|
|
@ -903,14 +941,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
||||||
(p) => p['id']?.toString() == id,
|
(p) => p['id']?.toString() == id,
|
||||||
orElse: () => <String, dynamic>{},
|
orElse: () => <String, dynamic>{},
|
||||||
);
|
);
|
||||||
return person['name']?.toString();
|
return person['alias_name']?.toString() ?? person['name']?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getAccountName(String? id) {
|
|
||||||
switch (id) {
|
|
||||||
case 'account1': return 'حساب جاری';
|
|
||||||
case 'account2': return 'حساب پسانداز';
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,14 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) file_saver_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
|
||||||
|
file_saver_plugin_register_with_registrar(file_saver_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_saver
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import file_picker
|
import file_picker
|
||||||
|
import file_saver
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
|
|
@ -13,6 +14,7 @@ import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
|
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,14 @@ packages:
|
||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.3"
|
version: "10.3.3"
|
||||||
|
file_saver:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_saver
|
||||||
|
sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e"
|
||||||
|
url: "https://pub.flutter-io.cn"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.14"
|
||||||
file_selector:
|
file_selector:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ dependencies:
|
||||||
persian_datetime_picker: ^3.2.0
|
persian_datetime_picker: ^3.2.0
|
||||||
shamsi_date: ^1.1.1
|
shamsi_date: ^1.1.1
|
||||||
intl: ^0.20.0
|
intl: ^0.20.0
|
||||||
|
file_saver: ^0.2.7
|
||||||
data_table_2: ^2.5.12
|
data_table_2: ^2.5.12
|
||||||
file_picker: ^10.3.3
|
file_picker: ^10.3.3
|
||||||
file_selector: ^1.0.4
|
file_selector: ^1.0.4
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_saver/file_saver_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSaverPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_saver
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue