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)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, Depends, Request, Body
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
import io
|
||||
import json
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from adapters.db.session import get_db
|
||||
from app.core.auth_dependency import get_current_user, AuthContext
|
||||
|
|
@ -17,6 +22,7 @@ from app.services.receipt_payment_service import (
|
|||
list_receipts_payments,
|
||||
delete_receipt_payment,
|
||||
)
|
||||
from adapters.db.models.business import Business
|
||||
|
||||
|
||||
router = APIRouter(tags=["receipts-payments"])
|
||||
|
|
@ -201,3 +207,439 @@ async def delete_receipt_payment_endpoint(
|
|||
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
|
||||
from .file_storage import *
|
||||
|
||||
# Import document line schemas
|
||||
from .document_line import *
|
||||
|
||||
# Re-export from parent schemas module
|
||||
import sys
|
||||
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_type import TaxType # noqa: F401
|
||||
from .bank_account import BankAccount # noqa: F401
|
||||
from .cash_register import CashRegister # noqa: F401
|
||||
from .petty_cash import PettyCash # noqa: F401
|
||||
from .check import Check # noqa: F401
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Document(Base):
|
|||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
fiscal_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("fiscal_years.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
registered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
|
@ -30,6 +31,7 @@ class Document(Base):
|
|||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="documents")
|
||||
fiscal_year = relationship("FiscalYear", back_populates="documents")
|
||||
currency = relationship("Currency", back_populates="documents")
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
lines = relationship("DocumentLine", back_populates="document", cascade="all, delete-orphan")
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@ class FiscalYear(Base):
|
|||
|
||||
# Relationships
|
||||
business = relationship("Business", back_populates="fiscal_years")
|
||||
documents = relationship("Document", back_populates="fiscal_year", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
|
||||
from fastapi import HTTPException, status, Request
|
||||
from .calendar import CalendarConverter, CalendarType
|
||||
|
|
@ -57,6 +57,22 @@ def format_datetime_fields(data: Any, request: Request) -> Any:
|
|||
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(value)["formatted"]
|
||||
else:
|
||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
||||
elif isinstance(value, date):
|
||||
# Convert date to datetime for processing
|
||||
dt_value = datetime.combine(value, datetime.min.time())
|
||||
# Format the main date field based on calendar type
|
||||
if calendar_type == "jalali":
|
||||
formatted_data[key] = CalendarConverter.to_jalali(dt_value)["date_only"]
|
||||
else:
|
||||
formatted_data[key] = value.isoformat()
|
||||
|
||||
# Add formatted date as additional field
|
||||
formatted_data[f"{key}_formatted"] = CalendarConverter.format_datetime(dt_value, calendar_type)
|
||||
# Convert raw date to the same calendar type as the formatted date
|
||||
if calendar_type == "jalali":
|
||||
formatted_data[f"{key}_raw"] = CalendarConverter.to_jalali(dt_value)["date_only"]
|
||||
else:
|
||||
formatted_data[f"{key}_raw"] = value.isoformat()
|
||||
elif isinstance(value, (dict, list)):
|
||||
formatted_data[key] = format_datetime_fields(value, request)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
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.currency import Currency
|
||||
from adapters.db.models.user import User
|
||||
from adapters.db.models.fiscal_year import FiscalYear
|
||||
from app.core.responses import ApiError
|
||||
|
||||
# تنظیم لاگر
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# نوعهای سند
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
business_id: int,
|
||||
person_id: int,
|
||||
is_receivable: bool
|
||||
) -> Account:
|
||||
"""
|
||||
ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی)
|
||||
دریافت حساب شخص (حساب دریافتنی یا پرداختنی عمومی)
|
||||
|
||||
Args:
|
||||
business_id: شناسه کسبوکار
|
||||
|
|
@ -65,7 +113,7 @@ def _get_or_create_person_account(
|
|||
is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی
|
||||
|
||||
Returns:
|
||||
Account: حساب شخص
|
||||
Account: حساب شخص عمومی
|
||||
"""
|
||||
person = db.query(Person).filter(
|
||||
and_(Person.id == person_id, Person.business_id == business_id)
|
||||
|
|
@ -74,53 +122,11 @@ def _get_or_create_person_account(
|
|||
if not person:
|
||||
raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404)
|
||||
|
||||
# کد حساب والد
|
||||
parent_code = "10401" if is_receivable else "20201"
|
||||
account_type = ACCOUNT_TYPE_RECEIVABLE if is_receivable else ACCOUNT_TYPE_PAYABLE
|
||||
# کد حساب عمومی (بدون ایجاد حساب جداگانه)
|
||||
account_code = "10401" if is_receivable else "20201"
|
||||
|
||||
# پیدا کردن حساب والد
|
||||
parent_account = db.query(Account).filter(
|
||||
and_(
|
||||
Account.business_id == None, # حسابهای عمومی
|
||||
Account.code == parent_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if not parent_account:
|
||||
raise ApiError(
|
||||
"PARENT_ACCOUNT_NOT_FOUND",
|
||||
f"Parent account with code {parent_code} not found",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
# بررسی وجود حساب شخص
|
||||
person_account_code = f"{parent_code}-{person_id}"
|
||||
person_account = db.query(Account).filter(
|
||||
and_(
|
||||
Account.business_id == business_id,
|
||||
Account.code == person_account_code
|
||||
)
|
||||
).first()
|
||||
|
||||
if not person_account:
|
||||
# ایجاد حساب جدید برای شخص
|
||||
account_name = f"{person.alias_name}"
|
||||
if is_receivable:
|
||||
account_name = f"طلب از {account_name}"
|
||||
else:
|
||||
account_name = f"بدهی به {account_name}"
|
||||
|
||||
person_account = Account(
|
||||
business_id=business_id,
|
||||
code=person_account_code,
|
||||
name=account_name,
|
||||
account_type=account_type,
|
||||
parent_id=parent_account.id,
|
||||
)
|
||||
db.add(person_account)
|
||||
db.flush() # برای دریافت ID
|
||||
|
||||
return person_account
|
||||
# استفاده از تابع کمکی
|
||||
return _get_fixed_account_by_code(db, account_code)
|
||||
|
||||
|
||||
def create_receipt_payment(
|
||||
|
|
@ -146,12 +152,17 @@ def create_receipt_payment(
|
|||
Returns:
|
||||
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()
|
||||
logger.info(f"نوع سند: {document_type}")
|
||||
if document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||
raise ApiError("INVALID_DOCUMENT_TYPE", "document_type must be 'receipt' or 'payment'", http_status=400)
|
||||
|
||||
is_receipt = (document_type == DOCUMENT_TYPE_RECEIPT)
|
||||
logger.info(f"آیا دریافت است: {is_receipt}")
|
||||
|
||||
# اعتبارسنجی تاریخ
|
||||
document_date = _parse_iso_date(data.get("document_date", datetime.now()))
|
||||
|
|
@ -165,13 +176,22 @@ def create_receipt_payment(
|
|||
if not currency:
|
||||
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", [])
|
||||
logger.info(f"تعداد خطوط اشخاص: {len(person_lines)}")
|
||||
logger.info(f"خطوط اشخاص: {person_lines}")
|
||||
if not person_lines or not isinstance(person_lines, list):
|
||||
raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400)
|
||||
|
||||
# اعتبارسنجی خطوط حسابها
|
||||
account_lines = data.get("account_lines", [])
|
||||
logger.info(f"تعداد خطوط حسابها: {len(account_lines)}")
|
||||
logger.info(f"خطوط حسابها: {account_lines}")
|
||||
if not account_lines or not isinstance(account_lines, list):
|
||||
raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400)
|
||||
|
||||
|
|
@ -213,6 +233,7 @@ def create_receipt_payment(
|
|||
# ایجاد سند
|
||||
document = Document(
|
||||
business_id=business_id,
|
||||
fiscal_year_id=fiscal_year.id,
|
||||
code=doc_code,
|
||||
document_type=document_type,
|
||||
document_date=document_date,
|
||||
|
|
@ -226,51 +247,74 @@ def create_receipt_payment(
|
|||
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")
|
||||
logger.info(f"person_id: {person_id}")
|
||||
if not person_id:
|
||||
logger.warning(f"خط شخص {i+1}: person_id موجود نیست، رد میشود")
|
||||
continue
|
||||
|
||||
amount = Decimal(str(person_line.get("amount", 0)))
|
||||
logger.info(f"مبلغ: {amount}")
|
||||
if amount <= 0:
|
||||
logger.warning(f"خط شخص {i+1}: مبلغ صفر یا منفی، رد میشود")
|
||||
continue
|
||||
|
||||
description = person_line.get("description", "").strip() or None
|
||||
logger.info(f"توضیحات: {description}")
|
||||
|
||||
# دریافت یا ایجاد حساب شخص
|
||||
# در دریافت: حساب دریافتنی (receivable)
|
||||
# در پرداخت: حساب پرداختنی (payable)
|
||||
person_account = _get_or_create_person_account(
|
||||
# دریافت حساب شخص عمومی
|
||||
# در دریافت: حساب دریافتنی (receivable) - کد 10401
|
||||
# در پرداخت: حساب پرداختنی (payable) - کد 20201
|
||||
logger.info(f"دریافت حساب شخص برای person_id={person_id}, is_receivable={is_receipt}")
|
||||
person_account = _get_person_account(
|
||||
db,
|
||||
business_id,
|
||||
int(person_id),
|
||||
is_receivable=is_receipt
|
||||
)
|
||||
logger.info(f"حساب شخص پیدا شد: id={person_account.id}, code={person_account.code}, name={person_account.name}")
|
||||
|
||||
# ایجاد خط سند برای شخص
|
||||
# در دریافت: شخص بستانکار (credit)
|
||||
# در پرداخت: شخص بدهکار (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(
|
||||
document_id=document.id,
|
||||
account_id=person_account.id,
|
||||
debit=amount if not is_receipt else Decimal(0),
|
||||
credit=amount if is_receipt else Decimal(0),
|
||||
person_id=int(person_id),
|
||||
quantity=person_line.get("quantity"),
|
||||
debit=debit_amount,
|
||||
credit=credit_amount,
|
||||
description=description,
|
||||
extra_info={
|
||||
"person_id": int(person_id),
|
||||
"person_name": person_line.get("person_name"),
|
||||
}
|
||||
)
|
||||
logger.info(f"خط سند شخص ایجاد شد: {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")
|
||||
logger.info(f"account_id: {account_id}")
|
||||
if not account_id:
|
||||
continue
|
||||
logger.info(f"خط حساب {i+1}: account_id موجود نیست، ادامه میدهد")
|
||||
|
||||
amount = Decimal(str(account_line.get("amount", 0)))
|
||||
logger.info(f"مبلغ: {amount}")
|
||||
if amount <= 0:
|
||||
logger.warning(f"خط حساب {i+1}: مبلغ صفر یا منفی، رد میشود")
|
||||
continue
|
||||
|
||||
description = account_line.get("description", "").strip() or None
|
||||
|
|
@ -278,7 +322,50 @@ def create_receipt_payment(
|
|||
transaction_date = account_line.get("transaction_date")
|
||||
commission = account_line.get("commission")
|
||||
|
||||
# بررسی وجود حساب
|
||||
logger.info(f"نوع تراکنش: {transaction_type}")
|
||||
logger.info(f"تاریخ تراکنش: {transaction_date}")
|
||||
logger.info(f"کمیسیون: {commission}")
|
||||
|
||||
# اضافه کردن کارمزد به مجموع
|
||||
if commission:
|
||||
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),
|
||||
|
|
@ -290,12 +377,15 @@ def create_receipt_payment(
|
|||
).first()
|
||||
|
||||
if not account:
|
||||
logger.error(f"خط حساب {i+1}: حساب پیدا نشد برای transaction_type: {transaction_type}")
|
||||
raise ApiError(
|
||||
"ACCOUNT_NOT_FOUND",
|
||||
f"Account with id {account_id} not found",
|
||||
f"Account not found for transaction_type: {transaction_type}",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
logger.info(f"حساب پیدا شد: id={account.id}, code={account.code}, name={account.name}")
|
||||
|
||||
# ایجاد اطلاعات اضافی برای خط سند
|
||||
extra_info = {}
|
||||
if transaction_type:
|
||||
|
|
@ -330,19 +420,141 @@ def create_receipt_payment(
|
|||
# ایجاد خط سند برای حساب
|
||||
# در دریافت: حساب بدهکار (debit) - دارایی افزایش مییابد
|
||||
# در پرداخت: حساب بستانکار (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(
|
||||
document_id=document.id,
|
||||
account_id=account.id,
|
||||
debit=amount if is_receipt else Decimal(0),
|
||||
credit=amount if not is_receipt else Decimal(0),
|
||||
person_id=person_id_for_line,
|
||||
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,
|
||||
extra_info=extra_info if extra_info else None,
|
||||
)
|
||||
logger.info(f"خط سند حساب ایجاد شد: {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.refresh(document)
|
||||
logger.info(f"سند با موفقیت ایجاد شد: id={document.id}, code={document.code}")
|
||||
|
||||
return document_to_dict(db, document)
|
||||
|
||||
|
|
@ -404,7 +616,8 @@ def list_receipts_payments(
|
|||
sort_by = query.get("sort_by", "document_date")
|
||||
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)
|
||||
q = q.order_by(col.desc() if sort_desc else col.asc())
|
||||
else:
|
||||
|
|
@ -464,6 +677,13 @@ def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
|||
line_dict = {
|
||||
"id": line.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_code": account.code,
|
||||
"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"]
|
||||
if "check_number" in line.extra_info:
|
||||
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)
|
||||
else:
|
||||
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_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 {
|
||||
"id": document.id,
|
||||
"code": document.code,
|
||||
"business_id": document.business_id,
|
||||
"document_type": document.document_type,
|
||||
"document_type_name": document_type_name,
|
||||
"document_date": document.document_date.isoformat(),
|
||||
"registered_at": document.registered_at.isoformat(),
|
||||
"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,
|
||||
"person_lines": person_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(),
|
||||
"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/bank_account.py
|
||||
adapters/api/v1/schema_models/check.py
|
||||
adapters/api/v1/schema_models/document_line.py
|
||||
adapters/api/v1/schema_models/email.py
|
||||
adapters/api/v1/schema_models/file_storage.py
|
||||
adapters/api/v1/schema_models/person.py
|
||||
|
|
@ -161,12 +162,12 @@ migrations/versions/20250120_000001_add_persons_tables.py
|
|||
migrations/versions/20250120_000002_add_join_permission.py
|
||||
migrations/versions/20250915_000001_init_auth_tables.py
|
||||
migrations/versions/20250916_000002_add_referral_fields.py
|
||||
migrations/versions/20250926_000010_add_person_code_and_types.py
|
||||
migrations/versions/20250926_000011_drop_person_is_active.py
|
||||
migrations/versions/20250927_000012_add_fiscal_years_table.py
|
||||
migrations/versions/20250927_000013_add_currencies_and_business_currencies.py
|
||||
migrations/versions/20250927_000014_add_documents_table.py
|
||||
migrations/versions/20250927_000015_add_document_lines_table.py
|
||||
migrations/versions/20250926_000010_add_person_code.py
|
||||
migrations/versions/20250926_000011_drop_active.py
|
||||
migrations/versions/20250927_000012_add_fiscal_years.py
|
||||
migrations/versions/20250927_000013_add_currencies.py
|
||||
migrations/versions/20250927_000014_add_documents.py
|
||||
migrations/versions/20250927_000015_add_lines.py
|
||||
migrations/versions/20250927_000016_add_accounts_table.py
|
||||
migrations/versions/20250927_000017_add_account_id_to_document_lines.py
|
||||
migrations/versions/20250927_000018_seed_currencies.py
|
||||
|
|
@ -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_010001_replace_accounts_chart_seed.py
|
||||
migrations/versions/20251012_000101_update_accounts_account_type_to_english.py
|
||||
migrations/versions/20251014_000201_add_person_id_to_document_lines.py
|
||||
migrations/versions/20251014_000301_add_product_id_to_document_lines.py
|
||||
migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py
|
||||
migrations/versions/20251014_000501_add_quantity_to_document_lines.py
|
||||
migrations/versions/4b2ea782bcb3_merge_heads.py
|
||||
migrations/versions/5553f8745c6e_add_support_tables.py
|
||||
migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py
|
||||
migrations/versions/7ecb63029764_merge_heads.py
|
||||
migrations/versions/9f9786ae7191_create_tax_units_table.py
|
||||
migrations/versions/a1443c153b47_merge_heads.py
|
||||
migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py
|
||||
migrations/versions/b2b68cf299a3_convert_unit_fields_to_string.py
|
||||
migrations/versions/c302bc2f2cb8_remove_person_type_column.py
|
||||
migrations/versions/caf3f4ef4b76_add_tax_units_table.py
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import sqlalchemy as sa
|
|||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250926_000010_add_person_code_and_types'
|
||||
revision = '20250926_000010_add_person_code'
|
||||
down_revision = '20250916_000002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
|
@ -3,8 +3,8 @@ import sqlalchemy as sa
|
|||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250926_000011_drop_person_is_active'
|
||||
down_revision = '20250926_000010_add_person_code_and_types'
|
||||
revision = '20250926_000011_drop_active'
|
||||
down_revision = '20250926_000010_add_person_code'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
@ -6,8 +6,8 @@ from sqlalchemy import inspect
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000012_add_fiscal_years_table'
|
||||
down_revision = '20250926_000011_drop_person_is_active'
|
||||
revision = '20250927_000012_add_fiscal_years'
|
||||
down_revision = '20250926_000011_drop_active'
|
||||
branch_labels = None
|
||||
depends_on = ('20250117_000003',)
|
||||
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000013_add_currencies'
|
||||
down_revision = '20250927_000012_add_fiscal_years'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Create currencies table if it doesn't exist
|
||||
if 'currencies' not in tables:
|
||||
op.create_table(
|
||||
'currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||
sa.Column('code', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique constraints and indexes
|
||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||
|
||||
# Create business_currencies association table if it doesn't exist
|
||||
if 'business_currencies' not in tables:
|
||||
op.create_table(
|
||||
'business_currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique and indexes for association
|
||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||
|
||||
# Add default_currency_id to businesses if not exists
|
||||
if 'businesses' in tables:
|
||||
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
||||
if 'default_currency_id' not in cols:
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
||||
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/foreign key/column default_currency_id if exists
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
try:
|
||||
batch_op.drop_index('ix_businesses_default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_column('default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||
op.drop_table('business_currencies')
|
||||
|
||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||
op.drop_table('currencies')
|
||||
|
||||
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
down_revision = '20250927_000012_add_fiscal_years_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create currencies table
|
||||
op.create_table(
|
||||
'currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('symbol', sa.String(length=16), nullable=False),
|
||||
sa.Column('code', sa.String(length=16), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
# Unique constraints and indexes
|
||||
op.create_unique_constraint('uq_currencies_name', 'currencies', ['name'])
|
||||
op.create_unique_constraint('uq_currencies_code', 'currencies', ['code'])
|
||||
op.create_index('ix_currencies_name', 'currencies', ['name'])
|
||||
|
||||
# Create business_currencies association table
|
||||
op.create_table(
|
||||
'business_currencies',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('business_id', sa.Integer(), nullable=False),
|
||||
sa.Column('currency_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['business_id'], ['businesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['currency_id'], ['currencies.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
# Add default_currency_id to businesses if not exists
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'businesses' in inspector.get_table_names():
|
||||
cols = {c['name'] for c in inspector.get_columns('businesses')}
|
||||
if 'default_currency_id' not in cols:
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
batch_op.add_column(sa.Column('default_currency_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_businesses_default_currency', 'currencies', ['default_currency_id'], ['id'], ondelete='RESTRICT')
|
||||
batch_op.create_index('ix_businesses_default_currency_id', ['default_currency_id'])
|
||||
# Unique and indexes for association
|
||||
op.create_unique_constraint('uq_business_currencies_business_currency', 'business_currencies', ['business_id', 'currency_id'])
|
||||
op.create_index('ix_business_currencies_business_id', 'business_currencies', ['business_id'])
|
||||
op.create_index('ix_business_currencies_currency_id', 'business_currencies', ['currency_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index/foreign key/column default_currency_id if exists
|
||||
with op.batch_alter_table('businesses') as batch_op:
|
||||
try:
|
||||
batch_op.drop_index('ix_businesses_default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_businesses_default_currency', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_column('default_currency_id')
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_index('ix_business_currencies_currency_id', table_name='business_currencies')
|
||||
op.drop_index('ix_business_currencies_business_id', table_name='business_currencies')
|
||||
op.drop_constraint('uq_business_currencies_business_currency', 'business_currencies', type_='unique')
|
||||
op.drop_table('business_currencies')
|
||||
|
||||
op.drop_index('ix_currencies_name', table_name='currencies')
|
||||
op.drop_constraint('uq_currencies_code', 'currencies', type_='unique')
|
||||
op.drop_constraint('uq_currencies_name', 'currencies', type_='unique')
|
||||
op.drop_table('currencies')
|
||||
|
||||
|
||||
|
|
@ -5,8 +5,8 @@ import sqlalchemy as sa
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000014_add_documents_table'
|
||||
down_revision = '20250927_000013_add_currencies_and_business_currencies'
|
||||
revision = '20250927_000014_add_documents'
|
||||
down_revision = '20250927_000013_add_currencies'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000015_add_document_lines_table'
|
||||
down_revision = '20250927_000014_add_documents_table'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'document_lines',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||
op.drop_table('document_lines')
|
||||
|
||||
|
||||
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
46
hesabixAPI/migrations/versions/20250927_000015_add_lines.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000015_add_lines'
|
||||
down_revision = '20250927_000014_add_documents'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Create document_lines table if it doesn't exist
|
||||
if 'document_lines' not in tables:
|
||||
op.create_table(
|
||||
'document_lines',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=False),
|
||||
sa.Column('debit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('credit', sa.Numeric(18, 2), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('extra_info', sa.JSON(), nullable=True),
|
||||
sa.Column('developer_data', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
mysql_charset='utf8mb4'
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index('ix_document_lines_document_id', 'document_lines', ['document_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_document_lines_document_id', table_name='document_lines')
|
||||
op.drop_table('document_lines')
|
||||
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import sqlalchemy as sa
|
|||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20250927_000016_add_accounts_table'
|
||||
down_revision = '20250927_000015_add_document_lines_table'
|
||||
down_revision = '20250927_000015_add_lines'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
|
@ -12,20 +13,46 @@ depends_on = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Check if document_lines table exists
|
||||
if 'document_lines' not in tables:
|
||||
return
|
||||
|
||||
# Get existing columns
|
||||
cols = {c['name'] for c in inspector.get_columns('document_lines')}
|
||||
|
||||
with op.batch_alter_table('document_lines') as batch_op:
|
||||
# Only add columns if they don't exist
|
||||
if 'bank_account_id' not in cols:
|
||||
batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True))
|
||||
if 'cash_register_id' not in cols:
|
||||
batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True))
|
||||
if 'petty_cash_id' not in cols:
|
||||
batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True))
|
||||
if 'check_id' not in cols:
|
||||
batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True))
|
||||
|
||||
# Only create foreign keys if the referenced tables exist
|
||||
if 'bank_accounts' in tables and 'bank_account_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL')
|
||||
if 'cash_registers' in tables and 'cash_register_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL')
|
||||
if 'petty_cash' in tables and 'petty_cash_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL')
|
||||
if 'checks' in tables and 'check_id' not in cols:
|
||||
batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
# Only create indexes if columns were added
|
||||
if 'bank_account_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id'])
|
||||
if 'cash_register_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id'])
|
||||
if 'petty_cash_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id'])
|
||||
if 'check_id' not in cols:
|
||||
batch_op.create_index('ix_document_lines_check_id', ['check_id'])
|
||||
|
||||
|
||||
|
|
@ -36,10 +63,23 @@ def downgrade() -> None:
|
|||
batch_op.drop_index('ix_document_lines_cash_register_id')
|
||||
batch_op.drop_index('ix_document_lines_bank_account_id')
|
||||
|
||||
# Try to drop foreign keys, ignore if they don't exist
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
batch_op.drop_column('check_id')
|
||||
batch_op.drop_column('petty_cash_id')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Create Date: 2025-09-20 14:02:19.543853
|
|||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5553f8745c6e'
|
||||
|
|
@ -18,6 +19,12 @@ depends_on = None
|
|||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
# Only create tables if they don't exist
|
||||
if 'support_categories' not in tables:
|
||||
op.create_table('support_categories',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
|
|
@ -28,6 +35,8 @@ def upgrade() -> None:
|
|||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False)
|
||||
|
||||
if 'support_priorities' not in tables:
|
||||
op.create_table('support_priorities',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
|
|
@ -39,6 +48,8 @@ def upgrade() -> None:
|
|||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False)
|
||||
|
||||
if 'support_statuses' not in tables:
|
||||
op.create_table('support_statuses',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
|
|
@ -50,6 +61,8 @@ def upgrade() -> None:
|
|||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False)
|
||||
|
||||
if 'support_tickets' not in tables:
|
||||
op.create_table('support_tickets',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
|
|
@ -76,6 +89,8 @@ def upgrade() -> None:
|
|||
op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False)
|
||||
op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False)
|
||||
|
||||
if 'support_messages' not in tables:
|
||||
op.create_table('support_messages',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('ticket_id', sa.Integer(), nullable=False),
|
||||
|
|
@ -91,6 +106,9 @@ def upgrade() -> None:
|
|||
op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False)
|
||||
op.create_index(op.f('ix_support_messages_sender_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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'caf3f4ef4b76'
|
||||
|
|
@ -18,49 +19,81 @@ depends_on = None
|
|||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Check if persons table exists and has the code column
|
||||
if 'persons' in inspector.get_table_names():
|
||||
cols = {c['name'] for c in inspector.get_columns('persons')}
|
||||
|
||||
# Only alter code column if it exists
|
||||
if 'code' in cols:
|
||||
op.alter_column('persons', 'code',
|
||||
existing_type=mysql.INTEGER(),
|
||||
comment='کد یکتا در هر کسب و کار',
|
||||
existing_nullable=True)
|
||||
|
||||
# Only alter person_type column if it exists
|
||||
if 'person_type' in cols:
|
||||
op.alter_column('persons', 'person_type',
|
||||
existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'),
|
||||
comment='نوع شخص',
|
||||
existing_nullable=False)
|
||||
|
||||
# Only alter person_types column if it exists
|
||||
if 'person_types' in cols:
|
||||
op.alter_column('persons', 'person_types',
|
||||
existing_type=mysql.TEXT(collation='utf8mb4_general_ci'),
|
||||
comment='لیست انواع شخص به صورت JSON',
|
||||
existing_nullable=True)
|
||||
|
||||
# Only alter commission columns if they exist
|
||||
if 'commission_sale_percent' in cols:
|
||||
op.alter_column('persons', 'commission_sale_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از فروش',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_return_percent' in cols:
|
||||
op.alter_column('persons', 'commission_sales_return_percent',
|
||||
existing_type=mysql.DECIMAL(precision=5, scale=2),
|
||||
comment='درصد پورسانت از برگشت از فروش',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_amount' in cols:
|
||||
op.alter_column('persons', 'commission_sales_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_sales_return_amount' in cols:
|
||||
op.alter_column('persons', 'commission_sales_return_amount',
|
||||
existing_type=mysql.DECIMAL(precision=12, scale=2),
|
||||
comment='مبلغ برگشت از فروش مبنا برای پورسانت',
|
||||
existing_nullable=True)
|
||||
|
||||
if 'commission_exclude_discounts' in cols:
|
||||
op.alter_column('persons', 'commission_exclude_discounts',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه تخفیف در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
if 'commission_exclude_additions_deductions' in cols:
|
||||
op.alter_column('persons', 'commission_exclude_additions_deductions',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
if 'commission_post_in_invoice_document' in cols:
|
||||
op.alter_column('persons', 'commission_post_in_invoice_document',
|
||||
existing_type=mysql.TINYINT(display_width=1),
|
||||
comment='ثبت پورسانت در سند حسابداری فاکتور',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'0'"))
|
||||
|
||||
# Continue with other operations
|
||||
op.alter_column('price_items', 'tier_name',
|
||||
existing_type=mysql.VARCHAR(length=64),
|
||||
comment='نام پله قیمت (تکی/عمده/همکار/...)',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import 'pages/business/cash_registers_page.dart';
|
|||
import 'pages/business/petty_cash_page.dart';
|
||||
import 'pages/business/checks_page.dart';
|
||||
import 'pages/business/check_form_page.dart';
|
||||
import 'pages/business/receipts_payments_page.dart';
|
||||
import 'pages/business/receipts_payments_list_page.dart';
|
||||
import 'pages/error_404_page.dart';
|
||||
import 'core/locale_controller.dart';
|
||||
import 'core/calendar_controller.dart';
|
||||
|
|
@ -795,7 +795,7 @@ class _MyAppState extends State<MyApp> {
|
|||
);
|
||||
},
|
||||
),
|
||||
// Checks: list, new, edit
|
||||
// Receipts & Payments: list with data table
|
||||
GoRoute(
|
||||
path: 'receipts-payments',
|
||||
name: 'business_receipts_payments',
|
||||
|
|
@ -807,7 +807,7 @@ class _MyAppState extends State<MyApp> {
|
|||
localeController: controller,
|
||||
calendarController: _calendarController!,
|
||||
themeController: themeController,
|
||||
child: ReceiptsPaymentsPage(
|
||||
child: ReceiptsPaymentsListPage(
|
||||
businessId: businessId,
|
||||
calendarController: _calendarController!,
|
||||
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 '../../core/api_client.dart';
|
||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||
import 'receipts_payments_list_page.dart' show BulkSettlementDialog;
|
||||
|
||||
class BusinessShell extends StatefulWidget {
|
||||
final int businessId;
|
||||
|
|
@ -68,6 +69,24 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> showAddReceiptPaymentDialog() async {
|
||||
final calendarController = widget.calendarController ?? await CalendarController.load();
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => BulkSettlementDialog(
|
||||
businessId: widget.businessId,
|
||||
calendarController: calendarController,
|
||||
isReceipt: true, // پیشفرض دریافت
|
||||
businessInfo: widget.authStore.currentBusiness,
|
||||
apiClient: ApiClient(),
|
||||
),
|
||||
);
|
||||
if (result == true) {
|
||||
// Refresh the receipts payments page if it's currently open
|
||||
_refreshCurrentPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshCurrentPage() {
|
||||
// Force a rebuild of the current page
|
||||
setState(() {
|
||||
|
|
@ -809,6 +828,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (child.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (child.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (child.label == t.expenseAndIncome) {
|
||||
// Navigate to add expense/income
|
||||
} else if (child.label == t.warehouses) {
|
||||
|
|
@ -951,6 +973,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (item.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (item.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (item.label == t.checks) {
|
||||
// Navigate to add check
|
||||
context.go('/business/${widget.businessId}/checks/new');
|
||||
|
|
@ -1122,6 +1147,9 @@ class _BusinessShellState extends State<BusinessShell> {
|
|||
} else if (child.label == t.invoice) {
|
||||
// Navigate to add invoice
|
||||
context.go('/business/${widget.businessId}/invoice/new');
|
||||
} else if (child.label == t.receiptsAndPayments) {
|
||||
// Show add receipt payment dialog
|
||||
showAddReceiptPaymentDialog();
|
||||
} else if (child.label == t.expenseAndIncome) {
|
||||
// Navigate to add expense/income
|
||||
} else if (child.label == t.warehouses) {
|
||||
|
|
|
|||
|
|
@ -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_number': tx.checkNumber,
|
||||
},
|
||||
if (tx.type == TransactionType.person) ...{
|
||||
'person_id': tx.personId,
|
||||
'person_name': tx.personName,
|
||||
},
|
||||
}).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(() {
|
||||
_currencies = currencies;
|
||||
_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) {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -246,6 +246,9 @@ class DataTableConfig<T> {
|
|||
final String? excelEndpoint;
|
||||
final String? pdfEndpoint;
|
||||
final Map<String, dynamic> Function()? getExportParams;
|
||||
final bool showExportButtons;
|
||||
final bool showExcelExport;
|
||||
final bool showPdfExport;
|
||||
|
||||
// Column settings configuration
|
||||
final String? tableId;
|
||||
|
|
@ -321,6 +324,9 @@ class DataTableConfig<T> {
|
|||
this.excelEndpoint,
|
||||
this.pdfEndpoint,
|
||||
this.getExportParams,
|
||||
this.showExportButtons = false,
|
||||
this.showExcelExport = true,
|
||||
this.showPdfExport = true,
|
||||
this.tableId,
|
||||
this.enableColumnSettings = true,
|
||||
this.showColumnSettingsButton = true,
|
||||
|
|
@ -452,6 +458,7 @@ class QueryInfo {
|
|||
'take': take,
|
||||
'skip': skip,
|
||||
'sort_desc': sortDesc,
|
||||
'sort_by': sortBy ?? 'document_date', // مقدار پیشفرض برای sort_by
|
||||
};
|
||||
|
||||
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) {
|
||||
json['filters'] = filters!.map((f) => f.toJson()).toList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'helpers/file_saver.dart';
|
||||
// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:data_table_2/data_table_2.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
|
@ -687,29 +687,49 @@ class _DataTableWidgetState<T> extends State<DataTableWidget<T>> {
|
|||
|
||||
// Cross-platform save using conditional FileSaver
|
||||
Future<void> _saveBytesToDownloads(dynamic data, String filename) async {
|
||||
List<int> bytes;
|
||||
Uint8List bytes;
|
||||
if (data is List<int>) {
|
||||
bytes = data;
|
||||
bytes = Uint8List.fromList(data);
|
||||
} else if (data is Uint8List) {
|
||||
bytes = data.toList();
|
||||
bytes = data;
|
||||
} else {
|
||||
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
|
||||
Future<void> _downloadPdf(dynamic data, String filename) async {
|
||||
// For Linux desktop, we'll save to Downloads folder
|
||||
print('Download PDF: $filename (Linux desktop - save to Downloads folder)');
|
||||
// TODO: Implement proper file saving for Linux
|
||||
try {
|
||||
await _saveBytesToDownloads(data, filename);
|
||||
print('✅ PDF downloaded successfully: $filename');
|
||||
} catch (e) {
|
||||
print('❌ Error downloading PDF: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadExcel(dynamic data, String filename) async {
|
||||
// For Linux desktop, we'll save to Downloads folder
|
||||
print('Download Excel: $filename (Linux desktop - save to Downloads folder)');
|
||||
// TODO: Implement proper file saving for Linux
|
||||
try {
|
||||
await _saveBytesToDownloads(data, filename);
|
||||
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:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/bank_account_service.dart';
|
||||
|
||||
|
|
@ -99,7 +100,10 @@ class _BankAccountComboboxWidgetState extends State<BankAccountComboboxWidget> {
|
|||
: res['items'];
|
||||
final items = ((itemsRaw as List<dynamic>? ?? const <dynamic>[])).map((e) {
|
||||
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();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:uuid/uuid.dart';
|
||||
import '../../models/invoice_transaction.dart';
|
||||
import '../../models/person_model.dart';
|
||||
import '../../models/account_tree_node.dart';
|
||||
import '../../core/date_utils.dart';
|
||||
import '../../core/calendar_controller.dart';
|
||||
import '../../utils/number_formatters.dart';
|
||||
|
|
@ -9,10 +10,12 @@ import '../../services/bank_account_service.dart';
|
|||
import '../../services/cash_register_service.dart';
|
||||
import '../../services/petty_cash_service.dart';
|
||||
import '../../services/person_service.dart';
|
||||
import '../../services/account_service.dart';
|
||||
import 'person_combobox_widget.dart';
|
||||
import 'bank_account_combobox_widget.dart';
|
||||
import 'cash_register_combobox_widget.dart';
|
||||
import 'petty_cash_combobox_widget.dart';
|
||||
import 'account_tree_combobox_widget.dart';
|
||||
import '../../models/invoice_type_model.dart';
|
||||
|
||||
class InvoiceTransactionsWidget extends StatefulWidget {
|
||||
|
|
@ -194,7 +197,7 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
|||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// تاریخ و مبلغ
|
||||
// تاریخ، مبلغ و کارمزد
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -206,6 +209,12 @@ class _InvoiceTransactionsWidgetState extends State<InvoiceTransactionsWidget> {
|
|||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildDetailRow(
|
||||
'مبلغ:',
|
||||
formatWithThousands(transaction.amount, decimalPlaces: 0),
|
||||
),
|
||||
),
|
||||
if (transaction.commission != null)
|
||||
Expanded(
|
||||
child: _buildDetailRow(
|
||||
|
|
@ -371,6 +380,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
final CashRegisterService _cashRegisterService = CashRegisterService();
|
||||
final PettyCashService _pettyCashService = PettyCashService();
|
||||
final PersonService _personService = PersonService();
|
||||
final AccountService _accountService = AccountService();
|
||||
|
||||
// فیلدهای خاص هر نوع تراکنش
|
||||
String? _selectedBankId;
|
||||
|
|
@ -378,7 +388,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
String? _selectedPettyCashId;
|
||||
String? _selectedCheckId;
|
||||
String? _selectedPersonId;
|
||||
String? _selectedAccountId;
|
||||
AccountTreeNode? _selectedAccount;
|
||||
|
||||
// لیستهای داده
|
||||
List<Map<String, dynamic>> _banks = [];
|
||||
|
|
@ -403,12 +413,44 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
_selectedPettyCashId = widget.transaction?.pettyCashId;
|
||||
_selectedCheckId = widget.transaction?.checkId;
|
||||
_selectedPersonId = widget.transaction?.personId;
|
||||
_selectedAccountId = widget.transaction?.accountId;
|
||||
|
||||
// اگر حساب انتخاب شده است، باید آن را از API دریافت کنیم
|
||||
if (widget.transaction?.accountId != null) {
|
||||
_loadSelectedAccount();
|
||||
}
|
||||
|
||||
// لود کردن دادهها از دیتابیس
|
||||
_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 {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -794,21 +836,17 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
}
|
||||
|
||||
Widget _buildAccountFields() {
|
||||
return DropdownButtonFormField<String>(
|
||||
initialValue: _selectedAccountId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'حساب *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'account1', child: Text('حساب جاری')),
|
||||
DropdownMenuItem(value: 'account2', child: Text('حساب پسانداز')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
return AccountTreeComboboxWidget(
|
||||
businessId: widget.businessId,
|
||||
selectedAccount: _selectedAccount,
|
||||
onChanged: (account) {
|
||||
setState(() {
|
||||
_selectedAccountId = value;
|
||||
_selectedAccount = account;
|
||||
});
|
||||
},
|
||||
label: 'حساب *',
|
||||
hintText: 'انتخاب حساب',
|
||||
isRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -848,8 +886,8 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
checkNumber: _getCheckNumber(_selectedCheckId),
|
||||
personId: _selectedPersonId,
|
||||
personName: _getPersonName(_selectedPersonId),
|
||||
accountId: _selectedAccountId,
|
||||
accountName: _getAccountName(_selectedAccountId),
|
||||
accountId: _selectedAccount?.id.toString(),
|
||||
accountName: _selectedAccount?.name,
|
||||
transactionDate: _transactionDate,
|
||||
amount: amount,
|
||||
commission: commission,
|
||||
|
|
@ -903,14 +941,7 @@ class _TransactionDialogState extends State<TransactionDialog> {
|
|||
(p) => p['id']?.toString() == id,
|
||||
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 <file_saver/file_saver_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_saver
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import FlutterMacOS
|
|||
import Foundation
|
||||
|
||||
import file_picker
|
||||
import file_saver
|
||||
import file_selector_macos
|
||||
import flutter_secure_storage_macos
|
||||
import path_provider_foundation
|
||||
|
|
@ -13,6 +14,7 @@ import shared_preferences_foundation
|
|||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
|
|
|
|||
|
|
@ -137,6 +137,14 @@ packages:
|
|||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ dependencies:
|
|||
persian_datetime_picker: ^3.2.0
|
||||
shamsi_date: ^1.1.1
|
||||
intl: ^0.20.0
|
||||
file_saver: ^0.2.7
|
||||
data_table_2: ^2.5.12
|
||||
file_picker: ^10.3.3
|
||||
file_selector: ^1.0.4
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_saver/file_saver_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSaverPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSaverPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_saver
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue