diff --git a/docs/RECEIPT_PAYMENT_SYSTEM.md b/docs/RECEIPT_PAYMENT_SYSTEM.md new file mode 100644 index 0000000..936b807 --- /dev/null +++ b/docs/RECEIPT_PAYMENT_SYSTEM.md @@ -0,0 +1,545 @@ +# 📝 سیستم دریافت و پرداخت (Receipt & Payment System) + +## 📌 مقدمه + +سیستم دریافت و پرداخت یک سیستم حسابداری است که برای ثبت تراکنش‌های مالی بین کسب‌وکار و اشخاص (مشتریان و تامین‌کنندگان) استفاده می‌شود. + +## 🎯 هدف + +این سیستم برای ثبت دو نوع سند طراحی شده است: + +1. **دریافت (Receipt)**: دریافت وجه از اشخاص (مشتریان) +2. **پرداخت (Payment)**: پرداخت به اشخاص (تامین‌کنندگان/فروشندگان) + +## 📊 ساختار داده + +### سند (Document) + +هر سند دریافت یا پرداخت شامل موارد زیر است: + +```json +{ + "id": 123, + "code": "RC-20250115-0001", + "business_id": 1, + "document_type": "receipt", // یا "payment" + "document_date": "2025-01-15", + "currency_id": 1, + "created_by_user_id": 5, + "person_lines": [ + { + "person_id": 10, + "person_name": "علی احمدی", + "amount": 1000000, + "description": "تسویه حساب" + } + ], + "account_lines": [ + { + "account_id": 456, + "account_name": "صندوق", + "amount": 1000000, + "description": "" + } + ] +} +``` + +### خطوط سند (Document Lines) + +هر سند شامل دو نوع خط است: + +1. **خطوط اشخاص (Person Lines)**: تراکنش‌های مربوط به اشخاص +2. **خطوط حساب‌ها (Account Lines)**: تراکنش‌های مربوط به حساب‌ها (صندوق، بانک، چک، ...) + +## 🧮 منطق حسابداری + +### 1️⃣ دریافت وجه از اشخاص (Receipt) + +**سناریو**: دریافت ۱,۰۰۰,۰۰۰ تومان از مشتری "علی احمدی" به صندوق + +#### ثبت در حساب‌ها: + +``` +صندوق (10202) بدهکار: 1,000,000 +حساب دریافتنی - علی احمدی (10401) بستانکار: 1,000,000 +``` + +#### منطق: +- **صندوق**: بدهکار می‌شود (چون دارایی افزایش یافته) +- **حساب دریافتنی شخص**: بستانکار می‌شود (چون بدهی مشتری کم شده) + +#### کد نمونه (Frontend): + +```dart +await service.createReceipt( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 10, + 'person_name': 'علی احمدی', + 'amount': 1000000, + 'description': 'تسویه حساب', + } + ], + accountLines: [ + { + 'account_id': 456, // شناسه حساب صندوق + 'amount': 1000000, + 'description': '', + } + ], +); +``` + +--- + +### 2️⃣ پرداخت به اشخاص (Payment) + +**سناریو**: پرداخت ۵۰۰,۰۰۰ تومان به تامین‌کننده "رضا محمدی" از بانک + +#### ثبت در حساب‌ها: + +``` +حساب پرداختنی - رضا محمدی (20201) بدهکار: 500,000 +بانک (10203) بستانکار: 500,000 +``` + +#### منطق: +- **حساب پرداختنی شخص**: بدهکار می‌شود (چون بدهی ما به تامین‌کننده کم شده) +- **بانک**: بستانکار می‌شود (چون دارایی کاهش یافته) + +#### کد نمونه (Frontend): + +```dart +await service.createPayment( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 20, + 'person_name': 'رضا محمدی', + 'amount': 500000, + 'description': 'پرداخت بدهی', + } + ], + accountLines: [ + { + 'account_id': 789, // شناسه حساب بانک + 'amount': 500000, + 'description': 'انتقال بانکی', + } + ], +); +``` + +--- + +## 🔧 نحوه استفاده از API + +### 1. ایجاد سند دریافت/پرداخت + +**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments/create` + +**Request Body:** + +```json +{ + "document_type": "receipt", + "document_date": "2025-01-15", + "currency_id": 1, + "person_lines": [ + { + "person_id": 10, + "person_name": "علی احمدی", + "amount": 1000000, + "description": "تسویه حساب" + } + ], + "account_lines": [ + { + "account_id": 456, + "amount": 1000000, + "description": "" + } + ], + "extra_info": {} +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "RECEIPT_PAYMENT_CREATED", + "data": { + "id": 123, + "code": "RC-20250115-0001", + "business_id": 1, + "document_type": "receipt", + "document_date": "2025-01-15", + "person_lines": [...], + "account_lines": [...] + } +} +``` + +### 2. دریافت لیست اسناد + +**Endpoint:** `POST /api/v1/businesses/{business_id}/receipts-payments` + +**Request Body:** + +```json +{ + "skip": 0, + "take": 20, + "sort_by": "document_date", + "sort_desc": true, + "document_type": "receipt", + "from_date": "2025-01-01", + "to_date": "2025-01-31", + "search": "" +} +``` + +### 3. دریافت جزئیات یک سند + +**Endpoint:** `GET /api/v1/receipts-payments/{document_id}` + +### 4. حذف سند + +**Endpoint:** `DELETE /api/v1/receipts-payments/{document_id}` + +--- + +## 📱 نحوه استفاده در Flutter + +### 1. Import کردن سرویس: + +```dart +import 'package:hesabix_ui/services/receipt_payment_service.dart'; +``` + +### 2. ایجاد instance: + +```dart +final service = ReceiptPaymentService(apiClient); +``` + +### 3. ایجاد سند دریافت: + +```dart +try { + final result = await service.createReceipt( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 10, + 'person_name': 'علی احمدی', + 'amount': 1000000, + 'description': 'تسویه حساب', + } + ], + accountLines: [ + { + 'account_id': 456, + 'amount': 1000000, + 'description': '', + } + ], + ); + + print('سند با موفقیت ثبت شد: ${result['code']}'); +} catch (e) { + print('خطا در ثبت سند: $e'); +} +``` + +--- + +## 🗂️ انواع حساب‌های مورد استفاده + +| کد حساب | نام حساب | نوع | توضیحات | +|---------|----------|-----|---------| +| `10401` | حساب دریافتنی | `4` | طلب از مشتریان | +| `20201` | حساب پرداختنی | `9` | بدهی به تامین‌کنندگان | +| `10202` | صندوق | `1` | صندوق | +| `10203` | بانک | `3` | حساب بانکی | +| `10403` | اسناد دریافتنی | `5` | چک دریافتی | +| `20202` | اسناد پرداختنی | `10` | چک پرداختی | + +--- + +## ✅ قوانین و محدودیت‌ها + +### 1. تعادل سند: +- مجموع مبالغ **person_lines** باید برابر مجموع مبالغ **account_lines** باشد +- در غیر این صورت خطای `UNBALANCED_AMOUNTS` برگردانده می‌شود + +### 2. اعتبارسنجی: +- حداقل یک خط برای اشخاص الزامی است +- حداقل یک خط برای حساب‌ها الزامی است +- تمام مبالغ باید مثبت باشند +- ارز باید معتبر باشد + +### 3. ایجاد خودکار حساب شخص: +- اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد می‌شود +- کد حساب: `{parent_code}-{person_id}` + - برای دریافت: `10401-{person_id}` + - برای پرداخت: `20201-{person_id}` + +--- + +## 🔄 جریان کار (Workflow) + +```mermaid +graph TD + A[شروع] --> B[کاربر وارد صفحه دریافت/پرداخت می‌شود] + B --> C[انتخاب نوع: دریافت یا پرداخت] + C --> D[کلیک بر روی دکمه افزودن] + D --> E[باز شدن دیالوگ] + E --> F[وارد کردن اطلاعات اشخاص] + F --> G[وارد کردن اطلاعات حساب‌ها] + G --> H{تعادل برقرار است؟} + H -->|خیر| I[نمایش اختلاف] + I --> F + H -->|بله| J[فعال شدن دکمه ذخیره] + J --> K[کلیک بر روی ذخیره] + K --> L[ارسال به سرور] + L --> M{موفق؟} + M -->|بله| N[نمایش پیام موفقیت] + M -->|خیر| O[نمایش پیام خطا] + N --> P[بستن دیالوگ] + O --> E + P --> Q[به‌روزرسانی لیست] + Q --> R[پایان] +``` + +--- + +## 🧪 مثال‌های کاربردی + +### مثال 1: دریافت نقدی از مشتری + +```dart +await service.createReceipt( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 10, + 'person_name': 'شرکت ABC', + 'amount': 5000000, + 'description': 'دریافت بابت فاکتور شماره 123', + } + ], + accountLines: [ + { + 'account_id': 456, // صندوق + 'amount': 5000000, + } + ], +); +``` + +**نتیجه در حساب‌ها:** +``` +صندوق (10202) بدهکار: 5,000,000 +حساب دریافتنی - شرکت ABC بستانکار: 5,000,000 +``` + +--- + +### مثال 2: دریافت با چک از مشتری + +```dart +await service.createReceipt( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 15, + 'person_name': 'علی رضایی', + 'amount': 3000000, + 'description': 'دریافت بابت فاکتور 456', + } + ], + accountLines: [ + { + 'account_id': 789, // اسناد دریافتنی (چک) + 'amount': 3000000, + 'description': 'چک شماره 12345678', + } + ], +); +``` + +**نتیجه در حساب‌ها:** +``` +اسناد دریافتنی (10403) بدهکار: 3,000,000 +حساب دریافتنی - علی رضایی بستانکار: 3,000,000 +``` + +--- + +### مثال 3: دریافت مختلط (نقد + چک) + +```dart +await service.createReceipt( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 20, + 'person_name': 'محمد حسینی', + 'amount': 10000000, + 'description': 'تسویه کامل', + } + ], + accountLines: [ + { + 'account_id': 456, // صندوق + 'amount': 4000000, + 'description': 'نقد', + }, + { + 'account_id': 789, // چک دریافتنی + 'amount': 6000000, + 'description': 'چک شماره 87654321', + } + ], +); +``` + +**نتیجه در حساب‌ها:** +``` +صندوق (10202) بدهکار: 4,000,000 +اسناد دریافتنی (10403) بدهکار: 6,000,000 +حساب دریافتنی - محمد حسینی بستانکار: 10,000,000 +``` + +--- + +### مثال 4: پرداخت نقدی به تامین‌کننده + +```dart +await service.createPayment( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 30, + 'person_name': 'شرکت XYZ', + 'amount': 8000000, + 'description': 'پرداخت بابت خرید کالا', + } + ], + accountLines: [ + { + 'account_id': 456, // صندوق + 'amount': 8000000, + } + ], +); +``` + +**نتیجه در حساب‌ها:** +``` +حساب پرداختنی - شرکت XYZ بدهکار: 8,000,000 +صندوق (10202) بستانکار: 8,000,000 +``` + +--- + +### مثال 5: پرداخت به چند تامین‌کننده + +```dart +await service.createPayment( + businessId: 1, + documentDate: DateTime.now(), + currencyId: 1, + personLines: [ + { + 'person_id': 35, + 'person_name': 'تامین‌کننده A', + 'amount': 2000000, + }, + { + 'person_id': 40, + 'person_name': 'تامین‌کننده B', + 'amount': 3000000, + } + ], + accountLines: [ + { + 'account_id': 890, // بانک + 'amount': 5000000, + } + ], +); +``` + +**نتیجه در حساب‌ها:** +``` +حساب پرداختنی - تامین‌کننده A بدهکار: 2,000,000 +حساب پرداختنی - تامین‌کننده B بدهکار: 3,000,000 +بانک (10203) بستانکار: 5,000,000 +``` + +--- + +## 🐛 خطاهای رایج و راه‌حل + +| کد خطا | توضیحات | راه‌حل | +|--------|---------|--------| +| `INVALID_DOCUMENT_TYPE` | نوع سند نامعتبر | از "receipt" یا "payment" استفاده کنید | +| `CURRENCY_REQUIRED` | ارز الزامی است | currency_id را ارسال کنید | +| `PERSON_LINES_REQUIRED` | حداقل یک خط شخص الزامی | person_lines را پر کنید | +| `ACCOUNT_LINES_REQUIRED` | حداقل یک خط حساب الزامی | account_lines را پر کنید | +| `UNBALANCED_AMOUNTS` | عدم تعادل مبالغ | مجموع person_lines و account_lines باید برابر باشد | +| `PERSON_NOT_FOUND` | شخص یافت نشد | شناسه شخص را بررسی کنید | +| `ACCOUNT_NOT_FOUND` | حساب یافت نشد | شناسه حساب را بررسی کنید | + +--- + +## 📝 نکات مهم + +1. **تعادل سند**: همیشه مطمئن شوید که مجموع مبالغ اشخاص با مجموع مبالغ حساب‌ها برابر است. + +2. **ایجاد خودکار حساب**: اگر حساب شخص وجود نداشته باشد، به صورت خودکار ایجاد می‌شود. + +3. **کد سند**: کد سند به صورت خودکار با فرمت زیر تولید می‌شود: + - دریافت: `RC-YYYYMMDD-NNNN` + - پرداخت: `PY-YYYYMMDD-NNNN` + +4. **منطق حسابداری**: + - **دریافت**: شخص بستانکار، حساب (صندوق/بانک) بدهکار + - **پرداخت**: شخص بدهکار، حساب (صندوق/بانک) بستانکار + +5. **چند شخص/چند حساب**: می‌توانید در یک سند چند شخص و چند حساب داشته باشید. + +--- + +## 📚 منابع مرتبط + +- [مستندات API](/hesabixAPI/README.md) +- [راهنمای استفاده از Flutter](/hesabixUI/hesabix_ui/README.md) +- [ساختار حساب‌ها](/docs/ACCOUNTS_STRUCTURE.md) + +--- + +**تاریخ ایجاد**: 2025-01-13 +**نسخه**: 1.0.0 +**توسعه‌دهنده**: تیم Hesabix + diff --git a/hesabixAPI/adapters/api/v1/checks.py b/hesabixAPI/adapters/api/v1/checks.py new file mode 100644 index 0000000..c223c7a --- /dev/null +++ b/hesabixAPI/adapters/api/v1/checks.py @@ -0,0 +1,163 @@ +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request, Body +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import success_response, format_datetime_fields, ApiError +from app.core.permissions import require_business_management_dep, require_business_access +from adapters.api.v1.schemas import QueryInfo +from adapters.api.v1.schema_models.check import ( + CheckCreateRequest, + CheckUpdateRequest, +) +from app.services.check_service import ( + create_check, + update_check, + delete_check, + get_check_by_id, + list_checks, +) + + +router = APIRouter(prefix="/checks", tags=["checks"]) + + +@router.post( + "/businesses/{business_id}/checks", + summary="لیست چک‌های کسب‌وکار", + description="دریافت لیست چک‌ها با جستجو/فیلتر", +) +@require_business_access("business_id") +async def list_checks_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + query_dict: Dict[str, Any] = { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + "search_fields": query_info.search_fields, + "filters": query_info.filters, + } + # additional params: person_id (accept from query params or body) + # from query params + if request.query_params.get("person_id"): + try: + query_dict["person_id"] = int(request.query_params.get("person_id")) + except Exception: + pass + # from request body (DataTable additionalParams) + try: + body_json = await request.json() + if isinstance(body_json, dict) and body_json.get("person_id") is not None: + try: + query_dict["person_id"] = int(body_json.get("person_id")) + except Exception: + pass + except Exception: + pass + result = list_checks(db, business_id, query_dict) + result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])] + return success_response(data=result, request=request, message="CHECKS_LIST_FETCHED") + + +@router.post( + "/businesses/{business_id}/checks/create", + summary="ایجاد چک", + description="ایجاد چک جدید برای کسب‌وکار", +) +@require_business_access("business_id") +async def create_check_endpoint( + request: Request, + business_id: int, + body: CheckCreateRequest = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + payload: Dict[str, Any] = body.model_dump(exclude_unset=True) + created = create_check(db, business_id, payload) + return success_response(data=format_datetime_fields(created, request), request=request, message="CHECK_CREATED") + + +@router.get( + "/checks/{check_id}", + summary="جزئیات چک", + description="دریافت جزئیات چک", +) +async def get_check_endpoint( + request: Request, + check_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + result = get_check_by_id(db, check_id) + if not result: + raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404) + try: + biz_id = int(result.get("business_id")) + except Exception: + biz_id = None + if biz_id is not None and not ctx.can_access_business(biz_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_DETAILS") + + +@router.put( + "/checks/{check_id}", + summary="ویرایش چک", + description="ویرایش اطلاعات چک", +) +async def update_check_endpoint( + request: Request, + check_id: int, + body: CheckUpdateRequest = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + payload: Dict[str, Any] = body.model_dump(exclude_unset=True) + result = update_check(db, check_id, payload) + if result is None: + raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404) + try: + biz_id = int(result.get("business_id")) + except Exception: + biz_id = None + if biz_id is not None and not ctx.can_access_business(biz_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + return success_response(data=format_datetime_fields(result, request), request=request, message="CHECK_UPDATED") + + +@router.delete( + "/checks/{check_id}", + summary="حذف چک", + description="حذف یک چک", +) +async def delete_check_endpoint( + request: Request, + check_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + result = get_check_by_id(db, check_id) + if result: + try: + biz_id = int(result.get("business_id")) + except Exception: + biz_id = None + if biz_id is not None and not ctx.can_access_business(biz_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + ok = delete_check(db, check_id) + if not ok: + raise ApiError("CHECK_NOT_FOUND", "Check not found", http_status=404) + return success_response(data=None, request=request, message="CHECK_DELETED") + + diff --git a/hesabixAPI/adapters/api/v1/receipts_payments.py b/hesabixAPI/adapters/api/v1/receipts_payments.py new file mode 100644 index 0000000..20ff595 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/receipts_payments.py @@ -0,0 +1,203 @@ +""" +API endpoints برای دریافت و پرداخت (Receipt & Payment) +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request, Body +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.responses import success_response, format_datetime_fields, ApiError +from app.core.permissions import require_business_management_dep, require_business_access +from adapters.api.v1.schemas import QueryInfo +from app.services.receipt_payment_service import ( + create_receipt_payment, + get_receipt_payment, + list_receipts_payments, + delete_receipt_payment, +) + + +router = APIRouter(tags=["receipts-payments"]) + + +@router.post( + "/businesses/{business_id}/receipts-payments", + summary="لیست اسناد دریافت و پرداخت", + description="دریافت لیست اسناد دریافت و پرداخت با فیلتر و جستجو", +) +@require_business_access("business_id") +async def list_receipts_payments_endpoint( + request: Request, + business_id: int, + query_info: QueryInfo = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """ + لیست اسناد دریافت و پرداخت + + پارامترهای اضافی در body: + - document_type: "receipt" یا "payment" (اختیاری) + - from_date: تاریخ شروع (اختیاری) + - to_date: تاریخ پایان (اختیاری) + """ + query_dict: Dict[str, Any] = { + "take": query_info.take, + "skip": query_info.skip, + "sort_by": query_info.sort_by, + "sort_desc": query_info.sort_desc, + "search": query_info.search, + } + + # دریافت پارامترهای اضافی از body + try: + body_json = await request.json() + if isinstance(body_json, dict): + for key in ["document_type", "from_date", "to_date"]: + if key in body_json: + query_dict[key] = body_json[key] + except Exception: + pass + + result = list_receipts_payments(db, business_id, query_dict) + result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])] + + return success_response( + data=result, + request=request, + message="RECEIPTS_PAYMENTS_LIST_FETCHED" + ) + + +@router.post( + "/businesses/{business_id}/receipts-payments/create", + summary="ایجاد سند دریافت یا پرداخت", + description="ایجاد سند دریافت یا پرداخت جدید", +) +@require_business_access("business_id") +async def create_receipt_payment_endpoint( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ + ایجاد سند دریافت یا پرداخت + + Body باید شامل موارد زیر باشد: + { + "document_type": "receipt" | "payment", + "document_date": "2025-01-15T10:30:00", + "currency_id": 1, + "person_lines": [ + { + "person_id": 123, + "person_name": "علی احمدی", + "amount": 1000000, + "description": "توضیحات (اختیاری)" + } + ], + "account_lines": [ + { + "account_id": 456, + "amount": 1000000, + "transaction_type": "bank" | "cash_register" | "petty_cash" | "check", + "transaction_date": "2025-01-15T10:30:00", + "commission": 5000, // اختیاری + "description": "توضیحات (اختیاری)", + // اطلاعات اضافی بر اساس نوع تراکنش: + "bank_id": "123", // برای نوع bank + "bank_name": "بانک ملی", + "cash_register_id": "456", // برای نوع cash_register + "cash_register_name": "صندوق اصلی", + "petty_cash_id": "789", // برای نوع petty_cash + "petty_cash_name": "تنخواهگردان فروش", + "check_id": "101", // برای نوع check + "check_number": "123456" + } + ], + "extra_info": {} // اختیاری + } + """ + created = create_receipt_payment(db, business_id, ctx.get_user_id(), body) + + return success_response( + data=format_datetime_fields(created, request), + request=request, + message="RECEIPT_PAYMENT_CREATED" + ) + + +@router.get( + "/receipts-payments/{document_id}", + summary="جزئیات سند دریافت/پرداخت", + description="دریافت جزئیات یک سند دریافت یا پرداخت", +) +async def get_receipt_payment_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """دریافت جزئیات سند""" + result = get_receipt_payment(db, document_id) + + if not result: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Receipt/Payment document not found", + http_status=404 + ) + + # بررسی دسترسی + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + return success_response( + data=format_datetime_fields(result, request), + request=request, + message="RECEIPT_PAYMENT_DETAILS" + ) + + +@router.delete( + "/receipts-payments/{document_id}", + summary="حذف سند دریافت/پرداخت", + description="حذف یک سند دریافت یا پرداخت", +) +async def delete_receipt_payment_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """حذف سند""" + # دریافت سند برای بررسی دسترسی + result = get_receipt_payment(db, document_id) + + if result: + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + ok = delete_receipt_payment(db, document_id) + + if not ok: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Receipt/Payment document not found", + http_status=404 + ) + + return success_response( + data=None, + request=request, + message="RECEIPT_PAYMENT_DELETED" + ) + diff --git a/hesabixAPI/adapters/api/v1/schema_models/check.py b/hesabixAPI/adapters/api/v1/schema_models/check.py new file mode 100644 index 0000000..dbcd69d --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/check.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Optional, Literal +from pydantic import BaseModel, Field, field_validator + + +class CheckCreateRequest(BaseModel): + type: Literal['received', 'transferred'] + person_id: Optional[int] = Field(default=None, ge=1) + issue_date: str + due_date: str + check_number: str = Field(..., min_length=1, max_length=50) + sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16) + bank_name: Optional[str] = Field(default=None, max_length=255) + branch_name: Optional[str] = Field(default=None, max_length=255) + amount: float = Field(..., gt=0) + currency_id: int = Field(..., ge=1) + + @field_validator('sayad_code') + @classmethod + def validate_sayad(cls, v: Optional[str]): + if v is None: + return v + if not v.isdigit(): + raise ValueError('شناسه صیاد باید فقط عددی باشد') + return v + + +class CheckUpdateRequest(BaseModel): + type: Optional[Literal['received', 'transferred']] = None + person_id: Optional[int] = Field(default=None, ge=1) + issue_date: Optional[str] = None + due_date: Optional[str] = None + check_number: Optional[str] = Field(default=None, min_length=1, max_length=50) + sayad_code: Optional[str] = Field(default=None, min_length=16, max_length=16) + bank_name: Optional[str] = Field(default=None, max_length=255) + branch_name: Optional[str] = Field(default=None, max_length=255) + amount: Optional[float] = Field(default=None, gt=0) + currency_id: Optional[int] = Field(default=None, ge=1) + + @field_validator('sayad_code') + @classmethod + def validate_sayad(cls, v: Optional[str]): + if v is None: + return v + if not v.isdigit(): + raise ValueError('شناسه صیاد باید فقط عددی باشد') + return v + + +class CheckResponse(BaseModel): + id: int + business_id: int + type: str + person_id: Optional[int] + person_name: Optional[str] + issue_date: str + due_date: str + check_number: str + sayad_code: Optional[str] + bank_name: Optional[str] + branch_name: Optional[str] + amount: float + currency_id: int + currency: Optional[str] + created_at: str + updated_at: str + + class Config: + from_attributes = True + + diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index 01498e3..aee5a31 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -39,3 +39,4 @@ from .tax_unit import TaxUnit # noqa: F401 from .tax_type import TaxType # noqa: F401 from .bank_account import BankAccount # noqa: F401 from .petty_cash import PettyCash # noqa: F401 +from .check import Check # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/check.py b/hesabixAPI/adapters/db/models/check.py new file mode 100644 index 0000000..f428344 --- /dev/null +++ b/hesabixAPI/adapters/db/models/check.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from sqlalchemy import ( + String, + Integer, + DateTime, + ForeignKey, + UniqueConstraint, + Numeric, + Enum as SQLEnum, + Index, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from adapters.db.session import Base + + +class CheckType(str, Enum): + RECEIVED = "received" + TRANSFERRED = "transferred" + + +class Check(Base): + __tablename__ = "checks" + __table_args__ = ( + # پیشنهاد: یکتا بودن شماره چک در سطح کسب‌وکار + UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'), + # پیشنهاد: یکتا بودن شناسه صیاد در سطح کسب‌وکار (چند NULL مجاز است) + UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'), + Index('ix_checks_business_type', 'business_id', 'type'), + Index('ix_checks_business_person', 'business_id', 'person_id'), + Index('ix_checks_business_issue_date', 'business_id', 'issue_date'), + Index('ix_checks_business_due_date', 'business_id', 'due_date'), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + business_id: Mapped[int] = mapped_column(Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True) + + type: Mapped[CheckType] = mapped_column(SQLEnum(CheckType, name="check_type"), nullable=False, index=True) + person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True) + + issue_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + due_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + + check_number: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + sayad_code: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + + bank_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) + currency_id: Mapped[int] = mapped_column(Integer, ForeignKey("currencies.id", ondelete="RESTRICT"), nullable=False, index=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # روابط + business = relationship("Business", backref="checks") + person = relationship("Person", lazy="joined") + currency = relationship("Currency") + + diff --git a/hesabixAPI/adapters/db/models/document_line.py b/hesabixAPI/adapters/db/models/document_line.py index 494012b..66b48a5 100644 --- a/hesabixAPI/adapters/db/models/document_line.py +++ b/hesabixAPI/adapters/db/models/document_line.py @@ -15,6 +15,13 @@ class DocumentLine(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) document_id: Mapped[int] = mapped_column(Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True) account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=True, index=True) + person_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("persons.id", ondelete="SET NULL"), nullable=True, index=True) + product_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("products.id", ondelete="SET NULL"), nullable=True, index=True) + bank_account_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("bank_accounts.id", ondelete="SET NULL"), nullable=True, index=True) + cash_register_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("cash_registers.id", ondelete="SET NULL"), nullable=True, index=True) + petty_cash_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("petty_cash.id", ondelete="SET NULL"), nullable=True, index=True) + check_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("checks.id", ondelete="SET NULL"), nullable=True, index=True) + quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 6), nullable=True, default=0) debit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0) credit: Mapped[Decimal] = mapped_column(Numeric(18, 2), nullable=False, default=0) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -26,5 +33,11 @@ class DocumentLine(Base): # Relationships document = relationship("Document", back_populates="lines") account = relationship("Account", back_populates="document_lines") + person = relationship("Person") + product = relationship("Product") + bank_account = relationship("BankAccount") + cash_register = relationship("CashRegister") + petty_cash = relationship("PettyCash") + check = relationship("Check") diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index b517d91..dc3aca0 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -30,6 +30,7 @@ from adapters.api.v1.support.priorities import router as support_priorities_rout from adapters.api.v1.support.statuses import router as support_statuses_router from adapters.api.v1.admin.file_storage import router as admin_file_storage_router from adapters.api.v1.admin.email_config import router as admin_email_config_router +from adapters.api.v1.receipts_payments import router as receipts_payments_router from app.core.i18n import negotiate_locale, Translator from app.core.error_handlers import register_error_handlers from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig @@ -299,10 +300,13 @@ def create_app() -> FastAPI: application.include_router(persons_router, prefix=settings.api_v1_prefix) application.include_router(customers_router, prefix=settings.api_v1_prefix) application.include_router(bank_accounts_router, prefix=settings.api_v1_prefix) + from adapters.api.v1.checks import router as checks_router + application.include_router(checks_router, prefix=settings.api_v1_prefix) application.include_router(cash_registers_router, prefix=settings.api_v1_prefix) application.include_router(petty_cash_router, prefix=settings.api_v1_prefix) application.include_router(tax_units_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix) + application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix) # Support endpoints application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") diff --git a/hesabixAPI/app/services/check_service.py b/hesabixAPI/app/services/check_service.py new file mode 100644 index 0000000..32db88a --- /dev/null +++ b/hesabixAPI/app/services/check_service.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from datetime import datetime + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, func + +from adapters.db.models.check import Check, CheckType +from adapters.db.models.person import Person +from adapters.db.models.currency import Currency +from app.core.responses import ApiError + + +def _parse_iso(dt: str) -> datetime: + try: + return datetime.fromisoformat(dt.replace('Z', '+00:00')) + except Exception: + raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400) + + +def create_check(db: Session, business_id: int, data: Dict[str, Any]) -> Dict[str, Any]: + ctype = str(data.get('type', '')).lower() + if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): + raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) + + person_id = data.get('person_id') + if ctype == CheckType.RECEIVED.value and not person_id: + raise ApiError("PERSON_REQUIRED", "person_id is required for received checks", http_status=400) + + issue_date = _parse_iso(str(data.get('issue_date'))) + due_date = _parse_iso(str(data.get('due_date'))) + if due_date < issue_date: + raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400) + + sayad = data.get('sayad_code') + if sayad is not None: + s = str(sayad).strip() + if s and (len(s) != 16 or not s.isdigit()): + raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400) + + amount = data.get('amount') + try: + amount_val = float(amount) + except Exception: + raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400) + if amount_val <= 0: + raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400) + + check_number = str(data.get('check_number', '')).strip() + if not check_number: + raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400) + + # یونیک بودن در سطح کسب‌وکار + exists = db.query(Check).filter(and_(Check.business_id == business_id, Check.check_number == check_number)).first() + if exists is not None: + raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400) + + if sayad: + exists_sayad = db.query(Check).filter(and_(Check.business_id == business_id, Check.sayad_code == sayad)).first() + if exists_sayad is not None: + raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400) + + obj = Check( + business_id=business_id, + type=CheckType(ctype), + person_id=int(person_id) if person_id else None, + issue_date=issue_date, + due_date=due_date, + check_number=check_number, + sayad_code=str(sayad).strip() if sayad else None, + bank_name=(str(data.get('bank_name')).strip() if data.get('bank_name') else None), + branch_name=(str(data.get('branch_name')).strip() if data.get('branch_name') else None), + amount=amount_val, + currency_id=int(data.get('currency_id')), + ) + + db.add(obj) + db.commit() + db.refresh(obj) + return check_to_dict(db, obj) + + +def get_check_by_id(db: Session, check_id: int) -> Optional[Dict[str, Any]]: + obj = db.query(Check).filter(Check.id == check_id).first() + return check_to_dict(db, obj) if obj else None + + +def update_check(db: Session, check_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + obj = db.query(Check).filter(Check.id == check_id).first() + if obj is None: + return None + + if 'type' in data and data['type'] is not None: + ctype = str(data['type']).lower() + if ctype not in (CheckType.RECEIVED.value, CheckType.TRANSFERRED.value): + raise ApiError("INVALID_CHECK_TYPE", "Invalid check type", http_status=400) + obj.type = CheckType(ctype) + + if 'person_id' in data: + obj.person_id = int(data['person_id']) if data['person_id'] is not None else None + + if 'issue_date' in data and data['issue_date'] is not None: + obj.issue_date = _parse_iso(str(data['issue_date'])) + if 'due_date' in data and data['due_date'] is not None: + obj.due_date = _parse_iso(str(data['due_date'])) + if obj.due_date < obj.issue_date: + raise ApiError("INVALID_DATES", "due_date must be >= issue_date", http_status=400) + + if 'check_number' in data and data['check_number'] is not None: + new_num = str(data['check_number']).strip() + if not new_num: + raise ApiError("CHECK_NUMBER_REQUIRED", "check_number is required", http_status=400) + exists = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.check_number == new_num, Check.id != obj.id)).first() + if exists is not None: + raise ApiError("DUPLICATE_CHECK_NUMBER", "Duplicate check number in this business", http_status=400) + obj.check_number = new_num + + if 'sayad_code' in data: + s = data['sayad_code'] + if s is not None: + s = str(s).strip() + if s and (len(s) != 16 or not s.isdigit()): + raise ApiError("INVALID_SAYAD", "sayad_code must be 16 digits", http_status=400) + if s: + exists_sayad = db.query(Check).filter(and_(Check.business_id == obj.business_id, Check.sayad_code == s, Check.id != obj.id)).first() + if exists_sayad is not None: + raise ApiError("DUPLICATE_SAYAD", "Duplicate sayad_code in this business", http_status=400) + obj.sayad_code = s if s else None + + for field in ["bank_name", "branch_name"]: + if field in data: + setattr(obj, field, (str(data[field]).strip() if data[field] is not None else None)) + + if 'amount' in data and data['amount'] is not None: + try: + amount_val = float(data['amount']) + except Exception: + raise ApiError("INVALID_AMOUNT", "amount must be a number", http_status=400) + if amount_val <= 0: + raise ApiError("INVALID_AMOUNT", "amount must be > 0", http_status=400) + obj.amount = amount_val + + if 'currency_id' in data and data['currency_id'] is not None: + obj.currency_id = int(data['currency_id']) + + db.commit() + db.refresh(obj) + return check_to_dict(db, obj) + + +def delete_check(db: Session, check_id: int) -> bool: + obj = db.query(Check).filter(Check.id == check_id).first() + if obj is None: + return False + db.delete(obj) + db.commit() + return True + + +def list_checks(db: Session, business_id: int, query: Dict[str, Any]) -> Dict[str, Any]: + q = db.query(Check).filter(Check.business_id == business_id) + + # جستجو + if query.get("search") and query.get("search_fields"): + term = f"%{query['search']}%" + conditions = [] + for f in query["search_fields"]: + if f == "check_number": + conditions.append(Check.check_number.ilike(term)) + elif f == "sayad_code": + conditions.append(Check.sayad_code.ilike(term)) + elif f == "bank_name": + conditions.append(Check.bank_name.ilike(term)) + elif f == "branch_name": + conditions.append(Check.branch_name.ilike(term)) + elif f == "person_name": + # join به persons + q = q.join(Person, Check.person_id == Person.id, isouter=True) + conditions.append(Person.alias_name.ilike(term)) + if conditions: + from sqlalchemy import or_ + q = q.filter(or_(*conditions)) + + # فیلترها + if query.get("filters"): + from app.core.calendar import CalendarConverter + for flt in query["filters"]: + prop = getattr(flt, 'property', None) if not isinstance(flt, dict) else flt.get('property') + op = getattr(flt, 'operator', None) if not isinstance(flt, dict) else flt.get('operator') + val = getattr(flt, 'value', None) if not isinstance(flt, dict) else flt.get('value') + if not prop or not op: + continue + if prop == 'type' and op == '=': + q = q.filter(Check.type == val) + elif prop == 'currency' and op == '=': + try: + q = q.filter(Check.currency_id == int(val)) + except Exception: + pass + elif prop == 'person_id' and op == '=': + try: + q = q.filter(Check.person_id == int(val)) + except Exception: + pass + elif prop in ('issue_date', 'due_date'): + # انتظار: فیلترهای بازه با اپراتورهای ">=" و "<=" از DataTable + try: + if isinstance(val, str) and val: + # ورودی تاریخ ممکن است بر اساس هدر تقویم باشد؛ در این لایه فرض بر ISO است (از فرانت ارسال می‌شود) + dt = _parse_iso(val) + col = getattr(Check, prop) + if op == ">=": + q = q.filter(col >= dt) + elif op == "<=": + q = q.filter(col <= dt) + except Exception: + pass + + # additional params: person_id + person_param = query.get('person_id') + if person_param: + try: + q = q.filter(Check.person_id == int(person_param)) + except Exception: + pass + + # مرتب‌سازی + sort_by = query.get("sort_by") or "created_at" + sort_desc = bool(query.get("sort_desc", True)) + col = getattr(Check, sort_by, Check.created_at) + q = q.order_by(col.desc() if sort_desc else col.asc()) + + # صفحه‌بندی + skip = int(query.get("skip", 0)) + take = int(query.get("take", 20)) + total = q.count() + items = q.offset(skip).limit(take).all() + + return { + "items": [check_to_dict(db, i) for i in items], + "pagination": { + "total": total, + "page": (skip // take) + 1, + "per_page": take, + "total_pages": (total + take - 1) // take, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + "query_info": query, + } + + +def check_to_dict(db: Session, obj: Optional[Check]) -> Optional[Dict[str, Any]]: + if obj is None: + return None + person_name = None + if obj.person_id: + p = db.query(Person).filter(Person.id == obj.person_id).first() + person_name = getattr(p, 'alias_name', None) + currency_title = None + try: + c = db.query(Currency).filter(Currency.id == obj.currency_id).first() + currency_title = c.title or c.code if c else None + except Exception: + pass + return { + "id": obj.id, + "business_id": obj.business_id, + "type": obj.type.value, + "person_id": obj.person_id, + "person_name": person_name, + "issue_date": obj.issue_date.isoformat(), + "due_date": obj.due_date.isoformat(), + "check_number": obj.check_number, + "sayad_code": obj.sayad_code, + "bank_name": obj.bank_name, + "branch_name": obj.branch_name, + "amount": float(obj.amount), + "currency_id": obj.currency_id, + "currency": currency_title, + "created_at": obj.created_at.isoformat(), + "updated_at": obj.updated_at.isoformat(), + } + + diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py new file mode 100644 index 0000000..bf544f3 --- /dev/null +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -0,0 +1,534 @@ +""" +سرویس دریافت و پرداخت (Receipt & Payment) + +این سرویس برای ثبت اسناد دریافت و پرداخت استفاده می‌شود که شامل: +- دریافت وجه از اشخاص (مشتریان) +- پرداخت به اشخاص (تامین‌کنندگان) +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from datetime import datetime, date +from decimal import Decimal + +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, func + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +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 app.core.responses import ApiError + + +# نوع‌های سند +DOCUMENT_TYPE_RECEIPT = "receipt" # دریافت +DOCUMENT_TYPE_PAYMENT = "payment" # پرداخت + +# نوع‌های حساب (از migration) +ACCOUNT_TYPE_RECEIVABLE = "person" # حساب دریافتنی +ACCOUNT_TYPE_PAYABLE = "person" # حساب پرداختنی +ACCOUNT_TYPE_CASH = "cash_register" # صندوق +ACCOUNT_TYPE_BANK = "bank" # بانک +ACCOUNT_TYPE_CHECK_RECEIVED = "check" # اسناد دریافتنی (چک دریافتی) +ACCOUNT_TYPE_CHECK_PAYABLE = "check" # اسناد پرداختنی (چک پرداختی) + + +def _parse_iso_date(dt: str | datetime | date) -> date: + """تبدیل تاریخ به فرمت date""" + if isinstance(dt, date): + return dt + if isinstance(dt, datetime): + return dt.date() + try: + parsed = datetime.fromisoformat(str(dt).replace('Z', '+00:00')) + return parsed.date() + except Exception: + raise ApiError("INVALID_DATE", f"Invalid date: {dt}", http_status=400) + + +def _get_or_create_person_account( + db: Session, + business_id: int, + person_id: int, + is_receivable: bool +) -> Account: + """ + ایجاد یا دریافت حساب شخص (حساب دریافتنی یا پرداختنی) + + Args: + business_id: شناسه کسب‌وکار + person_id: شناسه شخص + is_receivable: اگر True باشد، حساب دریافتنی و اگر False باشد حساب پرداختنی + + Returns: + Account: حساب شخص + """ + person = db.query(Person).filter( + and_(Person.id == person_id, Person.business_id == business_id) + ).first() + + 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 + + # پیدا کردن حساب والد + 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 + + +def create_receipt_payment( + db: Session, + business_id: int, + user_id: int, + data: Dict[str, Any] +) -> Dict[str, Any]: + """ + ایجاد سند دریافت یا پرداخت + + Args: + business_id: شناسه کسب‌وکار + user_id: شناسه کاربر ایجادکننده + data: اطلاعات سند شامل: + - document_type: "receipt" یا "payment" + - document_date: تاریخ سند + - currency_id: شناسه ارز + - person_lines: لیست تراکنش‌های اشخاص [{"person_id": int, "amount": float, "description": str?}, ...] + - account_lines: لیست تراکنش‌های حساب‌ها [{"account_id": int, "amount": float, "description": str?}, ...] + - extra_info: اطلاعات اضافی (اختیاری) + + Returns: + Dict: اطلاعات سند ایجاد شده + """ + # اعتبارسنجی نوع سند + document_type = str(data.get("document_type", "")).lower() + 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) + + # اعتبارسنجی تاریخ + document_date = _parse_iso_date(data.get("document_date", datetime.now())) + + # اعتبارسنجی ارز + currency_id = data.get("currency_id") + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + # اعتبارسنجی خطوط اشخاص + person_lines = data.get("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", []) + 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) + + # محاسبه مجموع مبالغ + person_total = sum(float(line.get("amount", 0)) for line in person_lines) + account_total = sum(float(line.get("amount", 0)) for line in account_lines) + + # بررسی تعادل مبالغ + if abs(person_total - account_total) > 0.01: # tolerance برای خطای ممیز شناور + raise ApiError( + "UNBALANCED_AMOUNTS", + f"Person total ({person_total}) must equal account total ({account_total})", + http_status=400 + ) + + # تولید کد سند + # فرمت: RP-YYYYMMDD-NNNN (RP = Receipt/Payment) + today = datetime.now().date() + prefix = f"{'RC' if is_receipt else 'PY'}-{today.strftime('%Y%m%d')}" + + last_doc = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.code.like(f"{prefix}-%") + ) + ).order_by(Document.code.desc()).first() + + if last_doc: + try: + last_num = int(last_doc.code.split("-")[-1]) + next_num = last_num + 1 + except Exception: + next_num = 1 + else: + next_num = 1 + + doc_code = f"{prefix}-{next_num:04d}" + + # ایجاد سند + document = Document( + business_id=business_id, + code=doc_code, + document_type=document_type, + document_date=document_date, + currency_id=int(currency_id), + created_by_user_id=user_id, + registered_at=datetime.utcnow(), + is_proforma=False, + extra_info=data.get("extra_info"), + ) + db.add(document) + db.flush() # برای دریافت document.id + + # ایجاد خطوط سند برای اشخاص + for person_line in person_lines: + person_id = person_line.get("person_id") + if not person_id: + continue + + amount = Decimal(str(person_line.get("amount", 0))) + if amount <= 0: + continue + + description = person_line.get("description", "").strip() or None + + # دریافت یا ایجاد حساب شخص + # در دریافت: حساب دریافتنی (receivable) + # در پرداخت: حساب پرداختنی (payable) + person_account = _get_or_create_person_account( + db, + business_id, + int(person_id), + is_receivable=is_receipt + ) + + # ایجاد خط سند برای شخص + # در دریافت: شخص بستانکار (credit) + # در پرداخت: شخص بدهکار (debit) + 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), + description=description, + extra_info={ + "person_id": int(person_id), + "person_name": person_line.get("person_name"), + } + ) + db.add(line) + + # ایجاد خطوط سند برای حساب‌ها + for account_line in account_lines: + account_id = account_line.get("account_id") + if not account_id: + continue + + amount = Decimal(str(account_line.get("amount", 0))) + if amount <= 0: + continue + + description = account_line.get("description", "").strip() or None + transaction_type = account_line.get("transaction_type") + 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 # حساب‌های عمومی + ) + ) + ).first() + + if not account: + raise ApiError( + "ACCOUNT_NOT_FOUND", + f"Account with id {account_id} not found", + http_status=404 + ) + + # ایجاد اطلاعات اضافی برای خط سند + extra_info = {} + if transaction_type: + extra_info["transaction_type"] = transaction_type + if transaction_date: + extra_info["transaction_date"] = transaction_date + if commission: + extra_info["commission"] = float(commission) + + # اطلاعات اضافی بر اساس نوع تراکنش + if transaction_type == "bank": + if account_line.get("bank_id"): + extra_info["bank_id"] = account_line.get("bank_id") + if account_line.get("bank_name"): + extra_info["bank_name"] = account_line.get("bank_name") + elif transaction_type == "cash_register": + if account_line.get("cash_register_id"): + extra_info["cash_register_id"] = account_line.get("cash_register_id") + if account_line.get("cash_register_name"): + extra_info["cash_register_name"] = account_line.get("cash_register_name") + elif transaction_type == "petty_cash": + if account_line.get("petty_cash_id"): + extra_info["petty_cash_id"] = account_line.get("petty_cash_id") + if account_line.get("petty_cash_name"): + extra_info["petty_cash_name"] = account_line.get("petty_cash_name") + elif transaction_type == "check": + if account_line.get("check_id"): + extra_info["check_id"] = account_line.get("check_id") + if account_line.get("check_number"): + extra_info["check_number"] = account_line.get("check_number") + + # ایجاد خط سند برای حساب + # در دریافت: حساب بدهکار (debit) - دارایی افزایش می‌یابد + # در پرداخت: حساب بستانکار (credit) - دارایی کاهش می‌یابد + 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), + description=description, + extra_info=extra_info if extra_info else None, + ) + db.add(line) + + # ذخیره تغییرات + db.commit() + db.refresh(document) + + return document_to_dict(db, document) + + +def get_receipt_payment(db: Session, document_id: int) -> Optional[Dict[str, Any]]: + """دریافت جزئیات یک سند دریافت/پرداخت""" + document = db.query(Document).filter(Document.id == document_id).first() + if not document: + return None + + if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT): + return None + + return document_to_dict(db, document) + + +def list_receipts_payments( + db: Session, + business_id: int, + query: Dict[str, Any] +) -> Dict[str, Any]: + """لیست اسناد دریافت و پرداخت""" + q = db.query(Document).filter( + and_( + Document.business_id == business_id, + Document.document_type.in_([DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT]) + ) + ) + + # فیلتر بر اساس نوع + doc_type = query.get("document_type") + if doc_type: + q = q.filter(Document.document_type == doc_type) + + # فیلتر بر اساس تاریخ + from_date = query.get("from_date") + to_date = query.get("to_date") + + if from_date: + try: + from_dt = _parse_iso_date(from_date) + q = q.filter(Document.document_date >= from_dt) + except Exception: + pass + + if to_date: + try: + to_dt = _parse_iso_date(to_date) + q = q.filter(Document.document_date <= to_dt) + except Exception: + pass + + # جستجو + search = query.get("search") + if search: + q = q.filter(Document.code.ilike(f"%{search}%")) + + # مرتب‌سازی + sort_by = query.get("sort_by", "document_date") + sort_desc = query.get("sort_desc", True) + + if hasattr(Document, sort_by): + col = getattr(Document, sort_by) + q = q.order_by(col.desc() if sort_desc else col.asc()) + else: + q = q.order_by(Document.document_date.desc()) + + # صفحه‌بندی + skip = int(query.get("skip", 0)) + take = int(query.get("take", 20)) + + total = q.count() + items = q.offset(skip).limit(take).all() + + return { + "items": [document_to_dict(db, doc) for doc in items], + "pagination": { + "total": total, + "page": (skip // take) + 1, + "per_page": take, + "total_pages": (total + take - 1) // take, + "has_next": skip + take < total, + "has_prev": skip > 0, + }, + "query_info": query, + } + + +def delete_receipt_payment(db: Session, document_id: int) -> bool: + """حذف سند دریافت/پرداخت""" + document = db.query(Document).filter(Document.id == document_id).first() + + if not document: + return False + + if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT): + return False + + db.delete(document) + db.commit() + + return True + + +def document_to_dict(db: Session, document: Document) -> Dict[str, Any]: + """تبدیل سند به دیکشنری""" + # دریافت خطوط سند + lines = db.query(DocumentLine).filter(DocumentLine.document_id == document.id).all() + + # جداسازی خطوط اشخاص و حساب‌ها + person_lines = [] + account_lines = [] + + for line in lines: + account = db.query(Account).filter(Account.id == line.account_id).first() + if not account: + continue + + line_dict = { + "id": line.id, + "account_id": line.account_id, + "account_name": account.name, + "account_code": account.code, + "account_type": account.account_type, + "debit": float(line.debit), + "credit": float(line.credit), + "amount": float(line.debit if line.debit > 0 else line.credit), + "description": line.description, + "extra_info": line.extra_info, + } + + # اضافه کردن اطلاعات اضافی از extra_info + if line.extra_info: + if "transaction_type" in line.extra_info: + line_dict["transaction_type"] = line.extra_info["transaction_type"] + if "transaction_date" in line.extra_info: + line_dict["transaction_date"] = line.extra_info["transaction_date"] + if "commission" in line.extra_info: + line_dict["commission"] = line.extra_info["commission"] + if "bank_id" in line.extra_info: + line_dict["bank_id"] = line.extra_info["bank_id"] + if "bank_name" in line.extra_info: + line_dict["bank_name"] = line.extra_info["bank_name"] + if "cash_register_id" in line.extra_info: + line_dict["cash_register_id"] = line.extra_info["cash_register_id"] + if "cash_register_name" in line.extra_info: + line_dict["cash_register_name"] = line.extra_info["cash_register_name"] + if "petty_cash_id" in line.extra_info: + line_dict["petty_cash_id"] = line.extra_info["petty_cash_id"] + if "petty_cash_name" in line.extra_info: + line_dict["petty_cash_name"] = line.extra_info["petty_cash_name"] + if "check_id" in line.extra_info: + 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 line.extra_info and line.extra_info.get("person_id"): + person_lines.append(line_dict) + else: + account_lines.append(line_dict) + + # دریافت اطلاعات کاربر ایجادکننده + created_by = db.query(User).filter(User.id == document.created_by_user_id).first() + created_by_name = f"{created_by.first_name} {created_by.last_name}".strip() if created_by else None + + # دریافت اطلاعات ارز + currency = db.query(Currency).filter(Currency.id == document.currency_id).first() + currency_code = currency.code if currency else None + + return { + "id": document.id, + "code": document.code, + "business_id": document.business_id, + "document_type": document.document_type, + "document_date": document.document_date.isoformat(), + "registered_at": document.registered_at.isoformat(), + "currency_id": document.currency_id, + "currency_code": currency_code, + "created_by_user_id": document.created_by_user_id, + "created_by_name": created_by_name, + "is_proforma": document.is_proforma, + "extra_info": document.extra_info, + "person_lines": person_lines, + "account_lines": account_lines, + "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 84a012c..475bd39 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -11,14 +11,17 @@ adapters/api/v1/business_users.py adapters/api/v1/businesses.py adapters/api/v1/cash_registers.py adapters/api/v1/categories.py +adapters/api/v1/checks.py adapters/api/v1/currencies.py adapters/api/v1/customers.py adapters/api/v1/health.py +adapters/api/v1/invoices.py adapters/api/v1/persons.py adapters/api/v1/petty_cash.py adapters/api/v1/price_lists.py adapters/api/v1/product_attributes.py adapters/api/v1/products.py +adapters/api/v1/receipts_payments.py adapters/api/v1/schemas.py adapters/api/v1/tax_types.py adapters/api/v1/tax_units.py @@ -28,6 +31,7 @@ adapters/api/v1/admin/file_storage.py 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/email.py adapters/api/v1/schema_models/file_storage.py adapters/api/v1/schema_models/person.py @@ -52,6 +56,7 @@ adapters/db/models/business_permission.py adapters/db/models/captcha.py adapters/db/models/cash_register.py adapters/db/models/category.py +adapters/db/models/check.py adapters/db/models/currency.py adapters/db/models/document.py adapters/db/models/document_line.py @@ -118,6 +123,7 @@ app/services/business_dashboard_service.py app/services/business_service.py app/services/captcha_service.py app/services/cash_register_service.py +app/services/check_service.py app/services/email_service.py app/services/file_storage_service.py app/services/person_service.py @@ -126,6 +132,7 @@ app/services/price_list_service.py app/services/product_attribute_service.py app/services/product_service.py app/services/query_service.py +app/services/receipt_payment_service.py app/services/pdf/__init__.py app/services/pdf/base_pdf_service.py app/services/pdf/modules/__init__.py @@ -180,6 +187,9 @@ migrations/versions/20251002_000101_add_bank_accounts_table.py migrations/versions/20251003_000201_add_cash_registers_table.py migrations/versions/20251003_010501_add_name_to_cash_registers.py migrations/versions/20251006_000001_add_tax_types_table_and_product_fks.py +migrations/versions/20251011_000901_add_checks_table.py +migrations/versions/20251011_010001_replace_accounts_chart_seed.py +migrations/versions/20251012_000101_update_accounts_account_type_to_english.py migrations/versions/4b2ea782bcb3_merge_heads.py migrations/versions/5553f8745c6e_add_support_tables.py migrations/versions/7891282548e9_merge_tax_types_and_unit_fields_heads.py diff --git a/hesabixAPI/migrations/versions/20251011_000901_add_checks_table.py b/hesabixAPI/migrations/versions/20251011_000901_add_checks_table.py new file mode 100644 index 0000000..ca2f74c --- /dev/null +++ b/hesabixAPI/migrations/versions/20251011_000901_add_checks_table.py @@ -0,0 +1,77 @@ +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '20251011_000901_add_checks_table' +down_revision: Union[str, None] = '1f0abcdd7300' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ایجاد ایمن جدول و ایندکس‌ها در صورت نبود + bind = op.get_bind() + inspector = sa.inspect(bind) + + # ایجاد type در صورت نیاز + try: + op.execute("SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_NAME='checks' LIMIT 1") + except Exception: + pass + + if 'checks' not in inspector.get_table_names(): + # Enum برای نوع چک + try: + # برخی درایورها ایجاد Enum را قبل از استفاده می‌خواهند + sa.Enum('received', 'transferred', name='check_type') + except Exception: + pass + op.create_table( + 'checks', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('business_id', sa.Integer(), sa.ForeignKey('businesses.id', ondelete='CASCADE'), nullable=False), + sa.Column('type', sa.Enum('received', 'transferred', name='check_type'), nullable=False), + sa.Column('person_id', sa.Integer(), sa.ForeignKey('persons.id', ondelete='SET NULL'), nullable=True), + sa.Column('issue_date', sa.DateTime(), nullable=False), + sa.Column('due_date', sa.DateTime(), nullable=False), + sa.Column('check_number', sa.String(length=50), nullable=False), + sa.Column('sayad_code', sa.String(length=16), nullable=True), + sa.Column('bank_name', sa.String(length=255), nullable=True), + sa.Column('branch_name', sa.String(length=255), nullable=True), + sa.Column('amount', sa.Numeric(18, 2), nullable=False), + sa.Column('currency_id', sa.Integer(), sa.ForeignKey('currencies.id', ondelete='RESTRICT'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('business_id', 'check_number', name='uq_checks_business_check_number'), + sa.UniqueConstraint('business_id', 'sayad_code', name='uq_checks_business_sayad_code'), + ) + + # ایجاد ایندکس‌ها اگر وجود ندارند + try: + existing_indexes = {idx['name'] for idx in inspector.get_indexes('checks')} + if 'ix_checks_business_type' not in existing_indexes: + op.create_index('ix_checks_business_type', 'checks', ['business_id', 'type']) + if 'ix_checks_business_person' not in existing_indexes: + op.create_index('ix_checks_business_person', 'checks', ['business_id', 'person_id']) + if 'ix_checks_business_issue_date' not in existing_indexes: + op.create_index('ix_checks_business_issue_date', 'checks', ['business_id', 'issue_date']) + if 'ix_checks_business_due_date' not in existing_indexes: + op.create_index('ix_checks_business_due_date', 'checks', ['business_id', 'due_date']) + except Exception: + pass + + +def downgrade() -> None: + # Drop indices + op.drop_index('ix_checks_business_due_date', table_name='checks') + op.drop_index('ix_checks_business_issue_date', table_name='checks') + op.drop_index('ix_checks_business_person', table_name='checks') + op.drop_index('ix_checks_business_type', table_name='checks') + # Drop table + op.drop_table('checks') + # Drop enum type (if supported) + try: + op.execute("DROP TYPE check_type") + except Exception: + pass diff --git a/hesabixAPI/migrations/versions/20251011_010001_replace_accounts_chart_seed.py b/hesabixAPI/migrations/versions/20251011_010001_replace_accounts_chart_seed.py new file mode 100644 index 0000000..dfd4715 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251011_010001_replace_accounts_chart_seed.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +""" +Replace accounts chart seed with the provided list (public accounts only) + +Revision ID: 20251011_010001_replace_accounts_chart_seed +Revises: 20251006_000001_add_tax_types_table_and_product_fks +Create Date: 2025-10-11 01:00:01.000001 +""" + + +# revision identifiers, used by Alembic. +revision = '20251011_010001_replace_accounts_chart_seed' +down_revision = '20251011_000901_add_checks_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # لیست کامل از کاربر (فقط فیلدهای لازم برای جدول accounts نگه داشته شده) + # نگاشت: id => extId (صرفاً برای حلقه والد/فرزند). در جدول id خودکار است + items = [ + {"id": 2452, "level": 1, "code": "1", "name": "دارایی ها", "parentId": 0, "accountType": 0}, + {"id": 2453, "level": 2, "code": "101", "name": "دارایی های جاری", "parentId": 2452, "accountType": 0}, + {"id": 2454, "level": 3, "code": "102", "name": "موجودی نقد و بانک", "parentId": 2453, "accountType": 0}, + {"id": 2455, "level": 4, "code": "10201", "name": "تنخواه گردان", "parentId": 2454, "accountType": 2}, + {"id": 2456, "level": 4, "code": "10202", "name": "صندوق", "parentId": 2454, "accountType": 1}, + {"id": 2457, "level": 4, "code": "10203", "name": "بانک", "parentId": 2454, "accountType": 3}, + {"id": 2458, "level": 4, "code": "10204", "name": "وجوه در راه", "parentId": 2454, "accountType": 0}, + {"id": 2459, "level": 3, "code": "103", "name": "سپرده های کوتاه مدت", "parentId": 2453, "accountType": 0}, + {"id": 2460, "level": 4, "code": "10301", "name": "سپرده شرکت در مناقصه و مزایده", "parentId": 2459, "accountType": 0}, + {"id": 2461, "level": 4, "code": "10302", "name": "ضمانت نامه بانکی", "parentId": 2459, "accountType": 0}, + {"id": 2462, "level": 4, "code": "10303", "name": "سایر سپرده ها", "parentId": 2459, "accountType": 0}, + {"id": 2463, "level": 3, "code": "104", "name": "حساب های دریافتنی", "parentId": 2453, "accountType": 0}, + {"id": 2464, "level": 4, "code": "10401", "name": "حساب های دریافتنی", "parentId": 2463, "accountType": 4}, + {"id": 2465, "level": 4, "code": "10402", "name": "ذخیره مطالبات مشکوک الوصول", "parentId": 2463, "accountType": 0}, + {"id": 2466, "level": 4, "code": "10403", "name": "اسناد دریافتنی", "parentId": 2463, "accountType": 5}, + {"id": 2467, "level": 4, "code": "10404", "name": "اسناد در جریان وصول", "parentId": 2463, "accountType": 6}, + {"id": 2468, "level": 3, "code": "105", "name": "سایر حساب های دریافتنی", "parentId": 2453, "accountType": 0}, + {"id": 2469, "level": 4, "code": "10501", "name": "وام کارکنان", "parentId": 2468, "accountType": 0}, + {"id": 2470, "level": 4, "code": "10502", "name": "سایر حساب های دریافتنی", "parentId": 2468, "accountType": 0}, + {"id": 2471, "level": 3, "code": "10101", "name": "پیش پرداخت ها", "parentId": 2453, "accountType": 0}, + {"id": 2472, "level": 3, "code": "10102", "name": "موجودی کالا", "parentId": 2453, "accountType": 7}, + {"id": 2473, "level": 3, "code": "10103", "name": "ملزومات", "parentId": 2453, "accountType": 0}, + {"id": 2474, "level": 3, "code": "10104", "name": "مالیات بر ارزش افزوده خرید", "parentId": 2453, "accountType": 8}, + {"id": 2475, "level": 2, "code": "106", "name": "دارایی های غیر جاری", "parentId": 2452, "accountType": 0}, + {"id": 2476, "level": 3, "code": "107", "name": "دارایی های ثابت", "parentId": 2475, "accountType": 0}, + {"id": 2477, "level": 4, "code": "10701", "name": "زمین", "parentId": 2476, "accountType": 0}, + {"id": 2478, "level": 4, "code": "10702", "name": "ساختمان", "parentId": 2476, "accountType": 0}, + {"id": 2479, "level": 4, "code": "10703", "name": "وسائط نقلیه", "parentId": 2476, "accountType": 0}, + {"id": 2480, "level": 4, "code": "10704", "name": "اثاثیه اداری", "parentId": 2476, "accountType": 0}, + {"id": 2481, "level": 3, "code": "108", "name": "استهلاک انباشته", "parentId": 2475, "accountType": 0}, + {"id": 2482, "level": 4, "code": "10801", "name": "استهلاک انباشته ساختمان", "parentId": 2481, "accountType": 0}, + {"id": 2483, "level": 4, "code": "10802", "name": "استهلاک انباشته وسائط نقلیه", "parentId": 2481, "accountType": 0}, + {"id": 2484, "level": 4, "code": "10803", "name": "استهلاک انباشته اثاثیه اداری", "parentId": 2481, "accountType": 0}, + {"id": 2485, "level": 3, "code": "109", "name": "سپرده های بلندمدت", "parentId": 2475, "accountType": 0}, + {"id": 2486, "level": 3, "code": "110", "name": "سایر دارائی ها", "parentId": 2475, "accountType": 0}, + {"id": 2487, "level": 4, "code": "11001", "name": "حق الامتیازها", "parentId": 2486, "accountType": 0}, + {"id": 2488, "level": 4, "code": "11002", "name": "نرم افزارها", "parentId": 2486, "accountType": 0}, + {"id": 2489, "level": 4, "code": "11003", "name": "سایر دارایی های نامشهود", "parentId": 2486, "accountType": 0}, + {"id": 2490, "level": 1, "code": "2", "name": "بدهی ها", "parentId": 0, "accountType": 0}, + {"id": 2491, "level": 2, "code": "201", "name": "بدهیهای جاری", "parentId": 2490, "accountType": 0}, + {"id": 2492, "level": 3, "code": "202", "name": "حساب ها و اسناد پرداختنی", "parentId": 2491, "accountType": 0}, + {"id": 2493, "level": 4, "code": "20201", "name": "حساب های پرداختنی", "parentId": 2492, "accountType": 9}, + {"id": 2494, "level": 4, "code": "20202", "name": "اسناد پرداختنی", "parentId": 2492, "accountType": 10}, + {"id": 2495, "level": 3, "code": "203", "name": "سایر حساب های پرداختنی", "parentId": 2491, "accountType": 0}, + {"id": 2496, "level": 4, "code": "20301", "name": "ذخیره مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 40}, + {"id": 2497, "level": 4, "code": "20302", "name": "مالیات بر درآمد پرداختنی", "parentId": 2495, "accountType": 12}, + {"id": 2498, "level": 4, "code": "20303", "name": "مالیات حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 0}, + {"id": 2499, "level": 4, "code": "20304", "name": "حق بیمه پرداختنی", "parentId": 2495, "accountType": 0}, + {"id": 2500, "level": 4, "code": "20305", "name": "حقوق و دستمزد پرداختنی", "parentId": 2495, "accountType": 42}, + {"id": 2501, "level": 4, "code": "20306", "name": "عیدی و پاداش پرداختنی", "parentId": 2495, "accountType": 0}, + {"id": 2502, "level": 4, "code": "20307", "name": "سایر هزینه های پرداختنی", "parentId": 2495, "accountType": 0}, + {"id": 2503, "level": 3, "code": "204", "name": "پیش دریافت ها", "parentId": 2491, "accountType": 0}, + {"id": 2504, "level": 4, "code": "20401", "name": "پیش دریافت فروش", "parentId": 2503, "accountType": 0}, + {"id": 2505, "level": 4, "code": "20402", "name": "سایر پیش دریافت ها", "parentId": 2503, "accountType": 0}, + {"id": 2506, "level": 3, "code": "20101", "name": "مالیات بر ارزش افزوده فروش", "parentId": 2491, "accountType": 11}, + {"id": 2507, "level": 2, "code": "205", "name": "بدهیهای غیر جاری", "parentId": 2490, "accountType": 0}, + {"id": 2508, "level": 3, "code": "206", "name": "حساب ها و اسناد پرداختنی بلندمدت", "parentId": 2507, "accountType": 0}, + {"id": 2509, "level": 4, "code": "20601", "name": "حساب های پرداختنی بلندمدت", "parentId": 2508, "accountType": 0}, + {"id": 2510, "level": 4, "code": "20602", "name": "اسناد پرداختنی بلندمدت", "parentId": 2508, "accountType": 0}, + {"id": 2511, "level": 3, "code": "20501", "name": "وام پرداختنی", "parentId": 2507, "accountType": 0}, + {"id": 2512, "level": 3, "code": "20502", "name": "ذخیره مزایای پایان خدمت کارکنان", "parentId": 2507, "accountType": 0}, + {"id": 2513, "level": 1, "code": "3", "name": "حقوق صاحبان سهام", "parentId": 0, "accountType": 0}, + {"id": 2514, "level": 2, "code": "301", "name": "سرمایه", "parentId": 2513, "accountType": 0}, + {"id": 2515, "level": 3, "code": "30101", "name": "سرمایه اولیه", "parentId": 2514, "accountType": 13}, + {"id": 2516, "level": 3, "code": "30102", "name": "افزایش یا کاهش سرمایه", "parentId": 2514, "accountType": 14}, + {"id": 2517, "level": 3, "code": "30103", "name": "اندوخته قانونی", "parentId": 2514, "accountType": 15}, + {"id": 2518, "level": 3, "code": "30104", "name": "برداشت ها", "parentId": 2514, "accountType": 16}, + {"id": 2519, "level": 3, "code": "30105", "name": "سهم سود و زیان", "parentId": 2514, "accountType": 17}, + {"id": 2520, "level": 3, "code": "30106", "name": "سود یا زیان انباشته (سنواتی)", "parentId": 2514, "accountType": 18}, + {"id": 2521, "level": 1, "code": "4", "name": "بهای تمام شده کالای فروخته شده", "parentId": 0, "accountType": 0}, + {"id": 2522, "level": 2, "code": "40001", "name": "بهای تمام شده کالای فروخته شده", "parentId": 2521, "accountType": 19}, + {"id": 2523, "level": 2, "code": "40002", "name": "برگشت از خرید", "parentId": 2521, "accountType": 20}, + {"id": 2524, "level": 2, "code": "40003", "name": "تخفیفات نقدی خرید", "parentId": 2521, "accountType": 21}, + {"id": 2525, "level": 1, "code": "5", "name": "فروش", "parentId": 0, "accountType": 0}, + {"id": 2526, "level": 2, "code": "50001", "name": "فروش کالا", "parentId": 2525, "accountType": 22}, + {"id": 2527, "level": 2, "code": "50002", "name": "برگشت از فروش", "parentId": 2525, "accountType": 23}, + {"id": 2528, "level": 2, "code": "50003", "name": "تخفیفات نقدی فروش", "parentId": 2525, "accountType": 24}, + {"id": 2529, "level": 1, "code": "6", "name": "درآمد", "parentId": 0, "accountType": 0}, + {"id": 2530, "level": 2, "code": "601", "name": "درآمد های عملیاتی", "parentId": 2529, "accountType": 0}, + {"id": 2531, "level": 3, "code": "60101", "name": "درآمد حاصل از فروش خدمات", "parentId": 2530, "accountType": 25}, + {"id": 2532, "level": 3, "code": "60102", "name": "برگشت از خرید خدمات", "parentId": 2530, "accountType": 26}, + {"id": 2533, "level": 3, "code": "60103", "name": "درآمد اضافه کالا", "parentId": 2530, "accountType": 27}, + {"id": 2534, "level": 3, "code": "60104", "name": "درآمد حمل کالا", "parentId": 2530, "accountType": 28}, + {"id": 2535, "level": 2, "code": "602", "name": "درآمد های غیر عملیاتی", "parentId": 2529, "accountType": 0}, + {"id": 2536, "level": 3, "code": "60201", "name": "درآمد حاصل از سرمایه گذاری", "parentId": 2535, "accountType": 0}, + {"id": 2537, "level": 3, "code": "60202", "name": "درآمد سود سپرده ها", "parentId": 2535, "accountType": 0}, + {"id": 2538, "level": 3, "code": "60203", "name": "سایر درآمد ها", "parentId": 2535, "accountType": 0}, + {"id": 2539, "level": 3, "code": "60204", "name": "درآمد تسعیر ارز", "parentId": 2535, "accountType": 36}, + {"id": 2540, "level": 1, "code": "7", "name": "هزینه ها", "parentId": 0, "accountType": 0}, + {"id": 2541, "level": 2, "code": "701", "name": "هزینه های پرسنلی", "parentId": 2540, "accountType": 0}, + {"id": 2542, "level": 3, "code": "702", "name": "هزینه حقوق و دستمزد", "parentId": 2541, "accountType": 0}, + {"id": 2543, "level": 4, "code": "70201", "name": "حقوق پایه", "parentId": 2542, "accountType": 0}, + {"id": 2544, "level": 4, "code": "70202", "name": "اضافه کار", "parentId": 2542, "accountType": 0}, + {"id": 2545, "level": 4, "code": "70203", "name": "حق شیفت و شب کاری", "parentId": 2542, "accountType": 0}, + {"id": 2546, "level": 4, "code": "70204", "name": "حق نوبت کاری", "parentId": 2542, "accountType": 0}, + {"id": 2547, "level": 4, "code": "70205", "name": "حق ماموریت", "parentId": 2542, "accountType": 0}, + {"id": 2548, "level": 4, "code": "70206", "name": "فوق العاده مسکن و خاروبار", "parentId": 2542, "accountType": 0}, + {"id": 2549, "level": 4, "code": "70207", "name": "حق اولاد", "parentId": 2542, "accountType": 0}, + {"id": 2550, "level": 4, "code": "70208", "name": "عیدی و پاداش", "parentId": 2542, "accountType": 0}, + {"id": 2551, "level": 4, "code": "70209", "name": "بازخرید سنوات خدمت کارکنان", "parentId": 2542, "accountType": 0}, + {"id": 2552, "level": 4, "code": "70210", "name": "بازخرید مرخصی", "parentId": 2542, "accountType": 0}, + {"id": 2553, "level": 4, "code": "70211", "name": "بیمه سهم کارفرما", "parentId": 2542, "accountType": 0}, + {"id": 2554, "level": 4, "code": "70212", "name": "بیمه بیکاری", "parentId": 2542, "accountType": 0}, + {"id": 2555, "level": 4, "code": "70213", "name": "حقوق مزایای متفرقه", "parentId": 2542, "accountType": 0}, + {"id": 2556, "level": 3, "code": "703", "name": "سایر هزینه های کارکنان", "parentId": 2541, "accountType": 0}, + {"id": 2557, "level": 4, "code": "70301", "name": "سفر و ماموریت", "parentId": 2556, "accountType": 0}, + {"id": 2558, "level": 4, "code": "70302", "name": "ایاب و ذهاب", "parentId": 2556, "accountType": 0}, + {"id": 2559, "level": 4, "code": "70303", "name": "سایر هزینه های کارکنان", "parentId": 2556, "accountType": 0}, + {"id": 2560, "level": 2, "code": "704", "name": "هزینه های عملیاتی", "parentId": 2540, "accountType": 0}, + {"id": 2561, "level": 3, "code": "70401", "name": "خرید خدمات", "parentId": 2560, "accountType": 30}, + {"id": 2562, "level": 3, "code": "70402", "name": "برگشت از فروش خدمات", "parentId": 2560, "accountType": 29}, + {"id": 2563, "level": 3, "code": "70403", "name": "هزینه حمل کالا", "parentId": 2560, "accountType": 31}, + {"id": 2564, "level": 3, "code": "70404", "name": "تعمیر و نگهداری اموال و اثاثیه", "parentId": 2560, "accountType": 0}, + {"id": 2565, "level": 3, "code": "70405", "name": "هزینه اجاره محل", "parentId": 2560, "accountType": 0}, + {"id": 2566, "level": 3, "code": "705", "name": "هزینه های عمومی", "parentId": 2560, "accountType": 0}, + {"id": 2567, "level": 4, "code": "70501", "name": "هزینه آب و برق و گاز و تلفن", "parentId": 2566, "accountType": 0}, + {"id": 2568, "level": 4, "code": "70502", "name": "هزینه پذیرایی و آبدارخانه", "parentId": 2566, "accountType": 0}, + {"id": 2569, "level": 3, "code": "70406", "name": "هزینه ملزومات مصرفی", "parentId": 2560, "accountType": 0}, + {"id": 2570, "level": 3, "code": "70407", "name": "هزینه کسری و ضایعات کالا", "parentId": 2560, "accountType": 32}, + {"id": 2571, "level": 3, "code": "70408", "name": "بیمه دارایی های ثابت", "parentId": 2560, "accountType": 0}, + {"id": 2572, "level": 2, "code": "706", "name": "هزینه های استهلاک", "parentId": 2540, "accountType": 0}, + {"id": 2573, "level": 3, "code": "70601", "name": "هزینه استهلاک ساختمان", "parentId": 2572, "accountType": 0}, + {"id": 2574, "level": 3, "code": "70602", "name": "هزینه استهلاک وسائط نقلیه", "parentId": 2572, "accountType": 0}, + {"id": 2575, "level": 3, "code": "70603", "name": "هزینه استهلاک اثاثیه", "parentId": 2572, "accountType": 0}, + {"id": 2576, "level": 2, "code": "707", "name": "هزینه های بازاریابی و توزیع و فروش", "parentId": 2540, "accountType": 0}, + {"id": 2577, "level": 3, "code": "70701", "name": "هزینه آگهی و تبلیغات", "parentId": 2576, "accountType": 0}, + {"id": 2578, "level": 3, "code": "70702", "name": "هزینه بازاریابی و پورسانت", "parentId": 2576, "accountType": 0}, + {"id": 2579, "level": 3, "code": "70703", "name": "سایر هزینه های توزیع و فروش", "parentId": 2576, "accountType": 0}, + {"id": 2580, "level": 2, "code": "708", "name": "هزینه های غیرعملیاتی", "parentId": 2540, "accountType": 0}, + {"id": 2581, "level": 3, "code": "709", "name": "هزینه های بانکی", "parentId": 2580, "accountType": 0}, + {"id": 2582, "level": 4, "code": "70901", "name": "سود و کارمزد وامها", "parentId": 2581, "accountType": 0}, + {"id": 2583, "level": 4, "code": "70902", "name": "کارمزد خدمات بانکی", "parentId": 2581, "accountType": 33}, + {"id": 2584, "level": 4, "code": "70903", "name": "جرائم دیرکرد بانکی", "parentId": 2581, "accountType": 0}, + {"id": 2585, "level": 3, "code": "70801", "name": "هزینه تسعیر ارز", "parentId": 2580, "accountType": 37}, + {"id": 2586, "level": 3, "code": "70802", "name": "هزینه مطالبات سوخت شده", "parentId": 2580, "accountType": 0}, + {"id": 2587, "level": 1, "code": "8", "name": "سایر حساب ها", "parentId": 0, "accountType": 0}, + {"id": 2588, "level": 2, "code": "801", "name": "حساب های انتظامی", "parentId": 2587, "accountType": 0}, + {"id": 2589, "level": 3, "code": "80101", "name": "حساب های انتظامی", "parentId": 2588, "accountType": 0}, + {"id": 2590, "level": 3, "code": "80102", "name": "طرف حساب های انتظامی", "parentId": 2588, "accountType": 0}, + {"id": 2591, "level": 2, "code": "802", "name": "حساب های کنترلی", "parentId": 2587, "accountType": 0}, + {"id": 2592, "level": 3, "code": "80201", "name": "کنترل کسری و اضافه کالا", "parentId": 2591, "accountType": 34}, + {"id": 2593, "level": 2, "code": "803", "name": "حساب خلاصه سود و زیان", "parentId": 2587, "accountType": 0}, + {"id": 2594, "level": 3, "code": "80301", "name": "خلاصه سود و زیان", "parentId": 2593, "accountType": 35}, + {"id": 2595, "level": 5, "code": "70503", "name": "هزینه آب", "parentId": 2567, "accountType": 0}, + {"id": 2596, "level": 5, "code": "70504", "name": "هزینه برق", "parentId": 2567, "accountType": 0}, + {"id": 2597, "level": 5, "code": "70505", "name": "هزینه گاز", "parentId": 2567, "accountType": 0}, + {"id": 2598, "level": 5, "code": "70506", "name": "هزینه تلفن", "parentId": 2567, "accountType": 0}, + {"id": 2600, "level": 4, "code": "20503", "name": "وام از بانک ملت", "parentId": 2511, "accountType": 0}, + {"id": 2601, "level": 4, "code": "10405", "name": "سود تحقق نیافته فروش اقساطی", "parentId": 2463, "accountType": 39}, + {"id": 2602, "level": 3, "code": "60205", "name": "سود فروش اقساطی", "parentId": 2535, "accountType": 38}, + {"id": 2603, "level": 4, "code": "70214", "name": "حق تاهل", "parentId": 2542, "accountType": 0}, + {"id": 2604, "level": 4, "code": "20504", "name": "وام از بانک پارسیان", "parentId": 2511, "accountType": 0}, + {"id": 2605, "level": 3, "code": "10105", "name": "مساعده", "parentId": 2453, "accountType": 0}, + {"id": 2606, "level": 3, "code": "60105", "name": "تعمیرات لوازم آشپزخانه", "parentId": 2530, "accountType": 0}, + {"id": 2607, "level": 4, "code": "10705", "name": "کامپیوتر", "parentId": 2476, "accountType": 0}, + {"id": 2608, "level": 3, "code": "60206", "name": "درامد حاصل از فروش ضایعات", "parentId": 2535, "accountType": 0}, + {"id": 2609, "level": 3, "code": "60207", "name": "سود فروش دارایی", "parentId": 2535, "accountType": 0}, + {"id": 2610, "level": 3, "code": "70803", "name": "زیان فروش دارایی", "parentId": 2580, "accountType": 0}, + {"id": 2611, "level": 3, "code": "10106", "name": "موجودی کالای در جریان ساخت", "parentId": 2453, "accountType": 41}, + {"id": 2612, "level": 3, "code": "20102", "name": "سربار تولید پرداختنی", "parentId": 2491, "accountType": 43}, + {"id": 2613, "level": 4, "code": "70507", "name": "هزینه جدید", "parentId": 2566, "accountType": 0}, + ] + + # ۱) حذف حساب‌های عمومی موجود که در لیست جدید نیستند + existing_codes = set(r[0] for r in conn.execute(sa.text("SELECT code FROM accounts WHERE business_id IS NULL")).fetchall()) + new_codes = {row["code"] for row in items} + to_delete = tuple(sorted(existing_codes - new_codes)) + if to_delete: + # حذف امن بر اساس کد و فقط عمومی + del_sql = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code") + for c in to_delete: + conn.execute(del_sql, {"code": c}) + + # ۲) درج/به‌روزرسانی حساب‌ها به‌همراه نگاشت والدین + ext_to_internal: dict[int, int] = {} + select_existing = sa.text("SELECT id FROM accounts WHERE business_id IS NULL AND code = :code LIMIT 1") + insert_q = sa.text( + """ + INSERT INTO accounts (name, business_id, account_type, code, parent_id, created_at, updated_at) + VALUES (:name, NULL, :account_type, :code, :parent_id, NOW(), NOW()) + """ + ) + update_q = sa.text( + """ + UPDATE accounts + SET name = :name, account_type = :account_type, parent_id = :parent_id, updated_at = NOW() + WHERE id = :id + """ + ) + + for item in items: + parent_internal = None + if item.get("parentId") and item["parentId"] in ext_to_internal: + parent_internal = ext_to_internal[item["parentId"]] + + res = conn.execute(select_existing, {"code": item["code"]}) + row = res.fetchone() + if row is None: + result = conn.execute( + insert_q, + { + "name": item["name"], + "account_type": str(item.get("accountType", 0)), + "code": item["code"], + "parent_id": parent_internal, + }, + ) + new_id = result.lastrowid if hasattr(result, "lastrowid") else None + if new_id is None: + # fallback: انتخاب مجدد بر اساس code + res2 = conn.execute(select_existing, {"code": item["code"]}) + row2 = res2.fetchone() + if row2: + new_id = row2[0] + if new_id is not None: + ext_to_internal[item["id"]] = int(new_id) + else: + acc_id = int(row[0]) + conn.execute( + update_q, + { + "id": acc_id, + "name": item["name"], + "account_type": str(item.get("accountType", 0)), + "parent_id": parent_internal, + }, + ) + ext_to_internal[item["id"]] = acc_id + + +def downgrade() -> None: + # در downgrade صرفاً کدهایی که در این میگریشن اضافه/بروز شده‌اند حذف می‌شوند + conn = op.get_bind() + codes = [ + "1","101","102","10201","10202","10203","10204","103","10301","10302","10303","104","10401","10402","10403","10404","105","10501","10502","10101","10102","10103","10104","106","107","10701","10702","10703","10704","108","10801","10802","10803","109","110","11001","11002","11003","2","201","202","20201","20202","203","20301","20302","20303","20304","20305","20306","20307","204","20401","20402","20101","205","206","20601","20602","20501","20502","3","301","30101","30102","30103","30104","30105","30106","4","40001","40002","40003","5","50001","50002","50003","6","601","60101","60102","60103","60104","602","60201","60202","60203","60204","7","701","702","70201","70202","70203","70204","70205","70206","70207","70208","70209","70210","70211","70212","70213","703","70301","70302","70303","704","70401","70402","70403","70404","70405","705","70501","70502","70406","70407","70408","706","70601","70602","70603","707","70701","70702","70703","708","709","70901","70902","70903","70801","70802","8","801","80101","80102","802","80201","803","80301","70503","70504","70505","70506","20503","10405","60205","70214","20504","10105","60105","10705","60206","60207","70803","10106","20102","70507" + ] + delete_q = sa.text("DELETE FROM accounts WHERE business_id IS NULL AND code = :code") + for code in codes: + conn.execute(delete_q, {"code": code}) + + diff --git a/hesabixAPI/migrations/versions/20251012_000101_update_accounts_account_type_to_english.py b/hesabixAPI/migrations/versions/20251012_000101_update_accounts_account_type_to_english.py new file mode 100644 index 0000000..9fd0546 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251012_000101_update_accounts_account_type_to_english.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +""" +Normalize accounts.account_type to English values and add constraint + +Revision ID: 20251012_000101_update_accounts_account_type_to_english +Revises: 20251011_010001_replace_accounts_chart_seed +Create Date: 2025-10-12 00:01:01.000001 +""" + + +# revision identifiers, used by Alembic. +revision = '20251012_000101_update_accounts_account_type_to_english' +down_revision = '20251011_010001_replace_accounts_chart_seed' +branch_labels = None +depends_on = None + + +ALLOWED_TYPES = ( + "bank", + "cash_register", + "petty_cash", + "check", + "person", + "product", + "service", + "accounting_document", +) + + +def upgrade() -> None: + conn = op.get_bind() + + # نگاشت مقادیر عددی/قدیمی به مقادیر انگلیسی جدید + mapping_updates: list[tuple[str, tuple[str, ...]]] = [ + ("bank", ("3",)), + ("cash_register", ("1",)), + ("petty_cash", ("2",)), + ("check", ("5", "6", "10")), + ("person", ("4", "9")), + ("product", ("7",)), + ("service", ("25", "26", "29", "30", "31")), + ] + + for new_val, old_vals in mapping_updates: + for old_val in old_vals: + conn.execute( + sa.text( + "UPDATE accounts SET account_type = :new_val WHERE account_type = :old_val" + ), + {"new_val": new_val, "old_val": old_val}, + ) + + # سایر مقادیر ناشناخته را به accounting_document تنظیم کن + placeholders = ", ".join([":v" + str(i) for i in range(len(ALLOWED_TYPES))]) + params = {("v" + str(i)): v for i, v in enumerate(ALLOWED_TYPES)} + conn.execute( + sa.text( + f"UPDATE accounts SET account_type = 'accounting_document' WHERE account_type NOT IN ({placeholders})" + ), + params, + ) + + # افزودن چک‌کانسترینت برای اطمینان از مقادیر مجاز (در صورت نبود) + # برخی پایگاه‌ها CHECK را نادیده می‌گیرند؛ این بخش ایمن با try/except است + try: + op.create_check_constraint( + "ck_accounts_account_type_allowed", + "accounts", + "account_type IN ('bank', 'cash_register', 'petty_cash', 'check', 'person', 'product', 'service', 'accounting_document')", + ) + except Exception: + # اگر از قبل وجود داشته باشد، نادیده بگیر + pass + + +def downgrade() -> None: + # حذف چک‌کانسترینت + op.drop_constraint("ck_accounts_account_type_allowed", "accounts", type_="check") + + conn = op.get_bind() + + # نگاشت معکوس ساده برای بازگشت به مقادیر عددی پایه + reverse_mapping: list[tuple[str, str]] = [ + ("bank", "3"), + ("cash_register", "1"), + ("petty_cash", "2"), + ("check", "5"), + ("person", "4"), + ("product", "7"), + ("service", "25"), + ("accounting_document", "0"), + ] + + for eng_val, legacy_val in reverse_mapping: + conn.execute( + sa.text( + "UPDATE accounts SET account_type = :legacy WHERE account_type = :eng" + ), + {"legacy": legacy_val, "eng": eng_val}, + ) + + diff --git a/hesabixAPI/migrations/versions/20251014_000201_add_person_id_to_document_lines.py b/hesabixAPI/migrations/versions/20251014_000201_add_person_id_to_document_lines.py new file mode 100644 index 0000000..27bc847 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251014_000201_add_person_id_to_document_lines.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251014_000201_add_person_id_to_document_lines' +down_revision = '20250927_000017_add_account_id_to_document_lines' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.add_column(sa.Column('person_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_document_lines_person_id_persons', 'persons', ['person_id'], ['id'], ondelete='SET NULL') + batch_op.create_index('ix_document_lines_person_id', ['person_id']) + + +def downgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.drop_index('ix_document_lines_person_id') + batch_op.drop_constraint('fk_document_lines_person_id_persons', type_='foreignkey') + batch_op.drop_column('person_id') + + diff --git a/hesabixAPI/migrations/versions/20251014_000301_add_product_id_to_document_lines.py b/hesabixAPI/migrations/versions/20251014_000301_add_product_id_to_document_lines.py new file mode 100644 index 0000000..15f1128 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251014_000301_add_product_id_to_document_lines.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251014_000301_add_product_id_to_document_lines' +down_revision = '20251014_000201_add_person_id_to_document_lines' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.add_column(sa.Column('product_id', sa.Integer(), nullable=True)) + batch_op.create_foreign_key('fk_document_lines_product_id_products', 'products', ['product_id'], ['id'], ondelete='SET NULL') + batch_op.create_index('ix_document_lines_product_id', ['product_id']) + + +def downgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.drop_index('ix_document_lines_product_id') + batch_op.drop_constraint('fk_document_lines_product_id_products', type_='foreignkey') + batch_op.drop_column('product_id') + + 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 new file mode 100644 index 0000000..48ce712 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251014_000401_add_payment_refs_to_document_lines' +down_revision = '20251014_000301_add_product_id_to_document_lines' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + 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)) + + 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') + + 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']) + + +def downgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.drop_index('ix_document_lines_check_id') + batch_op.drop_index('ix_document_lines_petty_cash_id') + batch_op.drop_index('ix_document_lines_cash_register_id') + batch_op.drop_index('ix_document_lines_bank_account_id') + + 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') + + batch_op.drop_column('check_id') + batch_op.drop_column('petty_cash_id') + batch_op.drop_column('cash_register_id') + batch_op.drop_column('bank_account_id') + + diff --git a/hesabixAPI/migrations/versions/20251014_000501_add_quantity_to_document_lines.py b/hesabixAPI/migrations/versions/20251014_000501_add_quantity_to_document_lines.py new file mode 100644 index 0000000..c2bf76a --- /dev/null +++ b/hesabixAPI/migrations/versions/20251014_000501_add_quantity_to_document_lines.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20251014_000501_add_quantity_to_document_lines' +down_revision = '20251014_000401_add_payment_refs_to_document_lines' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.add_column(sa.Column('quantity', sa.Numeric(18, 6), nullable=True, server_default=sa.text('0'))) + + +def downgrade() -> None: + with op.batch_alter_table('document_lines') as batch_op: + batch_op.drop_column('quantity') + + diff --git a/hesabixAPI/migrations/versions/7ecb63029764_merge_heads.py b/hesabixAPI/migrations/versions/7ecb63029764_merge_heads.py new file mode 100644 index 0000000..ed9d6d5 --- /dev/null +++ b/hesabixAPI/migrations/versions/7ecb63029764_merge_heads.py @@ -0,0 +1,24 @@ +"""merge heads + +Revision ID: 7ecb63029764 +Revises: 20250106_000004, 20251012_000101_update_accounts_account_type_to_english, 20251014_000501_add_quantity_to_document_lines +Create Date: 2025-10-14 12:36:58.259190 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7ecb63029764' +down_revision = ('20250106_000004', '20251012_000101_update_accounts_account_type_to_english', '20251014_000501_add_quantity_to_document_lines') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index bb96960..c650439 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -1091,5 +1091,14 @@ "pettyCashExportExcel": "Export petty cash to Excel", "pettyCashExportPdf": "Export petty cash to PDF", "pettyCashReport": "Petty Cash Report" + , + "accountTypeBank": "Bank", + "accountTypeCashRegister": "Cash Register", + "accountTypePettyCash": "Petty Cash", + "accountTypeCheck": "Check", + "accountTypePerson": "Person", + "accountTypeProduct": "Product", + "accountTypeService": "Service", + "accountTypeAccountingDocument": "Accounting Document" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index fb787d7..3f70a4f 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -1073,6 +1073,14 @@ "pettyCashDetails": "جزئیات تنخواه گردان", "pettyCashExportExcel": "خروجی Excel تنخواه گردان‌ها", "pettyCashExportPdf": "خروجی PDF تنخواه گردان‌ها", - "pettyCashReport": "گزارش تنخواه گردان‌ها" + "pettyCashReport": "گزارش تنخواه گردان‌ها", + "accountTypeBank": "بانک", + "accountTypeCashRegister": "صندوق", + "accountTypePettyCash": "تنخواه گردان", + "accountTypeCheck": "چک", + "accountTypePerson": "شخص", + "accountTypeProduct": "کالا", + "accountTypeService": "خدمات", + "accountTypeAccountingDocument": "سند حسابداری" } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index b65d678..bf0efd1 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -5719,6 +5719,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Petty Cash Report'** String get pettyCashReport; + + /// No description provided for @accountTypeBank. + /// + /// In en, this message translates to: + /// **'Bank'** + String get accountTypeBank; + + /// No description provided for @accountTypeCashRegister. + /// + /// In en, this message translates to: + /// **'Cash Register'** + String get accountTypeCashRegister; + + /// No description provided for @accountTypePettyCash. + /// + /// In en, this message translates to: + /// **'Petty Cash'** + String get accountTypePettyCash; + + /// No description provided for @accountTypeCheck. + /// + /// In en, this message translates to: + /// **'Check'** + String get accountTypeCheck; + + /// No description provided for @accountTypePerson. + /// + /// In en, this message translates to: + /// **'Person'** + String get accountTypePerson; + + /// No description provided for @accountTypeProduct. + /// + /// In en, this message translates to: + /// **'Product'** + String get accountTypeProduct; + + /// No description provided for @accountTypeService. + /// + /// In en, this message translates to: + /// **'Service'** + String get accountTypeService; + + /// No description provided for @accountTypeAccountingDocument. + /// + /// In en, this message translates to: + /// **'Accounting Document'** + String get accountTypeAccountingDocument; } class _AppLocalizationsDelegate diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index b2fbb91..eaceb47 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -2897,4 +2897,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pettyCashReport => 'Petty Cash Report'; + + @override + String get accountTypeBank => 'Bank'; + + @override + String get accountTypeCashRegister => 'Cash Register'; + + @override + String get accountTypePettyCash => 'Petty Cash'; + + @override + String get accountTypeCheck => 'Check'; + + @override + String get accountTypePerson => 'Person'; + + @override + String get accountTypeProduct => 'Product'; + + @override + String get accountTypeService => 'Service'; + + @override + String get accountTypeAccountingDocument => 'Accounting Document'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 4b4b3eb..75ec874 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -2876,4 +2876,28 @@ class AppLocalizationsFa extends AppLocalizations { @override String get pettyCashReport => 'گزارش تنخواه گردان‌ها'; + + @override + String get accountTypeBank => 'بانک'; + + @override + String get accountTypeCashRegister => 'صندوق'; + + @override + String get accountTypePettyCash => 'تنخواه گردان'; + + @override + String get accountTypeCheck => 'چک'; + + @override + String get accountTypePerson => 'شخص'; + + @override + String get accountTypeProduct => 'کالا'; + + @override + String get accountTypeService => 'خدمات'; + + @override + String get accountTypeAccountingDocument => 'سند حسابداری'; } diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index 0b2183c..851b3cc 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -38,6 +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/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -795,6 +796,26 @@ class _MyAppState extends State { }, ), // Checks: list, new, edit + 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: ReceiptsPaymentsPage( + businessId: businessId, + calendarController: _calendarController!, + authStore: _authStore!, + apiClient: ApiClient(), + ), + ); + }, + ), GoRoute( path: 'checks', name: 'business_checks', @@ -827,6 +848,7 @@ class _MyAppState extends State { child: CheckFormPage( businessId: businessId, authStore: _authStore!, + calendarController: _calendarController!, ), ); }, @@ -847,6 +869,7 @@ class _MyAppState extends State { businessId: businessId, authStore: _authStore!, checkId: checkId, + calendarController: _calendarController!, ), ); }, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart index 2171f62..9f0b97b 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/accounts_page.dart @@ -2,6 +2,45 @@ import 'package:flutter/material.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/api_client.dart'; +class AccountNode { + final String id; + final String code; + final String name; + final String? accountType; + final List children; + final bool hasChildren; + + const AccountNode({ + required this.id, + required this.code, + required this.name, + this.accountType, + this.children = const [], + this.hasChildren = false, + }); + + factory AccountNode.fromJson(Map json) { + final rawChildren = (json['children'] as List?) ?? const []; + final parsedChildren = rawChildren + .map((c) => AccountNode.fromJson(Map.from(c as Map))) + .toList(); + return AccountNode( + id: (json['id']?.toString() ?? json['code']?.toString() ?? UniqueKey().toString()), + code: json['code']?.toString() ?? '', + name: json['name']?.toString() ?? '', + accountType: json['account_type']?.toString(), + children: parsedChildren, + hasChildren: (json['has_children'] == true) || parsedChildren.isNotEmpty, + ); + } +} + +class _VisibleNode { + final AccountNode node; + final int level; + const _VisibleNode(this.node, this.level); +} + class AccountsPage extends StatefulWidget { final int businessId; const AccountsPage({super.key, required this.businessId}); @@ -13,7 +52,8 @@ class AccountsPage extends StatefulWidget { class _AccountsPageState extends State { bool _loading = true; String? _error; - List _tree = const []; + List _roots = const []; + final Set _expandedIds = {}; @override void initState() { @@ -26,7 +66,11 @@ class _AccountsPageState extends State { try { final api = ApiClient(); final res = await api.get('/api/v1/accounts/business/${widget.businessId}/tree'); - setState(() { _tree = res.data['data']['items'] ?? []; }); + final items = (res.data['data']['items'] as List?) ?? const []; + final parsed = items + .map((n) => AccountNode.fromJson(Map.from(n as Map))) + .toList(); + setState(() { _roots = parsed; }); } catch (e) { setState(() { _error = e.toString(); }); } finally { @@ -34,17 +78,86 @@ class _AccountsPageState extends State { } } - Widget _buildNode(Map node) { - final children = (node['children'] as List?) ?? const []; - if (children.isEmpty) { - return ListTile( - title: Text('${node['code']} - ${node['name']}'), - ); + List<_VisibleNode> _buildVisibleNodes() { + final List<_VisibleNode> result = <_VisibleNode>[]; + void dfs(AccountNode node, int level) { + result.add(_VisibleNode(node, level)); + if (_expandedIds.contains(node.id)) { + for (final child in node.children) { + dfs(child, level + 1); + } + } + } + for (final r in _roots) { + dfs(r, 0); + } + return result; + } + + void _toggleExpand(AccountNode node) { + setState(() { + if (_expandedIds.contains(node.id)) { + _expandedIds.remove(node.id); + } else { + if (node.hasChildren) { + _expandedIds.add(node.id); + } + } + }); + } + + String _localizedAccountType(AppLocalizations t, String? value) { + if (value == null || value.isEmpty) return '-'; + final ln = t.localeName; + if (ln.startsWith('fa')) { + switch (value) { + case 'bank': + return t.accountTypeBank; + case 'cash_register': + return t.accountTypeCashRegister; + case 'petty_cash': + return t.accountTypePettyCash; + case 'check': + return t.accountTypeCheck; + case 'person': + return t.accountTypePerson; + case 'product': + return t.accountTypeProduct; + case 'service': + return t.accountTypeService; + case 'accounting_document': + return t.accountTypeAccountingDocument; + default: + return value; + } + } + // English and other locales: humanize + String humanize(String v) { + return v + .split('_') + .map((p) => p.isEmpty ? p : (p[0].toUpperCase() + p.substring(1))) + .join(' '); + } + switch (value) { + case 'bank': + return t.accountTypeBank; + case 'cash_register': + return t.accountTypeCashRegister; + case 'petty_cash': + return t.accountTypePettyCash; + case 'check': + return t.accountTypeCheck; + case 'person': + return t.accountTypePerson; + case 'product': + return t.accountTypeProduct; + case 'service': + return t.accountTypeService; + case 'accounting_document': + return t.accountTypeAccountingDocument; + default: + return humanize(value); } - return ExpansionTile( - title: Text('${node['code']} - ${node['name']}'), - children: children.map((c) => _buildNode(Map.from(c))).toList(), - ); } @override @@ -52,13 +165,65 @@ class _AccountsPageState extends State { final t = AppLocalizations.of(context); if (_loading) return const Center(child: CircularProgressIndicator()); if (_error != null) return Center(child: Text(_error!)); + final visible = _buildVisibleNodes(); return Scaffold( appBar: AppBar(title: Text(t.chartOfAccounts)), - body: RefreshIndicator( - onRefresh: _fetch, - child: ListView( - children: _tree.map((n) => _buildNode(Map.from(n))).toList(), - ), + body: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Row( + children: [ + const SizedBox(width: 28), // expander space + Expanded(flex: 2, child: Text(t.code, style: const TextStyle(fontWeight: FontWeight.w600))), + Expanded(flex: 5, child: Text(t.title, style: const TextStyle(fontWeight: FontWeight.w600))), + Expanded(flex: 3, child: Text(t.type, style: const TextStyle(fontWeight: FontWeight.w600))), + ], + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _fetch, + child: ListView.builder( + itemCount: visible.length, + itemBuilder: (context, index) { + final item = visible[index]; + final node = item.node; + final level = item.level; + final isExpanded = _expandedIds.contains(node.id); + final canExpand = node.hasChildren; + return InkWell( + onTap: canExpand ? () => _toggleExpand(node) : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + SizedBox(width: 12.0 * level), + SizedBox( + width: 28, + child: canExpand + ? IconButton( + padding: EdgeInsets.zero, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: Icon(isExpanded ? Icons.expand_more : Icons.chevron_right), + onPressed: () => _toggleExpand(node), + ) + : const SizedBox.shrink(), + ), + Expanded(flex: 2, child: Text(node.code, style: const TextStyle(fontFeatures: []))), + Expanded(flex: 5, child: Text(node.name)), + Expanded(flex: 3, child: Text(_localizedAccountType(t, node.accountType))), + ], + ), + ), + ); + }, + ), + ), + ), + ], ), ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart index 9d46bbd..06f5453 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/check_form_page.dart @@ -1,21 +1,349 @@ import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../core/auth_store.dart'; +import '../../core/calendar_controller.dart'; +import '../../widgets/invoice/person_combobox_widget.dart'; +import '../../widgets/date_input_field.dart'; +import '../../widgets/banking/currency_picker_widget.dart'; +import '../../widgets/permission/access_denied_page.dart'; +import '../../services/check_service.dart'; -class CheckFormPage extends StatelessWidget { +class CheckFormPage extends StatefulWidget { final int businessId; final AuthStore authStore; final int? checkId; // null => new, not null => edit + final CalendarController? calendarController; const CheckFormPage({ super.key, required this.businessId, required this.authStore, this.checkId, + this.calendarController, }); + @override + State createState() => _CheckFormPageState(); +} + +class _CheckFormPageState extends State { + final _service = CheckService(); + + String? _type; // 'received' | 'transferred' + DateTime? _issueDate; + DateTime? _dueDate; + int? _currencyId; + dynamic _selectedPerson; // using Person type would be ideal; keep dynamic to avoid imports complexity + + final _checkNumberCtrl = TextEditingController(); + final _sayadCtrl = TextEditingController(); + final _bankCtrl = TextEditingController(); + final _branchCtrl = TextEditingController(); + final _amountCtrl = TextEditingController(); + + bool _loading = false; + + @override + void initState() { + super.initState(); + _type = 'received'; + _currencyId = widget.authStore.selectedCurrencyId; + _issueDate = DateTime.now(); + _dueDate = DateTime.now(); + if (widget.checkId != null) { + _loadData(); + } + } + + Future _loadData() async { + setState(() => _loading = true); + try { + final data = await _service.getById(widget.checkId!); + setState(() { + _type = (data['type'] as String?) ?? 'received'; + _checkNumberCtrl.text = (data['check_number'] ?? '') as String; + _sayadCtrl.text = (data['sayad_code'] ?? '') as String; + _bankCtrl.text = (data['bank_name'] ?? '') as String; + _branchCtrl.text = (data['branch_name'] ?? '') as String; + final amount = data['amount']; + _amountCtrl.text = amount == null ? '' : amount.toString(); + final issue = data['issue_date'] as String?; + final due = data['due_date'] as String?; + _issueDate = issue != null ? DateTime.tryParse(issue) : _issueDate; + _dueDate = due != null ? DateTime.tryParse(due) : _dueDate; + _currencyId = (data['currency_id'] is int) ? data['currency_id'] as int : _currencyId; + // person_id exists but PersonComboboxWidget needs model; leave unselected for now + }); + } catch (_) { + // ignore load errors for now + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String? _validate() { + if (_type != 'received' && _type != 'transferred') return 'نوع چک الزامی است'; + if (_type == 'received' && _selectedPerson == null) return 'انتخاب شخص برای چک دریافتی الزامی است'; + if ((_checkNumberCtrl.text.trim()).isEmpty) return 'شماره چک الزامی است'; + if (_sayadCtrl.text.trim().isNotEmpty && _sayadCtrl.text.trim().length != 16) return 'شناسه صیاد باید 16 رقم باشد'; + if (_issueDate == null) return 'تاریخ صدور الزامی است'; + if (_dueDate == null) return 'تاریخ سررسید الزامی است'; + if (_issueDate != null && _dueDate != null && _dueDate!.isBefore(_issueDate!)) return 'تاریخ سررسید نمی‌تواند قبل از تاریخ صدور باشد'; + final amount = num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()); + if (amount == null || amount <= 0) return 'مبلغ باید عددی بزرگتر از صفر باشد'; + if (_currencyId == null) return 'واحد پول الزامی است'; + return null; + } + + Future _save() async { + final error = _validate(); + if (error != null) { + _showError(error); + return; + } + setState(() => _loading = true); + try { + final payload = { + 'type': _type, + if (_selectedPerson != null) 'person_id': (_selectedPerson as dynamic).id, + 'issue_date': _issueDate!.toIso8601String(), + 'due_date': _dueDate!.toIso8601String(), + 'check_number': _checkNumberCtrl.text.trim(), + if (_sayadCtrl.text.trim().isNotEmpty) 'sayad_code': _sayadCtrl.text.trim(), + if (_bankCtrl.text.trim().isNotEmpty) 'bank_name': _bankCtrl.text.trim(), + if (_branchCtrl.text.trim().isNotEmpty) 'branch_name': _branchCtrl.text.trim(), + 'amount': num.tryParse(_amountCtrl.text.replaceAll(',', '').trim()), + 'currency_id': _currencyId, + }; + + if (widget.checkId == null) { + await _service.create(businessId: widget.businessId, payload: payload); + } else { + await _service.update(id: widget.checkId!, payload: payload); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.checkId == null ? 'چک ثبت شد' : 'چک ویرایش شد'), + ), + ); + Navigator.of(context).maybePop(); + } catch (e) { + _showError('خطا در ذخیره: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + @override + void dispose() { + _checkNumberCtrl.dispose(); + _sayadCtrl.dispose(); + _bankCtrl.dispose(); + _branchCtrl.dispose(); + _amountCtrl.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const SizedBox.expand(); + final t = AppLocalizations.of(context); + final isEdit = widget.checkId != null; + + if (!widget.authStore.canWriteSection('checks')) { + return AccessDeniedPage(message: t.accessDenied); + } + + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? t.edit : t.add), + actions: [ + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), + ), + ], + ), + body: IgnorePointer( + ignoring: _loading, + child: AbsorbPointer( + absorbing: _loading, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_loading) const LinearProgressIndicator(), + + // نوع چک + DropdownButtonFormField( + value: _type, + items: const [ + DropdownMenuItem(value: 'received', child: Text('دریافتی')), + DropdownMenuItem(value: 'transferred', child: Text('واگذار شده')), + ], + onChanged: (val) => setState(() => _type = val), + decoration: const InputDecoration( + labelText: 'نوع چک *', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + + // شخص (برای دریافتی) + if (_type == 'received') ...[ + PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: _selectedPerson, + onChanged: (p) => setState(() => _selectedPerson = p), + isRequired: true, + label: 'شخص (برای چک دریافتی)', + hintText: 'جست‌وجو و انتخاب شخص', + ), + const SizedBox(height: 12), + ], + + // تاریخ‌ها + Row( + children: [ + Expanded( + child: DateInputField( + value: _issueDate, + labelText: 'تاریخ صدور *', + hintText: 'انتخاب تاریخ صدور', + calendarController: widget.calendarController!, + onChanged: (d) => setState(() => _issueDate = d), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DateInputField( + value: _dueDate, + labelText: 'تاریخ سررسید *', + hintText: 'انتخاب تاریخ سررسید', + calendarController: widget.calendarController!, + onChanged: (d) => setState(() => _dueDate = d), + ), + ), + ], + ), + const SizedBox(height: 12), + + // شماره چک و شناسه صیاد + Row( + children: [ + Expanded( + child: TextFormField( + controller: _checkNumberCtrl, + decoration: const InputDecoration( + labelText: 'شماره چک *', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _sayadCtrl, + decoration: const InputDecoration( + labelText: 'شناسه صیاد', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // بانک و شعبه + Row( + children: [ + Expanded( + child: TextFormField( + controller: _bankCtrl, + decoration: const InputDecoration( + labelText: 'بانک صادرکننده', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _branchCtrl, + decoration: const InputDecoration( + labelText: 'شعبه', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // مبلغ و ارز + Row( + children: [ + Expanded( + child: TextFormField( + controller: _amountCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'مبلغ *', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: CurrencyPickerWidget( + businessId: widget.businessId, + selectedCurrencyId: _currencyId, + onChanged: (id) => setState(() => _currencyId = id), + label: 'واحد پول', + hintText: 'انتخاب واحد پول', + ), + ), + ], + ), + + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _loading ? null : _save, + icon: const Icon(Icons.save), + label: Text(t.save), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: _loading ? null : () => Navigator.of(context).maybePop(), + child: Text(t.cancel), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart index 43ec8ee..03574ea 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/checks_page.dart @@ -1,7 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; import '../../core/auth_store.dart'; +import '../../widgets/data_table/data_table_widget.dart'; +import '../../widgets/data_table/data_table_config.dart'; +import '../../widgets/permission/permission_widgets.dart'; +import '../../widgets/invoice/person_combobox_widget.dart'; +import '../../models/person_model.dart'; -class ChecksPage extends StatelessWidget { +class ChecksPage extends StatefulWidget { final int businessId; final AuthStore authStore; @@ -11,9 +18,152 @@ class ChecksPage extends StatelessWidget { required this.authStore, }); + @override + State createState() => _ChecksPageState(); +} + +class _ChecksPageState extends State { + final GlobalKey _tableKey = GlobalKey(); + Person? _selectedPerson; + + void _refresh() { + try { (_tableKey.currentState as dynamic)?.refresh(); } catch (_) {} + } + @override Widget build(BuildContext context) { - return const SizedBox.expand(); + final t = AppLocalizations.of(context); + + if (!widget.authStore.canReadSection('checks')) { + return AccessDeniedPage(message: t.accessDenied); + } + + return Scaffold( + body: DataTableWidget>( + key: _tableKey, + config: _buildConfig(t, context), + fromJson: (json) => json, + ), + ); + } + + DataTableConfig> _buildConfig(AppLocalizations t, BuildContext context) { + return DataTableConfig>( + endpoint: '/api/v1/checks/businesses/${widget.businessId}/checks', + title: (t.localeName == 'fa') ? 'چک‌ها' : 'Checks', + excelEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/excel', + pdfEndpoint: '/api/v1/checks/businesses/${widget.businessId}/checks/export/pdf', + getExportParams: () => {'business_id': widget.businessId, if (_selectedPerson != null) 'person_id': _selectedPerson!.id}, + additionalParams: { if (_selectedPerson != null) 'person_id': _selectedPerson!.id }, + showBackButton: true, + onBack: () => Navigator.of(context).maybePop(), + showTableIcon: false, + showRowNumbers: true, + enableRowSelection: true, + enableMultiRowSelection: true, + showColumnSearch: true, + showActiveFilters: true, + showClearFiltersButton: true, + columns: [ + TextColumn( + 'type', + 'نوع', + width: ColumnWidth.small, + filterType: ColumnFilterType.multiSelect, + filterOptions: const [ + FilterOption(value: 'received', label: 'دریافتی'), + FilterOption(value: 'transferred', label: 'واگذار شده'), + ], + formatter: (row) => (row['type'] == 'received') ? 'دریافتی' : (row['type'] == 'transferred' ? 'واگذار شده' : '-'), + ), + TextColumn('person_name', 'شخص', width: ColumnWidth.large, + formatter: (row) => (row['person_name'] ?? '-'), + ), + DateColumn( + 'issue_date', + 'تاریخ صدور', + width: ColumnWidth.medium, + filterType: ColumnFilterType.dateRange, + formatter: (row) => (row['issue_date'] ?? '-'), + ), + DateColumn( + 'due_date', + 'تاریخ سررسید', + width: ColumnWidth.medium, + filterType: ColumnFilterType.dateRange, + formatter: (row) => (row['due_date'] ?? '-'), + ), + TextColumn('check_number', 'شماره چک', width: ColumnWidth.medium, + formatter: (row) => (row['check_number'] ?? '-'), + ), + TextColumn('sayad_code', 'شناسه صیاد', width: ColumnWidth.medium, + formatter: (row) => (row['sayad_code'] ?? '-'), + ), + TextColumn('bank_name', 'بانک', width: ColumnWidth.medium, + formatter: (row) => (row['bank_name'] ?? '-'), + ), + TextColumn('branch_name', 'شعبه', width: ColumnWidth.medium, + formatter: (row) => (row['branch_name'] ?? '-'), + ), + NumberColumn('amount', 'مبلغ', width: ColumnWidth.medium, + formatter: (row) => (row['amount']?.toString() ?? '-'), + ), + TextColumn('currency', 'ارز', width: ColumnWidth.small, + formatter: (row) => (row['currency'] ?? '-'), + ), + ActionColumn('actions', t.actions, actions: [ + DataTableAction( + icon: Icons.edit, + label: t.edit, + onTap: (row) { + final id = row is Map ? row['id'] : null; + if (id is int) { + context.go('/business/${widget.businessId}/checks/$id/edit'); + } + }, + ), + ]), + ], + searchFields: ['check_number','sayad_code','bank_name','branch_name','person_name'], + filterFields: ['type','currency','issue_date','due_date'], + defaultPageSize: 20, + customHeaderActions: [ + // فیلتر شخص + SizedBox( + width: 280, + child: PersonComboboxWidget( + businessId: widget.businessId, + selectedPerson: _selectedPerson, + onChanged: (p) { + setState(() { _selectedPerson = p; }); + _refresh(); + }, + isRequired: false, + label: 'شخص', + hintText: 'جست‌وجوی شخص', + ), + ), + const SizedBox(width: 8), + PermissionButton( + section: 'checks', + action: 'add', + authStore: widget.authStore, + child: Tooltip( + message: t.add, + child: IconButton( + onPressed: () => context.go('/business/${widget.businessId}/checks/new'), + icon: const Icon(Icons.add), + ), + ), + ), + ], + onRowTap: (row) { + final id = row is Map ? row['id'] : null; + if (id is int) { + context.go('/business/${widget.businessId}/checks/$id/edit'); + } + }, + ); } } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart new file mode 100644 index 0000000..4905767 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_page.dart @@ -0,0 +1,691 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import '../../core/calendar_controller.dart'; +import '../../core/date_utils.dart' show HesabixDateUtils; +import '../../utils/number_formatters.dart' show formatWithThousands; +import '../../widgets/invoice/person_combobox_widget.dart'; +import '../../widgets/invoice/invoice_transactions_widget.dart'; +import '../../widgets/date_input_field.dart'; +import '../../models/invoice_transaction.dart'; +import '../../models/invoice_type_model.dart'; +import '../../models/person_model.dart'; +import '../../models/business_dashboard_models.dart'; +import '../../widgets/banking/currency_picker_widget.dart'; +import '../../core/auth_store.dart'; +import '../../core/api_client.dart'; +import '../../services/receipt_payment_service.dart'; + +class ReceiptsPaymentsPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + const ReceiptsPaymentsPage({ + super.key, + required this.businessId, + required this.calendarController, + required this.authStore, + required this.apiClient, + }); + + @override + State createState() => _ReceiptsPaymentsPageState(); +} + +class _ReceiptsPaymentsPageState extends State { + int _tabIndex = 0; + final List<_BulkSettlementDraft> _drafts = <_BulkSettlementDraft>[]; + + @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: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + t.receiptsAndPayments, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + FilledButton.icon( + onPressed: () async { + final draft = await showDialog<_BulkSettlementDraft>( + context: context, + builder: (_) => _BulkSettlementDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + isReceipt: _tabIndex == 0, + businessInfo: widget.authStore.currentBusiness, + apiClient: widget.apiClient, + ), + ); + if (draft != null) { + setState(() { + _drafts.removeWhere((d) => d.id == draft.id); + _drafts.add(draft); + }); + } + }, + icon: const Icon(Icons.add), + label: Text(t.add), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SegmentedButton( + segments: [ + ButtonSegment(value: 0, label: Text(t.receipts), icon: const Icon(Icons.download_done_outlined)), + ButtonSegment(value: 1, label: Text(t.payments), icon: const Icon(Icons.upload_outlined)), + ], + selected: {_tabIndex}, + onSelectionChanged: (set) => setState(() => _tabIndex = set.first), + ), + ), + const SizedBox(height: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: _DraftsList( + businessId: widget.businessId, + drafts: _drafts.where((d) => d.isReceipt == (_tabIndex == 0)).toList(), + onEdit: (d) async { + final updated = await showDialog<_BulkSettlementDraft>( + context: context, + builder: (_) => _BulkSettlementDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + isReceipt: d.isReceipt, + initial: d, + apiClient: widget.apiClient, + ), + ); + if (updated != null) { + setState(() { + final idx = _drafts.indexWhere((x) => x.id == updated.id); + if (idx >= 0) { + _drafts[idx] = updated; + } else { + _drafts.add(updated); + } + }); + } + }, + onDelete: (d) { + setState(() => _drafts.removeWhere((x) => x.id == d.id)); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _DraftsList extends StatelessWidget { + final int businessId; + final List<_BulkSettlementDraft> drafts; + final ValueChanged<_BulkSettlementDraft> onEdit; + final ValueChanged<_BulkSettlementDraft> onDelete; + const _DraftsList({ + required this.businessId, + required this.drafts, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + return Card( + margin: const EdgeInsets.all(8), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded(child: Text(t.receiptsAndPayments, style: Theme.of(context).textTheme.titleMedium)), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: drafts.isEmpty + ? Center(child: Text(t.noDataFound)) + : ListView.builder( + itemCount: drafts.length, + itemBuilder: (ctx, i) { + final d = drafts[i]; + final sumPersons = d.personLines.fold(0, (p, e) => p + e.amount); + final sumCenters = d.centerTransactions.fold(0, (p, e) => p + (e.amount.toDouble())); + return ListTile( + title: Text('${formatWithThousands(sumPersons)} | ${formatWithThousands(sumCenters)}'), + subtitle: Text('${HesabixDateUtils.formatForDisplay(d.documentDate, true)} • ${d.isReceipt ? t.receipts : t.payments}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => onEdit(d)), + IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => onDelete(d)), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _BulkSettlementDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final bool isReceipt; + final BusinessWithPermission? businessInfo; + final _BulkSettlementDraft? initial; + final ApiClient apiClient; + const _BulkSettlementDialog({ + required this.businessId, + required this.calendarController, + required this.isReceipt, + this.businessInfo, + this.initial, + required this.apiClient, + }); + + @override + State<_BulkSettlementDialog> createState() => _BulkSettlementDialogState(); +} + +class _BulkSettlementDialogState extends State<_BulkSettlementDialog> { + 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 = widget.initial?.documentDate ?? DateTime.now(); + _isReceipt = widget.initial?.isReceipt ?? widget.isReceipt; + _selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id; + if (widget.initial != null) { + _personLines.addAll(widget.initial!.personLines); + _centerTransactions.addAll(widget.initial!.centerTransactions); + } + } + + @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, + }, + }).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, null); + + // نمایش پیام موفقیت + 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 _BulkSettlementDraft { + final String id; + final bool isReceipt; + final DateTime documentDate; + final List<_PersonLine> personLines; + final List centerTransactions; + _BulkSettlementDraft({ + required this.id, + required this.isReceipt, + required this.documentDate, + required this.personLines, + required this.centerTransactions, + }); +} + +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/services/check_service.dart b/hesabixUI/hesabix_ui/lib/services/check_service.dart new file mode 100644 index 0000000..4d56a20 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/check_service.dart @@ -0,0 +1,72 @@ +import 'package:dio/dio.dart'; +import '../core/api_client.dart'; + +class CheckService { + final ApiClient _client; + CheckService({ApiClient? client}) : _client = client ?? ApiClient(); + + Future> list({required int businessId, required Map queryInfo}) async { + try { + final res = await _client.post>( + '/api/v1/checks/businesses/$businessId/checks', + data: queryInfo, + ); + final data = res.data ?? {}; + data['items'] ??= []; + return data; + } catch (e) { + return { + 'items': [], + 'pagination': { + 'total': 0, + 'page': 1, + 'per_page': queryInfo['take'] ?? 10, + 'total_pages': 0, + 'has_next': false, + 'has_prev': false, + }, + 'query_info': queryInfo, + }; + } + } + + Future> getById(int id) async { + final res = await _client.get>('/api/v1/checks/checks/$id'); + return (res.data?['data'] as Map? ?? {}); + } + + Future> create({required int businessId, required Map payload}) async { + final res = await _client.post>( + '/api/v1/checks/businesses/$businessId/checks/create', + data: payload, + ); + return (res.data?['data'] as Map? ?? {}); + } + + Future> update({required int id, required Map payload}) async { + final res = await _client.put>('/api/v1/checks/checks/$id', data: payload); + return (res.data?['data'] as Map? ?? {}); + } + + Future delete(int id) async { + await _client.delete>('/api/v1/checks/checks/$id'); + } + + Future>> exportExcel({required int businessId, required Map body}) async { + return await _client.post>( + '/api/v1/checks/businesses/$businessId/checks/export/excel', + data: body, + options: Options(responseType: ResponseType.bytes), + ); + } + + Future>> exportPdf({required int businessId, required Map body}) async { + return await _client.post>( + '/api/v1/checks/businesses/$businessId/checks/export/pdf', + data: body, + options: Options(responseType: ResponseType.bytes), + ); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart new file mode 100644 index 0000000..d7266b6 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart @@ -0,0 +1,185 @@ +import '../core/api_client.dart'; + +/// سرویس دریافت و پرداخت +class ReceiptPaymentService { + final ApiClient _apiClient; + + ReceiptPaymentService(this._apiClient); + + /// ایجاد سند دریافت یا پرداخت + /// + /// [businessId] شناسه کسب‌وکار + /// [documentType] نوع سند: "receipt" یا "payment" + /// [documentDate] تاریخ سند + /// [currencyId] شناسه ارز + /// [personLines] لیست تراکنش‌های اشخاص + /// [accountLines] لیست تراکنش‌های حساب‌ها + /// [extraInfo] اطلاعات اضافی (اختیاری) + Future> createReceiptPayment({ + required int businessId, + required String documentType, + required DateTime documentDate, + required int currencyId, + required List> personLines, + required List> accountLines, + Map? extraInfo, + }) async { + final response = await _apiClient.post( + '/businesses/$businessId/receipts-payments/create', + data: { + 'document_type': documentType, + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + 'person_lines': personLines, + 'account_lines': accountLines, + if (extraInfo != null) 'extra_info': extraInfo, + }, + ); + + return response.data['data'] as Map; + } + + /// دریافت لیست اسناد دریافت و پرداخت + /// + /// [businessId] شناسه کسب‌وکار + /// [documentType] فیلتر بر اساس نوع سند (اختیاری) + /// [fromDate] فیلتر تاریخ از (اختیاری) + /// [toDate] فیلتر تاریخ تا (اختیاری) + /// [skip] تعداد رکورد برای رد کردن + /// [take] تعداد رکورد برای دریافت + /// [search] عبارت جستجو (اختیاری) + Future> listReceiptsPayments({ + required int businessId, + String? documentType, + DateTime? fromDate, + DateTime? toDate, + int skip = 0, + int take = 20, + String? search, + String? sortBy, + bool sortDesc = true, + }) async { + final body = { + 'skip': skip, + 'take': take, + 'sort_desc': sortDesc, + if (sortBy != null) 'sort_by': sortBy, + if (search != null && search.isNotEmpty) 'search': search, + if (documentType != null) 'document_type': documentType, + if (fromDate != null) 'from_date': fromDate.toIso8601String(), + if (toDate != null) 'to_date': toDate.toIso8601String(), + }; + + final response = await _apiClient.post( + '/businesses/$businessId/receipts-payments', + data: body, + ); + + return response.data['data'] as Map; + } + + /// دریافت جزئیات یک سند دریافت/پرداخت + /// + /// [documentId] شناسه سند + Future> getReceiptPayment(int documentId) async { + final response = await _apiClient.get( + '/receipts-payments/$documentId', + ); + + return response.data['data'] as Map; + } + + /// حذف سند دریافت/پرداخت + /// + /// [documentId] شناسه سند + Future deleteReceiptPayment(int documentId) async { + await _apiClient.delete( + '/receipts-payments/$documentId', + ); + } + + /// ایجاد سند دریافت + /// + /// این متد یک wrapper ساده برای createReceiptPayment است + Future> createReceipt({ + required int businessId, + required DateTime documentDate, + required int currencyId, + required List> personLines, + required List> accountLines, + Map? extraInfo, + }) { + return createReceiptPayment( + businessId: businessId, + documentType: 'receipt', + documentDate: documentDate, + currencyId: currencyId, + personLines: personLines, + accountLines: accountLines, + extraInfo: extraInfo, + ); + } + + /// ایجاد سند پرداخت + /// + /// این متد یک wrapper ساده برای createReceiptPayment است + Future> createPayment({ + required int businessId, + required DateTime documentDate, + required int currencyId, + required List> personLines, + required List> accountLines, + Map? extraInfo, + }) { + return createReceiptPayment( + businessId: businessId, + documentType: 'payment', + documentDate: documentDate, + currencyId: currencyId, + personLines: personLines, + accountLines: accountLines, + extraInfo: extraInfo, + ); + } + + /// دریافت لیست فقط دریافت‌ها + Future> listReceipts({ + required int businessId, + DateTime? fromDate, + DateTime? toDate, + int skip = 0, + int take = 20, + String? search, + }) { + return listReceiptsPayments( + businessId: businessId, + documentType: 'receipt', + fromDate: fromDate, + toDate: toDate, + skip: skip, + take: take, + search: search, + ); + } + + /// دریافت لیست فقط پرداخت‌ها + Future> listPayments({ + required int businessId, + DateTime? fromDate, + DateTime? toDate, + int skip = 0, + int take = 20, + String? search, + }) { + return listReceiptsPayments( + businessId: businessId, + documentType: 'payment', + fromDate: fromDate, + toDate: toDate, + skip: skip, + take: take, + search: search, + ); + } +} + 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 294943f..538c111 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 @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'helpers/file_saver.dart'; -// // import 'dart:html' as html; // Not available on Linux // Not available on Linux +// // // import 'dart:html' as html; // Not available on Linux // Not available on Linux // Not available on Linux import 'package:flutter/material.dart'; import 'package:data_table_2/data_table_2.dart'; import 'package:dio/dio.dart'; @@ -698,13 +698,18 @@ class _DataTableWidgetState extends State> { await FileSaver.saveBytes(bytes, filename); } + // Platform-specific download functions for Linux // Platform-specific download functions for Linux Future _downloadPdf(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); + // 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 } Future _downloadExcel(dynamic data, String filename) async { - await _saveBytesToDownloads(data, filename); + // 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 } diff --git a/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup new file mode 100644 index 0000000..294943f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/data_table/data_table_widget.dart.backup @@ -0,0 +1,1702 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'helpers/file_saver.dart'; +// // import 'dart:html' as html; // Not available on Linux // Not available on Linux +import 'package:flutter/material.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'data_table_config.dart'; +import 'data_table_search_dialog.dart'; +import 'column_settings_dialog.dart'; +import 'helpers/data_table_utils.dart'; +import 'helpers/column_settings_service.dart'; + +/// Main reusable data table widget +class DataTableWidget extends StatefulWidget { + final DataTableConfig config; + final T Function(Map) fromJson; + final CalendarController? calendarController; + final VoidCallback? onRefresh; + + const DataTableWidget({ + super.key, + required this.config, + required this.fromJson, + this.calendarController, + this.onRefresh, + }); + + @override + State> createState() => _DataTableWidgetState(); +} + +class _DataTableWidgetState extends State> { + // Data state + List _items = []; + bool _loadingList = false; + String? _error; + + // Pagination state + int _page = 1; + int _limit = 20; + int _total = 0; + int _totalPages = 0; + + // Search state + final TextEditingController _searchCtrl = TextEditingController(); + Timer? _searchDebounce; + + // Column search state + final Map _columnSearchValues = {}; + final Map _columnSearchTypes = {}; + final Map _columnSearchControllers = {}; + + // Enhanced filter state + final Map> _columnMultiSelectValues = {}; + final Map _columnDateFromValues = {}; + final Map _columnDateToValues = {}; + + // Sorting state + String? _sortBy; + bool _sortDesc = false; + + // Row selection state + final Set _selectedRows = {}; + bool _isExporting = false; + + // Column settings state + ColumnSettings? _columnSettings; + List _visibleColumns = []; + bool _isLoadingColumnSettings = false; + + // Scroll controller for horizontal scrolling + late ScrollController _horizontalScrollController; + + @override + void initState() { + super.initState(); + _horizontalScrollController = ScrollController(); + _limit = widget.config.defaultPageSize; + _setupSearchListener(); + _loadColumnSettings(); + _fetchData(); + } + + /// Public method to refresh the data table + void refresh() { + _fetchData(); + } + + // Public helpers for external widgets (via GlobalKey) + List getSelectedRowIndices() { + return _selectedRows.toList(); + } + + List getSelectedItems() { + if (_selectedRows.isEmpty) return const []; + final list = []; + for (final i in _selectedRows) { + if (i >= 0 && i < _items.length) { + list.add(_items[i]); + } + } + return list; + } + + @override + void dispose() { + _searchCtrl.dispose(); + _searchDebounce?.cancel(); + _horizontalScrollController.dispose(); + for (var controller in _columnSearchControllers.values) { + controller.dispose(); + } + super.dispose(); + } + + void _setupSearchListener() { + _searchCtrl.addListener(() { + _searchDebounce?.cancel(); + _searchDebounce = Timer(widget.config.searchDebounce ?? const Duration(milliseconds: 500), () { + _page = 1; + _fetchData(); + }); + }); + } + + Future _loadColumnSettings() async { + if (!widget.config.enableColumnSettings) { + _visibleColumns = List.from(widget.config.columns); + return; + } + + setState(() { + _isLoadingColumnSettings = true; + }); + + try { + final tableId = widget.config.effectiveTableId; + final savedSettings = await ColumnSettingsService.getColumnSettings(tableId); + + ColumnSettings effectiveSettings; + if (savedSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + savedSettings, + widget.config.columnKeys, + ); + } else if (widget.config.initialColumnSettings != null) { + effectiveSettings = ColumnSettingsService.mergeWithDefaults( + widget.config.initialColumnSettings, + widget.config.columnKeys, + ); + } else { + effectiveSettings = ColumnSettingsService.getDefaultSettings(widget.config.columnKeys); + } + + setState(() { + _columnSettings = effectiveSettings; + _visibleColumns = _getVisibleColumnsFromSettings(effectiveSettings); + }); + } catch (e) { + debugPrint('Error loading column settings: $e'); + setState(() { + _visibleColumns = List.from(widget.config.columns); + }); + } finally { + setState(() { + _isLoadingColumnSettings = false; + }); + } + } + + List _getVisibleColumnsFromSettings(ColumnSettings settings) { + final visibleColumns = []; + + // Add columns in the order specified by settings + for (final key in settings.columnOrder) { + final column = widget.config.getColumnByKey(key); + if (column != null && settings.visibleColumns.contains(key)) { + visibleColumns.add(column); + } + } + + return visibleColumns; + } + + Future _fetchData() async { + setState(() => _loadingList = true); + _error = null; + + try { + final api = ApiClient(); + + // Build QueryInfo payload + final queryInfo = QueryInfo( + take: _limit, + skip: (_page - 1) * _limit, + sortDesc: _sortDesc, + sortBy: _sortBy, + search: _searchCtrl.text.trim().isNotEmpty ? _searchCtrl.text.trim() : null, + searchFields: widget.config.searchFields.isNotEmpty ? widget.config.searchFields : null, + filters: _buildFilters(), + ); + + // Add additional parameters + final requestData = queryInfo.toJson(); + if (widget.config.additionalParams != null) { + requestData.addAll(widget.config.additionalParams!); + } + + final res = await api.post>(widget.config.endpoint, data: requestData); + final body = res.data; + + if (body is Map) { + final response = DataTableResponse.fromJson(body, widget.fromJson); + + setState(() { + _items = response.items; + _page = response.page; + _limit = response.limit; + _total = response.total; + _totalPages = response.totalPages; + _selectedRows.clear(); // Clear selection when data changes + }); + + // Call the refresh callback if provided + if (widget.onRefresh != null) { + widget.onRefresh!(); + } else if (widget.config.onRefresh != null) { + widget.config.onRefresh!(); + } + } + } catch (e) { + setState(() { + _error = e.toString(); + }); + } finally { + setState(() => _loadingList = false); + } + } + + List _buildFilters() { + final filters = []; + + // Text search filters + for (var entry in _columnSearchValues.entries) { + final columnName = entry.key; + final searchValue = entry.value.trim(); + final searchType = _columnSearchTypes[columnName] ?? '*'; + + if (searchValue.isNotEmpty) { + filters.add(DataTableUtils.createColumnFilter( + columnName, + searchValue, + searchType, + )); + } + } + + // Multi-select filters + for (var entry in _columnMultiSelectValues.entries) { + final columnName = entry.key; + final selectedValues = entry.value; + + if (selectedValues.isNotEmpty) { + filters.add(DataTableUtils.createMultiSelectFilter( + columnName, + selectedValues, + )); + } + } + + // Date range filters + for (var entry in _columnDateFromValues.entries) { + final columnName = entry.key; + final fromDate = entry.value; + final toDate = _columnDateToValues[columnName]; + + if (fromDate != null && toDate != null) { + filters.addAll(DataTableUtils.createDateRangeFilter( + columnName, + fromDate, + toDate, + )); + } + } + + return filters; + } + + void _openColumnSearchDialog(String columnName, String columnLabel) { + // Get column configuration + final column = widget.config.getColumnByKey(columnName); + final filterType = column?.filterType; + final filterOptions = column?.filterOptions; + + // Initialize controller if not exists + if (!_columnSearchControllers.containsKey(columnName)) { + _columnSearchControllers[columnName] = TextEditingController( + text: _columnSearchValues[columnName] ?? '', + ); + } + + // Initialize search type if not exists + _columnSearchTypes[columnName] ??= '*'; + + showDialog( + context: context, + builder: (context) => DataTableSearchDialog( + columnName: columnName, + columnLabel: columnLabel, + searchValue: _columnSearchValues[columnName] ?? '', + searchType: _columnSearchTypes[columnName] ?? '*', + filterType: filterType, + filterOptions: filterOptions, + calendarController: widget.calendarController, + onApply: (value, type) { + setState(() { + _columnSearchValues[columnName] = value; + _columnSearchTypes[columnName] = type; + }); + _page = 1; + _fetchData(); + }, + onApplyMultiSelect: (values) { + setState(() { + _columnMultiSelectValues[columnName] = values; + }); + _page = 1; + _fetchData(); + }, + onApplyDateRange: (fromDate, toDate) { + setState(() { + _columnDateFromValues[columnName] = fromDate; + _columnDateToValues[columnName] = toDate; + }); + _page = 1; + _fetchData(); + }, + onClear: () { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnMultiSelectValues.remove(columnName); + _columnDateFromValues.remove(columnName); + _columnDateToValues.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + ), + ); + } + + + bool _hasActiveFilters() { + return _searchCtrl.text.isNotEmpty || + _columnSearchValues.isNotEmpty || + _columnMultiSelectValues.isNotEmpty || + _columnDateFromValues.isNotEmpty; + } + + void _clearAllFilters() { + setState(() { + _searchCtrl.clear(); + _sortBy = null; + _sortDesc = false; + _columnSearchValues.clear(); + _columnSearchTypes.clear(); + _columnMultiSelectValues.clear(); + _columnDateFromValues.clear(); + _columnDateToValues.clear(); + _selectedRows.clear(); + for (var controller in _columnSearchControllers.values) { + controller.clear(); + } + }); + _page = 1; + _fetchData(); + // Call the callback if provided + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _sortByColumn(String column) { + setState(() { + if (_sortBy == column) { + _sortDesc = !_sortDesc; + } else { + _sortBy = column; + _sortDesc = false; + } + }); + _fetchData(); + } + + void _toggleRowSelection(int rowIndex) { + if (!widget.config.enableRowSelection) return; + + setState(() { + if (widget.config.enableMultiRowSelection) { + if (_selectedRows.contains(rowIndex)) { + _selectedRows.remove(rowIndex); + } else { + _selectedRows.add(rowIndex); + } + } else { + _selectedRows.clear(); + _selectedRows.add(rowIndex); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _selectAllRows() { + if (!widget.config.enableRowSelection || !widget.config.enableMultiRowSelection) return; + + setState(() { + _selectedRows.clear(); + for (int i = 0; i < _items.length; i++) { + _selectedRows.add(i); + } + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + void _clearRowSelection() { + if (!widget.config.enableRowSelection) return; + + setState(() { + _selectedRows.clear(); + }); + + if (widget.config.onRowSelectionChanged != null) { + widget.config.onRowSelectionChanged!(_selectedRows); + } + } + + Future _openColumnSettingsDialog() async { + if (!widget.config.enableColumnSettings || _columnSettings == null) return; + + final result = await showDialog( + context: context, + builder: (context) => ColumnSettingsDialog( + columns: widget.config.columns, + currentSettings: _columnSettings!, + tableTitle: widget.config.title ?? 'Table', + ), + ); + + if (result != null) { + await _saveColumnSettings(result); + } + } + + Future _saveColumnSettings(ColumnSettings settings) async { + if (!widget.config.enableColumnSettings) return; + + try { + // Ensure at least one column is visible + final validatedSettings = _validateColumnSettings(settings); + + final tableId = widget.config.effectiveTableId; + await ColumnSettingsService.saveColumnSettings(tableId, validatedSettings); + + setState(() { + _columnSettings = validatedSettings; + _visibleColumns = _getVisibleColumnsFromSettings(validatedSettings); + }); + + // Call the callback if provided + if (widget.config.onColumnSettingsChanged != null) { + widget.config.onColumnSettingsChanged!(validatedSettings); + } + } catch (e) { + debugPrint('Error saving column settings: $e'); + if (mounted) { + final t = Localizations.of(context, AppLocalizations)!; + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.error}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + ColumnSettings _validateColumnSettings(ColumnSettings settings) { + // Ensure at least one column is visible + if (settings.visibleColumns.isEmpty && widget.config.columns.isNotEmpty) { + return settings.copyWith( + visibleColumns: [widget.config.columns.first.key], + columnOrder: [widget.config.columns.first.key], + ); + } + return settings; + } + + Future _exportData(String format, bool selectedOnly) async { + if (widget.config.excelEndpoint == null && widget.config.pdfEndpoint == null) { + return; + } + + final t = Localizations.of(context, AppLocalizations)!; + + setState(() { + _isExporting = true; + }); + + try { + final api = ApiClient(); + final endpoint = format == 'excel' + ? widget.config.excelEndpoint! + : widget.config.pdfEndpoint!; + + // Build QueryInfo object + final filters = >[]; + + // Add column filters + _columnSearchValues.forEach((column, value) { + if (value.isNotEmpty) { + final searchType = _columnSearchTypes[column] ?? 'contains'; + String operator; + switch (searchType) { + case 'contains': + operator = '*'; + break; + case 'startsWith': + operator = '*?'; + break; + case 'endsWith': + operator = '?*'; + break; + case 'exactMatch': + operator = '='; + break; + default: + operator = '*'; + } + filters.add({ + 'property': column, + 'operator': operator, + 'value': value, + }); + } + }); + + + final queryInfo = { + 'sort_by': _sortBy, + 'sort_desc': _sortDesc, + 'take': _limit, + 'skip': (_page - 1) * _limit, + 'search': _searchCtrl.text.isNotEmpty ? _searchCtrl.text : null, + 'search_fields': _searchCtrl.text.isNotEmpty && widget.config.searchFields.isNotEmpty + ? widget.config.searchFields + : null, + 'filters': filters.isNotEmpty ? filters : null, + }; + + final params = { + 'selected_only': selectedOnly, + }; + + // Add selected row indices if exporting selected only + if (selectedOnly && _selectedRows.isNotEmpty) { + params['selected_indices'] = _selectedRows.toList(); + } + + // Add export columns in current visible order (excluding ActionColumn) + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + params['export_columns'] = dataColumnsToShow.map((c) => { + 'key': c.key, + 'label': c.label, + }).toList(); + + // Add custom export parameters if provided + if (widget.config.getExportParams != null) { + final customParams = widget.config.getExportParams!(); + params.addAll(customParams); + } + + final response = await api.post( + endpoint, + data: { + ...queryInfo, + ...params, + }, + options: Options( + headers: { + // Calendar type based on current locale + 'X-Calendar-Type': (() { + final loc = Localizations.localeOf(context); + final lang = (loc.languageCode).toLowerCase(); + return (lang == 'fa') ? 'jalali' : 'gregorian'; + })(), + // Send full locale code if available (e.g., fa-IR) + 'Accept-Language': (() { + final loc = Localizations.localeOf(context); + final lang = loc.languageCode; + final country = loc.countryCode; + return (country != null && country.isNotEmpty) ? '$lang-$country' : lang; + })(), + }, + ), + responseType: ResponseType.bytes, // Both PDF and Excel now return binary data + ); + + if (response.data != null) { + // Determine filename from Content-Disposition header if present + String? contentDisposition = response.headers.value('content-disposition'); + String filename = 'export_${DateTime.now().millisecondsSinceEpoch}.${format == 'pdf' ? 'pdf' : 'xlsx'}'; + if (contentDisposition != null) { + try { + final parts = contentDisposition.split(';').map((s) => s.trim()); + for (final p in parts) { + if (p.toLowerCase().startsWith('filename=')) { + var name = p.substring('filename='.length).trim(); + if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) { + name = name.substring(1, name.length - 1); + } + if (name.isNotEmpty) { + filename = name; + } + break; + } + } + } catch (_) { + // Fallback to default filename + } + } + final expectedExt = format == 'pdf' ? '.pdf' : '.xlsx'; + if (!filename.toLowerCase().endsWith(expectedExt)) { + filename = '$filename$expectedExt'; + } + + if (format == 'pdf') { + await _downloadPdf(response.data, filename); + } else if (format == 'excel') { + await _downloadExcel(response.data, filename); + } + + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text(t.exportSuccess), + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } + } + } catch (e) { + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + content: Text('${t.exportError}: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isExporting = false; + }); + } + } + } + + // Cross-platform save using conditional FileSaver + Future _saveBytesToDownloads(dynamic data, String filename) async { + List bytes; + if (data is List) { + bytes = data; + } else if (data is Uint8List) { + bytes = data.toList(); + } else { + throw Exception('Unsupported binary data type: ${data.runtimeType}'); + } + await FileSaver.saveBytes(bytes, filename); + } + + // Platform-specific download functions for Linux + Future _downloadPdf(dynamic data, String filename) async { + await _saveBytesToDownloads(data, filename); + } + + Future _downloadExcel(dynamic data, String filename) async { + await _saveBytesToDownloads(data, filename); + } + + + double _measureTextWidth(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + maxLines: 1, + ) + ..layout(minWidth: 0, maxWidth: double.infinity); + return painter.width; + } + + @override + Widget build(BuildContext context) { + final t = Localizations.of(context, AppLocalizations)!; + final theme = Theme.of(context); + + return Card( + elevation: widget.config.boxShadow != null ? 2 : 0, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), + ), + child: Container( + padding: widget.config.padding ?? const EdgeInsets.all(16), + margin: widget.config.margin, + decoration: BoxDecoration( + color: widget.config.backgroundColor, + borderRadius: widget.config.borderRadius ?? BorderRadius.circular(12), + border: widget.config.showBorder + ? Border.all( + color: widget.config.borderColor ?? theme.dividerColor, + width: widget.config.borderWidth ?? 1.0, + ) + : null, + ), + child: Column( + children: [ + // Header + if (widget.config.title != null) ...[ + _buildHeader(t, theme), + const SizedBox(height: 16), + ], + + // Search + if (widget.config.showSearch) ...[ + _buildSearch(t, theme), + const SizedBox(height: 12), + ], + + // Active Filters + if (widget.config.showActiveFilters) ...[ + ActiveFiltersWidget( + columnSearchValues: _columnSearchValues, + columnSearchTypes: _columnSearchTypes, + columnMultiSelectValues: _columnMultiSelectValues, + columnDateFromValues: _columnDateFromValues, + columnDateToValues: _columnDateToValues, + fromDate: null, + toDate: null, + columns: widget.config.columns, + calendarController: widget.calendarController, + onRemoveColumnFilter: (columnName) { + setState(() { + _columnSearchValues.remove(columnName); + _columnSearchTypes.remove(columnName); + _columnMultiSelectValues.remove(columnName); + _columnDateFromValues.remove(columnName); + _columnDateToValues.remove(columnName); + _columnSearchControllers[columnName]?.clear(); + }); + _page = 1; + _fetchData(); + }, + onClearAll: _clearAllFilters, + ), + const SizedBox(height: 10), + ], + + // Data Table + Expanded( + child: _buildDataTable(t, theme), + ), + + // Footer with Pagination + if (widget.config.showPagination) ...[ + const SizedBox(height: 12), + _buildFooter(t, theme), + ], + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t, ThemeData theme) { + return Row( + children: [ + if (widget.config.showBackButton) ...[ + Tooltip( + message: MaterialLocalizations.of(context).backButtonTooltip, + child: IconButton( + onPressed: widget.config.onBack ?? () { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.arrow_back), + ), + ), + const SizedBox(width: 8), + ], + if (widget.config.showTableIcon) ...[ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.table_chart, + color: theme.colorScheme.onPrimaryContainer, + size: 18, + ), + ), + const SizedBox(width: 12), + ], + Text( + widget.config.title!, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + if (widget.config.subtitle != null) ...[ + const SizedBox(width: 8), + Text( + widget.config.subtitle!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const Spacer(), + + + // Clear filters button (only show when filters are applied) + if (widget.config.showClearFiltersButton && _hasActiveFilters()) ...[ + Tooltip( + message: t.clear, + child: IconButton( + onPressed: _clearAllFilters, + icon: const Icon(Icons.clear_all), + tooltip: t.clear, + ), + ), + const SizedBox(width: 4), + ], + + // Export buttons + if (widget.config.excelEndpoint != null || widget.config.pdfEndpoint != null) ...[ + _buildExportButtons(t, theme), + const SizedBox(width: 8), + ], + + // Custom header actions + if (widget.config.customHeaderActions != null) ...[ + const SizedBox(width: 8), + ...widget.config.customHeaderActions!, + ], + + // Actions menu + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: 'عملیات', + onSelected: (value) { + switch (value) { + case 'refresh': + _fetchData(); + break; + case 'columnSettings': + _openColumnSettingsDialog(); + break; + } + }, + itemBuilder: (context) => [ + if (widget.config.showRefreshButton) + PopupMenuItem( + value: 'refresh', + child: Row( + children: [ + const Icon(Icons.refresh, size: 20), + const SizedBox(width: 8), + Text(t.refresh), + ], + ), + ), + if (widget.config.showColumnSettingsButton && widget.config.enableColumnSettings) + PopupMenuItem( + value: 'columnSettings', + enabled: !_isLoadingColumnSettings, + child: Row( + children: [ + _isLoadingColumnSettings + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.view_column, size: 20), + const SizedBox(width: 8), + Text(t.columnSettings), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildExportButtons(AppLocalizations t, ThemeData theme) { + return _buildExportButton(t, theme); + } + + Widget _buildExportButton( + AppLocalizations t, + ThemeData theme, + ) { + return Tooltip( + message: t.export, + child: GestureDetector( + onTap: () => _showExportOptions(t, theme), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: _isExporting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ) + : Icon( + Icons.download, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + void _showExportOptions( + AppLocalizations t, + ThemeData theme, + ) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.download, color: theme.colorScheme.primary, size: 20), + const SizedBox(width: 8), + Text( + t.export, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const Divider(height: 1), + + // Excel options + if (widget.config.excelEndpoint != null) ...[ + ListTile( + leading: Icon(Icons.table_chart, color: Colors.green[600]), + title: Text(t.exportToExcel), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('excel', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.table_chart, color: theme.colorScheme.primary), + title: Text(t.exportToExcel), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('excel', true); + }, + ), + ], + + // PDF options + if (widget.config.pdfEndpoint != null) ...[ + if (widget.config.excelEndpoint != null) const Divider(height: 1), + + ListTile( + leading: Icon(Icons.picture_as_pdf, color: Colors.red[600]), + title: Text(t.exportToPdf), + subtitle: Text(t.exportAll), + onTap: () { + Navigator.pop(context); + _exportData('pdf', false); + }, + ), + + if (widget.config.enableRowSelection && _selectedRows.isNotEmpty) + ListTile( + leading: Icon(Icons.picture_as_pdf, color: theme.colorScheme.primary), + title: Text(t.exportToPdf), + subtitle: Text(t.exportSelected), + onTap: () { + Navigator.pop(context); + _exportData('pdf', true); + }, + ), + ], + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildFooter(AppLocalizations t, ThemeData theme) { + // Always show footer if pagination is enabled + + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + // Results info + Text( + '${t.showing} ${((_page - 1) * _limit) + 1} ${t.to} ${(_page * _limit).clamp(0, _total)} ${t.ofText} $_total ${t.results}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + + // Page size selector + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.recordsPerPage, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _limit, + items: widget.config.pageSizeOptions.map((size) { + return DropdownMenuItem( + value: size, + child: Text(size.toString()), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _limit = value; + _page = 1; + }); + _fetchData(); + } + }, + style: theme.textTheme.bodySmall, + underline: const SizedBox.shrink(), + isDense: true, + ), + ], + ), + const SizedBox(width: 16), + + // Pagination controls (only show if more than 1 page) + if (_totalPages > 1) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // First page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page = 1); + _fetchData(); + } : null, + icon: const Icon(Icons.first_page), + iconSize: 20, + tooltip: t.firstPage, + ), + + // Previous page + IconButton( + onPressed: _page > 1 ? () { + setState(() => _page--); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + tooltip: t.previousPage, + ), + + // Page numbers + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$_page / $_totalPages', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Next page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page++); + _fetchData(); + } : null, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + tooltip: t.nextPage, + ), + + // Last page + IconButton( + onPressed: _page < _totalPages ? () { + setState(() => _page = _totalPages); + _fetchData(); + } : null, + icon: const Icon(Icons.last_page), + iconSize: 20, + tooltip: t.lastPage, + ), + ], + ), + ], + ), + ); + } + + Widget _buildSearch(AppLocalizations t, ThemeData theme) { + return Row( + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search, size: 18), + hintText: t.searchInNameEmail, + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ), + ), + ), + ], + ); + } + + Widget _buildDataTable(AppLocalizations t, ThemeData theme) { + if (_loadingList) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.loadingWidget != null) + widget.config.loadingWidget! + else + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + widget.config.loadingMessage ?? t.loading, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.errorWidget != null) + widget.config.errorWidget! + else + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + widget.config.errorMessage ?? t.dataLoadingError, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _fetchData, + icon: const Icon(Icons.refresh), + label: Text(t.refresh), + ), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.config.emptyStateWidget != null) + widget.config.emptyStateWidget! + else + Icon( + Icons.inbox_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + Text( + widget.config.emptyStateMessage ?? t.noDataFound, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + // Build columns list + final List columns = []; + + // Add selection column if enabled (first) + if (widget.config.enableRowSelection) { + columns.add(DataColumn2( + label: widget.config.enableMultiRowSelection + ? Checkbox( + value: _selectedRows.length == _items.length && _items.isNotEmpty, + tristate: true, + onChanged: (value) { + if (value == true) { + _selectAllRows(); + } else { + _clearRowSelection(); + } + }, + ) + : const SizedBox.shrink(), + size: ColumnSize.S, + fixedWidth: 50.0, + )); + } + + // Add row number column if enabled (second) + if (widget.config.showRowNumbers) { + columns.add(DataColumn2( + label: Text( + '#', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + size: ColumnSize.S, + fixedWidth: 60.0, + )); + } + + // Resolve action column (if defined in config) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + + // Fixed action column immediately after selection and row number columns + if (actionColumn != null) { + columns.add(DataColumn2( + label: Text( + actionColumn.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + size: ColumnSize.S, + fixedWidth: 80.0, + )); + } + + // Add data columns (use visible columns if column settings are enabled), excluding ActionColumn + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + + columns.addAll(dataColumnsToShow.map((column) { + final headerTextStyle = theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ) ?? const TextStyle(fontSize: 14, fontWeight: FontWeight.w600); + final double baseWidth = DataTableUtils.getColumnWidth(column.width); + final double affordancePadding = 48.0; + final double headerTextWidth = _measureTextWidth(column.label, headerTextStyle) + affordancePadding; + final double computedWidth = math.max(baseWidth, headerTextWidth); + + return DataColumn2( + label: _ColumnHeaderWithSearch( + text: column.label, + sortBy: column.key, + currentSort: _sortBy, + sortDesc: _sortDesc, + onSort: widget.config.enableSorting ? _sortByColumn : (_) { }, + onSearch: widget.config.showColumnSearch && column.searchable + ? () => _openColumnSearchDialog(column.key, column.label) + : () { }, + hasActiveFilter: _columnSearchValues.containsKey(column.key), + enabled: widget.config.enableSorting && column.sortable, + ), + size: DataTableUtils.getColumnSize(column.width), + fixedWidth: computedWidth, + ); + })); + + return Scrollbar( + controller: _horizontalScrollController, + thumbVisibility: true, + child: DataTableTheme( + data: DataTableThemeData( + headingRowColor: WidgetStatePropertyAll( + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + ), + headingTextStyle: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + dividerThickness: 0.6, + ), + child: DataTable2( + columnSpacing: 8, + horizontalMargin: 8, + minWidth: widget.config.minTableWidth ?? 600, + horizontalScrollController: _horizontalScrollController, + headingRowHeight: 44, + columns: columns, + rows: _items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isSelected = _selectedRows.contains(index); + + // Build cells list + final List cells = []; + + // Add selection cell if enabled (first) + if (widget.config.enableRowSelection) { + cells.add(DataCell( + Checkbox( + value: isSelected, + onChanged: (value) => _toggleRowSelection(index), + ), + )); + } + + // Add row number cell if enabled (second) + if (widget.config.showRowNumbers) { + cells.add(DataCell( + Text( + '${((_page - 1) * _limit) + index + 1}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + )); + } + + // 3) Fixed action cell (immediately after selection and row number) + // Resolve action column once (same logic as header) + ActionColumn? actionColumn; + for (final c in widget.config.columns) { + if (c is ActionColumn) { + actionColumn = c; + break; + } + } + if (actionColumn != null) { + cells.add(DataCell( + _buildActionButtons(item, actionColumn), + )); + } + + // 4) Add data cells + if (widget.config.customRowBuilder != null) { + cells.add(DataCell( + widget.config.customRowBuilder!(item) ?? const SizedBox.shrink(), + )); + } else { + final columnsToShow = widget.config.enableColumnSettings && _visibleColumns.isNotEmpty + ? _visibleColumns + : widget.config.columns; + final dataColumnsToShow = columnsToShow.where((c) => c is! ActionColumn).toList(); + + cells.addAll(dataColumnsToShow.map((column) { + return DataCell( + _buildCellContent(item, column, index), + ); + })); + } + + return DataRow2( + selected: isSelected, + onTap: widget.config.onRowTap != null + ? () => widget.config.onRowTap!(item) + : null, + onDoubleTap: widget.config.onRowDoubleTap != null + ? () => widget.config.onRowDoubleTap!(item) + : null, + cells: cells, + ); + }).toList(), + ), + ), + ); + } + + Widget _buildCellContent(dynamic item, DataTableColumn column, int index) { + // 1) Custom widget builder takes precedence + if (column is CustomColumn && column.builder != null) { + return column.builder!(item, index); + } + + // 2) Action column + if (column is ActionColumn) { + return _buildActionButtons(item, column); + } + + // 3) If a formatter is provided on the column, call it with the full item + // This allows working with strongly-typed objects (not just Map) + if (column is TextColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is NumberColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + if (column is DateColumn && column.formatter != null) { + final text = column.formatter!(item) ?? ''; + return Text( + text, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + // 4) Fallback: get property value from Map items by key + final value = DataTableUtils.getCellValue(item, column.key); + final formattedValue = DataTableUtils.formatCellValue(value, column); + return Text( + formattedValue, + textAlign: _getTextAlign(column), + maxLines: _getMaxLines(column), + overflow: _getOverflow(column), + ); + } + + Widget _buildActionButtons(dynamic item, ActionColumn column) { + if (column.actions.isEmpty) return const SizedBox.shrink(); + + return PopupMenuButton( + tooltip: column.label, + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (index) { + final action = column.actions[index]; + if (action.enabled) action.onTap(item); + }, + itemBuilder: (context) { + return List.generate(column.actions.length, (index) { + final action = column.actions[index]; + return PopupMenuItem( + value: index, + enabled: action.enabled, + child: Row( + children: [ + Icon( + action.icon, + color: action.isDestructive + ? Theme.of(context).colorScheme.error + : (action.color ?? Theme.of(context).iconTheme.color), + size: 18, + ), + const SizedBox(width: 8), + Text(action.label), + ], + ), + ); + }); + }, + ); + } + + TextAlign _getTextAlign(DataTableColumn column) { + if (column is NumberColumn) return column.textAlign; + if (column is DateColumn) return column.textAlign; + if (column is TextColumn && column.textAlign != null) return column.textAlign!; + return TextAlign.start; + } + + int? _getMaxLines(DataTableColumn column) { + if (column is TextColumn) return column.maxLines; + return null; + } + + TextOverflow? _getOverflow(DataTableColumn column) { + if (column is TextColumn && column.overflow != null) { + return column.overflow! ? TextOverflow.ellipsis : null; + } + return null; + } +} + +/// Column header with search functionality +class _ColumnHeaderWithSearch extends StatelessWidget { + final String text; + final String sortBy; + final String? currentSort; + final bool sortDesc; + final Function(String) onSort; + final VoidCallback onSearch; + final bool hasActiveFilter; + final bool enabled; + + const _ColumnHeaderWithSearch({ + required this.text, + required this.sortBy, + required this.currentSort, + required this.sortDesc, + required this.onSort, + required this.onSearch, + required this.hasActiveFilter, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isActive = currentSort == sortBy; + + return InkWell( + onTap: enabled ? () => onSort(sortBy) : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + width: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + text, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: isActive ? theme.colorScheme.primary : theme.colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (enabled) ...[ + const SizedBox(width: 4), + if (isActive) + Icon( + sortDesc ? Icons.arrow_downward : Icons.arrow_upward, + size: 16, + color: theme.colorScheme.primary, + ) + else + Icon( + Icons.unfold_more, + size: 16, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + // Search button + InkWell( + onTap: onSearch, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: hasActiveFilter + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: hasActiveFilter + ? Border.all(color: theme.colorScheme.primary.withValues(alpha: 0.3)) + : null, + ), + child: Icon( + Icons.search, + size: 14, + color: hasActiveFilter + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + ), + ); + } +} 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 254e94f..5d53d1d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart @@ -54,7 +54,7 @@ class _InvoiceTransactionsWidgetState extends State { ), const SizedBox(width: 8), Text( - 'تراکنش‌های فاکتور', + 'تراکنش‌ها', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ),