diff --git a/docs/COMMISSION_IMPLEMENTATION.md b/docs/COMMISSION_IMPLEMENTATION.md new file mode 100644 index 0000000..c33aea8 --- /dev/null +++ b/docs/COMMISSION_IMPLEMENTATION.md @@ -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 ریال ✅ diff --git a/docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md b/docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md new file mode 100644 index 0000000..3ca2afe --- /dev/null +++ b/docs/RECEIPTS_PAYMENTS_LIST_IMPLEMENTATION.md @@ -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( + 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) diff --git a/hesabixAPI/adapters/api/v1/receipts_payments.py b/hesabixAPI/adapters/api/v1/receipts_payments.py index 20ff595..1d1b609 100644 --- a/hesabixAPI/adapters/api/v1/receipts_payments.py +++ b/hesabixAPI/adapters/api/v1/receipts_payments.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'{escape(header)}' 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'{escape(str(value))}') + rows_html.append(f'{"".join(row_cells)}') + + # Create HTML table + table_html = f""" + + + + + {title_text} + + + +
+
+
{title_text}
+
{label_biz}: {escape(business_name)}
+
+
{label_date}: {escape(now)}
+
+
+ + + {headers_html} + + + {''.join(rows_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", + }, + ) + diff --git a/hesabixAPI/adapters/api/v1/schema_models/__init__.py b/hesabixAPI/adapters/api/v1/schema_models/__init__.py index 03f73ed..6cbba5d 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/__init__.py +++ b/hesabixAPI/adapters/api/v1/schema_models/__init__.py @@ -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 diff --git a/hesabixAPI/adapters/api/v1/schema_models/document_line.py b/hesabixAPI/adapters/api/v1/schema_models/document_line.py new file mode 100644 index 0000000..84f3d76 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/document_line.py @@ -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 diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index aee5a31..d44c647 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -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 diff --git a/hesabixAPI/adapters/db/models/document.py b/hesabixAPI/adapters/db/models/document.py index 6d290cd..f5cfffe 100644 --- a/hesabixAPI/adapters/db/models/document.py +++ b/hesabixAPI/adapters/db/models/document.py @@ -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") diff --git a/hesabixAPI/adapters/db/models/fiscal_year.py b/hesabixAPI/adapters/db/models/fiscal_year.py index a3026b7..ca2c4ab 100644 --- a/hesabixAPI/adapters/db/models/fiscal_year.py +++ b/hesabixAPI/adapters/db/models/fiscal_year.py @@ -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") diff --git a/hesabixAPI/app/core/responses.py b/hesabixAPI/app/core/responses.py index 2c2730d..48bf07a 100644 --- a/hesabixAPI/app/core/responses.py +++ b/hesabixAPI/app/core/responses.py @@ -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: diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py index bf544f3..b27e85f 100644 --- a/hesabixAPI/app/services/receipt_payment_service.py +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -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,24 +322,70 @@ def create_receipt_payment( transaction_date = account_line.get("transaction_date") commission = account_line.get("commission") - # بررسی وجود حساب - account = db.query(Account).filter( - and_( - Account.id == int(account_id), - or_( - Account.business_id == business_id, - Account.business_id == None # حساب‌های عمومی + 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), + or_( + Account.business_id == business_id, + Account.business_id == None # حساب‌های عمومی + ) ) - ) - ).first() + ).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(), } diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index 475bd39..f5ef058 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -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 diff --git a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py b/hesabixAPI/migrations/versions/20250926_000010_add_person_code.py similarity index 96% rename from hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py rename to hesabixAPI/migrations/versions/20250926_000010_add_person_code.py index 9420d8d..f1de18a 100644 --- a/hesabixAPI/migrations/versions/20250926_000010_add_person_code_and_types.py +++ b/hesabixAPI/migrations/versions/20250926_000010_add_person_code.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 diff --git a/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py b/hesabixAPI/migrations/versions/20250926_000011_drop_active.py similarity index 91% rename from hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py rename to hesabixAPI/migrations/versions/20250926_000011_drop_active.py index 1d8c3e1..44038de 100644 --- a/hesabixAPI/migrations/versions/20250926_000011_drop_person_is_active.py +++ b/hesabixAPI/migrations/versions/20250926_000011_drop_active.py @@ -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 diff --git a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years.py similarity index 93% rename from hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py rename to hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years.py index baa60a2..e9d6a5d 100644 --- a/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years_table.py +++ b/hesabixAPI/migrations/versions/20250927_000012_add_fiscal_years.py @@ -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',) diff --git a/hesabixAPI/migrations/versions/20250927_000013_add_currencies.py b/hesabixAPI/migrations/versions/20250927_000013_add_currencies.py new file mode 100644 index 0000000..58c8083 --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000013_add_currencies.py @@ -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') + + diff --git a/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py b/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py deleted file mode 100644 index 934e96d..0000000 --- a/hesabixAPI/migrations/versions/20250927_000013_add_currencies_and_business_currencies.py +++ /dev/null @@ -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') - - diff --git a/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py b/hesabixAPI/migrations/versions/20250927_000014_add_documents.py similarity index 94% rename from hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py rename to hesabixAPI/migrations/versions/20250927_000014_add_documents.py index 8f94d86..e984537 100644 --- a/hesabixAPI/migrations/versions/20250927_000014_add_documents_table.py +++ b/hesabixAPI/migrations/versions/20250927_000014_add_documents.py @@ -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 diff --git a/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py b/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py deleted file mode 100644 index be3bbc8..0000000 --- a/hesabixAPI/migrations/versions/20250927_000015_add_document_lines_table.py +++ /dev/null @@ -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') - - diff --git a/hesabixAPI/migrations/versions/20250927_000015_add_lines.py b/hesabixAPI/migrations/versions/20250927_000015_add_lines.py new file mode 100644 index 0000000..3214ebf --- /dev/null +++ b/hesabixAPI/migrations/versions/20250927_000015_add_lines.py @@ -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') + + diff --git a/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py index 236159f..251ded2 100644 --- a/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py +++ b/hesabixAPI/migrations/versions/20250927_000016_add_accounts_table.py @@ -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 diff --git a/hesabixAPI/migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py b/hesabixAPI/migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py index 48ce712..dd66c8c 100644 --- a/hesabixAPI/migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py +++ b/hesabixAPI/migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py @@ -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,21 +13,47 @@ 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: - batch_op.add_column(sa.Column('bank_account_id', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('cash_register_id', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('petty_cash_id', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('check_id', sa.Integer(), nullable=True)) + # 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)) - batch_op.create_foreign_key('fk_document_lines_bank_account_id_bank_accounts', 'bank_accounts', ['bank_account_id'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key('fk_document_lines_cash_register_id_cash_registers', 'cash_registers', ['cash_register_id'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key('fk_document_lines_petty_cash_id_petty_cash', 'petty_cash', ['petty_cash_id'], ['id'], ondelete='SET NULL') - batch_op.create_foreign_key('fk_document_lines_check_id_checks', 'checks', ['check_id'], ['id'], ondelete='SET NULL') + # 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') - batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id']) - batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id']) - batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id']) - batch_op.create_index('ix_document_lines_check_id', ['check_id']) + # Only create indexes if columns were added + if 'bank_account_id' not in cols: + batch_op.create_index('ix_document_lines_bank_account_id', ['bank_account_id']) + if 'cash_register_id' not in cols: + batch_op.create_index('ix_document_lines_cash_register_id', ['cash_register_id']) + if 'petty_cash_id' not in cols: + batch_op.create_index('ix_document_lines_petty_cash_id', ['petty_cash_id']) + if 'check_id' not in cols: + batch_op.create_index('ix_document_lines_check_id', ['check_id']) def downgrade() -> None: @@ -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') - batch_op.drop_constraint('fk_document_lines_check_id_checks', type_='foreignkey') - batch_op.drop_constraint('fk_document_lines_petty_cash_id_petty_cash', type_='foreignkey') - batch_op.drop_constraint('fk_document_lines_cash_register_id_cash_registers', type_='foreignkey') - batch_op.drop_constraint('fk_document_lines_bank_account_id_bank_accounts', type_='foreignkey') + # 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') diff --git a/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py b/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py index c47f75f..3613468 100644 --- a/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py +++ b/hesabixAPI/migrations/versions/5553f8745c6e_add_support_tables.py @@ -8,6 +8,7 @@ Create Date: 2025-09-20 14:02:19.543853 from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = '5553f8745c6e' @@ -18,87 +19,104 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('support_categories', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) - op.create_table('support_priorities', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=50), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('color', sa.String(length=7), nullable=True), - sa.Column('order', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) - op.create_table('support_statuses', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=50), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('color', sa.String(length=7), nullable=True), - sa.Column('is_final', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) - op.create_table('support_tickets', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('title', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('category_id', sa.Integer(), nullable=False), - sa.Column('priority_id', sa.Integer(), nullable=False), - sa.Column('status_id', sa.Integer(), nullable=False), - sa.Column('assigned_operator_id', sa.Integer(), nullable=True), - sa.Column('is_internal', sa.Boolean(), nullable=False), - sa.Column('closed_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'), - sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'), - sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False) - op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False) - op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False) - op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) - op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) - op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False) - op.create_table('support_messages', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('ticket_id', sa.Integer(), nullable=False), - sa.Column('sender_id', sa.Integer(), nullable=False), - sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('is_internal', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False) - op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False) - op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) - op.alter_column('businesses', 'business_type', - existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), - type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), - existing_nullable=False) - op.alter_column('businesses', 'business_field', - existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), - type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), - existing_nullable=False) + bind = op.get_bind() + inspector = inspect(bind) + tables = set(inspector.get_table_names()) + + # Only create tables if they don't exist + if 'support_categories' not in tables: + op.create_table('support_categories', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_categories_name'), 'support_categories', ['name'], unique=False) + + if 'support_priorities' not in tables: + op.create_table('support_priorities', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('order', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_priorities_name'), 'support_priorities', ['name'], unique=False) + + if 'support_statuses' not in tables: + op.create_table('support_statuses', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('is_final', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_statuses_name'), 'support_statuses', ['name'], unique=False) + + if 'support_tickets' not in tables: + op.create_table('support_tickets', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('priority_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('assigned_operator_id', sa.Integer(), nullable=True), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('closed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['assigned_operator_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['category_id'], ['support_categories.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['priority_id'], ['support_priorities.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['status_id'], ['support_statuses.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_tickets_assigned_operator_id'), 'support_tickets', ['assigned_operator_id'], unique=False) + op.create_index(op.f('ix_support_tickets_category_id'), 'support_tickets', ['category_id'], unique=False) + op.create_index(op.f('ix_support_tickets_priority_id'), 'support_tickets', ['priority_id'], unique=False) + op.create_index(op.f('ix_support_tickets_status_id'), 'support_tickets', ['status_id'], unique=False) + op.create_index(op.f('ix_support_tickets_title'), 'support_tickets', ['title'], unique=False) + op.create_index(op.f('ix_support_tickets_user_id'), 'support_tickets', ['user_id'], unique=False) + + if 'support_messages' not in tables: + op.create_table('support_messages', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('ticket_id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('sender_type', sa.Enum('USER', 'OPERATOR', 'SYSTEM', name='sendertype'), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_support_messages_sender_id'), 'support_messages', ['sender_id'], unique=False) + op.create_index(op.f('ix_support_messages_sender_type'), 'support_messages', ['sender_type'], unique=False) + op.create_index(op.f('ix_support_messages_ticket_id'), 'support_messages', ['ticket_id'], unique=False) + + # Only alter columns if businesses table exists + if 'businesses' in tables: + op.alter_column('businesses', 'business_type', + existing_type=mysql.ENUM('شرکت', 'مغازه', 'فروشگاه', 'اتحادیه', 'باشگاه', 'موسسه', 'شخصی', collation='utf8mb4_general_ci'), + type_=sa.Enum('COMPANY', 'SHOP', 'STORE', 'UNION', 'CLUB', 'INSTITUTE', 'INDIVIDUAL', name='businesstype'), + existing_nullable=False) + op.alter_column('businesses', 'business_field', + existing_type=mysql.ENUM('تولیدی', 'بازرگانی', 'خدماتی', 'سایر', collation='utf8mb4_general_ci'), + type_=sa.Enum('MANUFACTURING', 'TRADING', 'SERVICE', 'OTHER', name='businessfield'), + existing_nullable=False) # ### end Alembic commands ### diff --git a/hesabixAPI/migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py b/hesabixAPI/migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py new file mode 100644 index 0000000..335bde8 --- /dev/null +++ b/hesabixAPI/migrations/versions/ac9e4b3dcffc_add_fiscal_year_to_documents.py @@ -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 ### diff --git a/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py b/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py index e27ba39..a781649 100644 --- a/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py +++ b/hesabixAPI/migrations/versions/caf3f4ef4b76_add_tax_units_table.py @@ -8,6 +8,7 @@ Create Date: 2025-09-30 14:46:58.614162 from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql +from sqlalchemy import inspect # revision identifiers, used by Alembic. revision = 'caf3f4ef4b76' @@ -18,49 +19,81 @@ depends_on = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('persons', 'code', - existing_type=mysql.INTEGER(), - comment='کد یکتا در هر کسب و کار', - existing_nullable=True) - op.alter_column('persons', 'person_type', - existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), - comment='نوع شخص', - existing_nullable=False) - op.alter_column('persons', 'person_types', - existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), - comment='لیست انواع شخص به صورت JSON', - existing_nullable=True) - op.alter_column('persons', 'commission_sale_percent', - existing_type=mysql.DECIMAL(precision=5, scale=2), - comment='درصد پورسانت از فروش', - existing_nullable=True) - op.alter_column('persons', 'commission_sales_return_percent', - existing_type=mysql.DECIMAL(precision=5, scale=2), - comment='درصد پورسانت از برگشت از فروش', - existing_nullable=True) - op.alter_column('persons', 'commission_sales_amount', - existing_type=mysql.DECIMAL(precision=12, scale=2), - comment='مبلغ فروش مبنا برای پورسانت', - existing_nullable=True) - op.alter_column('persons', 'commission_sales_return_amount', - existing_type=mysql.DECIMAL(precision=12, scale=2), - comment='مبلغ برگشت از فروش مبنا برای پورسانت', - existing_nullable=True) - op.alter_column('persons', 'commission_exclude_discounts', - existing_type=mysql.TINYINT(display_width=1), - comment='عدم محاسبه تخفیف در پورسانت', - existing_nullable=False, - existing_server_default=sa.text("'0'")) - op.alter_column('persons', 'commission_exclude_additions_deductions', - existing_type=mysql.TINYINT(display_width=1), - comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', - existing_nullable=False, - existing_server_default=sa.text("'0'")) - op.alter_column('persons', 'commission_post_in_invoice_document', - existing_type=mysql.TINYINT(display_width=1), - comment='ثبت پورسانت در سند حسابداری فاکتور', - existing_nullable=False, - existing_server_default=sa.text("'0'")) + bind = op.get_bind() + inspector = inspect(bind) + + # Check if persons table exists and has the code column + if 'persons' in inspector.get_table_names(): + cols = {c['name'] for c in inspector.get_columns('persons')} + + # Only alter code column if it exists + if 'code' in cols: + op.alter_column('persons', 'code', + existing_type=mysql.INTEGER(), + comment='کد یکتا در هر کسب و کار', + existing_nullable=True) + + # Only alter person_type column if it exists + if 'person_type' in cols: + op.alter_column('persons', 'person_type', + existing_type=mysql.ENUM('مشتری', 'بازاریاب', 'کارمند', 'تامین\u200cکننده', 'همکار', 'فروشنده', 'سهامدار', collation='utf8mb4_general_ci'), + comment='نوع شخص', + existing_nullable=False) + + # Only alter person_types column if it exists + if 'person_types' in cols: + op.alter_column('persons', 'person_types', + existing_type=mysql.TEXT(collation='utf8mb4_general_ci'), + comment='لیست انواع شخص به صورت JSON', + existing_nullable=True) + + # Only alter commission columns if they exist + if 'commission_sale_percent' in cols: + op.alter_column('persons', 'commission_sale_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + comment='درصد پورسانت از فروش', + existing_nullable=True) + + if 'commission_sales_return_percent' in cols: + op.alter_column('persons', 'commission_sales_return_percent', + existing_type=mysql.DECIMAL(precision=5, scale=2), + comment='درصد پورسانت از برگشت از فروش', + existing_nullable=True) + + if 'commission_sales_amount' in cols: + op.alter_column('persons', 'commission_sales_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment='مبلغ فروش مبنا برای پورسانت', + existing_nullable=True) + + if 'commission_sales_return_amount' in cols: + op.alter_column('persons', 'commission_sales_return_amount', + existing_type=mysql.DECIMAL(precision=12, scale=2), + comment='مبلغ برگشت از فروش مبنا برای پورسانت', + existing_nullable=True) + + if 'commission_exclude_discounts' in cols: + op.alter_column('persons', 'commission_exclude_discounts', + existing_type=mysql.TINYINT(display_width=1), + comment='عدم محاسبه تخفیف در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + + if 'commission_exclude_additions_deductions' in cols: + op.alter_column('persons', 'commission_exclude_additions_deductions', + existing_type=mysql.TINYINT(display_width=1), + comment='عدم محاسبه اضافات و کسورات فاکتور در پورسانت', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + + if 'commission_post_in_invoice_document' in cols: + op.alter_column('persons', 'commission_post_in_invoice_document', + existing_type=mysql.TINYINT(display_width=1), + comment='ثبت پورسانت در سند حسابداری فاکتور', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + + # Continue with other operations op.alter_column('price_items', 'tier_name', existing_type=mysql.VARCHAR(length=64), comment='نام پله قیمت (تکی/عمده/همکار/...)', diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 851b3cc..a86d561 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -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 { ); }, ), - // 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 { localeController: controller, calendarController: _calendarController!, themeController: themeController, - child: ReceiptsPaymentsPage( + child: ReceiptsPaymentsListPage( businessId: businessId, calendarController: _calendarController!, authStore: _authStore!, diff --git a/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart b/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart new file mode 100644 index 0000000..db2181d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart @@ -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 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 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?) + ?.map((child) => AccountTreeNode.fromJson(child as Map)) + .toList() ?? [], + ); + } + + Map 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 getSelectableAccounts() { + List selectable = []; + + if (!hasChildren) { + selectable.add(this); + } else { + for (final child in children) { + selectable.addAll(child.getSelectableAccounts()); + } + } + + return selectable; + } + + /// دریافت تمام حساب‌ها به صورت تخت (شامل همه سطوح) + List getAllAccounts() { + List all = [this]; + + for (final child in children) { + all.addAll(child.getAllAccounts()); + } + + return all; + } + + /// جستجو در درخت حساب‌ها بر اساس نام یا کد + List 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; +} diff --git a/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart b/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart new file mode 100644 index 0000000..fcd6170 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/receipt_payment_document.dart @@ -0,0 +1,222 @@ + +/// مدل خط شخص در سند دریافت/پرداخت +class PersonLine { + final int id; + final int? personId; + final String? personName; + final double amount; + final String? description; + final Map? extraInfo; + + const PersonLine({ + required this.id, + this.personId, + this.personName, + required this.amount, + this.description, + this.extraInfo, + }); + + factory PersonLine.fromJson(Map 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 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? 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 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 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? extraInfo; + final List personLines; + final List 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 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?) + ?.map((item) => PersonLine.fromJson(item)) + .toList() ?? [], + accountLines: (json['account_lines'] as List?) + ?.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 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; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 90a86bc..05d847c 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -14,6 +14,7 @@ import '../../widgets/category/category_tree_dialog.dart'; import '../../services/business_dashboard_service.dart'; import '../../core/api_client.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'receipts_payments_list_page.dart' show BulkSettlementDialog; class BusinessShell extends StatefulWidget { final int businessId; @@ -68,7 +69,25 @@ class _BusinessShellState extends State { super.dispose(); } - void _refreshCurrentPage() { + Future showAddReceiptPaymentDialog() async { + final calendarController = widget.calendarController ?? await CalendarController.load(); + final result = await showDialog( + context: context, + builder: (context) => BulkSettlementDialog( + businessId: widget.businessId, + calendarController: calendarController, + isReceipt: true, // پیش‌فرض دریافت + businessInfo: widget.authStore.currentBusiness, + apiClient: ApiClient(), + ), + ); + if (result == true) { + // Refresh the receipts payments page if it's currently open + _refreshCurrentPage(); + } + } + + void _refreshCurrentPage() { // Force a rebuild of the current page setState(() { // This will cause the current page to rebuild @@ -809,6 +828,9 @@ class _BusinessShellState extends State { } 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 { } 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 { } 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) { diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart new file mode 100644 index 0000000..12cba06 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart @@ -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 createState() => _ReceiptsPaymentsListPageState(); +} + +class _ReceiptsPaymentsListPageState extends State { + 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( + 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( + segments: [ + ButtonSegment( + value: null, + label: Text('همه'), + icon: const Icon(Icons.all_inclusive), + ), + ButtonSegment( + value: 'receipt', + label: Text(t.receipts), + icon: const Icon(Icons.download_done_outlined), + ), + ButtonSegment( + 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 _buildTableConfig(AppLocalizations t) { + return DataTableConfig( + 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( + 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 _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 createState() => _BulkSettlementDialogState(); +} + +class _BulkSettlementDialogState extends State { + final _formKey = GlobalKey(); + + late DateTime _docDate; + late bool _isReceipt; + int? _selectedCurrencyId; + final List<_PersonLine> _personLines = <_PersonLine>[]; + final List _centerTransactions = []; + + @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(0, (p, e) => p + e.amount); + final sumCenters = _centerTransactions.fold(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( + segments: [ + ButtonSegment(value: true, label: Text(t.receipts)), + ButtonSegment(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 _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> 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, + ); + } +} + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart index 4905767..c844781 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart @@ -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(); // ارسال به سرور diff --git a/hesabixUI/hesabix_ui/lib/services/account_service.dart b/hesabixUI/hesabix_ui/lib/services/account_service.dart new file mode 100644 index 0000000..dc9fd62 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/account_service.dart @@ -0,0 +1,22 @@ +import '../core/api_client.dart'; + +class AccountService { + final ApiClient _client; + AccountService({ApiClient? client}) : _client = client ?? ApiClient(); + + /// دریافت درخت حساب‌ها برای یک کسب و کار + Future> getAccountsTree({required int businessId}) async { + try { + final res = await _client.get>( + '/api/v1/accounts/business/$businessId/tree', + ); + + // API پاسخ را در فیلد 'data' برمی‌گرداند + final responseData = res.data?['data'] as Map?; + return responseData ?? {'items': []}; + } catch (e) { + print('خطا در دریافت درخت حساب‌ها: $e'); + return {'items': []}; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/receipt_payment_list_service.dart b/hesabixUI/hesabix_ui/lib/services/receipt_payment_list_service.dart new file mode 100644 index 0000000..1ea6602 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/receipt_payment_list_service.dart @@ -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 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 json) { + final pagination = json['pagination'] as Map? ?? {}; + + return ReceiptPaymentListResponse( + items: (json['items'] as List?) + ?.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 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 = { + '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? ?? {}; + return ReceiptPaymentListResponse.fromJson(data); + } else { + throw Exception('خطا در دریافت لیست اسناد: ${response.statusMessage}'); + } + } catch (e) { + throw Exception('خطا در دریافت لیست اسناد: $e'); + } + } + + /// دریافت جزئیات یک سند + Future 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? ?? {}; + return ReceiptPaymentDocument.fromJson(data); + } else { + return null; + } + } catch (e) { + throw Exception('خطا در دریافت جزئیات سند: $e'); + } + } + + /// حذف یک سند + Future delete(int documentId) async { + try { + final response = await _apiClient.delete('/receipts-payments/$documentId'); + + return response.statusCode == 200; + } catch (e) { + throw Exception('خطا در حذف سند: $e'); + } + } + + /// حذف چندین سند + Future deleteMultiple(List documentIds) async { + try { + // حذف تک‌تک اسناد + for (final id in documentIds) { + await delete(id); + } + return true; + } catch (e) { + throw Exception('خطا در حذف اسناد: $e'); + } + } + + /// دریافت آمار کلی + Future> getStats({ + required int businessId, + DateTime? fromDate, + DateTime? toDate, + }) async { + try { + final queryParams = { + '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? ?? {}; + receiptsCount = receiptsData['pagination']?['total'] ?? 0; + + final receipts = (receiptsData['items'] as List?) + ?.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? ?? {}; + paymentsCount = paymentsData['pagination']?['total'] ?? 0; + + final payments = (paymentsData['items'] as List?) + ?.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'); + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart index 00df5d0..2c51423 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/banking/currency_picker_widget.dart @@ -61,6 +61,16 @@ class _CurrencyPickerWidgetState extends State { 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(() { diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart index 4133600..18285c0 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_config.dart @@ -246,6 +246,9 @@ class DataTableConfig { final String? excelEndpoint; final String? pdfEndpoint; final Map Function()? getExportParams; + final bool showExportButtons; + final bool showExcelExport; + final bool showPdfExport; // Column settings configuration final String? tableId; @@ -321,6 +324,9 @@ class DataTableConfig { 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(); } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart index 538c111..8c2dd35 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart @@ -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 extends State> { // Cross-platform save using conditional FileSaver Future _saveBytesToDownloads(dynamic data, String filename) async { - List bytes; + Uint8List bytes; if (data is List) { - 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 _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 _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; + } } diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart new file mode 100644 index 0000000..74fd110 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart @@ -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 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 createState() => _AccountTreeComboboxWidgetState(); +} + +class _AccountTreeComboboxWidgetState extends State { + final AccountService _accountService = AccountService(); + List _accounts = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _loadAccounts(); + } + + Future _loadAccounts() async { + setState(() { + _isLoading = true; + }); + + try { + final response = await _accountService.getAccountsTree(businessId: widget.businessId); + final items = (response['items'] as List?) + ?.map((item) => AccountTreeNode.fromJson(item as Map)) + .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 accounts; + final AccountTreeNode? selectedAccount; + final ValueChanged onAccountSelected; + + const AccountSelectionDialog({ + super.key, + required this.accounts, + this.selectedAccount, + required this.onAccountSelected, + }); + + @override + State createState() => _AccountSelectionDialogState(); +} + +class _AccountSelectionDialogState extends State { + String _searchQuery = ''; + List _filteredAccounts = []; + final Set _expandedNodes = {}; + + @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), + ), + ), + ], + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart index 9fe2819..b053b83 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/bank_account_combobox_widget.dart @@ -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 { : res['items']; final items = ((itemsRaw as List? ?? const [])).map((e) { final m = Map.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(() { diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart index 5d53d1d..a34c518 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart @@ -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 { const SizedBox(height: 8), - // تاریخ و مبلغ + // تاریخ، مبلغ و کارمزد Row( children: [ Expanded( @@ -206,6 +209,12 @@ class _InvoiceTransactionsWidgetState extends State { ), ), ), + Expanded( + child: _buildDetailRow( + 'مبلغ:', + formatWithThousands(transaction.amount, decimalPlaces: 0), + ), + ), if (transaction.commission != null) Expanded( child: _buildDetailRow( @@ -371,6 +380,7 @@ class _TransactionDialogState extends State { 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 { String? _selectedPettyCashId; String? _selectedCheckId; String? _selectedPersonId; - String? _selectedAccountId; + AccountTreeNode? _selectedAccount; // لیست‌های داده List> _banks = []; @@ -403,12 +413,44 @@ class _TransactionDialogState extends State { _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 _loadSelectedAccount() async { + try { + final response = await _accountService.getAccountsTree(businessId: widget.businessId); + final items = (response['items'] as List?) + ?.map((item) => AccountTreeNode.fromJson(item as Map)) + .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 _loadData() async { setState(() { _isLoading = true; @@ -794,21 +836,17 @@ class _TransactionDialogState extends State { } Widget _buildAccountFields() { - return DropdownButtonFormField( - 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 { 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 { (p) => p['id']?.toString() == id, orElse: () => {}, ); - 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; - } - } } diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc index 85a2413..cc06ecd 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include 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); diff --git a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake index 62e3ed5..3ff8707 100644 --- a/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver file_selector_linux flutter_secure_storage_linux ) diff --git a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift index b52fe80..b034421 100644 --- a/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/hesabixUI/hesabix_ui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/hesabixUI/hesabix_ui/pubspec.lock b/hesabixUI/hesabix_ui/pubspec.lock index fcec841..f11cc48 100644 --- a/hesabixUI/hesabix_ui/pubspec.lock +++ b/hesabixUI/hesabix_ui/pubspec.lock @@ -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: diff --git a/hesabixUI/hesabix_ui/pubspec.yaml b/hesabixUI/hesabix_ui/pubspec.yaml index a5baff0..afa91a2 100644 --- a/hesabixUI/hesabix_ui/pubspec.yaml +++ b/hesabixUI/hesabix_ui/pubspec.yaml @@ -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 diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc index b53f20e..4f36997 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSaverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSaverPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake index 2b9f993..88921e5 100644 --- a/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake +++ b/hesabixUI/hesabix_ui/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_saver file_selector_windows flutter_secure_storage_windows )