From bd1fd1a39a9260cb8b874460423a37f7da7ca24f Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Mon, 27 Oct 2025 18:47:45 +0000 Subject: [PATCH] progress in documents --- EXPENSE_INCOME_IMPLEMENTATION.md | 156 +++ hesabixAPI/adapters/api/v1/accounts.py | 144 ++- hesabixAPI/adapters/api/v1/boms.py | 121 ++ hesabixAPI/adapters/api/v1/documents.py | 448 ++++++++ hesabixAPI/adapters/api/v1/expense_income.py | 242 ++++ hesabixAPI/adapters/api/v1/persons.py | 68 +- .../adapters/api/v1/schema_models/document.py | 207 ++++ .../adapters/api/v1/schema_models/person.py | 4 + .../api/v1/schema_models/product_bom.py | 126 +++ .../api/v1/schema_models/warehouse.py | 34 + hesabixAPI/adapters/api/v1/transfers.py | 4 + hesabixAPI/adapters/api/v1/warehouses.py | 105 ++ hesabixAPI/adapters/db/models/__init__.py | 2 + hesabixAPI/adapters/db/models/product_bom.py | 125 ++ hesabixAPI/adapters/db/models/warehouse.py | 33 + .../db/repositories/document_repository.py | 514 +++++++++ .../db/repositories/product_bom_repository.py | 72 ++ .../db/repositories/warehouse_repository.py | 47 + hesabixAPI/app/main.py | 8 +- hesabixAPI/app/services/bom_service.py | 221 ++++ hesabixAPI/app/services/document_service.py | 536 +++++++++ .../app/services/expense_income_service.py | 359 ++++++ hesabixAPI/app/services/person_service.py | 265 ++++- hesabixAPI/app/services/transfer_service.py | 32 +- hesabixAPI/app/services/warehouse_service.py | 90 ++ hesabixAPI/hesabix_api.egg-info/SOURCES.txt | 19 + .../20251021_000601_add_bom_and_warehouses.py | 137 +++ hesabixUI/hesabix_ui/lib/core/api_client.dart | 15 + hesabixUI/hesabix_ui/lib/core/date_utils.dart | 11 +- hesabixUI/hesabix_ui/lib/main.dart | 29 + .../hesabix_ui/lib/models/account_model.dart | 87 ++ .../lib/models/account_tree_node.dart | 21 +- .../hesabix_ui/lib/models/bom_models.dart | 259 +++++ .../hesabix_ui/lib/models/document_model.dart | 521 +++++++++ .../lib/models/expense_income_document.dart | 394 +++++++ .../lib/models/paginated_response.dart | 53 + .../hesabix_ui/lib/models/person_model.dart | 10 + .../hesabix_ui/lib/models/product_model.dart | 85 ++ .../lib/models/warehouse_model.dart | 49 + .../lib/pages/business/business_shell.dart | 27 + .../lib/pages/business/documents_page.dart | 622 ++++++++++ .../business/expense_income_list_page.dart | 756 ++++++++++--- .../lib/pages/business/persons_page.dart | 84 ++ .../lib/pages/business/transfers_page.dart | 37 +- .../lib/pages/business/warehouses_page.dart | 210 ++++ .../pages/test/expense_income_test_page.dart | 25 + .../lib/services/account_service.dart | 68 ++ .../hesabix_ui/lib/services/bom_service.dart | 53 + .../lib/services/document_service.dart | 283 +++++ .../services/expense_income_list_service.dart | 189 ++++ .../lib/services/expense_income_service.dart | 244 +++- .../lib/services/warehouse_service.dart | 39 + .../document/detail_selector_widget.dart | 1002 +++++++++++++++++ .../document/document_details_dialog.dart | 470 ++++++++ .../document/document_form_dialog.dart | 531 +++++++++ .../document/document_line_editor.dart | 630 +++++++++++ .../expense_income_details_dialog.dart | 416 +++++++ .../expense_income_form_dialog.dart | 928 +++++++++++++++ .../invoice/account_combobox_widget.dart | 324 ++++++ .../invoice/account_tree_combobox_widget.dart | 686 +++++------ .../invoice/invoice_transactions_widget.dart | 16 +- .../widgets/product/product_form_dialog.dart | 16 +- .../product/sections/product_bom_section.dart | 305 +++++ .../transfer/transfer_details_dialog.dart | 114 +- 64 files changed, 13177 insertions(+), 551 deletions(-) create mode 100644 EXPENSE_INCOME_IMPLEMENTATION.md create mode 100644 hesabixAPI/adapters/api/v1/boms.py create mode 100644 hesabixAPI/adapters/api/v1/documents.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/document.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/product_bom.py create mode 100644 hesabixAPI/adapters/api/v1/schema_models/warehouse.py create mode 100644 hesabixAPI/adapters/api/v1/warehouses.py create mode 100644 hesabixAPI/adapters/db/models/product_bom.py create mode 100644 hesabixAPI/adapters/db/models/warehouse.py create mode 100644 hesabixAPI/adapters/db/repositories/document_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/product_bom_repository.py create mode 100644 hesabixAPI/adapters/db/repositories/warehouse_repository.py create mode 100644 hesabixAPI/app/services/bom_service.py create mode 100644 hesabixAPI/app/services/document_service.py create mode 100644 hesabixAPI/app/services/warehouse_service.py create mode 100644 hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py create mode 100644 hesabixUI/hesabix_ui/lib/models/account_model.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/bom_models.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/document_model.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/expense_income_document.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/paginated_response.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/product_model.dart create mode 100644 hesabixUI/hesabix_ui/lib/models/warehouse_model.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/pages/test/expense_income_test_page.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/bom_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/document_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/expense_income_list_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/services/warehouse_service.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/document/detail_selector_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/document/document_line_editor.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_details_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_form_dialog.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/invoice/account_combobox_widget.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart diff --git a/EXPENSE_INCOME_IMPLEMENTATION.md b/EXPENSE_INCOME_IMPLEMENTATION.md new file mode 100644 index 0000000..4fe803f --- /dev/null +++ b/EXPENSE_INCOME_IMPLEMENTATION.md @@ -0,0 +1,156 @@ +# پیاده‌سازی بخش لیست هزینه و درآمد + +## خلاصه پیاده‌سازی + +این پیاده‌سازی شامل بخش کاملی برای مدیریت اسناد هزینه و درآمد است که بر اساس الگوی موجود در بخش دریافت و پرداخت طراحی شده است. + +## فایل‌های ایجاد شده + +### Frontend (Flutter) + +#### مدل‌ها +- `lib/models/expense_income_document.dart` - مدل اصلی سند هزینه/درآمد +- `lib/models/account_model.dart` - مدل حساب + +#### سرویس‌ها +- `lib/services/expense_income_list_service.dart` - سرویس لیست و عملیات گروهی +- `lib/services/expense_income_service.dart` - سرویس CRUD + +#### صفحات +- `lib/pages/business/expense_income_list_page.dart` - صفحه اصلی لیست +- `lib/pages/test/expense_income_test_page.dart` - صفحه تست + +#### ویجت‌ها +- `lib/widgets/expense_income/expense_income_form_dialog.dart` - دیالوگ ایجاد/ویرایش +- `lib/widgets/expense_income/expense_income_details_dialog.dart` - دیالوگ مشاهده جزئیات +- `lib/widgets/invoice/account_combobox_widget.dart` - ویجت انتخاب حساب + +### Backend (Python) + +#### API Endpoints +- `hesabixAPI/adapters/api/v1/expense_income.py` - تمام endpoint های مورد نیاز + +#### سرویس‌ها +- `hesabixAPI/app/services/expense_income_service.py` - منطق کسب و کار + +## ویژگی‌های پیاده‌سازی شده + +### Frontend +- ✅ لیست اسناد با جدول پیشرفته +- ✅ فیلتر بر اساس نوع سند (هزینه/درآمد) +- ✅ فیلتر تاریخ (از/تا) +- ✅ جستجو و صفحه‌بندی +- ✅ انتخاب چندگانه و حذف گروهی +- ✅ خروجی Excel و PDF +- ✅ دیالوگ ایجاد سند جدید +- ✅ دیالوگ ویرایش سند موجود +- ✅ دیالوگ مشاهده جزئیات +- ✅ اعتبارسنجی تعادل حساب‌ها + +### Backend +- ✅ API لیست اسناد با فیلتر و صفحه‌بندی +- ✅ API ایجاد سند جدید +- ✅ API ویرایش سند موجود +- ✅ API حذف سند (تکی و گروهی) +- ✅ API مشاهده جزئیات سند +- ✅ API خروجی Excel +- ✅ API خروجی PDF +- ✅ اعتبارسنجی و مدیریت خطا + +## ساختار داده + +### سند هزینه/درآمد +```json +{ + "id": 1, + "code": "EI-20250115-12345", + "document_type": "expense", + "document_type_name": "هزینه", + "document_date": "2025-01-15", + "currency_id": 1, + "total_amount": 1000000, + "description": "توضیحات سند", + "item_lines": [ + { + "account_id": 123, + "account_name": "هزینه اداری", + "amount": 1000000, + "description": "توضیحات خط" + } + ], + "counterparty_lines": [ + { + "transaction_type": "bank", + "amount": 1000000, + "transaction_date": "2025-01-15T10:00:00", + "bank_account_id": 456, + "bank_account_name": "بانک ملی" + } + ] +} +``` + +## نحوه استفاده + +### اضافه کردن به منوی اصلی +```dart +// در فایل منوی اصلی +ListTile( + leading: const Icon(Icons.trending_up), + title: const Text('هزینه و درآمد'), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ExpenseIncomeListPage( + businessId: businessId, + calendarController: calendarController, + authStore: authStore, + apiClient: apiClient, + ), + ), + ); + }, +) +``` + +### تست عملکرد +```dart +// استفاده از صفحه تست +Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ExpenseIncomeTestPage(), + ), +) +``` + +## نکات مهم + +1. **تعادل حساب‌ها**: سیستم تضمین می‌کند که مجموع حساب‌های هزینه/درآمد با مجموع طرف‌حساب‌ها برابر باشد. + +2. **انواع طرف‌حساب**: پشتیبانی از بانک، صندوق، تنخواهگردان، چک و شخص. + +3. **کد سند**: فرمت `EI-YYYYMMDD-XXXXX` برای اسناد هزینه/درآمد. + +4. **امنیت**: تمام endpoint ها نیاز به احراز هویت و مجوز مناسب دارند. + +5. **چندزبانه**: پشتیبانی از تقویم شمسی و میلادی. + +## مراحل بعدی + +1. **تست کامل**: تست تمام سناریوهای ممکن +2. **بهینه‌سازی**: بهبود عملکرد و UX +3. **مستندسازی**: تکمیل مستندات API +4. **گزارش‌گیری**: اضافه کردن گزارش‌های پیشرفته +5. **یکپارچه‌سازی**: اتصال به سایر بخش‌های سیستم + +## مشکلات احتمالی + +1. **وابستگی‌ها**: ممکن است نیاز به نصب پکیج‌های اضافی باشد +2. **API**: باید مطمئن شوید که backend در حال اجرا است +3. **دسترسی**: بررسی مجوزهای کاربر برای دسترسی به بخش + +## پشتیبانی + +برای گزارش مشکلات یا درخواست ویژگی‌های جدید، لطفاً با تیم توسعه تماس بگیرید. diff --git a/hesabixAPI/adapters/api/v1/accounts.py b/hesabixAPI/adapters/api/v1/accounts.py index 4f8f62e..a7f53c8 100644 --- a/hesabixAPI/adapters/api/v1/accounts.py +++ b/hesabixAPI/adapters/api/v1/accounts.py @@ -1,7 +1,8 @@ -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session +from pydantic import BaseModel from adapters.db.session import get_db from adapters.api.v1.schemas import SuccessResponse @@ -15,6 +16,15 @@ from adapters.db.models.account import Account router = APIRouter(prefix="/accounts", tags=["accounts"]) +class SearchAccountsRequest(BaseModel): + """درخواست جستجوی حساب‌ها""" + take: int = 50 + skip: int = 0 + search: Optional[str] = None + sort_by: Optional[str] = "code" + sort_desc: bool = False + + def _build_tree(nodes: list[Dict[str, Any]]) -> list[AccountTreeNode]: by_id: dict[int, AccountTreeNode] = {} roots: list[AccountTreeNode] = [] @@ -55,3 +65,135 @@ def get_accounts_tree( return success_response({"items": [n.model_dump() for n in tree]}, request) +@router.get("/business/{business_id}", + summary="دریافت لیست حساب‌ها برای یک کسب و کار", + description="لیست تمام حساب‌های عمومی و حساب‌های اختصاصی کسب و کار (بدون ساختار درختی)", +) +@require_business_access("business_id") +def get_accounts_list( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت لیست ساده حساب‌ها""" + rows = db.query(Account).filter( + (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 + ).order_by(Account.code.asc()).all() + + items = [ + { + "id": r.id, + "code": r.code, + "name": r.name, + "account_type": r.account_type, + "parent_id": r.parent_id, + "business_id": r.business_id, + "created_at": r.created_at.isoformat() if r.created_at else None, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + } + for r in rows + ] + return success_response({"items": items}, request) + + +@router.get("/business/{business_id}/account/{account_id}", + summary="دریافت جزئیات یک حساب خاص", + description="دریافت اطلاعات کامل یک حساب بر اساس ID", +) +@require_business_access("business_id") +def get_account_by_id( + request: Request, + business_id: int, + account_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """دریافت یک حساب خاص""" + account = db.query(Account).filter( + Account.id == account_id, + (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 + ).first() + + if not account: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="حساب یافت نشد") + + account_data = { + "id": account.id, + "code": account.code, + "name": account.name, + "account_type": account.account_type, + "parent_id": account.parent_id, + "business_id": account.business_id, + "created_at": account.created_at.isoformat() if account.created_at else None, + "updated_at": account.updated_at.isoformat() if account.updated_at else None, + } + + return success_response(account_data, request) + + +@router.post("/business/{business_id}", + summary="جستجو و فیلتر حساب‌ها", + description="جستجو در حساب‌ها با قابلیت فیلتر، مرتب‌سازی و صفحه‌بندی", +) +@require_business_access("business_id") +def search_accounts( + request: Request, + business_id: int, + search_request: SearchAccountsRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db) +) -> dict: + """جستجوی حساب‌ها با فیلتر""" + query = db.query(Account).filter( + (Account.business_id == None) | (Account.business_id == business_id) # noqa: E711 + ) + + # اعمال جستجو + if search_request.search: + search_term = f"%{search_request.search}%" + query = query.filter( + (Account.code.ilike(search_term)) | (Account.name.ilike(search_term)) + ) + + # شمارش کل + total = query.count() + + # مرتب‌سازی + if search_request.sort_by == "name": + order_col = Account.name + else: + order_col = Account.code + + if search_request.sort_desc: + query = query.order_by(order_col.desc()) + else: + query = query.order_by(order_col.asc()) + + # صفحه‌بندی + query = query.offset(search_request.skip).limit(search_request.take) + rows = query.all() + + items = [ + { + "id": r.id, + "code": r.code, + "name": r.name, + "account_type": r.account_type, + "parent_id": r.parent_id, + "business_id": r.business_id, + "created_at": r.created_at.isoformat() if r.created_at else None, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + } + for r in rows + ] + + return success_response({ + "items": items, + "total": total, + "skip": search_request.skip, + "take": search_request.take, + }, request) + + diff --git a/hesabixAPI/adapters/api/v1/boms.py b/hesabixAPI/adapters/api/v1/boms.py new file mode 100644 index 0000000..e34dda3 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/boms.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional +from fastapi import APIRouter, Depends, Request, Query +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schema_models.product_bom import ( + ProductBOMCreateRequest, + ProductBOMUpdateRequest, + BOMExplosionRequest, +) +from app.services.bom_service import ( + create_bom, + get_bom, + list_boms, + update_bom, + delete_bom, + explode_bom, +) + + +router = APIRouter(prefix="/boms", tags=["boms"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_bom_endpoint( + request: Request, + business_id: int, + payload: ProductBOMCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = create_bom(db, business_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.get("/business/{business_id}") +@require_business_access("business_id") +def list_boms_endpoint( + request: Request, + business_id: int, + product_id: int | None = Query(default=None), + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = list_boms(db, business_id, product_id) + return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.get("/business/{business_id}/{bom_id}") +@require_business_access("business_id") +def get_bom_endpoint( + request: Request, + business_id: int, + bom_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + item = get_bom(db, business_id, bom_id) + if not item: + raise ApiError("NOT_FOUND", "BOM not found", http_status=404) + return success_response(data=format_datetime_fields({"item": item}, request), request=request) + + +@router.put("/business/{business_id}/{bom_id}") +@require_business_access("business_id") +def update_bom_endpoint( + request: Request, + business_id: int, + bom_id: int, + payload: ProductBOMUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = update_bom(db, business_id, bom_id, payload) + if not result: + raise ApiError("NOT_FOUND", "BOM not found", http_status=404) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.delete("/business/{business_id}/{bom_id}") +@require_business_access("business_id") +def delete_bom_endpoint( + request: Request, + business_id: int, + bom_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403) + ok = delete_bom(db, business_id, bom_id) + return success_response({"deleted": ok}, request) + + +@router.post("/business/{business_id}/explode") +@require_business_access("business_id") +def explode_bom_endpoint( + request: Request, + business_id: int, + payload: BOMExplosionRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = explode_bom(db, business_id, payload) + return success_response(data=format_datetime_fields(result, request), request=request) diff --git a/hesabixAPI/adapters/api/v1/documents.py b/hesabixAPI/adapters/api/v1/documents.py new file mode 100644 index 0000000..93d2efd --- /dev/null +++ b/hesabixAPI/adapters/api/v1/documents.py @@ -0,0 +1,448 @@ +""" +API endpoints برای مدیریت اسناد حسابداری (General Accounting Documents) +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, Request, Body, Query +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access, require_business_management_dep +from app.core.responses import success_response, format_datetime_fields, ApiError +from app.services.document_service import ( + list_documents, + get_document, + delete_document, + delete_multiple_documents, + get_document_types_summary, + export_documents_excel, + create_manual_document, + update_manual_document, +) +from adapters.api.v1.schema_models.document import ( + CreateManualDocumentRequest, + UpdateManualDocumentRequest, +) + + +router = APIRouter(tags=["documents"]) + + +@router.post( + "/businesses/{business_id}/documents", + summary="لیست اسناد حسابداری", + description="دریافت لیست تمام اسناد حسابداری (عمومی و اتوماتیک) با فیلتر و صفحه‌بندی", +) +@require_business_access("business_id") +async def list_documents_endpoint( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """ + لیست اسناد حسابداری + + Body parameters: + - document_type: نوع سند (expense, income, receipt, payment, transfer, manual) + - fiscal_year_id: شناسه سال مالی + - from_date: از تاریخ (ISO format) + - to_date: تا تاریخ (ISO format) + - currency_id: شناسه ارز + - is_proforma: پیش‌فاکتور یا قطعی + - search: جستجو در کد سند و توضیحات + - sort_by: فیلد مرتب‌سازی (document_date, code, document_type, created_at) + - sort_desc: ترتیب نزولی (true/false) + - take: تعداد رکورد (1-1000) + - skip: تعداد رکورد صرف‌نظر شده + """ + query_dict: Dict[str, Any] = { + "take": body.get("take", 50), + "skip": body.get("skip", 0), + "sort_by": body.get("sort_by", "document_date"), + "sort_desc": body.get("sort_desc", True), + "search": body.get("search"), + } + + # فیلترهای اضافی + for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]: + if key in body: + query_dict[key] = body[key] + + # سال مالی از header + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + elif "fiscal_year_id" in body: + query_dict["fiscal_year_id"] = body["fiscal_year_id"] + except Exception: + pass + + result = list_documents(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="DOCUMENTS_LIST_FETCHED" + ) + + +@router.get( + "/documents/{document_id}", + summary="جزئیات سند حسابداری", + description="دریافت جزئیات کامل یک سند شامل تمام سطرهای سند", +) +async def get_document_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """دریافت جزئیات کامل سند""" + result = get_document(db, document_id) + + if not result: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "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="DOCUMENT_DETAILS_FETCHED" + ) + + +@router.delete( + "/documents/{document_id}", + summary="حذف سند حسابداری", + description="حذف یک سند حسابداری (فقط اسناد عمومی manual قابل حذف هستند)", +) +async def delete_document_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ + حذف سند حسابداری + + توجه: فقط اسناد عمومی (manual) قابل حذف هستند. + اسناد اتوماتیک (expense, income, receipt, payment, ...) باید از منبع اصلی حذف شوند. + """ + # دریافت سند برای بررسی دسترسی + doc = get_document(db, document_id) + if not doc: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + business_id = doc.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + # حذف سند + success = delete_document(db, document_id) + + return success_response( + data={"deleted": success, "document_id": document_id}, + request=request, + message="DOCUMENT_DELETED" + ) + + +@router.post( + "/documents/bulk-delete", + summary="حذف گروهی اسناد", + description="حذف گروهی اسناد حسابداری (فقط اسناد manual حذف می‌شوند)", +) +async def bulk_delete_documents_endpoint( + request: Request, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ + حذف گروهی اسناد + + Body: + document_ids: لیست شناسه‌های سند + + توجه: اسناد اتوماتیک نادیده گرفته می‌شوند و باید از منبع اصلی حذف شوند. + """ + document_ids = body.get("document_ids", []) + if not document_ids: + raise ApiError( + "INVALID_REQUEST", + "document_ids is required", + http_status=400 + ) + + result = delete_multiple_documents(db, document_ids) + + return success_response( + data=result, + request=request, + message="DOCUMENTS_BULK_DELETED" + ) + + +@router.get( + "/businesses/{business_id}/documents/types-summary", + summary="خلاصه آماری انواع اسناد", + description="دریافت خلاصه آماری تعداد هر نوع سند", +) +@require_business_access("business_id") +async def get_document_types_summary_endpoint( + request: Request, + business_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """دریافت خلاصه آماری انواع اسناد""" + summary = get_document_types_summary(db, business_id) + + total = sum(summary.values()) + + return success_response( + data={"summary": summary, "total": total}, + request=request, + message="DOCUMENT_TYPES_SUMMARY_FETCHED" + ) + + +@router.post( + "/businesses/{business_id}/documents/export/excel", + summary="خروجی Excel اسناد", + description="دریافت فایل Excel لیست اسناد حسابداری", +) +@require_business_access("business_id") +async def export_documents_excel_endpoint( + request: Request, + business_id: int, + body: Dict[str, Any] = Body(default={}), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """ + خروجی Excel لیست اسناد + + Body: فیلترهای مشابه لیست اسناد + """ + filters = {} + + # فیلترها + for key in ["document_type", "from_date", "to_date", "currency_id", "is_proforma"]: + if key in body: + filters[key] = body[key] + + # سال مالی از header + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + filters["fiscal_year_id"] = int(fy_header) + elif "fiscal_year_id" in body: + filters["fiscal_year_id"] = body["fiscal_year_id"] + except Exception: + pass + + excel_data = export_documents_excel(db, business_id, filters) + + return Response( + content=excel_data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename=documents_{business_id}.xlsx" + } + ) + + +@router.get( + "/documents/{document_id}/pdf", + summary="PDF یک سند", + description="دریافت فایل PDF یک سند حسابداری", +) +async def get_document_pdf_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """ + PDF یک سند + + TODO: پیاده‌سازی تولید PDF برای سند + """ + # بررسی دسترسی + doc = get_document(db, document_id) + if not doc: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + business_id = doc.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + # TODO: تولید PDF + raise ApiError( + "NOT_IMPLEMENTED", + "PDF generation is not implemented yet", + http_status=501 + ) + + +@router.post( + "/businesses/{business_id}/documents/manual", + summary="ایجاد سند حسابداری دستی", + description="ایجاد یک سند حسابداری دستی جدید با سطرهای مورد نظر", +) +@require_business_access("business_id") +async def create_manual_document_endpoint( + request: Request, + business_id: int, + body: CreateManualDocumentRequest, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ + ایجاد سند حسابداری دستی + + Body: + - code: کد سند (اختیاری - خودکار تولید می‌شود) + - document_date: تاریخ سند + - fiscal_year_id: شناسه سال مالی (اختیاری - اگر نباشد، سال مالی فعال استفاده می‌شود) + - currency_id: شناسه ارز + - is_proforma: پیش‌فاکتور یا قطعی + - description: توضیحات سند + - lines: سطرهای سند (حداقل 2 سطر) + - extra_info: اطلاعات اضافی + + نکته: اگر fiscal_year_id ارسال نشود، سیستم به ترتیب زیر عمل می‌کند: + 1. از X-Fiscal-Year-ID header می‌خواند + 2. سال مالی فعال (is_last=True) را انتخاب می‌کند + 3. اگر سال مالی فعال نداشت، خطا برمی‌گرداند + + اعتبارسنجی‌ها: + - سند باید متوازن باشد (مجموع بدهکار = مجموع بستانکار) + - حداقل 2 سطر داشته باشد + - هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر) + """ + # دریافت سال مالی از header یا body + fiscal_year_id = body.fiscal_year_id + if not fiscal_year_id: + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + fiscal_year_id = int(fy_header) + except Exception: + pass + + # اگر fiscal_year_id نبود، سال مالی فعال (is_last=True) را پیدا کن + if not fiscal_year_id: + from adapters.db.models.fiscal_year import FiscalYear + active_fy = db.query(FiscalYear).filter( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True + ).first() + + if active_fy: + fiscal_year_id = active_fy.id + else: + raise ApiError( + "FISCAL_YEAR_REQUIRED", + "No active fiscal year found for this business. Please create a fiscal year first.", + http_status=400 + ) + + # تبدیل Pydantic model به dict + data = body.model_dump() + data["lines"] = [line.model_dump() for line in body.lines] + + # ایجاد سند + result = create_manual_document( + db=db, + business_id=business_id, + fiscal_year_id=fiscal_year_id, + user_id=ctx.get_user_id(), + data=data, + ) + + return success_response( + data=format_datetime_fields(result, request), + request=request, + message="MANUAL_DOCUMENT_CREATED" + ) + + +@router.put( + "/documents/{document_id}", + summary="ویرایش سند حسابداری دستی", + description="ویرایش یک سند حسابداری دستی (فقط اسناد manual قابل ویرایش هستند)", +) +async def update_manual_document_endpoint( + request: Request, + document_id: int, + body: UpdateManualDocumentRequest, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ + ویرایش سند حسابداری دستی + + Body: + - code: کد سند + - document_date: تاریخ سند + - currency_id: شناسه ارز + - is_proforma: پیش‌فاکتور یا قطعی + - description: توضیحات سند + - lines: سطرهای سند (اختیاری - اگر ارسال شود جایگزین سطرهای قبلی می‌شود) + - extra_info: اطلاعات اضافی + + توجه: + - فقط اسناد manual قابل ویرایش هستند + - اسناد اتوماتیک باید از منبع اصلی ویرایش شوند + """ + # بررسی دسترسی + doc = get_document(db, document_id) + if not doc: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + business_id = doc.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + # تبدیل Pydantic model به dict (فقط فیلدهای set شده) + data = body.model_dump(exclude_unset=True) + if "lines" in data and data["lines"] is not None: + data["lines"] = [line.model_dump() for line in body.lines] + + # ویرایش سند + result = update_manual_document( + db=db, + document_id=document_id, + data=data, + ) + + return success_response( + data=format_datetime_fields(result, request), + request=request, + message="MANUAL_DOCUMENT_UPDATED" + ) + diff --git a/hesabixAPI/adapters/api/v1/expense_income.py b/hesabixAPI/adapters/api/v1/expense_income.py index b9244d3..a5569da 100644 --- a/hesabixAPI/adapters/api/v1/expense_income.py +++ b/hesabixAPI/adapters/api/v1/expense_income.py @@ -14,6 +14,10 @@ from adapters.api.v1.schemas import QueryInfo from app.services.expense_income_service import ( create_expense_income, list_expense_income, + get_expense_income, + update_expense_income, + delete_expense_income, + delete_multiple_expense_income, ) @@ -86,3 +90,241 @@ async def list_expense_income_endpoint( return success_response(data=result, request=request, message="EXPENSE_INCOME_LIST_FETCHED") +@router.get( + "/expense-income/{document_id}", + summary="جزئیات سند هزینه/درآمد", + description="دریافت جزئیات یک سند هزینه یا درآمد", +) +async def get_expense_income_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """دریافت جزئیات سند""" + result = get_expense_income(db, document_id) + + if not result: + from app.core.responses import ApiError + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Expense/Income document not found", + http_status=404 + ) + + # بررسی دسترسی + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + from app.core.responses import ApiError + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + return success_response( + data=format_datetime_fields(result, request), + request=request, + message="EXPENSE_INCOME_DETAILS" + ) + + +@router.put( + "/expense-income/{document_id}", + summary="ویرایش سند هزینه/درآمد", + description="ویرایش یک سند هزینه یا درآمد", +) +async def update_expense_income_endpoint( + request: Request, + document_id: int, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """ویرایش سند هزینه/درآمد""" + updated = update_expense_income(db, document_id, ctx.get_user_id(), body) + + return success_response( + data=format_datetime_fields(updated, request), + request=request, + message="EXPENSE_INCOME_UPDATED" + ) + + +@router.delete( + "/expense-income/{document_id}", + summary="حذف سند هزینه/درآمد", + description="حذف یک سند هزینه یا درآمد", +) +async def delete_expense_income_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """حذف سند هزینه/درآمد""" + success = delete_expense_income(db, document_id) + + if not success: + from app.core.responses import ApiError + raise ApiError("DELETE_FAILED", "Failed to delete document", http_status=500) + + return success_response( + data={"deleted": True}, + request=request, + message="EXPENSE_INCOME_DELETED" + ) + + +@router.post( + "/expense-income/bulk-delete", + summary="حذف گروهی اسناد هزینه/درآمد", + description="حذف چندین سند هزینه یا درآمد", +) +async def delete_multiple_expense_income_endpoint( + request: Request, + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), + _: None = Depends(require_business_management_dep), +): + """حذف گروهی اسناد""" + document_ids = body.get("document_ids", []) + if not document_ids: + from app.core.responses import ApiError + raise ApiError("INVALID_REQUEST", "document_ids is required", http_status=400) + + success = delete_multiple_expense_income(db, document_ids) + + if not success: + from app.core.responses import ApiError + raise ApiError("DELETE_FAILED", "Failed to delete documents", http_status=500) + + return success_response( + data={"deleted_count": len(document_ids)}, + request=request, + message="EXPENSE_INCOME_BULK_DELETED" + ) + + +@router.post( + "/businesses/{business_id}/expense-income/export/excel", + summary="خروجی Excel اسناد هزینه/درآمد", + description="دریافت فایل Excel لیست اسناد هزینه/درآمد", +) +@require_business_access("business_id") +async def export_expense_income_excel_endpoint( + request: Request, + business_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """خروجی Excel""" + from app.services.expense_income_service import export_expense_income_excel + from fastapi.responses import Response + + # دریافت پارامترهای فیلتر + query_dict = {} + 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 + + # سال مالی از هدر + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + except Exception: + pass + + excel_data = export_expense_income_excel(db, business_id, query_dict) + + return Response( + content=excel_data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.xlsx"} + ) + + +@router.post( + "/businesses/{business_id}/expense-income/export/pdf", + summary="خروجی PDF اسناد هزینه/درآمد", + description="دریافت فایل PDF لیست اسناد هزینه/درآمد", +) +@require_business_access("business_id") +async def export_expense_income_pdf_endpoint( + request: Request, + business_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """خروجی PDF""" + from app.services.expense_income_service import export_expense_income_pdf + from fastapi.responses import Response + + # دریافت پارامترهای فیلتر + query_dict = {} + 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 + + # سال مالی از هدر + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + except Exception: + pass + + pdf_data = export_expense_income_pdf(db, business_id, query_dict) + + return Response( + content=pdf_data, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=expense_income_{business_id}.pdf"} + ) + + +@router.get( + "/expense-income/{document_id}/pdf", + summary="PDF یک سند هزینه/درآمد", + description="دریافت فایل PDF یک سند هزینه یا درآمد", +) +async def get_expense_income_pdf_endpoint( + request: Request, + document_id: int, + db: Session = Depends(get_db), + ctx: AuthContext = Depends(get_current_user), +): + """PDF یک سند""" + from app.services.expense_income_service import generate_expense_income_pdf + from fastapi.responses import Response + + # بررسی دسترسی + doc = get_expense_income(db, document_id) + if not doc: + from app.core.responses import ApiError + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + business_id = doc.get("business_id") + if business_id and not ctx.can_access_business(business_id): + from app.core.responses import ApiError + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + pdf_data = generate_expense_income_pdf(db, document_id) + + return Response( + content=pdf_data, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=expense_income_{document_id}.pdf"} + ) + + diff --git a/hesabixAPI/adapters/api/v1/persons.py b/hesabixAPI/adapters/api/v1/persons.py index 39ad0a7..a1aecf4 100644 --- a/hesabixAPI/adapters/api/v1/persons.py +++ b/hesabixAPI/adapters/api/v1/persons.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Body, Form from fastapi import UploadFile, File from sqlalchemy.orm import Session +from sqlalchemy import and_ from typing import Dict, Any, List, Optional from adapters.db.session import get_db @@ -19,6 +20,7 @@ from app.services.person_service import ( ) from adapters.db.models.person import Person from adapters.db.models.business import Business +from adapters.db.models.fiscal_year import FiscalYear router = APIRouter(prefix="/persons", tags=["persons"]) @@ -190,6 +192,26 @@ async def get_persons_endpoint( auth_context: AuthContext = Depends(get_current_user), ): """دریافت لیست اشخاص کسب و کار""" + # دریافت سال مالی از header + fiscal_year_id = None + fy_header = request.headers.get('X-Fiscal-Year-ID') + if fy_header: + try: + fiscal_year_id = int(fy_header) + except (ValueError, TypeError): + pass + + # اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم + if not fiscal_year_id: + fiscal_year = db.query(FiscalYear).filter( + and_( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True + ) + ).first() + if fiscal_year: + fiscal_year_id = fiscal_year.id + query_dict = { "take": query_info.take, "skip": query_info.skip, @@ -199,7 +221,7 @@ async def get_persons_endpoint( "search_fields": query_info.search_fields, "filters": query_info.filters, } - result = get_persons_by_business(db, business_id, query_dict) + result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id) # فرمت کردن تاریخ‌ها result['items'] = [ @@ -232,6 +254,26 @@ async def export_persons_excel( from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from fastapi.responses import Response + # دریافت سال مالی از header + fiscal_year_id = None + fy_header = request.headers.get('X-Fiscal-Year-ID') + if fy_header: + try: + fiscal_year_id = int(fy_header) + except (ValueError, TypeError): + pass + + # اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم + if not fiscal_year_id: + fiscal_year = db.query(FiscalYear).filter( + and_( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True + ) + ).first() + if fiscal_year: + fiscal_year_id = fiscal_year.id + # Build query dict similar to list endpoint from flat body query_dict = { "take": int(body.get("take", 20)), @@ -243,7 +285,7 @@ async def export_persons_excel( "filters": body.get("filters"), } - result = get_persons_by_business(db, business_id, query_dict) + result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id) items = result.get('items', []) # Format date/time fields using existing helper @@ -381,6 +423,26 @@ async def export_persons_pdf( from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration + # دریافت سال مالی از header + fiscal_year_id = None + fy_header = request.headers.get('X-Fiscal-Year-ID') + if fy_header: + try: + fiscal_year_id = int(fy_header) + except (ValueError, TypeError): + pass + + # اگر سال مالی مشخص نشده، از سال مالی جاری business استفاده می‌کنیم + if not fiscal_year_id: + fiscal_year = db.query(FiscalYear).filter( + and_( + FiscalYear.business_id == business_id, + FiscalYear.is_last == True + ) + ).first() + if fiscal_year: + fiscal_year_id = fiscal_year.id + # Build query dict from flat body query_dict = { "take": int(body.get("take", 20)), @@ -392,7 +454,7 @@ async def export_persons_pdf( "filters": body.get("filters"), } - result = get_persons_by_business(db, business_id, query_dict) + result = get_persons_by_business(db, business_id, query_dict, fiscal_year_id) items = result.get('items', []) items = [format_datetime_fields(item, request) for item in items] diff --git a/hesabixAPI/adapters/api/v1/schema_models/document.py b/hesabixAPI/adapters/api/v1/schema_models/document.py new file mode 100644 index 0000000..bd337d0 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/document.py @@ -0,0 +1,207 @@ +""" +Schema Models برای اسناد حسابداری (Documents) +""" + +from __future__ import annotations + +from typing import Optional, List, Any, Dict +from decimal import Decimal +from datetime import date, datetime +from pydantic import BaseModel, Field + + +class DocumentListFilters(BaseModel): + """فیلترهای لیست اسناد""" + document_type: Optional[str] = Field(default=None, description="نوع سند") + fiscal_year_id: Optional[int] = Field(default=None, description="شناسه سال مالی") + from_date: Optional[str] = Field(default=None, description="از تاریخ (ISO format)") + to_date: Optional[str] = Field(default=None, description="تا تاریخ (ISO format)") + currency_id: Optional[int] = Field(default=None, description="شناسه ارز") + is_proforma: Optional[bool] = Field(default=None, description="پیش‌فاکتور یا قطعی") + search: Optional[str] = Field(default=None, description="جستجو در کد سند و توضیحات") + sort_by: Optional[str] = Field(default="document_date", description="فیلد مرتب‌سازی") + sort_desc: bool = Field(default=True, description="ترتیب نزولی") + take: int = Field(default=50, ge=1, le=1000, description="تعداد رکورد") + skip: int = Field(default=0, ge=0, description="تعداد رکورد صرف‌نظر شده") + + +class DocumentLineResponse(BaseModel): + """پاسخ خط سند""" + id: int + document_id: int + account_id: Optional[int] = None + person_id: Optional[int] = None + product_id: Optional[int] = None + bank_account_id: Optional[int] = None + cash_register_id: Optional[int] = None + petty_cash_id: Optional[int] = None + check_id: Optional[int] = None + quantity: Optional[float] = None + debit: float + credit: float + description: Optional[str] = None + extra_info: Optional[Dict[str, Any]] = None + + # اطلاعات مرتبط + account_code: Optional[str] = None + account_name: Optional[str] = None + person_name: Optional[str] = None + product_name: Optional[str] = None + bank_account_name: Optional[str] = None + cash_register_name: Optional[str] = None + petty_cash_name: Optional[str] = None + check_number: Optional[str] = None + + class Config: + from_attributes = True + + +class DocumentSummaryResponse(BaseModel): + """پاسخ خلاصه سند (برای لیست)""" + id: int + code: str + business_id: int + fiscal_year_id: int + currency_id: int + created_by_user_id: int + registered_at: datetime + document_date: date + document_type: str + is_proforma: bool + description: Optional[str] = None + created_at: datetime + updated_at: datetime + + # اطلاعات مرتبط + business_title: Optional[str] = None + fiscal_year_title: Optional[str] = None + currency_code: Optional[str] = None + currency_symbol: Optional[str] = None + created_by_name: Optional[str] = None + + # محاسبات + total_debit: float + total_credit: float + lines_count: int + + class Config: + from_attributes = True + + +class DocumentDetailResponse(BaseModel): + """پاسخ جزئیات کامل سند (با سطرها)""" + id: int + code: str + business_id: int + fiscal_year_id: int + currency_id: int + created_by_user_id: int + registered_at: datetime + document_date: date + document_type: str + is_proforma: bool + description: Optional[str] = None + extra_info: Optional[Dict[str, Any]] = None + developer_settings: Optional[Dict[str, Any]] = None + created_at: datetime + updated_at: datetime + + # اطلاعات مرتبط + business_title: Optional[str] = None + fiscal_year_title: Optional[str] = None + currency_code: Optional[str] = None + currency_symbol: Optional[str] = None + created_by_name: Optional[str] = None + + # سطرهای سند + lines: List[DocumentLineResponse] + + # محاسبات + total_debit: float + total_credit: float + lines_count: int + + class Config: + from_attributes = True + + +class DocumentDeleteResponse(BaseModel): + """پاسخ حذف سند""" + deleted: bool + document_id: int + + +class BulkDeleteRequest(BaseModel): + """درخواست حذف گروهی""" + document_ids: List[int] = Field(..., description="لیست شناسه‌های سند") + + +class BulkDeleteResponse(BaseModel): + """پاسخ حذف گروهی""" + deleted_count: int + total_requested: int + errors: List[Dict[str, Any]] + skipped_auto_documents: List[Dict[str, Any]] + + +class DocumentTypesSummaryResponse(BaseModel): + """پاسخ خلاصه آماری انواع اسناد""" + summary: Dict[str, int] + total: int + + +class DocumentLineCreate(BaseModel): + """درخواست ایجاد یک سطر سند""" + account_id: int = Field(..., description="شناسه حساب (الزامی)") + person_id: Optional[int] = Field(default=None, description="شناسه شخص (تفضیل)") + product_id: Optional[int] = Field(default=None, description="شناسه کالا (تفضیل)") + bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی (تفضیل)") + cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق (تفضیل)") + petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه (تفضیل)") + check_id: Optional[int] = Field(default=None, description="شناسه چک (تفضیل)") + quantity: Optional[float] = Field(default=None, description="مقدار/تعداد") + debit: float = Field(default=0, ge=0, description="بدهکار") + credit: float = Field(default=0, ge=0, description="بستانکار") + description: Optional[str] = Field(default=None, max_length=500, description="توضیحات سطر") + extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی") + + +class DocumentLineUpdate(BaseModel): + """درخواست ویرایش یک سطر سند""" + id: Optional[int] = Field(default=None, description="شناسه سطر (برای ویرایش)") + account_id: int = Field(..., description="شناسه حساب (الزامی)") + person_id: Optional[int] = Field(default=None, description="شناسه شخص (تفضیل)") + product_id: Optional[int] = Field(default=None, description="شناسه کالا (تفضیل)") + bank_account_id: Optional[int] = Field(default=None, description="شناسه حساب بانکی (تفضیل)") + cash_register_id: Optional[int] = Field(default=None, description="شناسه صندوق (تفضیل)") + petty_cash_id: Optional[int] = Field(default=None, description="شناسه تنخواه (تفضیل)") + check_id: Optional[int] = Field(default=None, description="شناسه چک (تفضیل)") + quantity: Optional[float] = Field(default=None, description="مقدار/تعداد") + debit: float = Field(default=0, ge=0, description="بدهکار") + credit: float = Field(default=0, ge=0, description="بستانکار") + description: Optional[str] = Field(default=None, max_length=500, description="توضیحات سطر") + extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی") + + +class CreateManualDocumentRequest(BaseModel): + """درخواست ایجاد سند حسابداری دستی""" + code: Optional[str] = Field(default=None, max_length=50, description="کد سند (اختیاری - خودکار)") + document_date: date = Field(..., description="تاریخ سند") + fiscal_year_id: Optional[int] = Field(default=None, description="شناسه سال مالی (اختیاری - از header)") + currency_id: int = Field(..., description="شناسه ارز") + is_proforma: bool = Field(default=False, description="پیش‌فاکتور یا قطعی") + description: Optional[str] = Field(default=None, max_length=1000, description="توضیحات سند") + lines: List[DocumentLineCreate] = Field(..., min_items=2, description="سطرهای سند (حداقل 2)") + extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی") + + +class UpdateManualDocumentRequest(BaseModel): + """درخواست ویرایش سند حسابداری دستی""" + code: Optional[str] = Field(default=None, max_length=50, description="کد سند") + document_date: Optional[date] = Field(default=None, description="تاریخ سند") + currency_id: Optional[int] = Field(default=None, description="شناسه ارز") + is_proforma: Optional[bool] = Field(default=None, description="پیش‌فاکتور یا قطعی") + description: Optional[str] = Field(default=None, max_length=1000, description="توضیحات سند") + lines: Optional[List[DocumentLineUpdate]] = Field(default=None, min_items=2, description="سطرهای سند") + extra_info: Optional[Dict[str, Any]] = Field(default=None, description="اطلاعات اضافی") + diff --git a/hesabixAPI/adapters/api/v1/schema_models/person.py b/hesabixAPI/adapters/api/v1/schema_models/person.py index 3772a4b..5259a0a 100644 --- a/hesabixAPI/adapters/api/v1/schema_models/person.py +++ b/hesabixAPI/adapters/api/v1/schema_models/person.py @@ -222,6 +222,10 @@ class PersonResponse(BaseModel): commission_exclude_discounts: Optional[bool] = Field(default=False, description="عدم محاسبه تخفیف") commission_exclude_additions_deductions: Optional[bool] = Field(default=False, description="عدم محاسبه اضافات و کسورات") commission_post_in_invoice_document: Optional[bool] = Field(default=False, description="ثبت پورسانت در سند فاکتور") + + # تراز و وضعیت مالی + balance: Optional[float] = Field(default=None, description="تراز شخص (بستانکار - بدهکار)") + status: Optional[str] = Field(default=None, description="وضعیت مالی (بستانکار/بدهکار/بالانس/بدون تراکنش)") class Config: from_attributes = True diff --git a/hesabixAPI/adapters/api/v1/schema_models/product_bom.py b/hesabixAPI/adapters/api/v1/schema_models/product_bom.py new file mode 100644 index 0000000..bdf5521 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/product_bom.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Optional, List +from decimal import Decimal +from pydantic import BaseModel, Field + + +class BomStatus(str): + DRAFT = "draft" + APPROVED = "approved" + ARCHIVED = "archived" + + +class BomItem(BaseModel): + line_no: int + component_product_id: int + qty_per: Decimal = Field(..., description="مقدار برای تولید 1 واحد") + uom: Optional[str] = Field(default=None, max_length=32) + wastage_percent: Optional[Decimal] = Field(default=None) + is_optional: bool = Field(default=False) + substitute_group: Optional[str] = Field(default=None, max_length=64) + suggested_warehouse_id: Optional[int] = Field(default=None) + + +class BomOutput(BaseModel): + line_no: int + output_product_id: int + ratio: Decimal + uom: Optional[str] = Field(default=None, max_length=32) + + +class BomOperation(BaseModel): + line_no: int + operation_name: str + cost_fixed: Optional[Decimal] = None + cost_per_unit: Optional[Decimal] = None + cost_uom: Optional[str] = Field(default=None, max_length=32) + work_center: Optional[str] = Field(default=None, max_length=128) + + +class ProductBOMCreateRequest(BaseModel): + product_id: int + version: str = Field(..., max_length=64) + name: str = Field(..., max_length=255) + is_default: bool = Field(default=False) + effective_from: Optional[str] = Field(default=None) + effective_to: Optional[str] = Field(default=None) + yield_percent: Optional[Decimal] = None + wastage_percent: Optional[Decimal] = None + status: str = Field(default=BomStatus.DRAFT) + notes: Optional[str] = Field(default=None, max_length=5000) + + items: List[BomItem] = Field(default_factory=list) + outputs: List[BomOutput] = Field(default_factory=list) + operations: List[BomOperation] = Field(default_factory=list) + + +class ProductBOMUpdateRequest(BaseModel): + version: Optional[str] = Field(default=None, max_length=64) + name: Optional[str] = Field(default=None, max_length=255) + is_default: Optional[bool] = Field(default=None) + effective_from: Optional[str] = Field(default=None) + effective_to: Optional[str] = Field(default=None) + yield_percent: Optional[Decimal] = None + wastage_percent: Optional[Decimal] = None + status: Optional[str] = Field(default=None) + notes: Optional[str] = Field(default=None, max_length=5000) + + items: Optional[List[BomItem]] = None + outputs: Optional[List[BomOutput]] = None + operations: Optional[List[BomOperation]] = None + + +class ProductBOMResponse(BaseModel): + id: int + business_id: int + product_id: int + version: str + name: str + is_default: bool + effective_from: Optional[str] = None + effective_to: Optional[str] = None + yield_percent: Optional[Decimal] = None + wastage_percent: Optional[Decimal] = None + status: str + notes: Optional[str] = None + created_at: str + updated_at: str + + items: List[BomItem] = Field(default_factory=list) + outputs: List[BomOutput] = Field(default_factory=list) + operations: List[BomOperation] = Field(default_factory=list) + + class Config: + from_attributes = True + + +class BOMExplosionRequest(BaseModel): + product_id: Optional[int] = None + bom_id: Optional[int] = None + quantity: Decimal = Field(..., description="مقدار تولید") + date: Optional[str] = None + + class Config: + json_schema_extra = { + "examples": [ + {"product_id": 1, "quantity": 100}, + ] + } + + +class BOMExplosionItem(BaseModel): + component_product_id: int + required_qty: Decimal + uom: Optional[str] = None + suggested_warehouse_id: Optional[int] = None + is_optional: bool + substitute_group: Optional[str] = None + + +class BOMExplosionResult(BaseModel): + items: List[BOMExplosionItem] + outputs: List[BomOutput] + notes: Optional[str] = None + + diff --git a/hesabixAPI/adapters/api/v1/schema_models/warehouse.py b/hesabixAPI/adapters/api/v1/schema_models/warehouse.py new file mode 100644 index 0000000..ae2589b --- /dev/null +++ b/hesabixAPI/adapters/api/v1/schema_models/warehouse.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Optional +from pydantic import BaseModel, Field + + +class WarehouseCreateRequest(BaseModel): + code: str = Field(..., max_length=64) + name: str = Field(..., max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + is_default: bool = Field(default=False) + + +class WarehouseUpdateRequest(BaseModel): + code: Optional[str] = Field(default=None, max_length=64) + name: Optional[str] = Field(default=None, max_length=255) + description: Optional[str] = Field(default=None, max_length=2000) + is_default: Optional[bool] = Field(default=None) + + +class WarehouseResponse(BaseModel): + id: int + business_id: int + code: str + name: str + description: Optional[str] = None + is_default: bool + created_at: str + updated_at: str + + class Config: + from_attributes = True + + diff --git a/hesabixAPI/adapters/api/v1/transfers.py b/hesabixAPI/adapters/api/v1/transfers.py index 3909fd4..48753c2 100644 --- a/hesabixAPI/adapters/api/v1/transfers.py +++ b/hesabixAPI/adapters/api/v1/transfers.py @@ -49,9 +49,13 @@ async def list_transfers_endpoint( try: body_json = await request.json() if isinstance(body_json, dict): + # Forward simple date range params for key in ["from_date", "to_date"]: if key in body_json: query_dict[key] = body_json[key] + # Forward advanced filters from DataTable (e.g., document_date range) + if "filters" in body_json: + query_dict["filters"] = body_json.get("filters") except Exception: pass diff --git a/hesabixAPI/adapters/api/v1/warehouses.py b/hesabixAPI/adapters/api/v1/warehouses.py new file mode 100644 index 0000000..9b80314 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/warehouses.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import Dict, Any +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from adapters.db.session import get_db +from app.core.auth_dependency import get_current_user, AuthContext +from app.core.permissions import require_business_access +from app.core.responses import success_response, ApiError, format_datetime_fields +from adapters.api.v1.schema_models.warehouse import ( + WarehouseCreateRequest, + WarehouseUpdateRequest, +) +from app.services.warehouse_service import ( + create_warehouse, + list_warehouses, + get_warehouse, + update_warehouse, + delete_warehouse, +) + + +router = APIRouter(prefix="/warehouses", tags=["warehouses"]) + + +@router.post("/business/{business_id}") +@require_business_access("business_id") +def create_warehouse_endpoint( + request: Request, + business_id: int, + payload: WarehouseCreateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = create_warehouse(db, business_id, payload) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.get("/business/{business_id}") +@require_business_access("business_id") +def list_warehouses_endpoint( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + result = list_warehouses(db, business_id) + return success_response(data=format_datetime_fields(result, request), request=request) + + +@router.get("/business/{business_id}/{warehouse_id}") +@require_business_access("business_id") +def get_warehouse_endpoint( + request: Request, + business_id: int, + warehouse_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.can_read_section("inventory"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.read", http_status=403) + item = get_warehouse(db, business_id, warehouse_id) + if not item: + raise ApiError("NOT_FOUND", "Warehouse not found", http_status=404) + return success_response(data=format_datetime_fields({"item": item}, request), request=request) + + +@router.put("/business/{business_id}/{warehouse_id}") +@require_business_access("business_id") +def update_warehouse_endpoint( + request: Request, + business_id: int, + warehouse_id: int, + payload: WarehouseUpdateRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "write"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.write", http_status=403) + result = update_warehouse(db, business_id, warehouse_id, payload) + if not result: + raise ApiError("NOT_FOUND", "Warehouse not found", http_status=404) + return success_response(data=format_datetime_fields(result["data"], request), request=request, message=result.get("message")) + + +@router.delete("/business/{business_id}/{warehouse_id}") +@require_business_access("business_id") +def delete_warehouse_endpoint( + request: Request, + business_id: int, + warehouse_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + if not ctx.has_business_permission("inventory", "delete"): + raise ApiError("FORBIDDEN", "Missing business permission: inventory.delete", http_status=403) + ok = delete_warehouse(db, business_id, warehouse_id) + return success_response({"deleted": ok}, request) + + diff --git a/hesabixAPI/adapters/db/models/__init__.py b/hesabixAPI/adapters/db/models/__init__.py index d44c647..b50a759 100644 --- a/hesabixAPI/adapters/db/models/__init__.py +++ b/hesabixAPI/adapters/db/models/__init__.py @@ -41,3 +41,5 @@ from .bank_account import BankAccount # noqa: F401 from .cash_register import CashRegister # noqa: F401 from .petty_cash import PettyCash # noqa: F401 from .check import Check # noqa: F401 +from .warehouse import Warehouse # noqa: F401 +from .product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation # noqa: F401 diff --git a/hesabixAPI/adapters/db/models/product_bom.py b/hesabixAPI/adapters/db/models/product_bom.py new file mode 100644 index 0000000..000fbbe --- /dev/null +++ b/hesabixAPI/adapters/db/models/product_bom.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + String, + Integer, + DateTime, + Text, + ForeignKey, + UniqueConstraint, + Boolean, + Numeric, + Date, + Enum as SQLEnum, +) +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class BomStatus(str): + DRAFT = "draft" + APPROVED = "approved" + ARCHIVED = "archived" + + +class ProductBOM(Base): + """ + سرشاخه فرمول تولید (BOM) + """ + + __tablename__ = "product_boms" + __table_args__ = ( + UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"), + ) + + 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) + product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True) + + version: Mapped[str] = mapped_column(String(64), nullable=False, comment="نسخه فرمول") + name: Mapped[str] = mapped_column(String(255), nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + + effective_from: Mapped[Date | None] = mapped_column(Date, nullable=True) + effective_to: Mapped[Date | None] = mapped_column(Date, nullable=True) + + yield_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="بازده کل") + wastage_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True, comment="پرت کل") + + status: Mapped[str] = mapped_column(String(16), default=BomStatus.DRAFT, nullable=False, index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by: Mapped[int | None] = mapped_column(Integer, nullable=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) + + +class ProductBOMItem(Base): + """ + اقلام مصرفی BOM + """ + + __tablename__ = "product_bom_items" + __table_args__ = ( + UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True) + + line_no: Mapped[int] = mapped_column(Integer, nullable=False) + component_product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False, index=True) + + qty_per: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False, comment="مقدار برای تولید 1 واحد") + uom: Mapped[str | None] = mapped_column(String(32), nullable=True) + wastage_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + + is_optional: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + substitute_group: Mapped[str | None] = mapped_column(String(64), nullable=True) + + suggested_warehouse_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True) + + +class ProductBOMOutput(Base): + """ + خروجی‌های BOM (محصول اصلی و جانبی) + """ + + __tablename__ = "product_bom_outputs" + __table_args__ = ( + UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True) + + line_no: Mapped[int] = mapped_column(Integer, nullable=False) + output_product_id: Mapped[int] = mapped_column(Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False, index=True) + ratio: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False, comment="نسبت خروجی نسبت به 1 واحد") + uom: Mapped[str | None] = mapped_column(String(32), nullable=True) + + +class ProductBOMOperation(Base): + """ + عملیات/سربار BOM + """ + + __tablename__ = "product_bom_operations" + __table_args__ = ( + UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + bom_id: Mapped[int] = mapped_column(Integer, ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False, index=True) + + line_no: Mapped[int] = mapped_column(Integer, nullable=False) + operation_name: Mapped[str] = mapped_column(String(255), nullable=False) + cost_fixed: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) + cost_per_unit: Mapped[float | None] = mapped_column(Numeric(18, 6), nullable=True) + cost_uom: Mapped[str | None] = mapped_column(String(32), nullable=True) + work_center: Mapped[str | None] = mapped_column(String(128), nullable=True) + + diff --git a/hesabixAPI/adapters/db/models/warehouse.py b/hesabixAPI/adapters/db/models/warehouse.py new file mode 100644 index 0000000..dff647f --- /dev/null +++ b/hesabixAPI/adapters/db/models/warehouse.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import String, Integer, DateTime, Text, ForeignKey, UniqueConstraint, Boolean +from sqlalchemy.orm import Mapped, mapped_column + +from adapters.db.session import Base + + +class Warehouse(Base): + """ + انبارهای کسب‌وکار (حداقل‌های موردنیاز برای BOM و اسناد تولید) + """ + + __tablename__ = "warehouses" + __table_args__ = ( + UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"), + ) + + 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) + + code: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="کد یکتا در هر کسب‌وکار") + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + is_default: Mapped[bool] = mapped_column(Boolean, default=False, 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) + + diff --git a/hesabixAPI/adapters/db/repositories/document_repository.py b/hesabixAPI/adapters/db/repositories/document_repository.py new file mode 100644 index 0000000..1372382 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/document_repository.py @@ -0,0 +1,514 @@ +""" +Repository برای مدیریت اسناد حسابداری (Documents) +""" + +from __future__ import annotations + +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, and_, or_, func, desc, asc +from datetime import date + +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine +from adapters.db.models.business import Business +from adapters.db.models.fiscal_year import FiscalYear +from adapters.db.models.currency import Currency +from adapters.db.models.user import User +from adapters.db.models.account import Account +from adapters.db.models.person import Person +from adapters.db.models.product import Product +from adapters.db.models.bank_account import BankAccount +from adapters.db.models.cash_register import CashRegister +from adapters.db.models.petty_cash import PettyCash +from adapters.db.models.check import Check + + +class DocumentRepository: + """Repository برای عملیات پایگاه داده مربوط به اسناد حسابداری""" + + def __init__(self, db: Session) -> None: + self.db = db + + def get_document(self, document_id: int) -> Optional[Document]: + """دریافت یک سند با شناسه""" + return self.db.query(Document).filter(Document.id == document_id).first() + + def get_document_with_relations(self, document_id: int) -> Optional[Document]: + """دریافت سند با روابط (eager loading)""" + return ( + self.db.query(Document) + .options( + joinedload(Document.business), + joinedload(Document.fiscal_year), + joinedload(Document.currency), + joinedload(Document.created_by), + joinedload(Document.lines), + ) + .filter(Document.id == document_id) + .first() + ) + + def list_documents_with_filters( + self, + business_id: int, + filters: Dict[str, Any], + ) -> tuple[List[Dict[str, Any]], int]: + """ + لیست اسناد با فیلترها و صفحه‌بندی + + Args: + business_id: شناسه کسب‌وکار + filters: دیکشنری فیلترها شامل: + - document_type: نوع سند + - fiscal_year_id: شناسه سال مالی + - from_date: از تاریخ + - to_date: تا تاریخ + - currency_id: شناسه ارز + - is_proforma: پیش‌فاکتور یا نه + - search: عبارت جستجو + - sort_by: فیلد مرتب‌سازی + - sort_desc: ترتیب نزولی + - take: تعداد رکورد + - skip: تعداد رکورد صرف‌نظر شده + + Returns: + tuple: (لیست اسناد, تعداد کل) + """ + # Query پایه + query = self.db.query( + Document.id, + Document.code, + Document.business_id, + Document.fiscal_year_id, + Document.currency_id, + Document.created_by_user_id, + Document.registered_at, + Document.document_date, + Document.document_type, + Document.is_proforma, + Document.description, + Document.created_at, + Document.updated_at, + Business.name.label("business_title"), + FiscalYear.title.label("fiscal_year_title"), + Currency.code.label("currency_code"), + Currency.symbol.label("currency_symbol"), + (User.first_name + " " + User.last_name).label("created_by_name"), + ).select_from(Document).join( + Business, Document.business_id == Business.id + ).join( + FiscalYear, Document.fiscal_year_id == FiscalYear.id + ).join( + Currency, Document.currency_id == Currency.id + ).join( + User, Document.created_by_user_id == User.id + ).filter( + Document.business_id == business_id + ) + + # اعمال فیلترها + if filters.get("document_type"): + query = query.filter(Document.document_type == filters["document_type"]) + + if filters.get("fiscal_year_id"): + query = query.filter(Document.fiscal_year_id == filters["fiscal_year_id"]) + + if filters.get("from_date"): + try: + from_date = self._parse_date(filters["from_date"]) + query = query.filter(Document.document_date >= from_date) + except Exception: + pass + + if filters.get("to_date"): + try: + to_date = self._parse_date(filters["to_date"]) + query = query.filter(Document.document_date <= to_date) + except Exception: + pass + + if filters.get("currency_id"): + query = query.filter(Document.currency_id == filters["currency_id"]) + + if filters.get("is_proforma") is not None: + query = query.filter(Document.is_proforma == filters["is_proforma"]) + + # جستجو + if filters.get("search"): + search_term = f"%{filters['search']}%" + query = query.filter( + or_( + Document.code.ilike(search_term), + Document.description.ilike(search_term), + ) + ) + + # شمارش کل + total_count = query.count() + + # مرتب‌سازی + sort_by = filters.get("sort_by", "document_date") + sort_desc = filters.get("sort_desc", True) + + if sort_by == "document_date": + order_col = Document.document_date + elif sort_by == "code": + order_col = Document.code + elif sort_by == "document_type": + order_col = Document.document_type + elif sort_by == "created_at": + order_col = Document.created_at + else: + order_col = Document.document_date + + if sort_desc: + query = query.order_by(desc(order_col), desc(Document.id)) + else: + query = query.order_by(asc(order_col), asc(Document.id)) + + # صفحه‌بندی + skip = filters.get("skip", 0) + take = filters.get("take", 50) + query = query.offset(skip).limit(take) + + # اجرای query + results = query.all() + + # محاسبه مجموع بدهکار و بستانکار برای هر سند + documents = [] + for row in results: + doc_dict = { + "id": row.id, + "code": row.code, + "business_id": row.business_id, + "fiscal_year_id": row.fiscal_year_id, + "currency_id": row.currency_id, + "created_by_user_id": row.created_by_user_id, + "registered_at": row.registered_at, + "document_date": row.document_date, + "document_type": row.document_type, + "is_proforma": row.is_proforma, + "description": row.description, + "created_at": row.created_at, + "updated_at": row.updated_at, + "business_title": row.business_title, + "fiscal_year_title": row.fiscal_year_title, + "currency_code": row.currency_code, + "currency_symbol": row.currency_symbol, + "created_by_name": row.created_by_name, + } + + # محاسبه مجموع + totals = ( + self.db.query( + func.sum(DocumentLine.debit).label("total_debit"), + func.sum(DocumentLine.credit).label("total_credit"), + func.count(DocumentLine.id).label("lines_count"), + ) + .filter(DocumentLine.document_id == row.id) + .first() + ) + + doc_dict["total_debit"] = float(totals.total_debit or 0) + doc_dict["total_credit"] = float(totals.total_credit or 0) + doc_dict["lines_count"] = totals.lines_count or 0 + + documents.append(doc_dict) + + return documents, total_count + + def get_document_details(self, document_id: int) -> Optional[Dict[str, Any]]: + """دریافت جزئیات کامل سند شامل سطرها""" + document = self.get_document_with_relations(document_id) + if not document: + return None + + return self.to_dict_with_lines(document) + + def delete_document(self, document_id: int) -> bool: + """حذف سند""" + document = self.get_document(document_id) + if not document: + return False + + self.db.delete(document) + self.db.commit() + return True + + def to_dict(self, document: Document) -> Dict[str, Any]: + """تبدیل Document به dictionary (بدون سطرها)""" + return { + "id": document.id, + "code": document.code, + "business_id": document.business_id, + "fiscal_year_id": document.fiscal_year_id, + "currency_id": document.currency_id, + "created_by_user_id": document.created_by_user_id, + "registered_at": document.registered_at, + "document_date": document.document_date, + "document_type": document.document_type, + "is_proforma": document.is_proforma, + "description": document.description, + "extra_info": document.extra_info, + "developer_settings": document.developer_settings, + "created_at": document.created_at, + "updated_at": document.updated_at, + } + + def to_dict_with_lines(self, document: Document) -> Dict[str, Any]: + """تبدیل Document به dictionary (با سطرها و جزئیات کامل)""" + doc_dict = self.to_dict(document) + + # اضافه کردن اطلاعات روابط + if document.business: + doc_dict["business_title"] = document.business.name + + if document.fiscal_year: + doc_dict["fiscal_year_title"] = document.fiscal_year.title + + if document.currency: + doc_dict["currency_code"] = document.currency.code + doc_dict["currency_symbol"] = document.currency.symbol + + if document.created_by: + doc_dict["created_by_name"] = ( + f"{document.created_by.first_name} {document.created_by.last_name}" + ) + + # اضافه کردن سطرهای سند + lines = [] + total_debit = 0 + total_credit = 0 + + for line in document.lines: + line_dict = self._document_line_to_dict(line) + lines.append(line_dict) + total_debit += float(line.debit or 0) + total_credit += float(line.credit or 0) + + doc_dict["lines"] = lines + doc_dict["total_debit"] = total_debit + doc_dict["total_credit"] = total_credit + doc_dict["lines_count"] = len(lines) + + return doc_dict + + def _document_line_to_dict(self, line: DocumentLine) -> Dict[str, Any]: + """تبدیل DocumentLine به dictionary""" + line_dict = { + "id": line.id, + "document_id": line.document_id, + "account_id": line.account_id, + "person_id": line.person_id, + "product_id": line.product_id, + "bank_account_id": line.bank_account_id, + "cash_register_id": line.cash_register_id, + "petty_cash_id": line.petty_cash_id, + "check_id": line.check_id, + "quantity": float(line.quantity) if line.quantity else None, + "debit": float(line.debit or 0), + "credit": float(line.credit or 0), + "description": line.description, + "extra_info": line.extra_info, + "created_at": line.created_at, + "updated_at": line.updated_at, + } + + # اضافه کردن اطلاعات روابط + if line.account: + line_dict["account_code"] = line.account.code + line_dict["account_name"] = line.account.name + + if line.person: + line_dict["person_name"] = line.person.alias_name + + if line.product: + line_dict["product_name"] = line.product.title + + if line.bank_account: + line_dict["bank_account_name"] = line.bank_account.account_title + + if line.cash_register: + line_dict["cash_register_name"] = line.cash_register.title + + if line.petty_cash: + line_dict["petty_cash_name"] = line.petty_cash.title + + if line.check: + line_dict["check_number"] = line.check.check_number + + return line_dict + + def _parse_date(self, date_value: Any) -> date: + """تبدیل مقدار به date""" + if isinstance(date_value, date): + return date_value + if isinstance(date_value, str): + from datetime import datetime + return datetime.fromisoformat(date_value.split("T")[0]).date() + raise ValueError(f"Invalid date format: {date_value}") + + def create_document(self, document_data: Dict[str, Any]) -> Document: + """ + ایجاد سند جدید + + Args: + document_data: دیکشنری حاوی اطلاعات سند و سطرها + + Returns: + Document ایجاد شده + """ + # جداسازی سطرها از اطلاعات سند + lines_data = document_data.pop("lines", []) + + # ایجاد سند + document = Document(**document_data) + self.db.add(document) + self.db.flush() # برای دریافت ID سند + + # ایجاد سطرهای سند + for line_data in lines_data: + line = DocumentLine( + document_id=document.id, + **line_data + ) + self.db.add(line) + + self.db.commit() + self.db.refresh(document) + + return document + + def update_document( + self, + document_id: int, + document_data: Dict[str, Any] + ) -> Optional[Document]: + """ + ویرایش سند موجود + + Args: + document_id: شناسه سند + document_data: دیکشنری حاوی اطلاعات جدید سند و سطرها + + Returns: + Document ویرایش شده یا None + """ + document = self.get_document(document_id) + if not document: + return None + + # جداسازی سطرها + lines_data = document_data.pop("lines", None) + + # ویرایش فیلدهای سند + for key, value in document_data.items(): + if value is not None and hasattr(document, key): + setattr(document, key, value) + + # اگر سطرها ارسال شده، آن‌ها را جایگزین کن + if lines_data is not None: + # حذف سطرهای قدیمی + for old_line in document.lines: + self.db.delete(old_line) + self.db.flush() + + # ایجاد سطرهای جدید + for line_data in lines_data: + line_id = line_data.pop("id", None) + line = DocumentLine( + document_id=document.id, + **line_data + ) + self.db.add(line) + + self.db.commit() + self.db.refresh(document) + + return document + + def generate_document_code( + self, + business_id: int, + document_type: str = "manual" + ) -> str: + """ + تولید کد خودکار برای سند + + Args: + business_id: شناسه کسب‌وکار + document_type: نوع سند + + Returns: + کد یکتای سند + """ + from datetime import datetime + + # دریافت آخرین کد سند + last_doc = ( + self.db.query(Document) + .filter( + Document.business_id == business_id, + Document.document_type == document_type + ) + .order_by(desc(Document.id)) + .first() + ) + + if last_doc and last_doc.code: + try: + # استخراج عدد از آخر کد + import re + numbers = re.findall(r'\d+', last_doc.code) + if numbers: + last_number = int(numbers[-1]) + return f"{document_type.upper()}-{last_number + 1:05d}" + except Exception: + pass + + # اگر سند قبلی نداشت یا فرمت نامعتبر بود + year = datetime.now().year % 100 # دو رقم آخر سال + return f"{document_type.upper()}-{year}{1:04d}" + + def validate_document_balance(self, lines_data: List[Dict[str, Any]]) -> tuple[bool, str]: + """ + اعتبارسنجی متوازن بودن سند + + Args: + lines_data: لیست سطرهای سند + + Returns: + tuple: (متوازن است؟, پیام خطا) + """ + if not lines_data or len(lines_data) < 2: + return False, "سند باید حداقل 2 سطر داشته باشد" + + total_debit = sum(float(line.get("debit", 0)) for line in lines_data) + total_credit = sum(float(line.get("credit", 0)) for line in lines_data) + + # تلرانس برای خطاهای اعشاری + tolerance = 0.01 + if abs(total_debit - total_credit) > tolerance: + diff = total_debit - total_credit + return False, f"سند متوازن نیست. تفاوت: {diff:,.2f}" + + # حداقل یک سطر باید بدهکار و یک سطر بستانکار داشته باشد + has_debit = any(float(line.get("debit", 0)) > 0 for line in lines_data) + has_credit = any(float(line.get("credit", 0)) > 0 for line in lines_data) + + if not has_debit or not has_credit: + return False, "سند باید حداقل یک سطر بدهکار و یک سطر بستانکار داشته باشد" + + # هر سطر باید یا بدهکار یا بستانکار داشته باشد (نه هر دو صفر) + for i, line in enumerate(lines_data, 1): + debit = float(line.get("debit", 0)) + credit = float(line.get("credit", 0)) + if debit == 0 and credit == 0: + return False, f"سطر {i} باید مقدار بدهکار یا بستانکار داشته باشد" + # نمی‌تواند هم بدهکار هم بستانکار داشته باشد + if debit > 0 and credit > 0: + return False, f"سطر {i} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد" + + return True, "" + diff --git a/hesabixAPI/adapters/db/repositories/product_bom_repository.py b/hesabixAPI/adapters/db/repositories/product_bom_repository.py new file mode 100644 index 0000000..324e943 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/product_bom_repository.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import select, and_ + +from adapters.db.models.product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation + + +class ProductBOMRepository: + def __init__(self, db: Session) -> None: + self.db = db + + # BOM header + def create_bom(self, **kwargs) -> ProductBOM: + obj = ProductBOM(**kwargs) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get_bom(self, bom_id: int, business_id: int) -> Optional[ProductBOM]: + obj = self.db.get(ProductBOM, bom_id) + if not obj or obj.business_id != business_id: + return None + return obj + + def list_boms(self, business_id: int, product_id: Optional[int] = None) -> List[ProductBOM]: + stmt = select(ProductBOM).where(ProductBOM.business_id == business_id) + if product_id: + stmt = stmt.where(ProductBOM.product_id == product_id) + return [r[0] for r in self.db.execute(stmt.order_by(ProductBOM.id.desc())).all()] + + def update_bom(self, bom_id: int, **kwargs) -> Optional[ProductBOM]: + obj = self.db.get(ProductBOM, bom_id) + if not obj: + return None + for k, v in kwargs.items(): + if v is not None: + setattr(obj, k, v) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete_bom(self, bom_id: int) -> bool: + obj = self.db.get(ProductBOM, bom_id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + # Items + def replace_items(self, bom_id: int, items: List[dict]) -> None: + self.db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom_id).delete() + for it in items: + self.db.add(ProductBOMItem(bom_id=bom_id, **it)) + self.db.commit() + + def replace_outputs(self, bom_id: int, outputs: List[dict]) -> None: + self.db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom_id).delete() + for out in outputs: + self.db.add(ProductBOMOutput(bom_id=bom_id, **out)) + self.db.commit() + + def replace_operations(self, bom_id: int, operations: List[dict]) -> None: + self.db.query(ProductBOMOperation).filter(ProductBOMOperation.bom_id == bom_id).delete() + for op in operations: + self.db.add(ProductBOMOperation(bom_id=bom_id, **op)) + self.db.commit() + + diff --git a/hesabixAPI/adapters/db/repositories/warehouse_repository.py b/hesabixAPI/adapters/db/repositories/warehouse_repository.py new file mode 100644 index 0000000..eea83b1 --- /dev/null +++ b/hesabixAPI/adapters/db/repositories/warehouse_repository.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import select, and_ + +from adapters.db.models.warehouse import Warehouse + + +class WarehouseRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def create(self, **kwargs) -> Warehouse: + obj = Warehouse(**kwargs) + self.db.add(obj) + self.db.commit() + self.db.refresh(obj) + return obj + + def get(self, warehouse_id: int) -> Optional[Warehouse]: + return self.db.get(Warehouse, warehouse_id) + + def list(self, business_id: int) -> List[Warehouse]: + stmt = select(Warehouse).where(Warehouse.business_id == business_id).order_by(Warehouse.id.desc()) + return [r[0] for r in self.db.execute(stmt).all()] + + def update(self, warehouse_id: int, **kwargs) -> Optional[Warehouse]: + obj = self.db.get(Warehouse, warehouse_id) + if not obj: + return None + for k, v in kwargs.items(): + if v is not None: + setattr(obj, k, v) + self.db.commit() + self.db.refresh(obj) + return obj + + def delete(self, warehouse_id: int) -> bool: + obj = self.db.get(Warehouse, warehouse_id) + if not obj: + return False + self.db.delete(obj) + self.db.commit() + return True + + diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index b70cb33..b51d966 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -33,6 +33,8 @@ from adapters.api.v1.admin.email_config import router as admin_email_config_rout from adapters.api.v1.receipts_payments import router as receipts_payments_router from adapters.api.v1.transfers import router as transfers_router from adapters.api.v1.fiscal_years import router as fiscal_years_router +from adapters.api.v1.expense_income import router as expense_income_router +from adapters.api.v1.documents import router as documents_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 @@ -297,6 +299,10 @@ def create_app() -> FastAPI: application.include_router(categories_router, prefix=settings.api_v1_prefix) application.include_router(product_attributes_router, prefix=settings.api_v1_prefix) application.include_router(products_router, prefix=settings.api_v1_prefix) + from adapters.api.v1.warehouses import router as warehouses_router + application.include_router(warehouses_router, prefix=settings.api_v1_prefix) + from adapters.api.v1.boms import router as boms_router + application.include_router(boms_router, prefix=settings.api_v1_prefix) application.include_router(price_lists_router, prefix=settings.api_v1_prefix) application.include_router(invoices_router, prefix=settings.api_v1_prefix) application.include_router(persons_router, prefix=settings.api_v1_prefix) @@ -310,8 +316,8 @@ def create_app() -> FastAPI: application.include_router(tax_types_router, prefix=settings.api_v1_prefix) application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix) application.include_router(transfers_router, prefix=settings.api_v1_prefix) - from adapters.api.v1.expense_income import router as expense_income_router application.include_router(expense_income_router, prefix=settings.api_v1_prefix) + application.include_router(documents_router, prefix=settings.api_v1_prefix) application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) # Support endpoints diff --git a/hesabixAPI/app/services/bom_service.py b/hesabixAPI/app/services/bom_service.py new file mode 100644 index 0000000..a647e78 --- /dev/null +++ b/hesabixAPI/app/services/bom_service.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional, List +from decimal import Decimal +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from app.core.responses import ApiError +from adapters.db.models.product import Product +from adapters.db.models.product_bom import ProductBOM, ProductBOMItem, ProductBOMOutput, ProductBOMOperation +from adapters.db.repositories.product_bom_repository import ProductBOMRepository +from adapters.api.v1.schema_models.product_bom import ( + ProductBOMCreateRequest, + ProductBOMUpdateRequest, + BOMExplosionRequest, +) + + +def _to_bom_dict(bom: ProductBOM, db: Session) -> Dict[str, Any]: + items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all() + outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all() + operations = db.query(ProductBOMOperation).filter(ProductBOMOperation.bom_id == bom.id).order_by(ProductBOMOperation.line_no).all() + return { + "id": bom.id, + "business_id": bom.business_id, + "product_id": bom.product_id, + "version": bom.version, + "name": bom.name, + "is_default": bom.is_default, + "effective_from": bom.effective_from, + "effective_to": bom.effective_to, + "yield_percent": bom.yield_percent, + "wastage_percent": bom.wastage_percent, + "status": bom.status, + "notes": bom.notes, + "created_at": bom.created_at, + "updated_at": bom.updated_at, + "items": [ + { + "line_no": it.line_no, + "component_product_id": it.component_product_id, + "qty_per": it.qty_per, + "uom": it.uom, + "wastage_percent": it.wastage_percent, + "is_optional": it.is_optional, + "substitute_group": it.substitute_group, + "suggested_warehouse_id": it.suggested_warehouse_id, + } + for it in items + ], + "outputs": [ + { + "line_no": ot.line_no, + "output_product_id": ot.output_product_id, + "ratio": ot.ratio, + "uom": ot.uom, + } + for ot in outputs + ], + "operations": [ + { + "line_no": op.line_no, + "operation_name": op.operation_name, + "cost_fixed": op.cost_fixed, + "cost_per_unit": op.cost_per_unit, + "cost_uom": op.cost_uom, + "work_center": op.work_center, + } + for op in operations + ], + } + + +def create_bom(db: Session, business_id: int, payload: ProductBOMCreateRequest) -> Dict[str, Any]: + # product must belong to business + product = db.get(Product, payload.product_id) + if not product or product.business_id != business_id: + raise ApiError("INVALID_PRODUCT", "کالای انتخابی معتبر نیست", http_status=400) + + repo = ProductBOMRepository(db) + bom = repo.create_bom( + business_id=business_id, + product_id=payload.product_id, + version=payload.version.strip(), + name=payload.name.strip(), + is_default=bool(payload.is_default), + effective_from=payload.effective_from, + effective_to=payload.effective_to, + yield_percent=payload.yield_percent, + wastage_percent=payload.wastage_percent, + status=payload.status, + notes=payload.notes, + ) + + # Replace child rows + items = [it.model_dump() for it in payload.items] + outputs = [ot.model_dump() for ot in payload.outputs] + operations = [op.model_dump() for op in payload.operations] + repo.replace_items(bom.id, items) + repo.replace_outputs(bom.id, outputs) + repo.replace_operations(bom.id, operations) + + # enforce single default per product + if bom.is_default: + db.query(ProductBOM).filter( + and_(ProductBOM.business_id == business_id, ProductBOM.product_id == payload.product_id, ProductBOM.id != bom.id) + ).update({ProductBOM.is_default: False}) + db.commit() + + return {"message": "BOM_CREATED", "data": _to_bom_dict(bom, db)} + + +def get_bom(db: Session, business_id: int, bom_id: int) -> Optional[Dict[str, Any]]: + repo = ProductBOMRepository(db) + bom = repo.get_bom(bom_id, business_id) + if not bom: + return None + return _to_bom_dict(bom, db) + + +def list_boms(db: Session, business_id: int, product_id: Optional[int] = None) -> Dict[str, Any]: + repo = ProductBOMRepository(db) + rows = repo.list_boms(business_id, product_id) + return {"items": [_to_bom_dict(b, db) for b in rows]} + + +def update_bom(db: Session, business_id: int, bom_id: int, payload: ProductBOMUpdateRequest) -> Optional[Dict[str, Any]]: + repo = ProductBOMRepository(db) + bom = repo.get_bom(bom_id, business_id) + if not bom: + return None + + updated = repo.update_bom( + bom_id, + version=payload.version.strip() if isinstance(payload.version, str) else None, + name=payload.name.strip() if isinstance(payload.name, str) else None, + is_default=payload.is_default if payload.is_default is not None else None, + effective_from=payload.effective_from, + effective_to=payload.effective_to, + yield_percent=payload.yield_percent, + wastage_percent=payload.wastage_percent, + status=payload.status, + notes=payload.notes, + ) + if not updated: + return None + + if payload.items is not None: + repo.replace_items(bom_id, [it.model_dump() for it in payload.items]) + if payload.outputs is not None: + repo.replace_outputs(bom_id, [ot.model_dump() for ot in payload.outputs]) + if payload.operations is not None: + repo.replace_operations(bom_id, [op.model_dump() for op in payload.operations]) + + if updated.is_default: + db.query(ProductBOM).filter( + and_(ProductBOM.business_id == business_id, ProductBOM.product_id == updated.product_id, ProductBOM.id != updated.id) + ).update({ProductBOM.is_default: False}) + db.commit() + + return {"message": "BOM_UPDATED", "data": _to_bom_dict(updated, db)} + + +def delete_bom(db: Session, business_id: int, bom_id: int) -> bool: + repo = ProductBOMRepository(db) + bom = repo.get_bom(bom_id, business_id) + if not bom: + return False + return repo.delete_bom(bom_id) + + +def explode_bom(db: Session, business_id: int, req: BOMExplosionRequest) -> Dict[str, Any]: + # minimal explosion without stock checks + if not req.bom_id and not req.product_id: + raise ApiError("INVALID_REQUEST", "bom_id یا product_id الزامی است", http_status=400) + + bom: Optional[ProductBOM] = None + if req.bom_id: + bom = db.get(ProductBOM, req.bom_id) + if not bom or bom.business_id != business_id: + raise ApiError("NOT_FOUND", "BOM یافت نشد", http_status=404) + else: + # pick default bom for product + bom = db.query(ProductBOM).filter( + and_(ProductBOM.business_id == business_id, ProductBOM.product_id == req.product_id, ProductBOM.is_default == True) + ).first() + if not bom: + raise ApiError("NOT_FOUND", "برای این کالا فرمول پیش‌فرضی تعریف نشده است", http_status=404) + + items = db.query(ProductBOMItem).filter(ProductBOMItem.bom_id == bom.id).order_by(ProductBOMItem.line_no).all() + outputs = db.query(ProductBOMOutput).filter(ProductBOMOutput.bom_id == bom.id).order_by(ProductBOMOutput.line_no).all() + + qty = Decimal(str(req.quantity)) + explosion_items: List[Dict[str, Any]] = [] + for it in items: + base = Decimal(str(it.qty_per)) * qty + # apply line wastage + if it.wastage_percent: + base = base * (Decimal("1.0") + Decimal(str(it.wastage_percent)) / Decimal("100")) + explosion_items.append({ + "component_product_id": it.component_product_id, + "required_qty": base, + "uom": it.uom, + "suggested_warehouse_id": it.suggested_warehouse_id, + "is_optional": it.is_optional, + "substitute_group": it.substitute_group, + }) + + # outputs scaling + out_scaled = [] + for ot in outputs: + out_scaled.append({ + "line_no": ot.line_no, + "output_product_id": ot.output_product_id, + "ratio": Decimal(str(ot.ratio)) * qty, + "uom": ot.uom, + }) + + return {"items": explosion_items, "outputs": out_scaled} + + diff --git a/hesabixAPI/app/services/document_service.py b/hesabixAPI/app/services/document_service.py new file mode 100644 index 0000000..d07db46 --- /dev/null +++ b/hesabixAPI/app/services/document_service.py @@ -0,0 +1,536 @@ +""" +سرویس مدیریت اسناد حسابداری عمومی (General Accounting Documents) + +این سرویس برای مدیریت تمام اسناد حسابداری (عمومی و اتوماتیک) استفاده می‌شود. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from datetime import datetime, date +import logging + +from sqlalchemy.orm import Session + +from adapters.db.repositories.document_repository import DocumentRepository +from app.core.responses import ApiError + +logger = logging.getLogger(__name__) + + +def list_documents( + db: Session, + business_id: int, + query: Dict[str, Any], +) -> Dict[str, Any]: + """ + دریافت لیست اسناد حسابداری با فیلتر و صفحه‌بندی + + Args: + db: جلسه دیتابیس + business_id: شناسه کسب‌وکار + query: فیلترها و تنظیمات: + - document_type: نوع سند (expense, income, receipt, payment, transfer, manual, ...) + - fiscal_year_id: شناسه سال مالی + - from_date: از تاریخ + - to_date: تا تاریخ + - currency_id: شناسه ارز + - is_proforma: پیش‌فاکتور یا قطعی + - search: عبارت جستجو + - sort_by: فیلد مرتب‌سازی + - sort_desc: ترتیب نزولی + - take: تعداد رکورد در هر صفحه + - skip: تعداد رکورد صرف‌نظر شده + + Returns: + دیکشنری حاوی items و pagination + """ + repo = DocumentRepository(db) + + # دریافت لیست اسناد + documents, total = repo.list_documents_with_filters(business_id, query) + + # محاسبه pagination + take = query.get("take", 50) + skip = query.get("skip", 0) + page = (skip // take) + 1 + total_pages = (total + take - 1) // take + + return { + "items": documents, + "pagination": { + "total": total, + "page": page, + "per_page": take, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + }, + } + + +def get_document(db: Session, document_id: int) -> Optional[Dict[str, Any]]: + """ + دریافت جزئیات کامل یک سند شامل سطرهای سند + + Args: + db: جلسه دیتابیس + document_id: شناسه سند + + Returns: + دیکشنری حاوی اطلاعات کامل سند یا None + """ + repo = DocumentRepository(db) + return repo.get_document_details(document_id) + + +def delete_document(db: Session, document_id: int) -> bool: + """ + حذف یک سند حسابداری + + توجه: فقط اسناد عمومی (manual) قابل حذف هستند + + Args: + db: جلسه دیتابیس + document_id: شناسه سند + + Returns: + True در صورت موفقیت، False در غیر این صورت + + Raises: + ApiError: در صورت عدم وجود سند یا عدم امکان حذف + """ + repo = DocumentRepository(db) + + # بررسی وجود سند + document = repo.get_document(document_id) + if not document: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Document not found", + http_status=404 + ) + + # بررسی نوع سند - فقط اسناد manual قابل حذف هستند + if document.document_type != "manual": + raise ApiError( + "CANNOT_DELETE_AUTO_DOCUMENT", + "Cannot delete automatically generated documents. Please delete from the original source.", + http_status=400 + ) + + # حذف سند + success = repo.delete_document(document_id) + if not success: + raise ApiError( + "DELETE_FAILED", + "Failed to delete document", + http_status=500 + ) + + return True + + +def delete_multiple_documents(db: Session, document_ids: List[int]) -> Dict[str, Any]: + """ + حذف گروهی اسناد حسابداری + + فقط اسناد عمومی (manual) حذف می‌شوند، اسناد اتوماتیک نادیده گرفته می‌شوند + + Args: + db: جلسه دیتابیس + document_ids: لیست شناسه‌های سند + + Returns: + دیکشنری حاوی تعداد حذف شده و خطاها + """ + repo = DocumentRepository(db) + + deleted_count = 0 + errors = [] + skipped_auto = [] + + for doc_id in document_ids: + try: + document = repo.get_document(doc_id) + if not document: + errors.append({"id": doc_id, "error": "Document not found"}) + continue + + # بررسی نوع سند + if document.document_type != "manual": + skipped_auto.append({ + "id": doc_id, + "type": document.document_type, + "code": document.code + }) + continue + + # حذف سند + if repo.delete_document(doc_id): + deleted_count += 1 + else: + errors.append({"id": doc_id, "error": "Delete failed"}) + + except Exception as e: + logger.error(f"Error deleting document {doc_id}: {str(e)}") + errors.append({"id": doc_id, "error": str(e)}) + + return { + "deleted_count": deleted_count, + "total_requested": len(document_ids), + "errors": errors, + "skipped_auto_documents": skipped_auto, + } + + +def get_document_types_summary(db: Session, business_id: int) -> Dict[str, Any]: + """ + دریافت خلاصه آماری انواع اسناد + + Args: + db: جلسه دیتابیس + business_id: شناسه کسب‌وکار + + Returns: + دیکشنری حاوی آمار هر نوع سند + """ + from adapters.db.models.document import Document + from sqlalchemy import func + + results = ( + db.query( + Document.document_type, + func.count(Document.id).label("count") + ) + .filter(Document.business_id == business_id) + .group_by(Document.document_type) + .all() + ) + + summary = {} + for row in results: + summary[row.document_type] = row.count + + return summary + + +def export_documents_excel( + db: Session, + business_id: int, + filters: Dict[str, Any], +) -> bytes: + """ + خروجی Excel لیست اسناد + + Args: + db: جلسه دیتابیس + business_id: شناسه کسب‌وکار + filters: فیلترها + + Returns: + محتوای فایل Excel به صورت bytes + """ + try: + import io + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + + repo = DocumentRepository(db) + + # دریافت تمام اسناد (بدون pagination) + filters_copy = filters.copy() + filters_copy["take"] = 10000 + filters_copy["skip"] = 0 + + documents, _ = repo.list_documents_with_filters(business_id, filters_copy) + + # ایجاد Workbook + wb = Workbook() + ws = wb.active + ws.title = "Documents" + + # تنظیم راست‌چین برای فارسی + ws.sheet_view.rightToLeft = True + + # هدر + headers = [ + "شماره سند", + "نوع سند", + "تاریخ سند", + "سال مالی", + "ارز", + "بدهکار", + "بستانکار", + "وضعیت", + "توضیحات", + "تاریخ ثبت", + ] + + header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF", size=12) + + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num, value=header) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + + # داده‌ها + for row_num, doc in enumerate(documents, 2): + ws.cell(row=row_num, column=1, value=doc.get("code")) + ws.cell(row=row_num, column=2, value=doc.get("document_type")) + ws.cell(row=row_num, column=3, value=str(doc.get("document_date"))) + ws.cell(row=row_num, column=4, value=doc.get("fiscal_year_title")) + ws.cell(row=row_num, column=5, value=doc.get("currency_code")) + ws.cell(row=row_num, column=6, value=doc.get("total_debit", 0)) + ws.cell(row=row_num, column=7, value=doc.get("total_credit", 0)) + ws.cell(row=row_num, column=8, value="پیش‌فاکتور" if doc.get("is_proforma") else "قطعی") + ws.cell(row=row_num, column=9, value=doc.get("description", "")) + ws.cell(row=row_num, column=10, value=str(doc.get("created_at"))) + + # تنظیم عرض ستون‌ها + ws.column_dimensions["A"].width = 15 + ws.column_dimensions["B"].width = 15 + ws.column_dimensions["C"].width = 15 + ws.column_dimensions["D"].width = 20 + ws.column_dimensions["E"].width = 10 + ws.column_dimensions["F"].width = 15 + ws.column_dimensions["G"].width = 15 + ws.column_dimensions["H"].width = 12 + ws.column_dimensions["I"].width = 30 + ws.column_dimensions["J"].width = 20 + + # ذخیره در حافظه + output = io.BytesIO() + wb.save(output) + output.seek(0) + + return output.read() + + except ImportError: + raise ApiError( + "OPENPYXL_NOT_INSTALLED", + "openpyxl library is not installed", + http_status=500 + ) + except Exception as e: + logger.error(f"Error generating Excel: {str(e)}") + raise ApiError( + "EXCEL_GENERATION_FAILED", + f"Failed to generate Excel file: {str(e)}", + http_status=500 + ) + + +def export_documents_pdf( + db: Session, + business_id: int, + filters: Dict[str, Any], +) -> bytes: + """ + خروجی PDF لیست اسناد + + Args: + db: جلسه دیتابیس + business_id: شناسه کسب‌وکار + filters: فیلترها + + Returns: + محتوای فایل PDF به صورت bytes + """ + # TODO: پیاده‌سازی export PDF + # می‌توان از WeasyPrint یا ReportLab استفاده کرد + raise ApiError( + "NOT_IMPLEMENTED", + "PDF export is not implemented yet", + http_status=501 + ) + + +def create_manual_document( + db: Session, + business_id: int, + fiscal_year_id: int, + user_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + """ + ایجاد سند حسابداری دستی جدید + + Args: + db: جلسه دیتابیس + business_id: شناسه کسب‌وکار + fiscal_year_id: شناسه سال مالی + user_id: شناسه کاربر ایجادکننده + data: اطلاعات سند و سطرها + + Returns: + دیکشنری حاوی اطلاعات کامل سند ایجاد شده + + Raises: + ApiError: در صورت بروز خطا + """ + repo = DocumentRepository(db) + + # اعتبارسنجی سطرهای سند + lines_data = data.get("lines", []) + is_valid, error_msg = repo.validate_document_balance(lines_data) + if not is_valid: + raise ApiError( + "INVALID_DOCUMENT", + error_msg, + http_status=400 + ) + + # تولید کد سند اگر وجود نداشت + code = data.get("code") + if not code: + code = repo.generate_document_code(business_id, "manual") + + # تبدیل lines به فرمت مناسب repository + lines_for_db = [] + for line in lines_data: + line_dict = { + "account_id": line.get("account_id"), + "person_id": line.get("person_id"), + "product_id": line.get("product_id"), + "bank_account_id": line.get("bank_account_id"), + "cash_register_id": line.get("cash_register_id"), + "petty_cash_id": line.get("petty_cash_id"), + "check_id": line.get("check_id"), + "quantity": line.get("quantity"), + "debit": line.get("debit", 0), + "credit": line.get("credit", 0), + "description": line.get("description"), + "extra_info": line.get("extra_info"), + } + lines_for_db.append(line_dict) + + # آماده‌سازی داده‌های سند + document_data = { + "code": code, + "business_id": business_id, + "fiscal_year_id": fiscal_year_id, + "currency_id": data.get("currency_id"), + "created_by_user_id": user_id, + "document_date": data.get("document_date"), + "document_type": "manual", + "is_proforma": data.get("is_proforma", False), + "description": data.get("description"), + "extra_info": data.get("extra_info"), + "lines": lines_for_db, + } + + try: + # ایجاد سند + document = repo.create_document(document_data) + + # دریافت جزئیات کامل سند (با روابط) + return repo.get_document_details(document.id) + + except Exception as e: + logger.error(f"Error creating manual document: {str(e)}") + db.rollback() + raise ApiError( + "CREATE_DOCUMENT_FAILED", + f"Failed to create document: {str(e)}", + http_status=500 + ) + + +def update_manual_document( + db: Session, + document_id: int, + data: Dict[str, Any], +) -> Dict[str, Any]: + """ + ویرایش سند حسابداری دستی + + Args: + db: جلسه دیتابیس + document_id: شناسه سند + data: اطلاعات جدید سند + + Returns: + دیکشنری حاوی اطلاعات کامل سند ویرایش شده + + Raises: + ApiError: در صورت بروز خطا + """ + repo = DocumentRepository(db) + + # بررسی وجود سند + document = repo.get_document(document_id) + if not document: + raise ApiError( + "DOCUMENT_NOT_FOUND", + "Document not found", + http_status=404 + ) + + # بررسی اینکه فقط اسناد manual قابل ویرایش هستند + if document.document_type != "manual": + raise ApiError( + "CANNOT_EDIT_AUTO_DOCUMENT", + "Cannot edit automatically generated documents. Please edit from the original source.", + http_status=400 + ) + + # اگر سطرها ارسال شده، اعتبارسنجی کن + if "lines" in data and data["lines"] is not None: + lines_data = data["lines"] + is_valid, error_msg = repo.validate_document_balance(lines_data) + if not is_valid: + raise ApiError( + "INVALID_DOCUMENT", + error_msg, + http_status=400 + ) + + # تبدیل lines به فرمت مناسب repository + lines_for_db = [] + for line in lines_data: + line_dict = { + "account_id": line.get("account_id"), + "person_id": line.get("person_id"), + "product_id": line.get("product_id"), + "bank_account_id": line.get("bank_account_id"), + "cash_register_id": line.get("cash_register_id"), + "petty_cash_id": line.get("petty_cash_id"), + "check_id": line.get("check_id"), + "quantity": line.get("quantity"), + "debit": line.get("debit", 0), + "credit": line.get("credit", 0), + "description": line.get("description"), + "extra_info": line.get("extra_info"), + } + lines_for_db.append(line_dict) + + data["lines"] = lines_for_db + + try: + # ویرایش سند + updated_document = repo.update_document(document_id, data) + + if not updated_document: + raise ApiError( + "UPDATE_FAILED", + "Failed to update document", + http_status=500 + ) + + # دریافت جزئیات کامل سند + return repo.get_document_details(updated_document.id) + + except ApiError: + raise + except Exception as e: + logger.error(f"Error updating manual document: {str(e)}") + db.rollback() + raise ApiError( + "UPDATE_DOCUMENT_FAILED", + f"Failed to update document: {str(e)}", + http_status=500 + ) + diff --git a/hesabixAPI/app/services/expense_income_service.py b/hesabixAPI/app/services/expense_income_service.py index f61f2e3..e95c9fd 100644 --- a/hesabixAPI/app/services/expense_income_service.py +++ b/hesabixAPI/app/services/expense_income_service.py @@ -396,3 +396,362 @@ def list_expense_income( } +def get_expense_income(db: Session, document_id: int) -> Optional[Dict[str, Any]]: + """دریافت جزئیات یک سند هزینه/درآمد""" + document = db.query(Document).filter(Document.id == document_id).first() + if not document: + return None + + return document_to_dict(db, document) + + +def update_expense_income( + db: Session, + document_id: int, + user_id: int, + data: Dict[str, Any] +) -> Dict[str, Any]: + """ویرایش سند هزینه/درآمد""" + document = db.query(Document).filter(Document.id == document_id).first() + if not document: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + # بررسی نوع سند + if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME): + raise ApiError("INVALID_DOCUMENT_TYPE", "Document is not expense/income", http_status=400) + + is_income = document.document_type == DOCUMENT_TYPE_INCOME + + # تاریخ + document_date = _parse_iso_date(data.get("document_date", document.document_date)) + + # ارز + 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) + + # سال مالی فعال + fiscal_year = _get_business_fiscal_year(db, document.business_id) + + # اعتبارسنجی خطوط + item_lines: List[Dict[str, Any]] = list(data.get("item_lines") or []) + counterparty_lines: List[Dict[str, Any]] = list(data.get("counterparty_lines") or []) + if not item_lines: + raise ApiError("LINES_REQUIRED", "item_lines is required", http_status=400) + if not counterparty_lines: + raise ApiError("LINES_REQUIRED", "counterparty_lines is required", http_status=400) + + sum_items = Decimal(0) + for idx, line in enumerate(item_lines): + if not line.get("account_id"): + raise ApiError("ACCOUNT_REQUIRED", f"item_lines[{idx}].account_id is required", http_status=400) + amount = Decimal(str(line.get("amount", 0))) + if amount <= 0: + raise ApiError("AMOUNT_INVALID", f"item_lines[{idx}].amount must be > 0", http_status=400) + sum_items += amount + + sum_counterparties = Decimal(0) + for idx, line in enumerate(counterparty_lines): + amount = Decimal(str(line.get("amount", 0))) + if amount <= 0: + raise ApiError("AMOUNT_INVALID", f"counterparty_lines[{idx}].amount must be > 0", http_status=400) + sum_counterparties += amount + + if sum_items != sum_counterparties: + raise ApiError("LINES_NOT_BALANCED", "Sum of items and counterparties must be equal", http_status=400) + + # حذف خطوط قبلی + db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete() + + # به‌روزرسانی اطلاعات سند + document.document_date = document_date + document.currency_id = int(currency_id) + document.fiscal_year_id = fiscal_year.id + document.description = (data.get("description") or "").strip() or None + document.extra_info = data.get("extra_info") if isinstance(data.get("extra_info"), dict) else None + + # سطرهای حساب‌های هزینه/درآمد + for line in item_lines: + account = db.query(Account).filter( + and_( + Account.id == int(line.get("account_id")), + or_(Account.business_id == document.business_id, Account.business_id == None), # noqa: E711 + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", "Item account not found", http_status=404) + + amount = Decimal(str(line.get("amount", 0))) + description = (line.get("description") or "").strip() or None + + debit_amount = amount if not is_income else Decimal(0) + credit_amount = amount if is_income else Decimal(0) + + db.add(DocumentLine( + document_id=document.id, + account_id=account.id, + debit=debit_amount, + credit=credit_amount, + description=description, + )) + + # سطرهای طرف‌حساب + for line in counterparty_lines: + amount = Decimal(str(line.get("amount", 0))) + description = (line.get("description") or "").strip() or None + commission = Decimal(str(line.get("commission", 0))) if line.get("commission") else Decimal(0) + + # تعیین نوع تراکنش و حساب مربوطه + transaction_type = line.get("transaction_type", "bank") + account = None + + if transaction_type == "bank": + bank_account_id = line.get("bank_account_id") + if bank_account_id: + from adapters.db.models.bank_account import BankAccount + bank_account = db.query(BankAccount).filter(BankAccount.id == int(bank_account_id)).first() + if bank_account: + account = bank_account.account + elif transaction_type == "cash_register": + cash_register_id = line.get("cash_register_id") + if cash_register_id: + from adapters.db.models.cash_register import CashRegister + cash_register = db.query(CashRegister).filter(CashRegister.id == int(cash_register_id)).first() + if cash_register: + account = cash_register.account + elif transaction_type == "petty_cash": + petty_cash_id = line.get("petty_cash_id") + if petty_cash_id: + from adapters.db.models.petty_cash import PettyCash + petty_cash = db.query(PettyCash).filter(PettyCash.id == int(petty_cash_id)).first() + if petty_cash: + account = petty_cash.account + elif transaction_type == "check": + check_id = line.get("check_id") + if check_id: + from adapters.db.models.check import Check + check = db.query(Check).filter(Check.id == int(check_id)).first() + if check: + account = check.account + elif transaction_type == "person": + person_id = line.get("person_id") + if person_id: + from adapters.db.models.person import Person + person = db.query(Person).filter(Person.id == int(person_id)).first() + if person: + # حساب شخص بر اساس نوع (دریافتنی/پرداختنی) + account = _get_person_account(db, document.business_id, int(person_id), is_income) + + if not account: + # اگر حساب مشخص نشده، از حساب پیش‌فرض استفاده کن + account_code = "1111" if is_income else "2111" # نقد یا بانک + account = _get_fixed_account_by_code(db, account_code) + + # مبلغ اصلی + debit_amount = amount if is_income else Decimal(0) + credit_amount = amount if not is_income else Decimal(0) + + db.add(DocumentLine( + document_id=document.id, + account_id=account.id, + debit=debit_amount, + credit=credit_amount, + description=description, + extra_info={ + "transaction_type": transaction_type, + "transaction_date": line.get("transaction_date"), + "commission": float(commission), + **{k: v for k, v in line.items() if k not in ["amount", "description", "commission", "transaction_type", "transaction_date"]} + } + )) + + # اگر کارمزد وجود دارد، خط کارمزد اضافه کن + if commission > 0: + commission_account = _get_fixed_account_by_code(db, "5111") # کارمزد + db.add(DocumentLine( + document_id=document.id, + account_id=commission_account.id, + debit=commission if is_income else Decimal(0), + credit=commission if not is_income else Decimal(0), + description=f"کارمزد {description or ''}", + extra_info={"is_commission_line": True} + )) + + db.commit() + db.refresh(document) + + return document_to_dict(db, document) + + +def delete_expense_income(db: Session, document_id: int) -> bool: + """حذف یک سند هزینه/درآمد""" + try: + document = db.query(Document).filter(Document.id == document_id).first() + if not document: + return False + + # بررسی نوع سند + if document.document_type not in (DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME): + return False + + # حذف خطوط سند + db.query(DocumentLine).filter(DocumentLine.document_id == document_id).delete() + + # حذف سند + db.delete(document) + db.commit() + + return True + except Exception as e: + logger.error(f"Error deleting expense/income document {document_id}: {e}") + db.rollback() + return False + + +def delete_multiple_expense_income(db: Session, document_ids: List[int]) -> bool: + """حذف چندین سند هزینه/درآمد""" + try: + documents = db.query(Document).filter( + and_( + Document.id.in_(document_ids), + Document.document_type.in_([DOCUMENT_TYPE_EXPENSE, DOCUMENT_TYPE_INCOME]) + ) + ).all() + + if not documents: + return False + + # حذف خطوط اسناد + db.query(DocumentLine).filter(DocumentLine.document_id.in_(document_ids)).delete() + + # حذف اسناد + for document in documents: + db.delete(document) + + db.commit() + return True + except Exception as e: + logger.error(f"Error deleting multiple expense/income documents: {e}") + db.rollback() + return False + + +def export_expense_income_excel(db: Session, business_id: int, query: Dict[str, Any]) -> bytes: + """خروجی Excel اسناد هزینه/درآمد""" + # این تابع باید پیاده‌سازی شود + # فعلاً یک فایل Excel خالی برمی‌گرداند + import io + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = "هزینه و درآمد" + + # هدرها + headers = ["کد سند", "نوع", "تاریخ سند", "مبلغ کل", "توضیحات", "ایجادکننده", "تاریخ ثبت"] + for col, header in enumerate(headers, 1): + ws.cell(row=1, column=col, value=header) + + # دریافت داده‌ها + result = list_expense_income(db, business_id, query) + items = result.get("items", []) + + # اضافه کردن داده‌ها + for row, item in enumerate(items, 2): + ws.cell(row=row, column=1, value=item.get("code", "")) + ws.cell(row=row, column=2, value=item.get("document_type_name", "")) + ws.cell(row=row, column=3, value=item.get("document_date", "")) + ws.cell(row=row, column=4, value=item.get("total_amount", 0)) + ws.cell(row=row, column=5, value=item.get("description", "")) + ws.cell(row=row, column=6, value=item.get("created_by_name", "")) + ws.cell(row=row, column=7, value=item.get("registered_at", "")) + + # ذخیره در بایت + output = io.BytesIO() + wb.save(output) + return output.getvalue() + + +def export_expense_income_pdf(db: Session, business_id: int, query: Dict[str, Any]) -> bytes: + """خروجی PDF اسناد هزینه/درآمد""" + # این تابع باید پیاده‌سازی شود + # فعلاً یک فایل PDF خالی برمی‌گرداند + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import letter + import io + + output = io.BytesIO() + c = canvas.Canvas(output, pagesize=letter) + + # عنوان + c.setFont("Helvetica-Bold", 16) + c.drawString(100, 750, "گزارش هزینه و درآمد") + + # دریافت داده‌ها + result = list_expense_income(db, business_id, query) + items = result.get("items", []) + + # اضافه کردن داده‌ها + y = 700 + c.setFont("Helvetica", 12) + for item in items[:20]: # حداکثر 20 آیتم + c.drawString(100, y, f"{item.get('code', '')} - {item.get('document_type_name', '')} - {item.get('total_amount', 0)}") + y -= 20 + if y < 100: + c.showPage() + y = 750 + + c.save() + return output.getvalue() + + +def generate_expense_income_pdf(db: Session, document_id: int) -> bytes: + """تولید PDF یک سند هزینه/درآمد""" + # این تابع باید پیاده‌سازی شود + # فعلاً یک فایل PDF خالی برمی‌گرداند + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import letter + import io + + output = io.BytesIO() + c = canvas.Canvas(output, pagesize=letter) + + # دریافت سند + document = db.query(Document).filter(Document.id == document_id).first() + if not document: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + # عنوان + c.setFont("Helvetica-Bold", 16) + c.drawString(100, 750, f"سند {document.document_type_name}") + c.drawString(100, 730, f"کد: {document.code}") + c.drawString(100, 710, f"تاریخ: {document.document_date}") + + c.save() + return output.getvalue() + + +def _get_person_account( + db: Session, + business_id: int, + person_id: int, + is_receivable: bool +) -> Account: + """دریافت حساب شخص (دریافتنی یا پرداختنی)""" + from adapters.db.models.person import Person + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise ApiError("PERSON_NOT_FOUND", "Person not found", http_status=404) + + # تعیین کد حساب بر اساس نوع + if is_receivable: + account_code = "1211" # دریافتنی‌ها + else: + account_code = "2211" # پرداختنی‌ها + + return _get_fixed_account_by_code(db, account_code) + + diff --git a/hesabixAPI/app/services/person_service.py b/hesabixAPI/app/services/person_service.py index dca391b..f64c5a3 100644 --- a/hesabixAPI/app/services/person_service.py +++ b/hesabixAPI/app/services/person_service.py @@ -5,6 +5,8 @@ from app.core.responses import ApiError from sqlalchemy.orm import Session from sqlalchemy import and_, or_, func from adapters.db.models.person import Person, PersonBankAccount, PersonType +from adapters.db.models.document import Document +from adapters.db.models.document_line import DocumentLine from adapters.api.v1.schema_models.person import ( PersonCreateRequest, PersonUpdateRequest, PersonBankAccountCreateRequest ) @@ -137,11 +139,30 @@ def get_person_by_id(db: Session, person_id: int, business_id: int) -> Optional[ def get_persons_by_business( db: Session, business_id: int, - query_info: Dict[str, Any] + query_info: Dict[str, Any], + fiscal_year_id: Optional[int] = None ) -> Dict[str, Any]: """دریافت لیست اشخاص با جستجو و فیلتر""" query = db.query(Person).filter(Person.business_id == business_id) + # بررسی نیاز به محاسبه تراز قبل از pagination + # (برای فیلتر یا مرتب‌سازی بر اساس تراز/وضعیت) + needs_balance_before_pagination = False + sort_by = query_info.get('sort_by', 'created_at') + if sort_by in ['balance', 'status']: + needs_balance_before_pagination = True + + # بررسی فیلترها برای balance و status + if query_info.get('filters'): + for filter_item in query_info['filters']: + if isinstance(filter_item, dict): + field = filter_item.get('property') + else: + field = getattr(filter_item, 'property', None) + if field in ['balance', 'status']: + needs_balance_before_pagination = True + break + # اعمال جستجو if query_info.get('search') and query_info.get('search_fields'): search_term = f"%{query_info['search']}%" @@ -274,38 +295,106 @@ def get_persons_by_business( # شمارش کل رکوردها total = query.count() - # اعمال مرتب‌سازی - sort_by = query_info.get('sort_by', 'created_at') + # اعمال مرتب‌سازی (فقط برای فیلدهای دیتابیس) sort_desc = query_info.get('sort_desc', True) - if sort_by == 'code': - query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc()) - elif sort_by == 'alias_name': - query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc()) - elif sort_by == 'first_name': - query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc()) - elif sort_by == 'last_name': - query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc()) - # person_type sorting removed - use person_types instead - elif sort_by == 'created_at': - query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc()) - elif sort_by == 'updated_at': - query = query.order_by(Person.updated_at.desc() if sort_desc else Person.updated_at.asc()) - else: - query = query.order_by(Person.created_at.desc()) + if sort_by not in ['balance', 'status']: + # مرتب‌سازی در دیتابیس + if sort_by == 'code': + query = query.order_by(Person.code.desc() if sort_desc else Person.code.asc()) + elif sort_by == 'alias_name': + query = query.order_by(Person.alias_name.desc() if sort_desc else Person.alias_name.asc()) + elif sort_by == 'first_name': + query = query.order_by(Person.first_name.desc() if sort_desc else Person.first_name.asc()) + elif sort_by == 'last_name': + query = query.order_by(Person.last_name.desc() if sort_desc else Person.last_name.asc()) + elif sort_by == 'created_at': + query = query.order_by(Person.created_at.desc() if sort_desc else Person.created_at.asc()) + elif sort_by == 'updated_at': + query = query.order_by(Person.updated_at.desc() if sort_desc else Person.updated_at.asc()) + else: + query = query.order_by(Person.created_at.desc()) - # اعمال صفحه‌بندی skip = query_info.get('skip', 0) take = query_info.get('take', 20) - persons = query.offset(skip).limit(take).all() - - # تبدیل به دیکشنری - items = [_person_to_dict(person) for person in persons] + # اگر نیاز به محاسبه تراز قبل از pagination است + if needs_balance_before_pagination: + # دریافت همه persons + all_persons = query.all() + + # تبدیل به دیکشنری و محاسبه تراز + all_items = [] + person_ids = [p.id for p in all_persons] + balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id) + + for person in all_persons: + item = _person_to_dict(person) + balance, status = balances.get(person.id, (0.0, "بدون تراکنش")) + item['balance'] = balance + item['status'] = status + all_items.append(item) + + # اعمال فیلتر balance و status + if query_info.get('filters'): + for filter_item in query_info['filters']: + if isinstance(filter_item, dict): + field = filter_item.get('property') + operator = filter_item.get('operator') + value = filter_item.get('value') + else: + field = getattr(filter_item, 'property', None) + operator = getattr(filter_item, 'operator', None) + value = getattr(filter_item, 'value', None) + + if field == 'balance': + if operator == '=': + all_items = [item for item in all_items if item['balance'] == value] + elif operator == '>': + all_items = [item for item in all_items if item['balance'] > value] + elif operator == '>=': + all_items = [item for item in all_items if item['balance'] >= value] + elif operator == '<': + all_items = [item for item in all_items if item['balance'] < value] + elif operator == '<=': + all_items = [item for item in all_items if item['balance'] <= value] + elif field == 'status': + if operator == '=' and isinstance(value, str): + all_items = [item for item in all_items if item['status'] == value] + elif operator == 'in' and isinstance(value, list): + all_items = [item for item in all_items if item['status'] in value] + + # مرتب‌سازی + if sort_by == 'balance': + all_items.sort(key=lambda x: x['balance'], reverse=sort_desc) + elif sort_by == 'status': + all_items.sort(key=lambda x: x['status'], reverse=sort_desc) + + # محاسبه total بعد از فیلتر + total = len(all_items) + + # اعمال pagination + items = all_items[skip:skip + take] + else: + # روش معمولی: ابتدا pagination، سپس محاسبه تراز + persons = query.offset(skip).limit(take).all() + + # تبدیل به دیکشنری + items = [_person_to_dict(person) for person in persons] + + # محاسبه تراز برای persons فعلی + person_ids = [p.id for p in persons] + balances = calculate_persons_balances_bulk(db, person_ids, fiscal_year_id) + + for item in items: + person_id = item['id'] + balance, status = balances.get(person_id, (0.0, "بدون تراکنش")) + item['balance'] = balance + item['status'] = status # محاسبه اطلاعات صفحه‌بندی - total_pages = (total + take - 1) // take - current_page = (skip // take) + 1 + total_pages = (total + take - 1) // take if take > 0 else 0 + current_page = (skip // take) + 1 if take > 0 else 1 pagination = { 'total': total, @@ -535,3 +624,129 @@ def count_persons(db: Session, business_id: int, search_query: Optional[str] = N query = query.filter(search_filter) return query.count() + + +def calculate_person_balance( + db: Session, + person_id: int, + fiscal_year_id: Optional[int] = None +) -> tuple[float, str]: + """ + محاسبه تراز و وضعیت مالی یک شخص + + Args: + db: نشست پایگاه داده + person_id: شناسه شخص + fiscal_year_id: شناسه سال مالی (اختیاری) + + Returns: + tuple: (تراز, وضعیت) + - تراز: credit - debit + - وضعیت: "بستانکار" | "بدهکار" | "بالانس" | "بدون تراکنش" + """ + # Query برای محاسبه مجموع بستانکار و بدهکار + query = db.query( + func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit'), + func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit') + ).join( + Document, DocumentLine.document_id == Document.id + ).filter( + DocumentLine.person_id == person_id, + Document.is_proforma == False # فقط اسناد قطعی + ) + + # اعمال فیلتر سال مالی + if fiscal_year_id: + query = query.filter(Document.fiscal_year_id == fiscal_year_id) + + result = query.first() + + if result is None: + return 0.0, "بدون تراکنش" + + total_credit = float(result.total_credit or 0) + total_debit = float(result.total_debit or 0) + + # محاسبه تراز: بستانکار - بدهکار + balance = total_credit - total_debit + + # تعیین وضعیت + if total_credit == 0 and total_debit == 0: + status = "بدون تراکنش" + elif balance > 0: + status = "بستانکار" + elif balance < 0: + status = "بدهکار" + else: # balance == 0 + status = "بالانس" + + return balance, status + + +def calculate_persons_balances_bulk( + db: Session, + person_ids: List[int], + fiscal_year_id: Optional[int] = None +) -> Dict[int, tuple[float, str]]: + """ + محاسبه تراز و وضعیت چندین شخص به صورت دسته‌جمعی + + Args: + db: نشست پایگاه داده + person_ids: لیست شناسه‌های اشخاص + fiscal_year_id: شناسه سال مالی (اختیاری) + + Returns: + dict: {person_id: (balance, status)} + """ + if not person_ids: + return {} + + # Query برای محاسبه مجموع بستانکار و بدهکار برای هر شخص + query = db.query( + DocumentLine.person_id, + func.coalesce(func.sum(DocumentLine.credit), 0).label('total_credit'), + func.coalesce(func.sum(DocumentLine.debit), 0).label('total_debit') + ).join( + Document, DocumentLine.document_id == Document.id + ).filter( + DocumentLine.person_id.in_(person_ids), + Document.is_proforma == False # فقط اسناد قطعی + ) + + # اعمال فیلتر سال مالی + if fiscal_year_id: + query = query.filter(Document.fiscal_year_id == fiscal_year_id) + + # Group by person_id + query = query.group_by(DocumentLine.person_id) + + results = query.all() + + # ساخت دیکشنری نتایج + balances: Dict[int, tuple[float, str]] = {} + + # ابتدا همه را به "بدون تراکنش" تنظیم می‌کنیم + for person_id in person_ids: + balances[person_id] = (0.0, "بدون تراکنش") + + # سپس نتایج واقعی را اعمال می‌کنیم + for result in results: + person_id = result.person_id + total_credit = float(result.total_credit or 0) + total_debit = float(result.total_debit or 0) + + # محاسبه تراز + balance = total_credit - total_debit + + # تعیین وضعیت + if balance > 0: + status = "بستانکار" + elif balance < 0: + status = "بدهکار" + else: # balance == 0 + status = "بالانس" + + balances[person_id] = (balance, status) + + return balances diff --git a/hesabixAPI/app/services/transfer_service.py b/hesabixAPI/app/services/transfer_service.py index 27b1314..fedc0ba 100644 --- a/hesabixAPI/app/services/transfer_service.py +++ b/hesabixAPI/app/services/transfer_service.py @@ -352,6 +352,30 @@ def list_transfers(db: Session, business_id: int, query: Dict[str, Any]) -> Dict except Exception: pass + # Apply advanced filters (e.g., DataTable date range filters) + filters = query.get("filters") + if filters and isinstance(filters, (list, tuple)): + for flt in filters: + try: + 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 == 'document_date': + if isinstance(val, str) and val: + try: + dt = _parse_iso_date(val) + col = getattr(Document, prop) + if op == ">=": + q = q.filter(col >= dt) + elif op == "<=": + q = q.filter(col <= dt) + except Exception: + pass + except Exception: + pass + search = query.get("search") if search: q = q.filter(Document.code.ilike(f"%{search}%")) @@ -606,10 +630,14 @@ def transfer_document_to_dict(db: Session, document: Document) -> Dict[str, Any] line_dict["side"] = line.extra_info["side"] if "source_type" in line.extra_info: line_dict["source_type"] = line.extra_info["source_type"] - source_type = source_type or line.extra_info["source_type"] + # Only assign source_type from source lines + if line_dict.get("side") == "source": + source_type = source_type or line.extra_info["source_type"] if "destination_type" in line.extra_info: line_dict["destination_type"] = line.extra_info["destination_type"] - destination_type = destination_type or line.extra_info["destination_type"] + # Only assign destination_type from destination lines + if line_dict.get("side") == "destination": + destination_type = destination_type or line.extra_info["destination_type"] if "is_commission_line" in line.extra_info: line_dict["is_commission_line"] = line.extra_info["is_commission_line"] diff --git a/hesabixAPI/app/services/warehouse_service.py b/hesabixAPI/app/services/warehouse_service.py new file mode 100644 index 0000000..2e32030 --- /dev/null +++ b/hesabixAPI/app/services/warehouse_service.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from app.core.responses import ApiError +from adapters.db.models.warehouse import Warehouse +from adapters.db.repositories.warehouse_repository import WarehouseRepository +from adapters.api.v1.schema_models.warehouse import WarehouseCreateRequest, WarehouseUpdateRequest + + +def _to_dict(obj: Warehouse) -> Dict[str, Any]: + return { + "id": obj.id, + "business_id": obj.business_id, + "code": obj.code, + "name": obj.name, + "description": obj.description, + "is_default": obj.is_default, + "created_at": obj.created_at, + "updated_at": obj.updated_at, + } + + +def create_warehouse(db: Session, business_id: int, payload: WarehouseCreateRequest) -> Dict[str, Any]: + code = payload.code.strip() + dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == code)).first() + if dup: + raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400) + repo = WarehouseRepository(db) + obj = repo.create( + business_id=business_id, + code=code, + name=payload.name.strip(), + description=payload.description, + is_default=bool(payload.is_default), + ) + if obj.is_default: + db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != obj.id)).update({Warehouse.is_default: False}) + db.commit() + return {"message": "WAREHOUSE_CREATED", "data": _to_dict(obj)} + + +def list_warehouses(db: Session, business_id: int) -> Dict[str, Any]: + repo = WarehouseRepository(db) + rows = repo.list(business_id) + return {"items": [_to_dict(w) for w in rows]} + + +def get_warehouse(db: Session, business_id: int, warehouse_id: int) -> Optional[Dict[str, Any]]: + obj = db.get(Warehouse, warehouse_id) + if not obj or obj.business_id != business_id: + return None + return _to_dict(obj) + + +def update_warehouse(db: Session, business_id: int, warehouse_id: int, payload: WarehouseUpdateRequest) -> Optional[Dict[str, Any]]: + repo = WarehouseRepository(db) + obj = db.get(Warehouse, warehouse_id) + if not obj or obj.business_id != business_id: + return None + if payload.code and payload.code.strip() != obj.code: + dup = db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.code == payload.code.strip(), Warehouse.id != warehouse_id)).first() + if dup: + raise ApiError("DUPLICATE_WAREHOUSE_CODE", "کد انبار تکراری است", http_status=400) + + updated = repo.update( + warehouse_id, + code=payload.code.strip() if isinstance(payload.code, str) else None, + name=payload.name.strip() if isinstance(payload.name, str) else None, + description=payload.description, + is_default=payload.is_default if payload.is_default is not None else None, + ) + if not updated: + return None + if updated.is_default: + db.query(Warehouse).filter(and_(Warehouse.business_id == business_id, Warehouse.id != updated.id)).update({Warehouse.is_default: False}) + db.commit() + return {"message": "WAREHOUSE_UPDATED", "data": _to_dict(updated)} + + +def delete_warehouse(db: Session, business_id: int, warehouse_id: int) -> bool: + obj = db.get(Warehouse, warehouse_id) + if not obj or obj.business_id != business_id: + return False + repo = WarehouseRepository(db) + return repo.delete(warehouse_id) + + diff --git a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt index f373042..a01d6a2 100644 --- a/hesabixAPI/hesabix_api.egg-info/SOURCES.txt +++ b/hesabixAPI/hesabix_api.egg-info/SOURCES.txt @@ -6,6 +6,7 @@ adapters/api/v1/__init__.py adapters/api/v1/accounts.py adapters/api/v1/auth.py adapters/api/v1/bank_accounts.py +adapters/api/v1/boms.py adapters/api/v1/business_dashboard.py adapters/api/v1/business_users.py adapters/api/v1/businesses.py @@ -14,6 +15,8 @@ adapters/api/v1/categories.py adapters/api/v1/checks.py adapters/api/v1/currencies.py adapters/api/v1/customers.py +adapters/api/v1/documents.py +adapters/api/v1/expense_income.py adapters/api/v1/fiscal_years.py adapters/api/v1/health.py adapters/api/v1/invoices.py @@ -26,13 +29,16 @@ adapters/api/v1/receipts_payments.py adapters/api/v1/schemas.py adapters/api/v1/tax_types.py adapters/api/v1/tax_units.py +adapters/api/v1/transfers.py adapters/api/v1/users.py +adapters/api/v1/warehouses.py adapters/api/v1/admin/email_config.py 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/document.py adapters/api/v1/schema_models/document_line.py adapters/api/v1/schema_models/email.py adapters/api/v1/schema_models/file_storage.py @@ -40,6 +46,8 @@ adapters/api/v1/schema_models/person.py adapters/api/v1/schema_models/price_list.py adapters/api/v1/schema_models/product.py adapters/api/v1/schema_models/product_attribute.py +adapters/api/v1/schema_models/product_bom.py +adapters/api/v1/schema_models/warehouse.py adapters/api/v1/support/__init__.py adapters/api/v1/support/categories.py adapters/api/v1/support/operator.py @@ -72,9 +80,11 @@ adapters/db/models/price_list.py adapters/db/models/product.py adapters/db/models/product_attribute.py adapters/db/models/product_attribute_link.py +adapters/db/models/product_bom.py adapters/db/models/tax_type.py adapters/db/models/tax_unit.py adapters/db/models/user.py +adapters/db/models/warehouse.py adapters/db/models/support/__init__.py adapters/db/models/support/category.py adapters/db/models/support/message.py @@ -87,6 +97,7 @@ adapters/db/repositories/business_permission_repo.py adapters/db/repositories/business_repo.py adapters/db/repositories/cash_register_repository.py adapters/db/repositories/category_repository.py +adapters/db/repositories/document_repository.py adapters/db/repositories/email_config_repository.py adapters/db/repositories/file_storage_repository.py adapters/db/repositories/fiscal_year_repo.py @@ -94,8 +105,10 @@ adapters/db/repositories/password_reset_repo.py adapters/db/repositories/petty_cash_repository.py adapters/db/repositories/price_list_repository.py adapters/db/repositories/product_attribute_repository.py +adapters/db/repositories/product_bom_repository.py adapters/db/repositories/product_repository.py adapters/db/repositories/user_repo.py +adapters/db/repositories/warehouse_repository.py adapters/db/repositories/support/__init__.py adapters/db/repositories/support/category_repository.py adapters/db/repositories/support/message_repository.py @@ -120,13 +133,16 @@ app/core/smart_normalizer.py app/services/api_key_service.py app/services/auth_service.py app/services/bank_account_service.py +app/services/bom_service.py app/services/bulk_price_update_service.py 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/document_service.py app/services/email_service.py +app/services/expense_income_service.py app/services/file_storage_service.py app/services/person_service.py app/services/petty_cash_service.py @@ -135,6 +151,8 @@ app/services/product_attribute_service.py app/services/product_service.py app/services/query_service.py app/services/receipt_payment_service.py +app/services/transfer_service.py +app/services/warehouse_service.py app/services/pdf/__init__.py app/services/pdf/base_pdf_service.py app/services/pdf/modules/__init__.py @@ -196,6 +214,7 @@ migrations/versions/20251014_000201_add_person_id_to_document_lines.py migrations/versions/20251014_000301_add_product_id_to_document_lines.py migrations/versions/20251014_000401_add_payment_refs_to_document_lines.py migrations/versions/20251014_000501_add_quantity_to_document_lines.py +migrations/versions/20251021_000601_add_bom_and_warehouses.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/20251021_000601_add_bom_and_warehouses.py b/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py new file mode 100644 index 0000000..6fd6e31 --- /dev/null +++ b/hesabixAPI/migrations/versions/20251021_000601_add_bom_and_warehouses.py @@ -0,0 +1,137 @@ +"""add BOM and warehouses tables + +Revision ID: 20251021_000601_add_bom_and_warehouses +Revises: 20251014_000501_add_quantity_to_document_lines +Create Date: 2025-10-21 00:06:01.000000 + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20251021_000601_add_bom_and_warehouses" +down_revision = "20251014_000501_add_quantity_to_document_lines" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # warehouses + op.create_table( + "warehouses", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("business_id", sa.Integer(), sa.ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("business_id", "code", name="uq_warehouses_business_code"), + ) + op.create_index("ix_warehouses_business_id", "warehouses", ["business_id"]) + op.create_index("ix_warehouses_code", "warehouses", ["code"]) + op.create_index("ix_warehouses_name", "warehouses", ["name"]) + op.create_index("ix_warehouses_is_default", "warehouses", ["is_default"]) + + # product_boms + op.create_table( + "product_boms", + 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("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("version", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("effective_from", sa.Date(), nullable=True), + sa.Column("effective_to", sa.Date(), nullable=True), + sa.Column("yield_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("'draft'")), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("business_id", "product_id", "version", name="uq_product_bom_version_per_product"), + ) + op.create_index("ix_product_boms_business_id", "product_boms", ["business_id"]) + op.create_index("ix_product_boms_product_id", "product_boms", ["product_id"]) + op.create_index("ix_product_boms_is_default", "product_boms", ["is_default"]) + op.create_index("ix_product_boms_status", "product_boms", ["status"]) + + # product_bom_items + op.create_table( + "product_bom_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("component_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), + sa.Column("qty_per", sa.Numeric(18, 6), nullable=False), + sa.Column("uom", sa.String(length=32), nullable=True), + sa.Column("wastage_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("is_optional", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("substitute_group", sa.String(length=64), nullable=True), + sa.Column("suggested_warehouse_id", sa.Integer(), sa.ForeignKey("warehouses.id", ondelete="SET NULL"), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_items_line"), + ) + op.create_index("ix_product_bom_items_bom_id", "product_bom_items", ["bom_id"]) + op.create_index("ix_product_bom_items_component_product_id", "product_bom_items", ["component_product_id"]) + + # product_bom_outputs + op.create_table( + "product_bom_outputs", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("output_product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="RESTRICT"), nullable=False), + sa.Column("ratio", sa.Numeric(18, 6), nullable=False), + sa.Column("uom", sa.String(length=32), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_outputs_line"), + ) + op.create_index("ix_product_bom_outputs_bom_id", "product_bom_outputs", ["bom_id"]) + op.create_index("ix_product_bom_outputs_output_product_id", "product_bom_outputs", ["output_product_id"]) + + # product_bom_operations + op.create_table( + "product_bom_operations", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("bom_id", sa.Integer(), sa.ForeignKey("product_boms.id", ondelete="CASCADE"), nullable=False), + sa.Column("line_no", sa.Integer(), nullable=False), + sa.Column("operation_name", sa.String(length=255), nullable=False), + sa.Column("cost_fixed", sa.Numeric(18, 2), nullable=True), + sa.Column("cost_per_unit", sa.Numeric(18, 6), nullable=True), + sa.Column("cost_uom", sa.String(length=32), nullable=True), + sa.Column("work_center", sa.String(length=128), nullable=True), + sa.UniqueConstraint("bom_id", "line_no", name="uq_bom_operations_line"), + ) + op.create_index("ix_product_bom_operations_bom_id", "product_bom_operations", ["bom_id"]) + + +def downgrade() -> None: + op.drop_index("ix_product_bom_operations_bom_id", table_name="product_bom_operations") + op.drop_table("product_bom_operations") + + op.drop_index("ix_product_bom_outputs_output_product_id", table_name="product_bom_outputs") + op.drop_index("ix_product_bom_outputs_bom_id", table_name="product_bom_outputs") + op.drop_table("product_bom_outputs") + + op.drop_index("ix_product_bom_items_component_product_id", table_name="product_bom_items") + op.drop_index("ix_product_bom_items_bom_id", table_name="product_bom_items") + op.drop_table("product_bom_items") + + op.drop_index("ix_product_boms_status", table_name="product_boms") + op.drop_index("ix_product_boms_is_default", table_name="product_boms") + op.drop_index("ix_product_boms_product_id", table_name="product_boms") + op.drop_index("ix_product_boms_business_id", table_name="product_boms") + op.drop_table("product_boms") + + op.drop_index("ix_warehouses_is_default", table_name="warehouses") + op.drop_index("ix_warehouses_name", table_name="warehouses") + op.drop_index("ix_warehouses_code", table_name="warehouses") + op.drop_index("ix_warehouses_business_id", table_name="warehouses") + op.drop_table("warehouses") + + diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index 2566b2c..cd45022 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -207,6 +207,21 @@ class ApiClient { ); return response.data ?? []; } + + // Download Excel API + Future> downloadExcel(String path, {Map? params}) async { + final response = await post>( + path, + data: params, + responseType: ResponseType.bytes, + options: Options( + headers: { + 'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ), + ); + return response.data ?? []; + } } // Utilities diff --git a/hesabixUI/hesabix_ui/lib/core/date_utils.dart b/hesabixUI/hesabix_ui/lib/core/date_utils.dart index 8d03811..4994b10 100644 --- a/hesabixUI/hesabix_ui/lib/core/date_utils.dart +++ b/hesabixUI/hesabix_ui/lib/core/date_utils.dart @@ -67,10 +67,15 @@ class HesabixDateUtils { return monthNames[month - 1]; } - /// Format date for API (always Gregorian) - static String formatForAPI(DateTime? date) { + /// Format date with both Jalali and Gregorian for display + static String formatDualCalendar(DateTime? date) { if (date == null) return ''; - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + + final jalali = Jalali.fromDateTime(date); + final jalaliStr = '${jalali.year}/${jalali.month.toString().padLeft(2, '0')}/${jalali.day.toString().padLeft(2, '0')}'; + final gregorianStr = '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; + + return '$jalaliStr (میلادی: $gregorianStr)'; } /// Parse date from API (always Gregorian) diff --git a/hesabixUI/hesabix_ui/lib/main.dart b/hesabixUI/hesabix_ui/lib/main.dart index eea3d9e..7bd2925 100644 --- a/hesabixUI/hesabix_ui/lib/main.dart +++ b/hesabixUI/hesabix_ui/lib/main.dart @@ -41,6 +41,8 @@ import 'pages/business/check_form_page.dart'; import 'pages/business/receipts_payments_list_page.dart'; import 'pages/business/expense_income_list_page.dart'; import 'pages/business/transfers_page.dart'; +import 'pages/business/documents_page.dart'; +import 'pages/business/warehouses_page.dart'; import 'pages/error_404_page.dart'; import 'core/locale_controller.dart'; import 'core/calendar_controller.dart'; @@ -761,6 +763,33 @@ class _MyAppState extends State { ); }, ), + GoRoute( + path: '/business/:business_id/warehouses', + name: 'business_warehouses', + pageBuilder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return NoTransitionPage( + child: WarehousesPage( + businessId: businessId, + ), + ); + }, + ), + GoRoute( + path: '/business/:business_id/documents', + name: 'business_documents', + pageBuilder: (context, state) { + final businessId = int.parse(state.pathParameters['business_id']!); + return NoTransitionPage( + child: DocumentsPage( + businessId: businessId, + calendarController: _calendarController!, + authStore: _authStore!, + apiClient: ApiClient(), + ), + ); + }, + ), GoRoute( path: '/business/:business_id/checks', name: 'business_checks', diff --git a/hesabixUI/hesabix_ui/lib/models/account_model.dart b/hesabixUI/hesabix_ui/lib/models/account_model.dart new file mode 100644 index 0000000..180cb79 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/account_model.dart @@ -0,0 +1,87 @@ +class Account { + final int? id; + final int? businessId; // nullable - برای حساب‌های عمومی + final String name; + final String code; + final String accountType; + final int? parentId; + final String? description; + final bool isActive; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Account({ + this.id, + this.businessId, // nullable شد + required this.name, + required this.code, + required this.accountType, + this.parentId, + this.description, + this.isActive = true, + this.createdAt, + this.updatedAt, + }); + + factory Account.fromJson(Map json) { + return Account( + id: json['id'] as int?, + businessId: json['business_id'] as int?, // nullable + name: json['name'] as String, + code: json['code'] as String, + accountType: json['account_type'] as String, + parentId: json['parent_id'] as int?, + description: json['description'] as String?, + isActive: json['is_active'] as bool? ?? true, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null, + updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'business_id': businessId, + 'name': name, + 'code': code, + 'account_type': accountType, + 'parent_id': parentId, + 'description': description, + 'is_active': isActive, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + Account copyWith({ + int? id, + int? businessId, + String? name, + String? code, + String? accountType, + int? parentId, + String? description, + bool? isActive, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Account( + id: id ?? this.id, + businessId: businessId ?? this.businessId, + name: name ?? this.name, + code: code ?? this.code, + accountType: accountType ?? this.accountType, + parentId: parentId ?? this.parentId, + description: description ?? this.description, + isActive: isActive ?? this.isActive, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// آیا این حساب یک حساب عمومی است؟ + bool get isGeneralAccount => businessId == null; + + /// نمایش نام کامل با کد + String get displayName => '$code - $name'; +} diff --git a/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart b/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart index db2181d..97248f7 100644 --- a/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart +++ b/hesabixUI/hesabix_ui/lib/models/account_tree_node.dart @@ -1,3 +1,5 @@ +import 'account_model.dart'; + class AccountTreeNode { final int id; final String code; @@ -81,9 +83,26 @@ class AccountTreeNode { }).toList(); } + /// آیا این نود قابل انتخاب است؟ (فقط leaf nodes) + bool get isSelectable => !hasChildren; + + /// نمایش نام کامل با کد + String get displayName => '$code - $name'; + + /// تبدیل به Account + Account toAccount() { + return Account( + id: id, + code: code, + name: name, + accountType: accountType ?? 'accounting_document', + parentId: parentId, + ); + } + @override String toString() { - return '$code - $name'; + return displayName; } @override diff --git a/hesabixUI/hesabix_ui/lib/models/bom_models.dart b/hesabixUI/hesabix_ui/lib/models/bom_models.dart new file mode 100644 index 0000000..bc61683 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/bom_models.dart @@ -0,0 +1,259 @@ +class BomItem { + final int lineNo; + final int componentProductId; + final double qtyPer; + final String? uom; + final double? wastagePercent; + final bool isOptional; + final String? substituteGroup; + final int? suggestedWarehouseId; + + const BomItem({ + required this.lineNo, + required this.componentProductId, + required this.qtyPer, + this.uom, + this.wastagePercent, + this.isOptional = false, + this.substituteGroup, + this.suggestedWarehouseId, + }); + + factory BomItem.fromJson(Map json) { + return BomItem( + lineNo: (json['line_no'] ?? json['lineNo']) as int, + componentProductId: (json['component_product_id'] ?? json['componentProductId']) as int, + qtyPer: double.tryParse(json['qty_per']?.toString() ?? '0') ?? 0, + uom: json['uom'] as String?, + wastagePercent: json['wastage_percent'] != null ? double.tryParse(json['wastage_percent'].toString()) : null, + isOptional: (json['is_optional'] ?? false) as bool, + substituteGroup: json['substitute_group'] as String?, + suggestedWarehouseId: json['suggested_warehouse_id'] as int?, + ); + } + + Map toJson() { + return { + 'line_no': lineNo, + 'component_product_id': componentProductId, + 'qty_per': qtyPer, + 'uom': uom, + 'wastage_percent': wastagePercent, + 'is_optional': isOptional, + 'substitute_group': substituteGroup, + 'suggested_warehouse_id': suggestedWarehouseId, + }; + } +} + +class BomOutput { + final int lineNo; + final int outputProductId; + final double ratio; + final String? uom; + + const BomOutput({ + required this.lineNo, + required this.outputProductId, + required this.ratio, + this.uom, + }); + + factory BomOutput.fromJson(Map json) { + return BomOutput( + lineNo: (json['line_no'] ?? json['lineNo']) as int, + outputProductId: (json['output_product_id'] ?? json['outputProductId']) as int, + ratio: double.tryParse(json['ratio']?.toString() ?? '0') ?? 0, + uom: json['uom'] as String?, + ); + } + + Map toJson() { + return { + 'line_no': lineNo, + 'output_product_id': outputProductId, + 'ratio': ratio, + 'uom': uom, + }; + } +} + +class BomOperation { + final int lineNo; + final String operationName; + final double? costFixed; + final double? costPerUnit; + final String? costUom; + final String? workCenter; + + const BomOperation({ + required this.lineNo, + required this.operationName, + this.costFixed, + this.costPerUnit, + this.costUom, + this.workCenter, + }); + + factory BomOperation.fromJson(Map json) { + return BomOperation( + lineNo: (json['line_no'] ?? json['lineNo']) as int, + operationName: (json['operation_name'] ?? json['operationName'] ?? '') as String, + costFixed: json['cost_fixed'] != null ? double.tryParse(json['cost_fixed'].toString()) : null, + costPerUnit: json['cost_per_unit'] != null ? double.tryParse(json['cost_per_unit'].toString()) : null, + costUom: json['cost_uom'] as String?, + workCenter: json['work_center'] as String?, + ); + } + + Map toJson() { + return { + 'line_no': lineNo, + 'operation_name': operationName, + 'cost_fixed': costFixed, + 'cost_per_unit': costPerUnit, + 'cost_uom': costUom, + 'work_center': workCenter, + }; + } +} + +class ProductBOM { + final int? id; + final int businessId; + final int productId; + final String version; + final String name; + final bool isDefault; + final String? effectiveFrom; + final String? effectiveTo; + final double? yieldPercent; + final double? wastagePercent; + final String status; + final String? notes; + final DateTime? createdAt; + final DateTime? updatedAt; + + final List items; + final List outputs; + final List operations; + + const ProductBOM({ + this.id, + required this.businessId, + required this.productId, + required this.version, + required this.name, + this.isDefault = false, + this.effectiveFrom, + this.effectiveTo, + this.yieldPercent, + this.wastagePercent, + this.status = 'draft', + this.notes, + this.createdAt, + this.updatedAt, + this.items = const [], + this.outputs = const [], + this.operations = const [], + }); + + factory ProductBOM.fromJson(Map json) { + final items = (json['items'] as List? ?? const []) + .map((e) => BomItem.fromJson(Map.from(e as Map))) + .toList(); + final outputs = (json['outputs'] as List? ?? const []) + .map((e) => BomOutput.fromJson(Map.from(e as Map))) + .toList(); + final operations = (json['operations'] as List? ?? const []) + .map((e) => BomOperation.fromJson(Map.from(e as Map))) + .toList(); + return ProductBOM( + id: json['id'] as int?, + businessId: (json['business_id'] ?? json['businessId']) as int, + productId: (json['product_id'] ?? json['productId']) as int, + version: (json['version'] ?? '') as String, + name: (json['name'] ?? '') as String, + isDefault: (json['is_default'] ?? json['isDefault'] ?? false) as bool, + effectiveFrom: json['effective_from'] as String?, + effectiveTo: json['effective_to'] as String?, + yieldPercent: json['yield_percent'] != null ? double.tryParse(json['yield_percent'].toString()) : null, + wastagePercent: json['wastage_percent'] != null ? double.tryParse(json['wastage_percent'].toString()) : null, + status: (json['status'] ?? 'draft') as String, + notes: json['notes'] as String?, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null, + updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null, + items: items, + outputs: outputs, + operations: operations, + ); + } + + Map toJson() { + return { + 'id': id, + 'business_id': businessId, + 'product_id': productId, + 'version': version, + 'name': name, + 'is_default': isDefault, + 'effective_from': effectiveFrom, + 'effective_to': effectiveTo, + 'yield_percent': yieldPercent, + 'wastage_percent': wastagePercent, + 'status': status, + 'notes': notes, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'items': items.map((e) => e.toJson()).toList(), + 'outputs': outputs.map((e) => e.toJson()).toList(), + 'operations': operations.map((e) => e.toJson()).toList(), + }; + } +} + +class BomExplosionItem { + final int componentProductId; + final double requiredQty; + final String? uom; + final int? suggestedWarehouseId; + final bool isOptional; + final String? substituteGroup; + + const BomExplosionItem({ + required this.componentProductId, + required this.requiredQty, + this.uom, + this.suggestedWarehouseId, + this.isOptional = false, + this.substituteGroup, + }); + + factory BomExplosionItem.fromJson(Map json) { + return BomExplosionItem( + componentProductId: (json['component_product_id'] ?? json['componentProductId']) as int, + requiredQty: double.tryParse(json['required_qty']?.toString() ?? '0') ?? 0, + uom: json['uom'] as String?, + suggestedWarehouseId: json['suggested_warehouse_id'] as int?, + isOptional: (json['is_optional'] ?? false) as bool, + substituteGroup: json['substitute_group'] as String?, + ); + } +} + +class BomExplosionResult { + final List items; + final List outputs; + + const BomExplosionResult({this.items = const [], this.outputs = const []}); + + factory BomExplosionResult.fromJson(Map json) { + final items = (json['items'] as List? ?? const []) + .map((e) => BomExplosionItem.fromJson(Map.from(e as Map))) + .toList(); + final outputs = (json['outputs'] as List? ?? const []) + .map((e) => BomOutput.fromJson(Map.from(e as Map))) + .toList(); + return BomExplosionResult(items: items, outputs: outputs); + } +} diff --git a/hesabixUI/hesabix_ui/lib/models/document_model.dart b/hesabixUI/hesabix_ui/lib/models/document_model.dart new file mode 100644 index 0000000..4afa631 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/document_model.dart @@ -0,0 +1,521 @@ +/// مدل سند حسابداری (Document) +class DocumentModel { + final int id; + final String code; + final int businessId; + final int fiscalYearId; + final int currencyId; + final int createdByUserId; + final DateTime registeredAt; + final DateTime documentDate; + final String documentType; + final bool isProforma; + final String? description; + final DateTime createdAt; + final DateTime updatedAt; + + // اطلاعات مرتبط + final String? businessTitle; + final String? fiscalYearTitle; + final String? currencyCode; + final String? currencySymbol; + final String? createdByName; + + // محاسبات + final double totalDebit; + final double totalCredit; + final int linesCount; + + // سطرهای سند (فقط برای جزئیات) + final List? lines; + + // اطلاعات اضافی + final Map? extraInfo; + final Map? developerSettings; + + // فیلدهای formatted از سرور (برای نمایش) + final String? documentDateRaw; + final String? registeredAtRaw; + final String? createdAtRaw; + final String? updatedAtRaw; + + DocumentModel({ + required this.id, + required this.code, + required this.businessId, + required this.fiscalYearId, + required this.currencyId, + required this.createdByUserId, + required this.registeredAt, + required this.documentDate, + required this.documentType, + required this.isProforma, + this.description, + required this.createdAt, + required this.updatedAt, + this.businessTitle, + this.fiscalYearTitle, + this.currencyCode, + this.currencySymbol, + this.createdByName, + required this.totalDebit, + required this.totalCredit, + required this.linesCount, + this.lines, + this.extraInfo, + this.developerSettings, + this.documentDateRaw, + this.registeredAtRaw, + this.createdAtRaw, + this.updatedAtRaw, + }); + + factory DocumentModel.fromJson(Map json) { + return DocumentModel( + id: json['id'] as int, + code: json['code'] as String, + businessId: json['business_id'] as int, + fiscalYearId: json['fiscal_year_id'] as int, + currencyId: json['currency_id'] as int, + createdByUserId: json['created_by_user_id'] as int, + registeredAt: _parseDateTime(json['registered_at']), + documentDate: _parseDateTime(json['document_date']), + documentType: json['document_type'] as String, + isProforma: json['is_proforma'] as bool? ?? false, + description: json['description'] as String?, + createdAt: _parseDateTime(json['created_at']), + updatedAt: _parseDateTime(json['updated_at']), + businessTitle: json['business_title'] as String?, + fiscalYearTitle: json['fiscal_year_title'] as String?, + currencyCode: json['currency_code'] as String?, + currencySymbol: json['currency_symbol'] as String?, + createdByName: json['created_by_name'] as String?, + totalDebit: (json['total_debit'] as num?)?.toDouble() ?? 0.0, + totalCredit: (json['total_credit'] as num?)?.toDouble() ?? 0.0, + linesCount: json['lines_count'] as int? ?? 0, + lines: json['lines'] != null + ? (json['lines'] as List) + .map((line) => DocumentLineModel.fromJson(line as Map)) + .toList() + : null, + extraInfo: json['extra_info'] as Map?, + developerSettings: json['developer_settings'] as Map?, + documentDateRaw: json['document_date_raw'] as String? ?? json['document_date'] as String?, + registeredAtRaw: json['registered_at_raw'] as String? ?? json['registered_at'] as String?, + createdAtRaw: json['created_at_raw'] as String? ?? json['created_at'] as String?, + updatedAtRaw: json['updated_at_raw'] as String? ?? json['updated_at'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'code': code, + 'business_id': businessId, + 'fiscal_year_id': fiscalYearId, + 'currency_id': currencyId, + 'created_by_user_id': createdByUserId, + 'registered_at': registeredAt.toIso8601String(), + 'document_date': documentDate.toIso8601String(), + 'document_type': documentType, + 'is_proforma': isProforma, + 'description': description, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'business_title': businessTitle, + 'fiscal_year_title': fiscalYearTitle, + 'currency_code': currencyCode, + 'currency_symbol': currencySymbol, + 'created_by_name': createdByName, + 'total_debit': totalDebit, + 'total_credit': totalCredit, + 'lines_count': linesCount, + 'lines': lines?.map((line) => line.toJson()).toList(), + 'extra_info': extraInfo, + 'developer_settings': developerSettings, + }; + } + + /// دریافت نام فارسی نوع سند + String getDocumentTypeName() { + switch (documentType) { + case 'expense': + return 'هزینه'; + case 'income': + return 'درآمد'; + case 'receipt': + return 'دریافت'; + case 'payment': + return 'پرداخت'; + case 'transfer': + return 'انتقال'; + case 'manual': + return 'سند دستی'; + case 'invoice': + return 'فاکتور'; + default: + return documentType; + } + } + + /// آیا سند قابل ویرایش است؟ + bool get isEditable => documentType == 'manual'; + + /// آیا سند قابل حذف است؟ + bool get isDeletable => documentType == 'manual'; + + /// دریافت وضعیت سند + String get statusText => isProforma ? 'پیش‌فاکتور' : 'قطعی'; + + /// Parse DateTime from various formats + static DateTime _parseDateTime(dynamic value) { + if (value == null) return DateTime.now(); + if (value is DateTime) return value; + + String dateStr = value.toString(); + + // Try ISO format first + try { + return DateTime.parse(dateStr); + } catch (e) { + // If ISO parse fails, try other formats + // Format: "1404/07/23 14:02:20" or "1404/07/23" + try { + // Remove time if exists and just use current datetime + // Since we can't easily parse Jalali dates without conversion + // we'll just return a valid DateTime + return DateTime.now(); + } catch (e) { + return DateTime.now(); + } + } + } +} + +/// مدل سطر سند حسابداری (Document Line) +class DocumentLineModel { + final int id; + final int documentId; + final int? accountId; + final int? personId; + final int? productId; + final int? bankAccountId; + final int? cashRegisterId; + final int? pettyCashId; + final int? checkId; + final double? quantity; + final double debit; + final double credit; + final String? description; + final Map? extraInfo; + final DateTime createdAt; + final DateTime updatedAt; + + // اطلاعات مرتبط + final String? accountCode; + final String? accountName; + final String? personName; + final String? productName; + final String? bankAccountName; + final String? cashRegisterName; + final String? pettyCashName; + final String? checkNumber; + + DocumentLineModel({ + required this.id, + required this.documentId, + this.accountId, + this.personId, + this.productId, + this.bankAccountId, + this.cashRegisterId, + this.pettyCashId, + this.checkId, + this.quantity, + required this.debit, + required this.credit, + this.description, + this.extraInfo, + required this.createdAt, + required this.updatedAt, + this.accountCode, + this.accountName, + this.personName, + this.productName, + this.bankAccountName, + this.cashRegisterName, + this.pettyCashName, + this.checkNumber, + }); + + factory DocumentLineModel.fromJson(Map json) { + return DocumentLineModel( + id: json['id'] as int, + documentId: json['document_id'] as int, + accountId: json['account_id'] as int?, + personId: json['person_id'] as int?, + productId: json['product_id'] as int?, + bankAccountId: json['bank_account_id'] as int?, + cashRegisterId: json['cash_register_id'] as int?, + pettyCashId: json['petty_cash_id'] as int?, + checkId: json['check_id'] as int?, + quantity: (json['quantity'] as num?)?.toDouble(), + debit: (json['debit'] as num?)?.toDouble() ?? 0.0, + credit: (json['credit'] as num?)?.toDouble() ?? 0.0, + description: json['description'] as String?, + extraInfo: json['extra_info'] as Map?, + createdAt: DocumentModel._parseDateTime(json['created_at']), + updatedAt: DocumentModel._parseDateTime(json['updated_at']), + accountCode: json['account_code'] as String?, + accountName: json['account_name'] as String?, + personName: json['person_name'] as String?, + productName: json['product_name'] as String?, + bankAccountName: json['bank_account_name'] as String?, + cashRegisterName: json['cash_register_name'] as String?, + pettyCashName: json['petty_cash_name'] as String?, + checkNumber: json['check_number'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'document_id': documentId, + 'account_id': accountId, + 'person_id': personId, + 'product_id': productId, + 'bank_account_id': bankAccountId, + 'cash_register_id': cashRegisterId, + 'petty_cash_id': pettyCashId, + 'check_id': checkId, + 'quantity': quantity, + 'debit': debit, + 'credit': credit, + 'description': description, + 'extra_info': extraInfo, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'account_code': accountCode, + 'account_name': accountName, + 'person_name': personName, + 'product_name': productName, + 'bank_account_name': bankAccountName, + 'cash_register_name': cashRegisterName, + 'petty_cash_name': pettyCashName, + 'check_number': checkNumber, + }; + } + + /// دریافت نام کامل حساب + String get fullAccountName { + if (accountCode != null && accountName != null) { + return '$accountCode - $accountName'; + } + return accountName ?? accountCode ?? '-'; + } + + /// دریافت نام طرف‌حساب + String? get counterpartyName { + return personName ?? + bankAccountName ?? + cashRegisterName ?? + pettyCashName ?? + checkNumber; + } +} + + +/// مدل درخواست ایجاد سطر سند +class DocumentLineCreateRequest { + final int accountId; + final int? personId; + final int? productId; + final int? bankAccountId; + final int? cashRegisterId; + final int? pettyCashId; + final int? checkId; + final double? quantity; + final double debit; + final double credit; + final String? description; + final Map? extraInfo; + + DocumentLineCreateRequest({ + required this.accountId, + this.personId, + this.productId, + this.bankAccountId, + this.cashRegisterId, + this.pettyCashId, + this.checkId, + this.quantity, + this.debit = 0, + this.credit = 0, + this.description, + this.extraInfo, + }); + + Map toJson() { + return { + 'account_id': accountId, + if (personId != null) 'person_id': personId, + if (productId != null) 'product_id': productId, + if (bankAccountId != null) 'bank_account_id': bankAccountId, + if (cashRegisterId != null) 'cash_register_id': cashRegisterId, + if (pettyCashId != null) 'petty_cash_id': pettyCashId, + if (checkId != null) 'check_id': checkId, + if (quantity != null) 'quantity': quantity, + 'debit': debit, + 'credit': credit, + if (description != null) 'description': description, + if (extraInfo != null) 'extra_info': extraInfo, + }; + } +} + + +/// مدل درخواست ایجاد سند دستی +class CreateManualDocumentRequest { + final String? code; + final DateTime documentDate; + final int? fiscalYearId; + final int currencyId; + final bool isProforma; + final String? description; + final List lines; + final Map? extraInfo; + + CreateManualDocumentRequest({ + this.code, + required this.documentDate, + this.fiscalYearId, + required this.currencyId, + this.isProforma = false, + this.description, + required this.lines, + this.extraInfo, + }); + + Map toJson() { + return { + if (code != null) 'code': code, + 'document_date': documentDate.toIso8601String().split('T')[0], // فقط تاریخ + if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId, + 'currency_id': currencyId, + 'is_proforma': isProforma, + if (description != null) 'description': description, + 'lines': lines.map((line) => line.toJson()).toList(), + if (extraInfo != null) 'extra_info': extraInfo, + }; + } + + /// اعتبارسنجی درخواست + String? validate() { + if (lines.length < 2) { + return 'سند باید حداقل 2 سطر داشته باشد'; + } + + double totalDebit = 0; + double totalCredit = 0; + + for (int i = 0; i < lines.length; i++) { + final line = lines[i]; + + // بررسی که هر سطر یا بدهکار یا بستانکار داشته باشد + if (line.debit == 0 && line.credit == 0) { + return 'سطر ${i + 1} باید مقدار بدهکار یا بستانکار داشته باشد'; + } + + // نمی‌تواند هم بدهکار هم بستانکار داشته باشد + if (line.debit > 0 && line.credit > 0) { + return 'سطر ${i + 1} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد'; + } + + totalDebit += line.debit; + totalCredit += line.credit; + } + + // بررسی متوازن بودن سند + if ((totalDebit - totalCredit).abs() > 0.01) { + final diff = totalDebit - totalCredit; + return 'سند متوازن نیست. تفاوت: ${diff.toStringAsFixed(2)}'; + } + + return null; // اعتبارسنجی موفق + } +} + + +/// مدل درخواست ویرایش سند دستی +class UpdateManualDocumentRequest { + final String? code; + final DateTime? documentDate; + final int? currencyId; + final bool? isProforma; + final String? description; + final List? lines; + final Map? extraInfo; + + UpdateManualDocumentRequest({ + this.code, + this.documentDate, + this.currencyId, + this.isProforma, + this.description, + this.lines, + this.extraInfo, + }); + + Map toJson() { + final map = {}; + + if (code != null) map['code'] = code; + if (documentDate != null) { + map['document_date'] = documentDate!.toIso8601String().split('T')[0]; + } + if (currencyId != null) map['currency_id'] = currencyId; + if (isProforma != null) map['is_proforma'] = isProforma; + if (description != null) map['description'] = description; + if (lines != null) { + map['lines'] = lines!.map((line) => line.toJson()).toList(); + } + if (extraInfo != null) map['extra_info'] = extraInfo; + + return map; + } + + /// اعتبارسنجی درخواست + String? validate() { + if (lines != null) { + if (lines!.length < 2) { + return 'سند باید حداقل 2 سطر داشته باشد'; + } + + double totalDebit = 0; + double totalCredit = 0; + + for (int i = 0; i < lines!.length; i++) { + final line = lines![i]; + + if (line.debit == 0 && line.credit == 0) { + return 'سطر ${i + 1} باید مقدار بدهکار یا بستانکار داشته باشد'; + } + + if (line.debit > 0 && line.credit > 0) { + return 'سطر ${i + 1} نمی‌تواند همزمان بدهکار و بستانکار داشته باشد'; + } + + totalDebit += line.debit; + totalCredit += line.credit; + } + + if ((totalDebit - totalCredit).abs() > 0.01) { + final diff = totalDebit - totalCredit; + return 'سند متوازن نیست. تفاوت: ${diff.toStringAsFixed(2)}'; + } + } + + return null; + } +} + diff --git a/hesabixUI/hesabix_ui/lib/models/expense_income_document.dart b/hesabixUI/hesabix_ui/lib/models/expense_income_document.dart new file mode 100644 index 0000000..5e782ff --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/expense_income_document.dart @@ -0,0 +1,394 @@ +import 'package:hesabix_ui/models/account_model.dart'; + +/// مدل سند هزینه/درآمد +class ExpenseIncomeDocument { + final int id; + final String code; + final String documentType; // "expense" یا "income" + final String documentTypeName; // "هزینه" یا "درآمد" + final DateTime documentDate; + final int currencyId; + final String? currencyCode; + final double totalAmount; + final String? description; + final List itemLines; + final List counterpartyLines; + final int itemLinesCount; + final int counterpartyLinesCount; + final String? createdByName; + final DateTime registeredAt; + final Map? extraInfo; + + const ExpenseIncomeDocument({ + required this.id, + required this.code, + required this.documentType, + required this.documentTypeName, + required this.documentDate, + required this.currencyId, + this.currencyCode, + required this.totalAmount, + this.description, + required this.itemLines, + required this.counterpartyLines, + required this.itemLinesCount, + required this.counterpartyLinesCount, + this.createdByName, + required this.registeredAt, + this.extraInfo, + }); + + /// آیا این سند درآمد است؟ + bool get isIncome => documentType == 'income'; + + /// آیا این سند هزینه است؟ + bool get isExpense => documentType == 'expense'; + + /// نام حساب‌های آیتم‌ها + String? get itemAccountNames { + if (itemLines.isEmpty) return null; + return itemLines.map((line) => line.accountName).join(', '); + } + + /// اطلاعات طرف‌حساب‌ها + String? get counterpartyInfo { + if (counterpartyLines.isEmpty) return null; + return counterpartyLines.map((line) => line.displayName).join(', '); + } + + factory ExpenseIncomeDocument.fromJson(Map json) { + return ExpenseIncomeDocument( + id: json['id'] as int, + code: json['code'] as String, + documentType: json['document_type'] as String, + documentTypeName: json['document_type_name'] as String? ?? + (json['document_type'] == 'income' ? 'درآمد' : 'هزینه'), + documentDate: DateTime.parse(json['document_date'] as String), + currencyId: json['currency_id'] as int, + currencyCode: json['currency_code'] as String?, + totalAmount: (json['total_amount'] as num).toDouble(), + description: json['description'] as String?, + itemLines: (json['item_lines'] as List?) + ?.map((line) => ItemLine.fromJson(line as Map)) + .toList() ?? [], + counterpartyLines: (json['counterparty_lines'] as List?) + ?.map((line) => CounterpartyLine.fromJson(line as Map)) + .toList() ?? [], + itemLinesCount: json['item_lines_count'] as int? ?? 0, + counterpartyLinesCount: json['counterparty_lines_count'] as int? ?? 0, + createdByName: json['created_by_name'] as String?, + registeredAt: DateTime.parse(json['registered_at'] as String), + extraInfo: json['extra_info'] as Map?, + ); + } + + Map toJson() { + return { + 'id': id, + 'code': code, + 'document_type': documentType, + 'document_type_name': documentTypeName, + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + 'currency_code': currencyCode, + 'total_amount': totalAmount, + 'description': description, + 'item_lines': itemLines.map((line) => line.toJson()).toList(), + 'counterparty_lines': counterpartyLines.map((line) => line.toJson()).toList(), + 'item_lines_count': itemLinesCount, + 'counterparty_lines_count': counterpartyLinesCount, + 'created_by_name': createdByName, + 'registered_at': registeredAt.toIso8601String(), + 'extra_info': extraInfo, + }; + } +} + +/// خط آیتم (حساب هزینه/درآمد) +class ItemLine { + final int id; + final int accountId; + final String accountCode; + final String accountName; + final double amount; + final String? description; + + const ItemLine({ + required this.id, + required this.accountId, + required this.accountCode, + required this.accountName, + required this.amount, + this.description, + }); + + factory ItemLine.fromJson(Map json) { + return ItemLine( + id: json['id'] as int, + accountId: json['account_id'] as int, + accountCode: json['account_code'] as String, + accountName: json['account_name'] as String, + amount: (json['amount'] as num).toDouble(), + description: json['description'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'account_id': accountId, + 'account_code': accountCode, + 'account_name': accountName, + 'amount': amount, + 'description': description, + }; + } +} + +/// خط طرف‌حساب +class CounterpartyLine { + final int id; + final String transactionType; + final String transactionTypeName; + final double amount; + final DateTime transactionDate; + final String? description; + final double? commission; + + // فیلدهای اختیاری بر اساس نوع تراکنش + final int? bankAccountId; + final String? bankAccountName; + final int? cashRegisterId; + final String? cashRegisterName; + final int? pettyCashId; + final String? pettyCashName; + final int? checkId; + final String? checkNumber; + final int? personId; + final String? personName; + + const CounterpartyLine({ + required this.id, + required this.transactionType, + required this.transactionTypeName, + required this.amount, + required this.transactionDate, + this.description, + this.commission, + this.bankAccountId, + this.bankAccountName, + this.cashRegisterId, + this.cashRegisterName, + this.pettyCashId, + this.pettyCashName, + this.checkId, + this.checkNumber, + this.personId, + this.personName, + }); + + /// نام نمایشی طرف‌حساب + String get displayName { + switch (transactionType) { + case 'bank': + return bankAccountName ?? 'حساب بانکی'; + case 'cash_register': + return cashRegisterName ?? 'صندوق'; + case 'petty_cash': + return pettyCashName ?? 'تنخواهگردان'; + case 'check': + return checkNumber != null ? 'چک $checkNumber' : 'چک'; + case 'person': + return personName ?? 'شخص'; + default: + return transactionTypeName; + } + } + + factory CounterpartyLine.fromJson(Map json) { + return CounterpartyLine( + id: json['id'] as int, + transactionType: json['transaction_type'] as String, + transactionTypeName: json['transaction_type_name'] as String? ?? + _getTransactionTypeName(json['transaction_type'] as String), + amount: (json['amount'] as num).toDouble(), + transactionDate: DateTime.parse(json['transaction_date'] as String), + description: json['description'] as String?, + commission: json['commission'] != null ? (json['commission'] as num).toDouble() : null, + bankAccountId: json['bank_account_id'] as int?, + bankAccountName: json['bank_account_name'] as String?, + cashRegisterId: json['cash_register_id'] as int?, + cashRegisterName: json['cash_register_name'] as String?, + pettyCashId: json['petty_cash_id'] as int?, + pettyCashName: json['petty_cash_name'] as String?, + checkId: json['check_id'] as int?, + checkNumber: json['check_number'] as String?, + personId: json['person_id'] as int?, + personName: json['person_name'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'transaction_type': transactionType, + 'transaction_type_name': transactionTypeName, + 'amount': amount, + 'transaction_date': transactionDate.toIso8601String(), + 'description': description, + 'commission': commission, + 'bank_account_id': bankAccountId, + 'bank_account_name': bankAccountName, + 'cash_register_id': cashRegisterId, + 'cash_register_name': cashRegisterName, + 'petty_cash_id': pettyCashId, + 'petty_cash_name': pettyCashName, + 'check_id': checkId, + 'check_number': checkNumber, + 'person_id': personId, + 'person_name': personName, + }; + } + + static String _getTransactionTypeName(String type) { + switch (type) { + case 'bank': + return 'بانک'; + case 'cash_register': + return 'صندوق'; + case 'petty_cash': + return 'تنخواهگردان'; + case 'check': + return 'چک'; + case 'person': + return 'شخص'; + default: + return type; + } + } +} + +/// نوع تراکنش برای فرم +enum TransactionType { + bank('bank', 'بانک'), + cashRegister('cash_register', 'صندوق'), + pettyCash('petty_cash', 'تنخواهگردان'), + check('check', 'چک'), + person('person', 'شخص'); + + const TransactionType(this.value, this.displayName); + + final String value; + final String displayName; + + static TransactionType? fromValue(String value) { + for (final type in TransactionType.values) { + if (type.value == value) return type; + } + return null; + } +} + +/// داده‌های خط آیتم برای فرم +class ItemLineData { + final int? accountId; + final String? accountName; + final double amount; + final String? description; + + const ItemLineData({ + this.accountId, + this.accountName, + required this.amount, + this.description, + }); + + ItemLineData copyWith({ + int? accountId, + String? accountName, + double? amount, + String? description, + }) { + return ItemLineData( + accountId: accountId ?? this.accountId, + accountName: accountName ?? this.accountName, + amount: amount ?? this.amount, + description: description ?? this.description, + ); + } +} + +/// داده‌های خط طرف‌حساب برای فرم +class CounterpartyLineData { + final TransactionType transactionType; + final double amount; + final DateTime transactionDate; + final String? description; + final double? commission; + + // فیلدهای اختیاری بر اساس نوع تراکنش + final int? bankAccountId; + final String? bankAccountName; + final int? cashRegisterId; + final String? cashRegisterName; + final int? pettyCashId; + final String? pettyCashName; + final int? checkId; + final String? checkNumber; + final int? personId; + final String? personName; + + const CounterpartyLineData({ + required this.transactionType, + required this.amount, + required this.transactionDate, + this.description, + this.commission, + this.bankAccountId, + this.bankAccountName, + this.cashRegisterId, + this.cashRegisterName, + this.pettyCashId, + this.pettyCashName, + this.checkId, + this.checkNumber, + this.personId, + this.personName, + }); + + CounterpartyLineData copyWith({ + TransactionType? transactionType, + double? amount, + DateTime? transactionDate, + String? description, + double? commission, + int? bankAccountId, + String? bankAccountName, + int? cashRegisterId, + String? cashRegisterName, + int? pettyCashId, + String? pettyCashName, + int? checkId, + String? checkNumber, + int? personId, + String? personName, + }) { + return CounterpartyLineData( + transactionType: transactionType ?? this.transactionType, + amount: amount ?? this.amount, + transactionDate: transactionDate ?? this.transactionDate, + description: description ?? this.description, + commission: commission ?? this.commission, + bankAccountId: bankAccountId ?? this.bankAccountId, + bankAccountName: bankAccountName ?? this.bankAccountName, + cashRegisterId: cashRegisterId ?? this.cashRegisterId, + cashRegisterName: cashRegisterName ?? this.cashRegisterName, + pettyCashId: pettyCashId ?? this.pettyCashId, + pettyCashName: pettyCashName ?? this.pettyCashName, + checkId: checkId ?? this.checkId, + checkNumber: checkNumber ?? this.checkNumber, + personId: personId ?? this.personId, + personName: personName ?? this.personName, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/models/paginated_response.dart b/hesabixUI/hesabix_ui/lib/models/paginated_response.dart new file mode 100644 index 0000000..2fc6ca8 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/paginated_response.dart @@ -0,0 +1,53 @@ +/// مدل پاسخ صفحه‌بندی شده +class PaginatedResponse { + final List items; + final int totalCount; + final int page; + final int pageSize; + final bool hasNextPage; + final bool hasPreviousPage; + + const PaginatedResponse({ + required this.items, + required this.totalCount, + required this.page, + required this.pageSize, + required this.hasNextPage, + required this.hasPreviousPage, + }); + + factory PaginatedResponse.fromJson( + Map json, + T Function(Map) fromJsonT, + ) { + final items = (json['items'] as List?) + ?.map((item) => fromJsonT(item as Map)) + .toList() ?? []; + + final totalCount = json['total_count'] as int? ?? 0; + final page = json['page'] as int? ?? 1; + final pageSize = json['page_size'] as int? ?? 20; + final hasNextPage = json['has_next_page'] as bool? ?? false; + final hasPreviousPage = json['has_previous_page'] as bool? ?? false; + + return PaginatedResponse( + items: items, + totalCount: totalCount, + page: page, + pageSize: pageSize, + hasNextPage: hasNextPage, + hasPreviousPage: hasPreviousPage, + ); + } + + Map toJson() { + return { + 'items': items, + 'total_count': totalCount, + 'page': page, + 'page_size': pageSize, + 'has_next_page': hasNextPage, + 'has_previous_page': hasPreviousPage, + }; + } +} diff --git a/hesabixUI/hesabix_ui/lib/models/person_model.dart b/hesabixUI/hesabix_ui/lib/models/person_model.dart index 991c5c7..2ec3ef3 100644 --- a/hesabixUI/hesabix_ui/lib/models/person_model.dart +++ b/hesabixUI/hesabix_ui/lib/models/person_model.dart @@ -131,6 +131,10 @@ class Person { final bool commissionExcludeDiscounts; final bool commissionExcludeAdditionsDeductions; final bool commissionPostInInvoiceDocument; + + // تراز و وضعیت مالی + final double? balance; + final String? status; Person({ this.id, @@ -167,6 +171,8 @@ class Person { this.commissionExcludeDiscounts = false, this.commissionExcludeAdditionsDeductions = false, this.commissionPostInInvoiceDocument = false, + this.balance, + this.status, }); factory Person.fromJson(Map json) { @@ -211,6 +217,8 @@ class Person { commissionExcludeDiscounts: json['commission_exclude_discounts'] ?? false, commissionExcludeAdditionsDeductions: json['commission_exclude_additions_deductions'] ?? false, commissionPostInInvoiceDocument: json['commission_post_in_invoice_document'] ?? false, + balance: (json['balance'] as num?)?.toDouble(), + status: json['status'] as String?, ); } @@ -250,6 +258,8 @@ class Person { 'commission_exclude_discounts': commissionExcludeDiscounts, 'commission_exclude_additions_deductions': commissionExcludeAdditionsDeductions, 'commission_post_in_invoice_document': commissionPostInInvoiceDocument, + 'balance': balance, + 'status': status, }; } diff --git a/hesabixUI/hesabix_ui/lib/models/product_model.dart b/hesabixUI/hesabix_ui/lib/models/product_model.dart new file mode 100644 index 0000000..d46adb6 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/product_model.dart @@ -0,0 +1,85 @@ +/// مدل ساده برای کالا - برای استفاده در انتخابگرهای تفصیل +class Product { + final int? id; + final int businessId; + final String? code; + final String name; + final String itemType; + final String? description; + final int? categoryId; + final bool trackInventory; + final num? baseSalesPrice; + final num? basePurchasePrice; + final String? mainUnit; + final String? secondaryUnit; + final bool isActive; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Product({ + this.id, + required this.businessId, + this.code, + required this.name, + this.itemType = 'کالا', + this.description, + this.categoryId, + this.trackInventory = false, + this.baseSalesPrice, + this.basePurchasePrice, + this.mainUnit, + this.secondaryUnit, + this.isActive = true, + this.createdAt, + this.updatedAt, + }); + + factory Product.fromJson(Map json) { + return Product( + id: json['id'] as int?, + businessId: (json['business_id'] ?? json['businessId']) as int, + code: json['code'] as String?, + name: (json['name'] ?? '') as String, + itemType: (json['item_type'] ?? 'کالا') as String, + description: json['description'] as String?, + categoryId: json['category_id'] as int?, + trackInventory: (json['track_inventory'] ?? false) as bool, + baseSalesPrice: json['base_sales_price'] != null ? num.tryParse(json['base_sales_price'].toString()) : null, + basePurchasePrice: json['base_purchase_price'] != null ? num.tryParse(json['base_purchase_price'].toString()) : null, + mainUnit: json['main_unit'] as String?, + secondaryUnit: json['secondary_unit'] as String?, + isActive: (json['is_active'] ?? true) as bool, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null, + updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'business_id': businessId, + 'code': code, + 'name': name, + 'item_type': itemType, + 'description': description, + 'category_id': categoryId, + 'track_inventory': trackInventory, + 'base_sales_price': baseSalesPrice, + 'base_purchase_price': basePurchasePrice, + 'main_unit': mainUnit, + 'secondary_unit': secondaryUnit, + 'is_active': isActive, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + /// نمایش نام کامل برای UI + String get displayName { + if (code != null && code!.isNotEmpty) { + return '$code - $name'; + } + return name; + } +} + diff --git a/hesabixUI/hesabix_ui/lib/models/warehouse_model.dart b/hesabixUI/hesabix_ui/lib/models/warehouse_model.dart new file mode 100644 index 0000000..de32856 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/models/warehouse_model.dart @@ -0,0 +1,49 @@ +class Warehouse { + final int? id; + final int businessId; + final String code; + final String name; + final String? description; + final bool isDefault; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Warehouse({ + this.id, + required this.businessId, + required this.code, + required this.name, + this.description, + this.isDefault = false, + this.createdAt, + this.updatedAt, + }); + + factory Warehouse.fromJson(Map json) { + return Warehouse( + id: json['id'] as int?, + businessId: (json['business_id'] ?? json['businessId']) as int, + code: (json['code'] ?? '') as String, + name: (json['name'] ?? '') as String, + description: json['description'] as String?, + isDefault: (json['is_default'] ?? json['isDefault'] ?? false) as bool, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'].toString()) : null, + updatedAt: json['updated_at'] != null ? DateTime.tryParse(json['updated_at'].toString()) : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'business_id': businessId, + 'code': code, + 'name': name, + 'description': description, + 'is_default': isDefault, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart index 05d847c..4c76df8 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/business_shell.dart @@ -15,6 +15,7 @@ import '../../services/business_dashboard_service.dart'; import '../../core/api_client.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'receipts_payments_list_page.dart' show BulkSettlementDialog; +import '../../widgets/document/document_form_dialog.dart'; class BusinessShell extends StatefulWidget { final int businessId; @@ -629,6 +630,26 @@ class _BusinessShellState extends State { } } + Future showAddDocumentDialog() async { + final calendarController = widget.calendarController ?? await CalendarController.load(); + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => DocumentFormDialog( + businessId: widget.businessId, + calendarController: calendarController, + authStore: widget.authStore, + apiClient: ApiClient(), + fiscalYearId: null, // TODO: از context یا state بگیریم + currencyId: 1, // TODO: از تنظیمات بگیریم + ), + ); + if (result == true) { + // Document was successfully added, refresh the current page + _refreshCurrentPage(); + } + } + bool isExpanded(_MenuItem item) { if (item.label == t.productsAndServices) return _isProductsAndServicesExpanded; @@ -979,6 +1000,9 @@ class _BusinessShellState extends State { } else if (item.label == t.checks) { // Navigate to add check context.go('/business/${widget.businessId}/checks/new'); + } else if (item.label == t.documents) { + // Show add document dialog + showAddDocumentDialog(); } // سایر مسیرهای افزودن در آینده متصل می‌شوند }, @@ -1081,6 +1105,9 @@ class _BusinessShellState extends State { } else if (item.label == t.checks) { // Navigate to add check context.go('/business/${widget.businessId}/checks/new'); + } else if (item.label == t.documents) { + // Show add document dialog + showAddDocumentDialog(); } }, child: Container( diff --git a/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart new file mode 100644 index 0000000..8c7677f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/documents_page.dart @@ -0,0 +1,622 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/document_model.dart'; +import 'package:hesabix_ui/services/document_service.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_config.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; +import 'package:hesabix_ui/widgets/document/document_details_dialog.dart'; +import 'package:hesabix_ui/widgets/document/document_form_dialog.dart'; + +/// صفحه لیست اسناد حسابداری (عمومی و اتوماتیک) +class DocumentsPage extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + + const DocumentsPage({ + super.key, + required this.businessId, + required this.calendarController, + required this.authStore, + required this.apiClient, + }); + + @override + State createState() => _DocumentsPageState(); +} + +class _DocumentsPageState extends State { + late DocumentService _service; + String? _selectedDocumentType; + DateTime? _fromDate; + DateTime? _toDate; + final GlobalKey _tableKey = GlobalKey(); + int _selectedCount = 0; + + // انواع اسناد + final Map _documentTypes = { + 'all': 'همه', + 'manual': 'سند دستی', + 'expense': 'هزینه', + 'income': 'درآمد', + 'receipt': 'دریافت', + 'payment': 'پرداخت', + 'transfer': 'انتقال', + 'invoice': 'فاکتور', + }; + + @override + void initState() { + super.initState(); + _service = DocumentService(widget.apiClient); + } + + /// تازه‌سازی داده‌های جدول + void _refreshData() { + final state = _tableKey.currentState; + if (state != null) { + try { + (state as dynamic).refresh(); + return; + } catch (_) {} + } + if (mounted) setState(() {}); + } + + @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: [ + // فیلترها + _buildFilters(t), + + // جدول داده‌ها + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: DataTableWidget( + key: _tableKey, + config: _buildTableConfig(t), + fromJson: (json) => DocumentModel.fromJson(json), + calendarController: widget.calendarController, + ), + ), + ), + ], + ), + ), + ); + } + + /// ساخت فیلترها + Widget _buildFilters(AppLocalizations t) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + children: [ + // فیلتر نوع سند + SizedBox( + width: 200, + child: DropdownButtonFormField( + initialValue: _selectedDocumentType, + decoration: const InputDecoration( + labelText: 'نوع سند', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + isDense: true, + ), + items: _documentTypes.entries.map((entry) { + return DropdownMenuItem( + value: entry.key == 'all' ? null : entry.key, + child: Text(entry.value), + ); + }).toList(), + onChanged: (value) { + setState(() { + _selectedDocumentType = value; + }); + _refreshData(); + }, + ), + ), + const SizedBox(width: 8), + + // فیلتر از تاریخ + Expanded( + child: DateInputField( + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _fromDate = date); + _refreshData(); + }, + labelText: 'از تاریخ', + hintText: 'انتخاب تاریخ شروع', + ), + ), + const SizedBox(width: 8), + + // فیلتر تا تاریخ + Expanded( + child: DateInputField( + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _toDate = date); + _refreshData(); + }, + labelText: 'تا تاریخ', + hintText: 'انتخاب تاریخ پایان', + ), + ), + const SizedBox(width: 8), + + // دکمه پاک کردن فیلترها + IconButton( + onPressed: () { + setState(() { + _selectedDocumentType = null; + _fromDate = null; + _toDate = null; + }); + _refreshData(); + }, + icon: const Icon(Icons.clear), + tooltip: 'پاک کردن فیلتر', + ), + const Spacer(), + + // دکمه افزودن سند جدید + ElevatedButton.icon( + onPressed: _createNewDocument, + icon: const Icon(Icons.add), + label: const Text('سند جدید'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ), + ], + ), + ); + } + + /// ایجاد سند جدید + Future _createNewDocument() async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => DocumentFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + fiscalYearId: null, // TODO: از context یا state بگیریم + currencyId: 1, // TODO: از تنظیمات بگیریم + ), + ); + + if (result == true) { + _refreshData(); + } + } + + /// ساخت تنظیمات جدول + DataTableConfig _buildTableConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/businesses/${widget.businessId}/documents', + title: 'اسناد حسابداری', + excelEndpoint: '/businesses/${widget.businessId}/documents/export/excel', + customHeaderActions: [ + if (_selectedCount > 0) + Tooltip( + message: 'حذف انتخاب‌شده‌ها', + child: FilledButton.icon( + onPressed: _handleBulkDelete, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.errorContainer, + foregroundColor: Theme.of(context).colorScheme.onErrorContainer, + ), + icon: const Icon(Icons.delete_forever), + label: Text('حذف ($_selectedCount)'), + ), + ), + ], + getExportParams: () => { + 'business_id': widget.businessId, + 'document_type': _selectedDocumentType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + }, + additionalParams: { + if (_selectedDocumentType != null) + 'document_type': _selectedDocumentType!, + if (_fromDate != null) + 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) + 'to_date': _toDate!.toUtc().toIso8601String(), + }, + columns: [ + // شماره سند + TextColumn( + 'code', + 'شماره سند', + width: ColumnWidth.medium, + formatter: (item) => item.code, + ), + + // نوع سند + CustomColumn( + 'document_type', + 'نوع', + width: ColumnWidth.medium, + builder: (item, index) { + final doc = item as DocumentModel; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getDocumentTypeColor(doc.documentType).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getDocumentTypeColor(doc.documentType), + width: 1, + ), + ), + child: Text( + doc.getDocumentTypeName(), + style: TextStyle( + color: _getDocumentTypeColor(doc.documentType), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + + // تاریخ سند + TextColumn( + 'document_date', + 'تاریخ', + width: ColumnWidth.medium, + formatter: (item) => item.documentDateRaw ?? '-', + ), + + // سال مالی + TextColumn( + 'fiscal_year_title', + 'سال مالی', + width: ColumnWidth.medium, + formatter: (item) => item.fiscalYearTitle ?? '-', + ), + + // بدهکار + NumberColumn( + 'total_debit', + 'بدهکار', + width: ColumnWidth.large, + formatter: (item) => formatWithThousands(item.totalDebit.toInt()), + suffix: ' ریال', + ), + + // بستانکار + NumberColumn( + 'total_credit', + 'بستانکار', + width: ColumnWidth.large, + formatter: (item) => formatWithThousands(item.totalCredit.toInt()), + suffix: ' ریال', + ), + + // وضعیت + CustomColumn( + 'is_proforma', + 'وضعیت', + width: ColumnWidth.small, + builder: (item, index) { + final doc = item as DocumentModel; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: doc.isProforma + ? Colors.orange.withValues(alpha: 0.1) + : Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + doc.statusText, + style: TextStyle( + color: doc.isProforma ? Colors.orange : Colors.green, + fontSize: 11, + ), + ), + ); + }, + ), + + // توضیحات + TextColumn( + 'description', + 'توضیحات', + width: ColumnWidth.large, + formatter: (item) => item.description ?? '-', + ), + + // عملیات + ActionColumn( + 'actions', + 'عملیات', + width: ColumnWidth.medium, + actions: [ + // مشاهده - برای همه اسناد + DataTableAction( + icon: Icons.visibility, + label: 'مشاهده', + onTap: (item) => _showDocumentDetails(item as DocumentModel), + ), + // ویرایش - فقط برای manual + DataTableAction( + icon: Icons.edit, + label: 'ویرایش', + onTap: (item) => _editDocument(item as DocumentModel), + enabled: true, + ), + // حذف - فقط برای manual + DataTableAction( + icon: Icons.delete, + label: 'حذف', + onTap: (item) => _deleteDocument(item as DocumentModel), + isDestructive: true, + ), + ], + ), + ], + searchFields: ['code', 'description'], + filterFields: ['document_type'], + dateRangeField: 'document_date', + showSearch: true, + showFilters: true, + showPagination: true, + showColumnSearch: true, + showRefreshButton: true, + showClearFiltersButton: true, + enableRowSelection: true, + enableMultiRowSelection: true, + showExportButtons: true, + showExcelExport: true, + showPdfExport: false, + defaultPageSize: 50, + pageSizeOptions: [20, 50, 100, 200], + onRowSelectionChanged: (rows) { + setState(() { + _selectedCount = rows.length; + }); + }, + ); + } + + /// رنگ بر اساس نوع سند + Color _getDocumentTypeColor(String type) { + switch (type) { + case 'manual': + return Colors.blue; + case 'expense': + return Colors.red; + case 'income': + return Colors.green; + case 'receipt': + return Colors.teal; + case 'payment': + return Colors.orange; + case 'transfer': + return Colors.purple; + case 'invoice': + return Colors.indigo; + default: + return Colors.grey; + } + } + + /// نمایش جزئیات سند + Future _showDocumentDetails(DocumentModel doc) async { + await showDialog( + context: context, + builder: (context) => DocumentDetailsDialog( + documentId: doc.id, + calendarController: widget.calendarController, + ), + ); + } + + /// ویرایش سند + Future _editDocument(DocumentModel doc) async { + if (!doc.isEditable) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('فقط اسناد دستی قابل ویرایش هستند'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // بارگذاری جزئیات کامل سند (با سطرها) + try { + final fullDocument = await _service.getDocument(doc.id); + + if (!mounted) return; + + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => DocumentFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + authStore: widget.authStore, + apiClient: widget.apiClient, + document: fullDocument, // حالت ویرایش + fiscalYearId: fullDocument.fiscalYearId, + currencyId: fullDocument.currencyId, + ), + ); + + if (result == true) { + _refreshData(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در بارگذاری سند: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// حذف سند + Future _deleteDocument(DocumentModel doc) async { + if (!doc.isDeletable) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('فقط اسناد دستی قابل حذف هستند'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('تأیید حذف'), + content: Text('آیا از حذف سند ${doc.code} اطمینان دارید؟'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('انصراف'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('حذف'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await _service.deleteDocument(doc.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند با موفقیت حذف شد')), + ); + _refreshData(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در حذف سند: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } + + /// حذف گروهی اسناد + Future _handleBulkDelete() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('تأیید حذف گروهی'), + content: Text( + 'آیا از حذف $_selectedCount سند انتخاب شده اطمینان دارید؟\n\nتوجه: فقط اسناد دستی حذف خواهند شد.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('انصراف'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('حذف'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + // دریافت آیتم‌های انتخاب شده از جدول + final state = _tableKey.currentState; + if (state != null) { + final selectedRows = + (state as dynamic).getSelectedRows() as List; + final documentIds = selectedRows.map((doc) => doc.id).toList(); + + if (documentIds.isNotEmpty) { + final result = await _service.bulkDeleteDocuments(documentIds); + + if (mounted) { + final deletedCount = result['deleted_count'] as int; + final skipped = result['skipped_auto_documents'] as List; + + String message = '$deletedCount سند با موفقیت حذف شد'; + if (skipped.isNotEmpty) { + message += '\n${skipped.length} سند اتوماتیک نادیده گرفته شد'; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + _refreshData(); + } + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در حذف گروهی: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart index e53a19d..5fd4a00 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/expense_income_list_page.dart @@ -1,170 +1,670 @@ import 'package:flutter/material.dart'; -import '../../core/api_client.dart'; -import '../../core/auth_store.dart'; -import '../../core/calendar_controller.dart'; -import '../../core/date_utils.dart' show HesabixDateUtils; -import '../../utils/number_formatters.dart' show formatWithThousands; -import '../../services/expense_income_service.dart'; -import 'expense_income_dialog.dart'; +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/expense_income_document.dart'; +import 'package:hesabix_ui/services/expense_income_list_service.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_widget.dart'; +import 'package:hesabix_ui/widgets/data_table/data_table_config.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; +import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils; +import 'package:hesabix_ui/widgets/expense_income/expense_income_form_dialog.dart'; +import 'package:hesabix_ui/widgets/expense_income/expense_income_details_dialog.dart'; +/// صفحه لیست اسناد هزینه و درآمد با ویجت جدول class ExpenseIncomeListPage extends StatefulWidget { final int businessId; final CalendarController calendarController; final AuthStore authStore; final ApiClient apiClient; - const ExpenseIncomeListPage({super.key, required this.businessId, required this.calendarController, required this.authStore, required this.apiClient}); + + const ExpenseIncomeListPage({ + super.key, + required this.businessId, + required this.calendarController, + required this.authStore, + required this.apiClient, + }); @override State createState() => _ExpenseIncomeListPageState(); } class _ExpenseIncomeListPageState extends State { - int _tabIndex = 0; // 0 expense, 1 income - final List> _items = >[]; - int _skip = 0; - int _take = 20; - int _total = 0; - bool _loading = false; + late ExpenseIncomeListService _service; + String? _selectedDocumentType; + DateTime? _fromDate; + DateTime? _toDate; + // کلید کنترل جدول برای دسترسی به selection و refresh + final GlobalKey _tableKey = GlobalKey(); + int _selectedCount = 0; // تعداد سطرهای انتخاب‌شده @override void initState() { super.initState(); - _load(); + _service = ExpenseIncomeListService(widget.apiClient); } - Future _load() async { - setState(() => _loading = true); - try { - final svc = ExpenseIncomeService(widget.apiClient); - final res = await svc.list( - businessId: widget.businessId, - documentType: _tabIndex == 0 ? 'expense' : 'income', - skip: _skip, - take: _take, - ); - final data = (res['items'] as List? ?? const []).cast>(); - setState(() { - _items - ..clear() - ..addAll(data); - _total = (res['pagination']?['total'] as int?) ?? data.length; - }); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'), backgroundColor: Colors.red)); - } finally { - if (mounted) setState(() => _loading = false); + /// تازه‌سازی داده‌های جدول + void _refreshData() { + final state = _tableKey.currentState; + if (state != null) { + try { + // استفاده از متد عمومی refresh در ویجت جدول + // نوت: دسترسی دینامیک چون State کلاس خصوصی است + // ignore: avoid_dynamic_calls + (state as dynamic).refresh(); + return; + } catch (_) {} } + if (mounted) setState(() {}); } @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: [ - const Expanded(child: Text('هزینه و درآمد', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600))), - SegmentedButton( - segments: const [ButtonSegment(value: 0, label: Text('هزینه')), ButtonSegment(value: 1, label: Text('درآمد'))], - selected: {_tabIndex}, - onSelectionChanged: (s) async { - setState(() { _tabIndex = s.first; _skip = 0; }); - await _load(); - }, - ), - const SizedBox(width: 12), - FilledButton.icon( - onPressed: () async { - final ok = await showDialog( - context: context, - builder: (_) => ExpenseIncomeDialog( - businessId: widget.businessId, - calendarController: widget.calendarController, - authStore: widget.authStore, - apiClient: widget.apiClient, - ), - ); - if (ok == true) _load(); - }, - icon: const Icon(Icons.add), - label: const Text('افزودن'), - ), - ], - ), - ), + // هدر صفحه + _buildHeader(t), + + // فیلترها + _buildFilters(t), + + // جدول داده‌ها Expanded( - child: Card( - margin: const EdgeInsets.all(8), - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _items.isEmpty - ? const Center(child: Text('داده‌ای یافت نشد')) - : ListView.separated( - itemCount: _items.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (ctx, i) { - final it = _items[i]; - final code = (it['code'] ?? '').toString(); - final type = (it['document_type'] ?? '').toString(); - final dateStr = (it['document_date'] ?? '').toString(); - final date = dateStr.isNotEmpty ? DateTime.tryParse(dateStr) : null; - final sumItems = _sum(it['items'] as List?); - final sumCps = _sum(it['counterparties'] as List?); - return ListTile( - title: Text(code), - subtitle: Text('${type == 'income' ? 'درآمد' : 'هزینه'} • ${date != null ? HesabixDateUtils.formatForDisplay(date, true) : '-'}'), - trailing: Text('${formatWithThousands(sumItems)} | ${formatWithThousands(sumCps)}'), - onTap: () async { - final ok = await showDialog( - context: context, - builder: (_) => ExpenseIncomeDialog( - businessId: widget.businessId, - calendarController: widget.calendarController, - authStore: widget.authStore, - apiClient: widget.apiClient, - initial: it, - ), - ); - if (ok == true) _load(); - }, - ); - }, - ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: DataTableWidget( + key: _tableKey, + config: _buildTableConfig(t), + fromJson: (json) => ExpenseIncomeDocument.fromJson(json), + calendarController: widget.calendarController, + ), ), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Row( - children: [ - Text('$_skip - ${_skip + _items.length} از $_total'), - const Spacer(), - IconButton(onPressed: _skip <= 0 ? null : () { setState(() { _skip = (_skip - _take).clamp(0, _total); }); _load(); }, icon: const Icon(Icons.chevron_right)), - IconButton(onPressed: (_skip + _take) >= _total ? null : () { setState(() { _skip = _skip + _take; }); _load(); }, icon: const Icon(Icons.chevron_left)), - ], - ), - ) ], ), ), ); } - double _sum(List? lines) { - if (lines == null) return 0; - double s = 0; - for (final l in lines) { - final m = (l as Map); - s += ((m['debit'] ?? 0) as num).toDouble(); - s += ((m['credit'] ?? 0) as num).toDouble(); - } - return s; + /// ساخت هدر صفحه + Widget _buildHeader(AppLocalizations t) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'هزینه و درآمد', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'مدیریت اسناد هزینه و درآمد', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: _onAddNew, + icon: const Icon(Icons.add), + label: Text(t.add), + ), + ], + ), + ); } -} + /// ساخت بخش فیلترها + Widget _buildFilters(AppLocalizations t) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // فیلتر نوع سند + Expanded( + flex: 2, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: null, + label: Text('همه'), + icon: const Icon(Icons.all_inclusive), + ), + ButtonSegment( + value: 'expense', + label: Text('هزینه‌ها'), + icon: const Icon(Icons.trending_down), + ), + ButtonSegment( + value: 'income', + label: Text('درآمدها'), + icon: const Icon(Icons.trending_up), + ), + ], + selected: _selectedDocumentType != null ? {_selectedDocumentType} : {}, + onSelectionChanged: (set) { + setState(() { + _selectedDocumentType = set.first; + }); + // refresh data when filter changes + _refreshData(); + }, + ), + ), + + const SizedBox(width: 16), + + // فیلتر تاریخ + Expanded( + flex: 3, + child: Row( + children: [ + Expanded( + child: DateInputField( + value: _fromDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _fromDate = date); + _refreshData(); + }, + labelText: 'از تاریخ', + hintText: 'انتخاب تاریخ شروع', + ), + ), + const SizedBox(width: 8), + Expanded( + child: DateInputField( + value: _toDate, + calendarController: widget.calendarController, + onChanged: (date) { + setState(() => _toDate = date); + _refreshData(); + }, + labelText: 'تا تاریخ', + hintText: 'انتخاب تاریخ پایان', + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + setState(() { + _fromDate = null; + _toDate = null; + }); + _refreshData(); + }, + icon: const Icon(Icons.clear), + tooltip: 'پاک کردن فیلتر تاریخ', + ), + ], + ), + ), + ], + ), + ); + } + /// ساخت تنظیمات جدول + DataTableConfig _buildTableConfig(AppLocalizations t) { + return DataTableConfig( + endpoint: '/businesses/${widget.businessId}/expense-income', + title: 'هزینه و درآمد', + excelEndpoint: '/businesses/${widget.businessId}/expense-income/export/excel', + pdfEndpoint: '/businesses/${widget.businessId}/expense-income/export/pdf', + // دکمه حذف گروهی در هدر جدول + customHeaderActions: [ + Tooltip( + message: 'حذف انتخاب‌شده‌ها', + child: FilledButton.icon( + onPressed: _selectedCount > 0 ? _onBulkDelete : null, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.errorContainer, + foregroundColor: Theme.of(context).colorScheme.onErrorContainer, + ), + icon: const Icon(Icons.delete_forever), + label: Text('حذف (${_selectedCount})'), + ), + ), + ], + getExportParams: () => { + 'business_id': widget.businessId, + // همیشه document_type را ارسال کن، حتی اگر null باشد + 'document_type': _selectedDocumentType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + }, + columns: [ + // کد سند + TextColumn( + 'code', + 'کد سند', + width: ColumnWidth.medium, + formatter: (item) => item.code, + ), + + // نوع سند + TextColumn( + 'document_type', + 'نوع', + width: ColumnWidth.small, + formatter: (item) => item.documentTypeName, + ), + + // تاریخ سند + DateColumn( + 'document_date', + 'تاریخ سند', + width: ColumnWidth.medium, + formatter: (item) => HesabixDateUtils.formatForDisplay(item.documentDate, widget.calendarController.isJalali), + ), + + // مبلغ کل + NumberColumn( + 'total_amount', + 'مبلغ کل', + width: ColumnWidth.large, + formatter: (item) => formatWithThousands(item.totalAmount), + suffix: ' ریال', + ), + + // نام حساب‌ها + TextColumn( + 'item_accounts', + 'حساب‌ها', + width: ColumnWidth.medium, + formatter: (item) => item.itemAccountNames ?? 'نامشخص', + ), + + // اطلاعات طرف‌حساب + TextColumn( + 'counterparty_info', + 'طرف‌حساب', + width: ColumnWidth.medium, + formatter: (item) => item.counterpartyInfo ?? 'نامشخص', + ), + + // توضیحات + TextColumn( + 'description', + 'توضیحات', + width: ColumnWidth.large, + formatter: (item) => item.description ?? '', + ), + + // تعداد خطوط + NumberColumn( + 'lines_count', + 'خطوط', + width: ColumnWidth.small, + formatter: (item) => (item.itemLinesCount + item.counterpartyLinesCount).toString(), + ), + + // ایجادکننده + TextColumn( + 'created_by_name', + 'ایجادکننده', + width: ColumnWidth.medium, + formatter: (item) => item.createdByName ?? 'نامشخص', + ), + + // تاریخ ثبت + DateColumn( + 'registered_at', + 'تاریخ ثبت', + width: ColumnWidth.medium, + formatter: (item) => HesabixDateUtils.formatForDisplay(item.registeredAt, widget.calendarController.isJalali), + ), + + // عملیات + ActionColumn( + 'actions', + 'عملیات', + width: ColumnWidth.medium, + actions: [ + DataTableAction( + icon: Icons.visibility, + label: 'مشاهده', + onTap: (item) => _onView(item), + ), + DataTableAction( + icon: Icons.edit, + label: 'ویرایش', + onTap: (item) => _onEdit(item), + ), + DataTableAction( + icon: Icons.delete, + label: 'حذف', + onTap: (item) => _onDelete(item), + isDestructive: true, + ), + ], + ), + ], + searchFields: ['code', 'created_by_name'], + filterFields: ['document_type'], + dateRangeField: 'document_date', + showSearch: true, + showFilters: true, + showPagination: true, + showColumnSearch: true, + showRefreshButton: true, + showClearFiltersButton: true, + enableRowSelection: true, + enableMultiRowSelection: true, + showExportButtons: true, + showExcelExport: true, + showPdfExport: true, + defaultPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + onRowSelectionChanged: (rows) { + setState(() { + _selectedCount = rows.length; + }); + }, + additionalParams: { + // همیشه document_type را ارسال کن، حتی اگر null باشد + 'document_type': _selectedDocumentType, + if (_fromDate != null) 'from_date': _fromDate!.toUtc().toIso8601String(), + if (_toDate != null) 'to_date': _toDate!.toUtc().toIso8601String(), + }, + onRowTap: (item) => _onView(item), + onRowDoubleTap: (item) => _onEdit(item), + emptyStateMessage: 'هیچ سند هزینه یا درآمدی یافت نشد', + loadingMessage: 'در حال بارگذاری اسناد...', + errorMessage: 'خطا در بارگذاری اسناد', + ); + } + + /// افزودن سند جدید + void _onAddNew() async { + final result = await showDialog( + context: context, + builder: (_) => ExpenseIncomeFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + isIncome: false, // پیش‌فرض هزینه + businessInfo: widget.authStore.currentBusiness, + apiClient: widget.apiClient, + ), + ); + + // اگر سند با موفقیت ثبت شد، جدول را تازه‌سازی کن + if (result == true) { + _refreshData(); + } + } + + /// مشاهده جزئیات سند + void _onView(ExpenseIncomeDocument document) async { + try { + // دریافت جزئیات کامل سند + final fullDoc = await _service.getById(document.id); + if (fullDoc == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند یافت نشد')), + ); + return; + } + + // نمایش دیالوگ مشاهده جزئیات + await showDialog( + context: context, + builder: (_) => ExpenseIncomeDetailsDialog( + document: fullDoc, + calendarController: widget.calendarController, + businessId: widget.businessId, + apiClient: widget.apiClient, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری جزئیات: $e')), + ); + } + } + + /// ویرایش سند + void _onEdit(ExpenseIncomeDocument document) async { + try { + // دریافت جزئیات کامل سند + final fullDoc = await _service.getById(document.id); + if (fullDoc == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند یافت نشد')), + ); + return; + } + + final result = await showDialog( + context: context, + builder: (_) => ExpenseIncomeFormDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + isIncome: fullDoc.isIncome, + businessInfo: widget.authStore.currentBusiness, + apiClient: widget.apiClient, + initialDocument: fullDoc, + ), + ); + + if (result == true) { + _refreshData(); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در آماده‌سازی ویرایش: $e')), + ); + } + } + + /// حذف سند + void _onDelete(ExpenseIncomeDocument document) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('تأیید حذف'), + content: Text('حذف سند ${document.code} غیرقابل بازگشت است. آیا ادامه می‌دهید؟'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + FilledButton( + onPressed: () async { + Navigator.pop(context); + await _performDelete(document); + }, + child: const Text('حذف'), + ), + ], + ), + ); + } + + /// انجام عملیات حذف + Future _performDelete(ExpenseIncomeDocument document) async { + try { + // نمایش لودینگ هنگام حذف + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + final success = await _service.delete(document.id); + if (success) { + if (mounted) { + Navigator.pop(context); // بستن لودینگ + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('سند ${document.code} با موفقیت حذف شد'), + backgroundColor: Colors.green, + ), + ); + setState(() { + _selectedCount = 0; // پاک‌سازی شمارنده انتخاب پس از حذف + }); + _refreshData(); + } + } else { + if (mounted) Navigator.pop(context); + throw Exception('خطا در حذف سند'); + } + } catch (e) { + if (mounted) { + // بستن لودینگ در صورت بروز خطا + Navigator.pop(context); + + String message = 'خطا در حذف سند'; + int? statusCode; + if (e is DioException) { + statusCode = e.response?.statusCode; + final data = e.response?.data; + try { + final detail = (data is Map) ? data['detail'] : null; + if (detail is Map) { + final err = detail['error']; + if (err is Map) { + final m = err['message']; + if (m is String && m.trim().isNotEmpty) { + message = m; + } + } + } + } catch (_) { + // ignore parse errors + } + + if (statusCode == 404) { + message = 'سند یافت نشد یا قبلاً حذف شده است'; + _refreshData(); + } else if (statusCode == 403) { + message = 'دسترسی لازم برای حذف این سند را ندارید'; + } else if (statusCode == 409) { + // پیام از سرور استخراج شده است (مثلاً سند قفل/دارای وابستگی) + if (message == 'خطا در حذف سند') { + message = 'حذف این سند امکان‌پذیر نیست'; + } + } + } else { + message = e.toString(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// حذف گروهی اسناد انتخاب‌شده + Future _onBulkDelete() async { + // استخراج آیتم‌های انتخاب‌شده از جدول + final state = _tableKey.currentState; + if (state == null) return; + + List selectedItems = const []; + try { + // ignore: avoid_dynamic_calls + selectedItems = (state as dynamic).getSelectedItems(); + } catch (_) {} + + if (selectedItems.isEmpty) return; + + // نگاشت به مدل و شناسه‌ها + final docs = selectedItems.cast(); + final ids = docs.map((d) => d.id).toList(); + final codes = docs.map((d) => d.code).toList(); + + // تایید کاربر + final confirmed = await showDialog( + context: context, + builder: (ctx) { + return AlertDialog( + title: const Text('تأیید حذف گروهی'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('تعداد اسناد انتخاب‌شده: ${ids.length}'), + const SizedBox(height: 8), + Text('این عملیات غیرقابل بازگشت است. ادامه می‌دهید؟'), + if (codes.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('نمونه کدها: ${codes.take(5).join(', ')}${codes.length > 5 ? ' ...' : ''}'), + ], + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('حذف')), + ], + ); + }, + ); + + if (confirmed != true) return; + + // نمایش لودینگ + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + try { + await _service.deleteMultiple(ids); + if (!mounted) return; + Navigator.pop(context); // بستن لودینگ + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${ids.length} سند با موفقیت حذف شد'), + backgroundColor: Colors.green, + ), + ); + setState(() { + _selectedCount = 0; // پاک‌سازی شمارنده انتخاب پس از حذف گروهی + }); + _refreshData(); + } catch (e) { + if (!mounted) return; + Navigator.pop(context); // بستن لودینگ + String message = 'خطا در حذف اسناد'; + if (e is DioException) { + message = e.message ?? message; + } else { + message = e.toString(); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + ), + ); + } + } +} \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart index 482aae4..a9f4bd5 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/persons_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:hesabix_ui/l10n/app_localizations.dart'; import 'package:hesabix_ui/core/api_client.dart'; import '../../widgets/data_table/data_table_widget.dart'; @@ -228,6 +229,89 @@ class _PersonsPageState extends State { width: ColumnWidth.large, formatter: (person) => person.website ?? '-', ), + CustomColumn( + 'balance', + 'تراز', + width: ColumnWidth.medium, + sortable: true, + formatter: (person) { + final balance = person.balance ?? 0.0; + final formatter = NumberFormat('#,##0', 'en_US'); + return formatter.format(balance); + }, + builder: (person, index) { + final balance = person.balance ?? 0.0; + final formatter = NumberFormat('#,##0', 'en_US'); + final formattedBalance = formatter.format(balance); + + Color balanceColor; + if (balance > 0) { + balanceColor = Colors.green; + } else if (balance < 0) { + balanceColor = Colors.red; + } else { + balanceColor = Colors.grey; + } + + return Text( + formattedBalance, + style: TextStyle( + color: balanceColor, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ); + }, + ), + CustomColumn( + 'status', + 'وضعیت', + width: ColumnWidth.medium, + filterType: ColumnFilterType.multiSelect, + filterOptions: [ + FilterOption(value: 'بستانکار', label: 'بستانکار'), + FilterOption(value: 'بدهکار', label: 'بدهکار'), + FilterOption(value: 'بالانس', label: 'بالانس'), + FilterOption(value: 'بدون تراکنش', label: 'بدون تراکنش'), + ], + formatter: (person) => person.status ?? '-', + builder: (person, index) { + final status = person.status ?? '-'; + Color statusColor; + switch (status) { + case 'بستانکار': + statusColor = Colors.green; + break; + case 'بدهکار': + statusColor = Colors.red; + break; + case 'بالانس': + statusColor = Colors.blue; + break; + case 'بدون تراکنش': + statusColor = Colors.grey; + break; + default: + statusColor = Colors.black; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: statusColor.withOpacity(0.3)), + ), + child: Text( + status, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + }, + ), ActionColumn( 'actions', t.actions, diff --git a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart index 1fc06d8..b013d64 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/transfers_page.dart @@ -190,10 +190,16 @@ class _TransfersPageState extends State { formatter: (it) => (it.description ?? '').isNotEmpty ? it.description! : _composeDesc(it), ), TextColumn( - 'route', - 'مبدا → مقصد', + 'source', + 'مبدا', width: ColumnWidth.large, - formatter: (it) => _composeRoute(it), + formatter: (it) => _composeSource(it), + ), + TextColumn( + 'destination', + 'مقصد', + width: ColumnWidth.large, + formatter: (it) => _composeDestination(it), ), DateColumn( 'document_date', @@ -231,7 +237,7 @@ class _TransfersPageState extends State { ], ), ], - searchFields: ['code', 'created_by_name'], + searchFields: ['code', 'created_by_name', 'source', 'destination'], dateRangeField: 'document_date', showSearch: true, showFilters: true, @@ -272,17 +278,19 @@ class _TransfersPageState extends State { } } - String _composeRoute(TransferDocument it) { - final src = '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim(); - final dst = '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim(); - if (src.isEmpty && dst.isEmpty) return ''; - return '$src → $dst'; + String _composeSource(TransferDocument it) { + return '${_typeFa(it.sourceType)} ${it.sourceName ?? ''}'.trim(); + } + + String _composeDestination(TransferDocument it) { + return '${_typeFa(it.destinationType)} ${it.destinationName ?? ''}'.trim(); } String _composeDesc(TransferDocument it) { - final r = _composeRoute(it); - if (r.isEmpty) return ''; - return 'انتقال $r'; + final src = _composeSource(it); + final dst = _composeDestination(it); + if (src.isEmpty && dst.isEmpty) return ''; + return 'انتقال $src → $dst'; } void _onAddNew() async { @@ -305,7 +313,10 @@ class _TransfersPageState extends State { if (!mounted) return; showDialog( context: context, - builder: (_) => TransferDetailsDialog(document: full), + builder: (_) => TransferDetailsDialog( + document: full, + calendarController: widget.calendarController, + ), ); } diff --git a/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart new file mode 100644 index 0000000..3763da7 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/business/warehouses_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import '../../services/warehouse_service.dart'; +import '../../models/warehouse_model.dart'; + +class WarehousesPage extends StatefulWidget { + final int businessId; + const WarehousesPage({super.key, required this.businessId}); + + @override + State createState() => _WarehousesPageState(); +} + +class _WarehousesPageState extends State { + final WarehouseService _service = WarehouseService(); + bool _loading = true; + String? _error; + List _items = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + setState(() { + _loading = true; + _error = null; + }); + final items = await _service.listWarehouses(businessId: widget.businessId); + if (!mounted) return; + setState(() { + _items = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('مدیریت انبارها')), + floatingActionButton: FloatingActionButton( + onPressed: _showCreateDialog, + child: const Icon(Icons.add), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700))) + : RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, idx) { + final w = _items[idx]; + return ListTile( + leading: Icon(w.isDefault ? Icons.star : Icons.store, color: w.isDefault ? Colors.orange : null), + title: Text('${w.code} - ${w.name}'), + subtitle: Text(w.description ?? ''), + onTap: () => _showEditDialog(w), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _delete(w), + ), + ); + }, + ), + ), + ); + } + + Future _showCreateDialog() async { + final codeCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + bool isDefault = false; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('افزودن انبار'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: codeCtrl, decoration: const InputDecoration(labelText: 'کد')), + TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'نام')), + StatefulBuilder(builder: (ctx, setSt) { + return CheckboxListTile( + value: isDefault, + onChanged: (v) => setSt(() => isDefault = v ?? false), + title: const Text('پیش‌فرض'), + controlAffinity: ListTileControlAffinity.leading, + ); + }), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')), + ], + ), + ); + if (ok != true) return; + try { + final created = await _service.createWarehouse( + businessId: widget.businessId, + payload: { + 'code': codeCtrl.text.trim(), + 'name': nameCtrl.text.trim(), + 'is_default': isDefault, + }, + ); + if (!mounted) return; + setState(() { + _items = [created, ..._items]; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } + + Future _showEditDialog(Warehouse w) async { + final codeCtrl = TextEditingController(text: w.code); + final nameCtrl = TextEditingController(text: w.name); + bool isDefault = w.isDefault; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('ویرایش انبار'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: codeCtrl, decoration: const InputDecoration(labelText: 'کد')), + TextField(controller: nameCtrl, decoration: const InputDecoration(labelText: 'نام')), + StatefulBuilder(builder: (ctx, setSt) { + return CheckboxListTile( + value: isDefault, + onChanged: (v) => setSt(() => isDefault = v ?? false), + title: const Text('پیش‌فرض'), + controlAffinity: ListTileControlAffinity.leading, + ); + }), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')), + ], + ), + ); + if (ok != true) return; + try { + final updated = await _service.updateWarehouse( + businessId: widget.businessId, + warehouseId: w.id!, + payload: { + 'code': codeCtrl.text.trim(), + 'name': nameCtrl.text.trim(), + 'is_default': isDefault, + }, + ); + if (!mounted) return; + setState(() { + _items = _items.map((e) => e.id == updated.id ? updated : e).toList(); + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } + + Future _delete(Warehouse w) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('حذف انبار'), + content: Text('آیا از حذف «${w.name}» مطمئن هستید؟'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('حذف')), + ], + ), + ); + if (ok != true) return; + try { + final deleted = await _service.deleteWarehouse(businessId: widget.businessId, warehouseId: w.id!); + if (!mounted) return; + if (deleted) { + setState(() { + _items = _items.where((e) => e.id != w.id).toList(); + }); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } +} + + + + diff --git a/hesabixUI/hesabix_ui/lib/pages/test/expense_income_test_page.dart b/hesabixUI/hesabix_ui/lib/pages/test/expense_income_test_page.dart new file mode 100644 index 0000000..86b75d4 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/pages/test/expense_income_test_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/pages/business/expense_income_list_page.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; +import 'package:hesabix_ui/core/api_client.dart'; + +/// صفحه تست برای لیست هزینه و درآمد +class ExpenseIncomeTestPage extends StatelessWidget { + const ExpenseIncomeTestPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('تست لیست هزینه و درآمد'), + ), + body: ExpenseIncomeListPage( + businessId: 1, // ID کسب و کار تست + calendarController: CalendarController(), + authStore: AuthStore(), + apiClient: ApiClient(), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/services/account_service.dart b/hesabixUI/hesabix_ui/lib/services/account_service.dart index dc9fd62..1c84346 100644 --- a/hesabixUI/hesabix_ui/lib/services/account_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/account_service.dart @@ -1,4 +1,5 @@ import '../core/api_client.dart'; +import '../models/account_model.dart'; class AccountService { final ApiClient _client; @@ -19,4 +20,71 @@ class AccountService { return {'items': []}; } } + + /// دریافت لیست حساب‌ها برای یک کسب و کار + Future> getAccounts({required int businessId}) async { + try { + final res = await _client.get>( + '/api/v1/accounts/business/$businessId', + ); + + final responseData = res.data?['data'] as Map?; + return responseData ?? {'items': []}; + } catch (e) { + print('خطا در دریافت حساب‌ها: $e'); + return {'items': []}; + } + } + + /// دریافت یک حساب خاص با ID + Future> getAccount({ + required int businessId, + required int accountId, + }) async { + try { + final res = await _client.get>( + '/api/v1/accounts/business/$businessId/account/$accountId', + ); + + final responseData = res.data?['data'] as Map?; + if (responseData == null) { + throw Exception('حساب یافت نشد'); + } + return responseData; + } catch (e) { + print('خطا در دریافت حساب $accountId: $e'); + rethrow; + } + } + + /// جستجوی حساب‌ها + Future> searchAccounts({ + required int businessId, + String? searchQuery, + int limit = 50, + }) async { + try { + final requestData = { + 'take': limit, + 'skip': 0, + 'sort_by': 'name', + 'sort_desc': false, + }; + + if (searchQuery != null && searchQuery.isNotEmpty) { + requestData['search'] = searchQuery; + } + + final res = await _client.post>( + '/api/v1/accounts/business/$businessId', + data: requestData, + ); + + final responseData = res.data?['data'] as Map?; + return responseData ?? {'items': []}; + } catch (e) { + print('خطا در جستجوی حساب‌ها: $e'); + return {'items': []}; + } + } } diff --git a/hesabixUI/hesabix_ui/lib/services/bom_service.dart b/hesabixUI/hesabix_ui/lib/services/bom_service.dart new file mode 100644 index 0000000..e388ec4 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/bom_service.dart @@ -0,0 +1,53 @@ +import '../core/api_client.dart'; +import '../models/bom_models.dart'; + +class BomService { + final ApiClient _api; + BomService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> list({required int businessId, int? productId}) async { + final res = await _api.get>( + '/api/v1/boms/business/$businessId', + query: productId != null ? {'product_id': productId} : null, + ); + final data = res.data?['data'] as Map? ?? {}; + final items = data['items'] as List? ?? const []; + return items.map((e) => ProductBOM.fromJson(Map.from(e as Map))).toList(); + } + + Future create({required int businessId, required Map payload}) async { + final res = await _api.post>('/api/v1/boms/business/$businessId', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return ProductBOM.fromJson(data); + } + + Future getById({required int businessId, required int bomId}) async { + final res = await _api.get>('/api/v1/boms/business/$businessId/$bomId'); + final data = (res.data?['data']?['item'] as Map? ?? {}); + return ProductBOM.fromJson(data); + } + + Future update({required int businessId, required int bomId, required Map payload}) async { + final res = await _api.put>('/api/v1/boms/business/$businessId/$bomId', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return ProductBOM.fromJson(data); + } + + Future delete({required int businessId, required int bomId}) async { + final res = await _api.delete>('/api/v1/boms/business/$businessId/$bomId'); + return res.statusCode == 200 && (res.data?['data']?['deleted'] == true); + } + + Future explode({required int businessId, int? productId, int? bomId, required double quantity}) async { + final payload = { + if (productId != null) 'product_id': productId, + if (bomId != null) 'bom_id': bomId, + 'quantity': quantity, + }; + final res = await _api.post>('/api/v1/boms/business/$businessId/explode', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return BomExplosionResult.fromJson(data); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/services/document_service.dart b/hesabixUI/hesabix_ui/lib/services/document_service.dart new file mode 100644 index 0000000..dc9ad3f --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/document_service.dart @@ -0,0 +1,283 @@ +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/document_model.dart'; + +/// سرویس مدیریت اسناد حسابداری +class DocumentService { + final ApiClient _apiClient; + + DocumentService(this._apiClient); + + /// دریافت لیست اسناد با فیلتر و صفحه‌بندی + /// + /// Parameters: + /// - [businessId]: شناسه کسب‌وکار + /// - [documentType]: نوع سند (expense, income, receipt, payment, transfer, manual) + /// - [fiscalYearId]: شناسه سال مالی + /// - [fromDate]: از تاریخ (ISO format) + /// - [toDate]: تا تاریخ (ISO format) + /// - [currencyId]: شناسه ارز + /// - [isProforma]: پیش‌فاکتور یا قطعی + /// - [search]: جستجو در کد سند و توضیحات + /// - [sortBy]: فیلد مرتب‌سازی + /// - [sortDesc]: ترتیب نزولی + /// - [page]: شماره صفحه + /// - [perPage]: تعداد رکورد در هر صفحه + Future> listDocuments({ + required int businessId, + String? documentType, + int? fiscalYearId, + String? fromDate, + String? toDate, + int? currencyId, + bool? isProforma, + String? search, + String sortBy = 'document_date', + bool sortDesc = true, + int page = 1, + int perPage = 50, + }) async { + try { + final skip = (page - 1) * perPage; + + final body = { + 'take': perPage, + 'skip': skip, + 'sort_by': sortBy, + 'sort_desc': sortDesc, + if (documentType != null) 'document_type': documentType, + if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId, + if (fromDate != null) 'from_date': fromDate, + if (toDate != null) 'to_date': toDate, + if (currencyId != null) 'currency_id': currencyId, + if (isProforma != null) 'is_proforma': isProforma, + if (search != null && search.isNotEmpty) 'search': search, + }; + + final response = await _apiClient.post( + '/businesses/$businessId/documents', + data: body, + ); + + if (response.data['success'] == true) { + final data = response.data['data']; + return { + 'items': (data['items'] as List) + .map((json) => DocumentModel.fromJson(json as Map)) + .toList(), + 'pagination': data['pagination'], + }; + } + + throw Exception(response.data['message'] ?? 'خطا در دریافت لیست اسناد'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'); + } + rethrow; + } + } + + /// دریافت جزئیات یک سند + Future getDocument(int documentId) async { + try { + final response = await _apiClient.get('/documents/$documentId'); + + if (response.data['success'] == true) { + return DocumentModel.fromJson(response.data['data'] as Map); + } + + throw Exception(response.data['message'] ?? 'خطا در دریافت جزئیات سند'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'); + } + rethrow; + } + } + + /// حذف یک سند (فقط اسناد manual) + Future deleteDocument(int documentId) async { + try { + final response = await _apiClient.delete('/documents/$documentId'); + + if (response.data['success'] == true) { + return true; + } + + throw Exception(response.data['message'] ?? 'خطا در حذف سند'); + } catch (e) { + if (e is DioException) { + final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور'; + throw Exception(errorMessage); + } + rethrow; + } + } + + /// حذف گروهی اسناد + Future> bulkDeleteDocuments(List documentIds) async { + try { + final response = await _apiClient.post( + '/documents/bulk-delete', + data: {'document_ids': documentIds}, + ); + + if (response.data['success'] == true) { + return response.data['data'] as Map; + } + + throw Exception(response.data['message'] ?? 'خطا در حذف گروهی اسناد'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'); + } + rethrow; + } + } + + /// دریافت خلاصه آماری انواع اسناد + Future> getDocumentTypesSummary(int businessId) async { + try { + final response = await _apiClient.get( + '/businesses/$businessId/documents/types-summary', + ); + + if (response.data['success'] == true) { + final summary = response.data['data']['summary'] as Map; + return summary.map((key, value) => MapEntry(key, value as int)); + } + + throw Exception(response.data['message'] ?? 'خطا در دریافت آمار'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در ارتباط با سرور'); + } + rethrow; + } + } + + /// خروجی Excel لیست اسناد + Future exportToExcel({ + required int businessId, + String? documentType, + int? fiscalYearId, + String? fromDate, + String? toDate, + int? currencyId, + bool? isProforma, + }) async { + try { + final body = { + if (documentType != null) 'document_type': documentType, + if (fiscalYearId != null) 'fiscal_year_id': fiscalYearId, + if (fromDate != null) 'from_date': fromDate, + if (toDate != null) 'to_date': toDate, + if (currencyId != null) 'currency_id': currencyId, + if (isProforma != null) 'is_proforma': isProforma, + }; + + final response = await _apiClient.post( + '/businesses/$businessId/documents/export/excel', + data: body, + options: Options( + responseType: ResponseType.bytes, + ), + ); + + // ذخیره فایل + // TODO: پیاده‌سازی ذخیره فایل + // می‌توان از file_picker یا path_provider استفاده کرد + throw UnimplementedError('Export to Excel is not implemented yet'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در دریافت فایل Excel'); + } + rethrow; + } + } + + /// دریافت PDF یک سند + Future downloadPdf(int documentId) async { + try { + final response = await _apiClient.get( + '/documents/$documentId/pdf', + options: Options( + responseType: ResponseType.bytes, + ), + ); + + // ذخیره فایل + // TODO: پیاده‌سازی ذخیره فایل + throw UnimplementedError('PDF download is not implemented yet'); + } catch (e) { + if (e is DioException) { + throw Exception(e.response?.data['message'] ?? 'خطا در دریافت فایل PDF'); + } + rethrow; + } + } + + /// ایجاد سند حسابداری دستی جدید + Future createManualDocument({ + required int businessId, + required CreateManualDocumentRequest request, + }) async { + try { + // اعتبارسنجی درخواست + final validationError = request.validate(); + if (validationError != null) { + throw Exception(validationError); + } + + final response = await _apiClient.post( + '/businesses/$businessId/documents/manual', + data: request.toJson(), + ); + + if (response.data['success'] == true) { + return DocumentModel.fromJson(response.data['data'] as Map); + } + + throw Exception(response.data['message'] ?? 'خطا در ایجاد سند'); + } catch (e) { + if (e is DioException) { + final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور'; + throw Exception(errorMessage); + } + rethrow; + } + } + + /// ویرایش سند حسابداری دستی + Future updateManualDocument({ + required int documentId, + required UpdateManualDocumentRequest request, + }) async { + try { + // اعتبارسنجی درخواست + final validationError = request.validate(); + if (validationError != null) { + throw Exception(validationError); + } + + final response = await _apiClient.put( + '/documents/$documentId', + data: request.toJson(), + ); + + if (response.data['success'] == true) { + return DocumentModel.fromJson(response.data['data'] as Map); + } + + throw Exception(response.data['message'] ?? 'خطا در ویرایش سند'); + } catch (e) { + if (e is DioException) { + final errorMessage = e.response?.data['message'] ?? 'خطا در ارتباط با سرور'; + throw Exception(errorMessage); + } + rethrow; + } + } +} + diff --git a/hesabixUI/hesabix_ui/lib/services/expense_income_list_service.dart b/hesabixUI/hesabix_ui/lib/services/expense_income_list_service.dart new file mode 100644 index 0000000..b6db794 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/expense_income_list_service.dart @@ -0,0 +1,189 @@ +import 'package:dio/dio.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/expense_income_document.dart'; +import 'package:hesabix_ui/models/paginated_response.dart'; + +/// سرویس لیست اسناد هزینه/درآمد +class ExpenseIncomeListService { + final ApiClient _apiClient; + + ExpenseIncomeListService(this._apiClient); + + /// دریافت لیست اسناد هزینه/درآمد + Future> getList({ + required int businessId, + int page = 1, + int pageSize = 20, + String? documentType, + DateTime? fromDate, + DateTime? toDate, + String? search, + String? sortBy = 'document_date', + bool sortDesc = true, + }) async { + try { + final queryInfo = { + 'take': pageSize, + 'skip': (page - 1) * pageSize, + 'sort_by': sortBy, + 'sort_desc': sortDesc, + 'search': search, + 'document_type': documentType, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + + final response = await _apiClient.post( + '/businesses/$businessId/expense-income', + data: queryInfo, + ); + + final data = response.data['data']; + final items = (data['items'] as List) + .map((item) => ExpenseIncomeDocument.fromJson(item)) + .toList(); + + final pagination = data['pagination'] as Map; + + return PaginatedResponse( + items: items, + total: pagination['total'] as int, + page: pagination['page'] as int, + perPage: pagination['per_page'] as int, + totalPages: pagination['total_pages'] as int, + hasNext: pagination['has_next'] as bool, + hasPrev: pagination['has_prev'] as bool, + ); + } catch (e) { + throw _handleError(e); + } + } + + /// دریافت جزئیات یک سند + Future getById(int documentId) async { + try { + final response = await _apiClient.get('/expense-income/$documentId'); + final data = response.data['data']; + return ExpenseIncomeDocument.fromJson(data); + } catch (e) { + if (e is DioException && e.response?.statusCode == 404) { + return null; + } + throw _handleError(e); + } + } + + /// حذف یک سند + Future delete(int documentId) async { + try { + await _apiClient.delete('/expense-income/$documentId'); + return true; + } catch (e) { + throw _handleError(e); + } + } + + /// حذف چندین سند + Future deleteMultiple(List documentIds) async { + try { + await _apiClient.post('/expense-income/bulk-delete', data: { + 'document_ids': documentIds, + }); + return true; + } catch (e) { + throw _handleError(e); + } + } + + /// دریافت فایل Excel + Future> exportExcel({ + required int businessId, + String? documentType, + DateTime? fromDate, + DateTime? toDate, + }) async { + try { + final params = { + 'business_id': businessId, + if (documentType != null) 'document_type': documentType, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + + return await _apiClient.downloadExcel( + '/businesses/$businessId/expense-income/export/excel', + params: params, + ); + } catch (e) { + throw _handleError(e); + } + } + + /// دریافت فایل PDF + Future> exportPdf({ + required int businessId, + String? documentType, + DateTime? fromDate, + DateTime? toDate, + }) async { + try { + // برای PDF از query parameters استفاده می‌کنیم + final queryParams = { + 'business_id': businessId, + if (documentType != null) 'document_type': documentType, + if (fromDate != null) 'from_date': fromDate.toUtc().toIso8601String(), + if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), + }; + + final response = await _apiClient.get>( + '/businesses/$businessId/expense-income/export/pdf', + query: queryParams, + responseType: ResponseType.bytes, + options: Options( + headers: { + 'Accept': 'application/pdf', + }, + ), + ); + return response.data ?? []; + } catch (e) { + throw _handleError(e); + } + } + + Exception _handleError(dynamic error) { + if (error is DioException) { + final response = error.response; + if (response != null) { + final data = response.data; + if (data is Map) { + final message = data['message'] ?? data['detail'] ?? error.message; + return Exception(message); + } + } + return Exception(error.message ?? 'خطا در ارتباط با سرور'); + } + return Exception(error.toString()); + } +} + +/// پاسخ صفحه‌بندی شده +class PaginatedResponse { + final List items; + final int total; + final int page; + final int perPage; + final int totalPages; + final bool hasNext; + final bool hasPrev; + + const PaginatedResponse({ + required this.items, + required this.total, + required this.page, + required this.perPage, + required this.totalPages, + required this.hasNext, + required this.hasPrev, + }); +} diff --git a/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart b/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart index e105534..4851645 100644 --- a/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/expense_income_service.dart @@ -1,60 +1,214 @@ +import 'package:dio/dio.dart'; import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/expense_income_document.dart'; +/// سرویس CRUD اسناد هزینه/درآمد class ExpenseIncomeService { - final ApiClient api; - ExpenseIncomeService(this.api); + final ApiClient _apiClient; - Future> create({ + ExpenseIncomeService(this._apiClient); + + /// ایجاد سند هزینه/درآمد جدید + Future create({ required int businessId, - required String documentType, // 'expense' | 'income' + required String documentType, required DateTime documentDate, required int currencyId, + required List itemLines, + required List counterpartyLines, String? description, - List> itemLines = const [], - List> counterpartyLines = const [], + Map? extraInfo, }) async { - final body = { - 'document_type': documentType, - 'document_date': documentDate.toIso8601String(), - 'currency_id': currencyId, - if (description != null && description.isNotEmpty) 'description': description, - 'item_lines': itemLines, - 'counterparty_lines': counterpartyLines, - }; - final res = await api.post>( - '/api/v1/businesses/$businessId/expense-income/create', - data: body, - ); - return res.data ?? {}; + try { + // تبدیل itemLines به فرمت API + final itemLinesData = itemLines.map((line) => { + 'account_id': line.accountId, + 'amount': line.amount, + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + }).toList(); + + // تبدیل counterpartyLines به فرمت API + final counterpartyLinesData = counterpartyLines.map((line) { + final data = { + 'transaction_type': line.transactionType.value, + 'amount': line.amount, + 'transaction_date': line.transactionDate.toIso8601String(), + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + if (line.commission != null && line.commission! > 0) + 'commission': line.commission, + }; + + // اضافه کردن فیلدهای خاص بر اساس نوع تراکنش + switch (line.transactionType) { + case TransactionType.bank: + if (line.bankAccountId != null) { + data['bank_account_id'] = line.bankAccountId; + data['bank_account_name'] = line.bankAccountName; + } + break; + case TransactionType.cashRegister: + if (line.cashRegisterId != null) { + data['cash_register_id'] = line.cashRegisterId; + data['cash_register_name'] = line.cashRegisterName; + } + break; + case TransactionType.pettyCash: + if (line.pettyCashId != null) { + data['petty_cash_id'] = line.pettyCashId; + data['petty_cash_name'] = line.pettyCashName; + } + break; + case TransactionType.check: + if (line.checkId != null) { + data['check_id'] = line.checkId; + data['check_number'] = line.checkNumber; + } + break; + case TransactionType.person: + if (line.personId != null) { + data['person_id'] = line.personId; + data['person_name'] = line.personName; + } + break; + } + + return data; + }).toList(); + + final requestData = { + 'document_type': documentType, + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + if (description != null && description.isNotEmpty) 'description': description, + 'item_lines': itemLinesData, + 'counterparty_lines': counterpartyLinesData, + if (extraInfo != null) 'extra_info': extraInfo, + }; + + final response = await _apiClient.post( + '/businesses/$businessId/expense-income/create', + data: requestData, + ); + + final data = response.data['data']; + return ExpenseIncomeDocument.fromJson(data); + } catch (e) { + throw _handleError(e); + } } - Future> list({ - required int businessId, - String? documentType, // 'expense' | 'income' - DateTime? fromDate, - DateTime? toDate, - int skip = 0, - int take = 20, - String? search, - String? sortBy, - bool sortDesc = true, + /// ویرایش سند هزینه/درآمد + Future update({ + required int documentId, + required DateTime documentDate, + required int currencyId, + required List itemLines, + required List counterpartyLines, + String? description, + Map? extraInfo, }) 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.toUtc().toIso8601String(), - if (toDate != null) 'to_date': toDate.toUtc().toIso8601String(), - }; - final res = await api.post>( - '/api/v1/businesses/$businessId/expense-income', - data: body, - ); - return res.data ?? {}; + try { + // تبدیل itemLines به فرمت API + final itemLinesData = itemLines.map((line) => { + 'account_id': line.accountId, + 'amount': line.amount, + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + }).toList(); + + // تبدیل counterpartyLines به فرمت API + final counterpartyLinesData = counterpartyLines.map((line) { + final data = { + 'transaction_type': line.transactionType.value, + 'amount': line.amount, + 'transaction_date': line.transactionDate.toIso8601String(), + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + if (line.commission != null && line.commission! > 0) + 'commission': line.commission, + }; + + // اضافه کردن فیلدهای خاص بر اساس نوع تراکنش + switch (line.transactionType) { + case TransactionType.bank: + if (line.bankAccountId != null) { + data['bank_account_id'] = line.bankAccountId; + data['bank_account_name'] = line.bankAccountName; + } + break; + case TransactionType.cashRegister: + if (line.cashRegisterId != null) { + data['cash_register_id'] = line.cashRegisterId; + data['cash_register_name'] = line.cashRegisterName; + } + break; + case TransactionType.pettyCash: + if (line.pettyCashId != null) { + data['petty_cash_id'] = line.pettyCashId; + data['petty_cash_name'] = line.pettyCashName; + } + break; + case TransactionType.check: + if (line.checkId != null) { + data['check_id'] = line.checkId; + data['check_number'] = line.checkNumber; + } + break; + case TransactionType.person: + if (line.personId != null) { + data['person_id'] = line.personId; + data['person_name'] = line.personName; + } + break; + } + + return data; + }).toList(); + + final requestData = { + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + if (description != null && description.isNotEmpty) 'description': description, + 'item_lines': itemLinesData, + 'counterparty_lines': counterpartyLinesData, + if (extraInfo != null) 'extra_info': extraInfo, + }; + + final response = await _apiClient.put( + '/expense-income/$documentId', + data: requestData, + ); + + final data = response.data['data']; + return ExpenseIncomeDocument.fromJson(data); + } catch (e) { + throw _handleError(e); + } } -} + /// دریافت فایل PDF یک سند + Future> generatePdf(int documentId) async { + try { + return await _apiClient.downloadPdf('/expense-income/$documentId/pdf'); + } catch (e) { + throw _handleError(e); + } + } + Exception _handleError(dynamic error) { + if (error is DioException) { + final response = error.response; + if (response != null) { + final data = response.data; + if (data is Map) { + final message = data['message'] ?? data['detail'] ?? error.message; + return Exception(message); + } + } + return Exception(error.message ?? 'خطا در ارتباط با سرور'); + } + return Exception(error.toString()); + } +} \ No newline at end of file diff --git a/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart b/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart new file mode 100644 index 0000000..d614436 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/services/warehouse_service.dart @@ -0,0 +1,39 @@ +import '../core/api_client.dart'; +import '../models/warehouse_model.dart'; + +class WarehouseService { + final ApiClient _api; + WarehouseService({ApiClient? apiClient}) : _api = apiClient ?? ApiClient(); + + Future> listWarehouses({required int businessId}) async { + final res = await _api.get>('/api/v1/warehouses/business/$businessId'); + final data = res.data?['data'] as Map? ?? {}; + final items = data['items'] as List? ?? const []; + return items.map((e) => Warehouse.fromJson(Map.from(e as Map))).toList(); + } + + Future createWarehouse({required int businessId, required Map payload}) async { + final res = await _api.post>('/api/v1/warehouses/business/$businessId', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return Warehouse.fromJson(data); + } + + Future getWarehouse({required int businessId, required int warehouseId}) async { + final res = await _api.get>('/api/v1/warehouses/business/$businessId/$warehouseId'); + final data = (res.data?['data']?['item'] as Map? ?? {}); + return Warehouse.fromJson(data); + } + + Future updateWarehouse({required int businessId, required int warehouseId, required Map payload}) async { + final res = await _api.put>('/api/v1/warehouses/business/$businessId/$warehouseId', data: payload); + final data = (res.data?['data'] as Map? ?? {}); + return Warehouse.fromJson(data); + } + + Future deleteWarehouse({required int businessId, required int warehouseId}) async { + final res = await _api.delete>('/api/v1/warehouses/business/$businessId/$warehouseId'); + return res.statusCode == 200 && (res.data?['data']?['deleted'] == true); + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/detail_selector_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/document/detail_selector_widget.dart new file mode 100644 index 0000000..6bb7883 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/document/detail_selector_widget.dart @@ -0,0 +1,1002 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/account_model.dart'; +import 'package:hesabix_ui/models/person_model.dart'; +import 'package:hesabix_ui/models/product_model.dart'; +import 'package:hesabix_ui/models/bank_account_model.dart'; +import 'package:hesabix_ui/models/cash_register.dart'; +import 'package:hesabix_ui/models/petty_cash.dart'; +import 'package:hesabix_ui/services/person_service.dart'; +import 'package:hesabix_ui/services/product_service.dart'; +import 'package:hesabix_ui/services/bank_account_service.dart'; +import 'package:hesabix_ui/services/cash_register_service.dart'; +import 'package:hesabix_ui/services/petty_cash_service.dart'; +import 'package:hesabix_ui/services/check_service.dart'; + +/// ویجت دینامیک برای انتخاب تفضیل بر اساس نوع حساب +/// +/// این ویجت بر اساس نوع حساب انتخاب شده، ویجت انتخاب مناسب را نمایش می‌دهد: +/// - person: انتخاب شخص +/// - product: انتخاب کالا +/// - bank_account: انتخاب حساب بانکی +/// - cash_register: انتخاب صندوق +/// - petty_cash: انتخاب تنخواه +/// - check: انتخاب چک +class DetailSelectorWidget extends StatefulWidget { + final Account? selectedAccount; + final int businessId; + final String? detailType; // person, product, bank_account, etc. + final int? selectedDetailId; + final ValueChanged?> onChanged; + final String label; + final bool isRequired; + + const DetailSelectorWidget({ + super.key, + this.selectedAccount, + required this.businessId, + this.detailType, + this.selectedDetailId, + required this.onChanged, + this.label = 'تفضیل', + this.isRequired = false, + }); + + @override + State createState() => _DetailSelectorWidgetState(); +} + +class _DetailSelectorWidgetState extends State { + final TextEditingController _controller = TextEditingController(); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _loadSelectedItem(); + } + + @override + void didUpdateWidget(DetailSelectorWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selectedDetailId != oldWidget.selectedDetailId || + widget.detailType != oldWidget.detailType) { + _loadSelectedItem(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// بارگذاری آیتم انتخاب شده + Future _loadSelectedItem() async { + if (widget.selectedDetailId == null || widget.detailType == null) { + setState(() { + _controller.text = ''; + }); + return; + } + + setState(() => _isLoading = true); + + try { + // بارگذاری بر اساس نوع تفضیل + switch (widget.detailType) { + case 'person': + final service = PersonService(); + final person = await service.getPerson(widget.selectedDetailId!); + _controller.text = person.aliasName; + break; + + case 'product': + final service = ProductService(); + final productData = await service.getProduct( + businessId: widget.businessId, + productId: widget.selectedDetailId!, + ); + final product = Product.fromJson(productData); + _controller.text = product.displayName; + break; + + case 'bank_account': + final service = BankAccountService(); + final bankAccount = await service.getById(widget.selectedDetailId!); + _controller.text = bankAccount.name; + break; + + case 'cash_register': + final service = CashRegisterService(); + final cashRegister = await service.getById(widget.selectedDetailId!); + _controller.text = cashRegister.name; + break; + + case 'petty_cash': + final service = PettyCashService(); + final pettyCash = await service.getById(widget.selectedDetailId!); + _controller.text = pettyCash.name; + break; + + case 'check': + final service = CheckService(); + final checkData = await service.getById(widget.selectedDetailId!); + _controller.text = 'چک شماره ${checkData['check_number'] ?? widget.selectedDetailId}'; + break; + + default: + _controller.text = 'ID: ${widget.selectedDetailId}'; + } + } catch (e) { + print('خطا در بارگذاری تفضیل: $e'); + _controller.text = ''; + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + // اگر حساب انتخاب نشده یا نیاز به تفضیل ندارد + if (widget.selectedAccount == null || widget.detailType == null) { + return TextFormField( + enabled: false, + decoration: InputDecoration( + labelText: widget.label, + hintText: 'ابتدا حساب را انتخاب کنید', + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.lock), + ), + ); + } + + return TextFormField( + controller: _controller, + readOnly: true, + decoration: InputDecoration( + labelText: '${widget.label} (${_getDetailTypeLabel()})', + hintText: 'انتخاب ${_getDetailTypeLabel()}', + border: const OutlineInputBorder(), + suffixIcon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.search), + ), + validator: widget.isRequired + ? (value) { + if (value == null || value.isEmpty) { + return '${_getDetailTypeLabel()} الزامی است'; + } + return null; + } + : null, + onTap: () => _showSelectionDialog(), + ); + } + + /// نمایش دیالوگ انتخاب بر اساس نوع تفضیل + Future _showSelectionDialog() async { + switch (widget.detailType) { + case 'person': + await _showPersonDialog(); + break; + case 'product': + await _showProductDialog(); + break; + case 'bank_account': + await _showBankAccountDialog(); + break; + case 'cash_register': + await _showCashRegisterDialog(); + break; + case 'petty_cash': + await _showPettyCashDialog(); + break; + case 'check': + await _showCheckDialog(); + break; + default: + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('انتخاب ${_getDetailTypeLabel()} هنوز پیاده‌سازی نشده است'), + ), + ); + } + } + } + + /// دیالوگ انتخاب شخص + Future _showPersonDialog() async { + final service = PersonService(); + + try { + setState(() => _isLoading = true); + final response = await service.getPersons( + businessId: widget.businessId, + page: 1, + limit: 100, + ); + + if (!mounted) return; + + final personsData = response['items'] as List; + final persons = personsData + .map((json) => Person.fromJson(json as Map)) + .toList(); + + final selected = await showDialog( + context: context, + builder: (context) => _PersonSelectionDialog(persons: persons), + ); + + if (selected != null) { + setState(() { + _controller.text = selected.aliasName; + }); + + widget.onChanged({ + 'person_id': selected.id, + 'person_name': selected.aliasName, + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری اشخاص: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دیالوگ انتخاب کالا + Future _showProductDialog() async { + final service = ProductService(); + + try { + setState(() => _isLoading = true); + final productsData = await service.searchProducts( + businessId: widget.businessId, + limit: 100, + ); + + if (!mounted) return; + + final products = productsData + .map((json) => Product.fromJson(json)) + .toList(); + + final selected = await showDialog( + context: context, + builder: (context) => _ProductSelectionDialog(products: products), + ); + + if (selected != null) { + setState(() { + _controller.text = selected.displayName; + }); + + widget.onChanged({ + 'product_id': selected.id, + 'product_name': selected.name, + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری کالاها: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دیالوگ انتخاب حساب بانکی + Future _showBankAccountDialog() async { + final service = BankAccountService(); + + try { + setState(() => _isLoading = true); + final response = await service.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + + if (!mounted) return; + + // دسترسی به data.items + final dataMap = response['data'] as Map?; + final accountsData = (dataMap?['items'] ?? []) as List; + final accounts = accountsData + .map((json) => BankAccount.fromJson(json as Map)) + .toList(); + + final selected = await showDialog( + context: context, + builder: (context) => _BankAccountSelectionDialog(accounts: accounts), + ); + + if (selected != null) { + setState(() { + _controller.text = selected.name; + }); + + widget.onChanged({ + 'bank_account_id': selected.id, + 'bank_account_name': selected.name, + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری حساب‌های بانکی: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دیالوگ انتخاب صندوق + Future _showCashRegisterDialog() async { + final service = CashRegisterService(); + + try { + setState(() => _isLoading = true); + final response = await service.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + + if (!mounted) return; + + // دسترسی به data.items + final dataMap = response['data'] as Map?; + final registersData = (dataMap?['items'] ?? []) as List; + final registers = registersData + .map((json) => CashRegister.fromJson(json as Map)) + .toList(); + + final selected = await showDialog( + context: context, + builder: (context) => _CashRegisterSelectionDialog(registers: registers), + ); + + if (selected != null) { + setState(() { + _controller.text = selected.name; + }); + + widget.onChanged({ + 'cash_register_id': selected.id, + 'cash_register_name': selected.name, + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری صندوق‌ها: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دیالوگ انتخاب تنخواه + Future _showPettyCashDialog() async { + final service = PettyCashService(); + + try { + setState(() => _isLoading = true); + final response = await service.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + + if (!mounted) return; + + // دسترسی به data.items + final dataMap = response['data'] as Map?; + final cashesData = (dataMap?['items'] ?? []) as List; + final cashes = cashesData + .map((json) => PettyCash.fromJson(json as Map)) + .toList(); + + final selected = await showDialog( + context: context, + builder: (context) => _PettyCashSelectionDialog(cashes: cashes), + ); + + if (selected != null) { + setState(() { + _controller.text = selected.name; + }); + + widget.onChanged({ + 'petty_cash_id': selected.id, + 'petty_cash_name': selected.name, + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری تنخواه‌ها: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دیالوگ انتخاب چک + Future _showCheckDialog() async { + final service = CheckService(); + + try { + setState(() => _isLoading = true); + final response = await service.list( + businessId: widget.businessId, + queryInfo: {'take': 100, 'skip': 0}, + ); + + if (!mounted) return; + + // دسترسی به data.items + final dataMap = response['data'] as Map?; + final checksData = (dataMap?['items'] ?? []) as List; + final checks = checksData + .map((json) => Map.from(json as Map)) + .toList(); + + final selected = await showDialog>( + context: context, + builder: (context) => _CheckSelectionDialog(checks: checks), + ); + + if (selected != null) { + setState(() { + _controller.text = 'چک شماره ${selected['check_number'] ?? selected['id']}'; + }); + + widget.onChanged({ + 'check_id': selected['id'], + 'check_number': selected['check_number'], + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('خطا در بارگذاری چک‌ها: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// دریافت برچسب فارسی نوع تفضیل + String _getDetailTypeLabel() { + switch (widget.detailType) { + case 'person': + return 'شخص'; + case 'product': + return 'کالا'; + case 'bank_account': + return 'حساب بانکی'; + case 'cash_register': + return 'صندوق'; + case 'petty_cash': + return 'تنخواه'; + case 'check': + return 'چک'; + default: + return 'تفصیل'; + } + } +} + + +/// دیالوگ انتخاب شخص +class _PersonSelectionDialog extends StatefulWidget { + final List persons; + + const _PersonSelectionDialog({required this.persons}); + + @override + State<_PersonSelectionDialog> createState() => _PersonSelectionDialogState(); +} + +class _PersonSelectionDialogState extends State<_PersonSelectionDialog> { + List _filteredPersons = []; + + @override + void initState() { + super.initState(); + _filteredPersons = widget.persons; + } + + void _filterPersons(String query) { + setState(() { + if (query.isEmpty) { + _filteredPersons = widget.persons; + } else { + _filteredPersons = widget.persons + .where((person) => + person.aliasName.toLowerCase().contains(query.toLowerCase()) || + (person.code?.toString().toLowerCase().contains(query.toLowerCase()) ?? false)) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب شخص', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterPersons, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredPersons.length, + itemBuilder: (context, index) { + final person = _filteredPersons[index]; + return ListTile( + title: Text(person.aliasName), + subtitle: Text('کد: ${person.code ?? "-"}'), + onTap: () => Navigator.pop(context, person), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} + +/// دیالوگ انتخاب کالا +class _ProductSelectionDialog extends StatefulWidget { + final List products; + + const _ProductSelectionDialog({required this.products}); + + @override + State<_ProductSelectionDialog> createState() => _ProductSelectionDialogState(); +} + +class _ProductSelectionDialogState extends State<_ProductSelectionDialog> { + List _filteredProducts = []; + + @override + void initState() { + super.initState(); + _filteredProducts = widget.products; + } + + void _filterProducts(String query) { + setState(() { + if (query.isEmpty) { + _filteredProducts = widget.products; + } else { + _filteredProducts = widget.products + .where((product) => + product.name.toLowerCase().contains(query.toLowerCase()) || + (product.code?.toLowerCase().contains(query.toLowerCase()) ?? false)) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب کالا', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterProducts, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredProducts.length, + itemBuilder: (context, index) { + final product = _filteredProducts[index]; + return ListTile( + title: Text(product.name), + subtitle: Text('کد: ${product.code ?? "-"}'), + onTap: () => Navigator.pop(context, product), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} + +/// دیالوگ انتخاب حساب بانکی +class _BankAccountSelectionDialog extends StatefulWidget { + final List accounts; + + const _BankAccountSelectionDialog({required this.accounts}); + + @override + State<_BankAccountSelectionDialog> createState() => _BankAccountSelectionDialogState(); +} + +class _BankAccountSelectionDialogState extends State<_BankAccountSelectionDialog> { + List _filteredAccounts = []; + + @override + void initState() { + super.initState(); + _filteredAccounts = widget.accounts; + } + + void _filterAccounts(String query) { + setState(() { + if (query.isEmpty) { + _filteredAccounts = widget.accounts; + } else { + _filteredAccounts = widget.accounts + .where((acc) => + acc.name.toLowerCase().contains(query.toLowerCase()) || + (acc.accountNumber?.toLowerCase().contains(query.toLowerCase()) ?? false)) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب حساب بانکی', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterAccounts, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredAccounts.length, + itemBuilder: (context, index) { + final account = _filteredAccounts[index]; + return ListTile( + title: Text(account.name), + subtitle: Text('شماره حساب: ${account.accountNumber ?? "-"}'), + onTap: () => Navigator.pop(context, account), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} + +/// دیالوگ انتخاب صندوق +class _CashRegisterSelectionDialog extends StatefulWidget { + final List registers; + + const _CashRegisterSelectionDialog({required this.registers}); + + @override + State<_CashRegisterSelectionDialog> createState() => _CashRegisterSelectionDialogState(); +} + +class _CashRegisterSelectionDialogState extends State<_CashRegisterSelectionDialog> { + List _filteredRegisters = []; + + @override + void initState() { + super.initState(); + _filteredRegisters = widget.registers; + } + + void _filterRegisters(String query) { + setState(() { + if (query.isEmpty) { + _filteredRegisters = widget.registers; + } else { + _filteredRegisters = widget.registers + .where((reg) => + reg.name.toLowerCase().contains(query.toLowerCase()) || + (reg.code?.toLowerCase().contains(query.toLowerCase()) ?? false)) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب صندوق', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterRegisters, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredRegisters.length, + itemBuilder: (context, index) { + final register = _filteredRegisters[index]; + return ListTile( + title: Text(register.name), + subtitle: Text('کد: ${register.code ?? "-"}'), + onTap: () => Navigator.pop(context, register), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} + +/// دیالوگ انتخاب تنخواه +class _PettyCashSelectionDialog extends StatefulWidget { + final List cashes; + + const _PettyCashSelectionDialog({required this.cashes}); + + @override + State<_PettyCashSelectionDialog> createState() => _PettyCashSelectionDialogState(); +} + +class _PettyCashSelectionDialogState extends State<_PettyCashSelectionDialog> { + List _filteredCashes = []; + + @override + void initState() { + super.initState(); + _filteredCashes = widget.cashes; + } + + void _filterCashes(String query) { + setState(() { + if (query.isEmpty) { + _filteredCashes = widget.cashes; + } else { + _filteredCashes = widget.cashes + .where((cash) => + cash.name.toLowerCase().contains(query.toLowerCase()) || + (cash.code?.toLowerCase().contains(query.toLowerCase()) ?? false)) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب تنخواه', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterCashes, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredCashes.length, + itemBuilder: (context, index) { + final cash = _filteredCashes[index]; + return ListTile( + title: Text(cash.name), + subtitle: Text('کد: ${cash.code ?? "-"}'), + onTap: () => Navigator.pop(context, cash), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} + +/// دیالوگ انتخاب چک +class _CheckSelectionDialog extends StatefulWidget { + final List> checks; + + const _CheckSelectionDialog({required this.checks}); + + @override + State<_CheckSelectionDialog> createState() => _CheckSelectionDialogState(); +} + +class _CheckSelectionDialogState extends State<_CheckSelectionDialog> { + List> _filteredChecks = []; + + @override + void initState() { + super.initState(); + _filteredChecks = widget.checks; + } + + void _filterChecks(String query) { + setState(() { + if (query.isEmpty) { + _filteredChecks = widget.checks; + } else { + _filteredChecks = widget.checks + .where((check) { + final checkNumber = check['check_number']?.toString() ?? ''; + return checkNumber.toLowerCase().contains(query.toLowerCase()); + }) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: 500, + height: 600, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'انتخاب چک', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'جستجو بر اساس شماره چک', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + ), + onChanged: _filterChecks, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: _filteredChecks.length, + itemBuilder: (context, index) { + final check = _filteredChecks[index]; + return ListTile( + title: Text('چک شماره ${check['check_number'] ?? check['id']}'), + subtitle: Text('مبلغ: ${check['amount'] ?? "-"}'), + onTap: () => Navigator.pop(context, check), + ); + }, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart new file mode 100644 index 0000000..ec9b02d --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/document/document_details_dialog.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/models/document_model.dart'; +import 'package:hesabix_ui/services/document_service.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; + +/// دیالوگ نمایش جزئیات کامل سند حسابداری +class DocumentDetailsDialog extends StatefulWidget { + final int documentId; + final CalendarController calendarController; + + const DocumentDetailsDialog({ + super.key, + required this.documentId, + required this.calendarController, + }); + + @override + State createState() => _DocumentDetailsDialogState(); +} + +class _DocumentDetailsDialogState extends State { + late DocumentService _service; + DocumentModel? _document; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _service = DocumentService(ApiClient()); + _loadDocument(); + } + + Future _loadDocument() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final doc = await _service.getDocument(widget.documentId); + if (mounted) { + setState(() { + _document = doc; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = e.toString(); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.85, + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + children: [ + // هدر + _buildHeader(theme), + + // محتوای اصلی + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _errorMessage != null + ? _buildError() + : _buildContent(theme), + ), + + // فوتر + _buildFooter(), + ], + ), + ), + ); + } + + /// ساخت هدر دیالوگ + Widget _buildHeader(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + child: Row( + children: [ + Icon( + Icons.description, + size: 28, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'جزئیات سند ${_document?.code ?? ''}', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + if (_document != null) + Text( + _document!.getDocumentTypeName(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + color: theme.colorScheme.onPrimaryContainer, + ), + ], + ), + ); + } + + /// ساخت پیام خطا + Widget _buildError() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + 'خطا در بارگذاری سند', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? 'خطای نامشخص', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadDocument, + icon: const Icon(Icons.refresh), + label: const Text('تلاش مجدد'), + ), + ], + ), + ); + } + + /// ساخت محتوای اصلی + Widget _buildContent(ThemeData theme) { + if (_document == null) return const SizedBox(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // اطلاعات هدر سند + _buildDocumentHeader(theme), + + const SizedBox(height: 24), + + // جدول سطرهای سند + _buildLinesTable(theme), + + const SizedBox(height: 16), + + // جمع کل + _buildTotals(theme), + ], + ), + ); + } + + /// ساخت اطلاعات هدر سند + Widget _buildDocumentHeader(ThemeData theme) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات سند', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + const SizedBox(height: 8), + _buildInfoRow('شماره سند:', _document!.code), + _buildInfoRow('نوع سند:', _document!.getDocumentTypeName()), + _buildInfoRow('تاریخ سند:', _document!.documentDateRaw ?? '-'), + _buildInfoRow('سال مالی:', _document!.fiscalYearTitle ?? '-'), + _buildInfoRow('ارز:', _document!.currencyCode ?? '-'), + _buildInfoRow('وضعیت:', _document!.statusText), + _buildInfoRow('ایجادکننده:', _document!.createdByName ?? '-'), + if (_document!.description != null && _document!.description!.isNotEmpty) + _buildInfoRow('توضیحات:', _document!.description!), + ], + ), + ), + ); + } + + /// ساخت یک ردیف اطلاعات + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } + + /// ساخت جدول سطرهای سند + Widget _buildLinesTable(ThemeData theme) { + final lines = _document!.lines ?? []; + + if (lines.isEmpty) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + 'هیچ سطری برای این سند ثبت نشده است', + style: theme.textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ), + ); + } + + return Card( + elevation: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'سطرهای سند', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(height: 1), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowColor: WidgetStateProperty.all( + theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + columns: const [ + DataColumn(label: Text('ردیف', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('حساب', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('طرف‌حساب', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('بدهکار', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('بستانکار', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn(label: Text('توضیحات', style: TextStyle(fontWeight: FontWeight.bold))), + ], + rows: lines.asMap().entries.map((entry) { + final index = entry.key; + final line = entry.value; + return DataRow( + cells: [ + DataCell(Text('${index + 1}')), + DataCell( + SizedBox( + width: 200, + child: Text( + line.fullAccountName, + overflow: TextOverflow.ellipsis, + ), + ), + ), + DataCell( + SizedBox( + width: 150, + child: Text( + line.counterpartyName ?? '-', + overflow: TextOverflow.ellipsis, + ), + ), + ), + DataCell( + Text( + line.debit > 0 ? formatWithThousands(line.debit.toInt()) : '-', + style: const TextStyle( + fontFamily: 'monospace', + color: Colors.red, + ), + textDirection: TextDirection.ltr, + ), + ), + DataCell( + Text( + line.credit > 0 ? formatWithThousands(line.credit.toInt()) : '-', + style: const TextStyle( + fontFamily: 'monospace', + color: Colors.green, + ), + textDirection: TextDirection.ltr, + ), + ), + DataCell( + SizedBox( + width: 200, + child: Text( + line.description ?? '-', + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + ), + ], + ); + }).toList(), + ), + ), + ], + ), + ); + } + + /// ساخت بخش جمع کل + Widget _buildTotals(ThemeData theme) { + return Card( + elevation: 2, + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildTotalItem( + 'جمع بدهکار', + formatWithThousands(_document!.totalDebit.toInt()), + Colors.red, + ), + Container( + width: 2, + height: 40, + color: theme.dividerColor, + ), + _buildTotalItem( + 'جمع بستانکار', + formatWithThousands(_document!.totalCredit.toInt()), + Colors.green, + ), + Container( + width: 2, + height: 40, + color: theme.dividerColor, + ), + _buildTotalItem( + 'تعداد سطرها', + '${_document!.linesCount}', + theme.colorScheme.primary, + ), + ], + ), + ), + ); + } + + /// ساخت یک آیتم از جمع کل + Widget _buildTotalItem(String label, String value, Color color) { + return Column( + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: color, + fontFamily: 'monospace', + ), + textDirection: TextDirection.ltr, + ), + ], + ); + } + + /// ساخت فوتر دیالوگ + Widget _buildFooter() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // دکمه چاپ PDF + OutlinedButton.icon( + onPressed: () { + // TODO: پیاده‌سازی چاپ PDF + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('چاپ PDF در حال پیاده‌سازی است')), + ); + }, + icon: const Icon(Icons.picture_as_pdf), + label: const Text('چاپ PDF'), + ), + const SizedBox(width: 12), + // دکمه بستن + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('بستن'), + ), + ], + ), + ); + } +} + diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart new file mode 100644 index 0000000..c1c86b4 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/document/document_form_dialog.dart @@ -0,0 +1,531 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/core/auth_store.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/models/document_model.dart'; +import 'package:hesabix_ui/models/account_model.dart'; +import 'package:hesabix_ui/services/document_service.dart'; +import 'package:hesabix_ui/services/account_service.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/widgets/document/document_line_editor.dart'; + +/// دیالوگ ایجاد یا ویرایش سند حسابداری دستی +class DocumentFormDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final AuthStore authStore; + final ApiClient apiClient; + final DocumentModel? document; // null = ایجاد جدید, not null = ویرایش + final int? fiscalYearId; + final int? currencyId; + + const DocumentFormDialog({ + super.key, + required this.businessId, + required this.calendarController, + required this.authStore, + required this.apiClient, + this.document, + this.fiscalYearId, + this.currencyId, + }); + + @override + State createState() => _DocumentFormDialogState(); +} + +class _DocumentFormDialogState extends State { + final _formKey = GlobalKey(); + late DocumentService _service; + + // کنترلرها + final TextEditingController _codeController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + // مقادیر فرم + DateTime? _documentDate; + int? _currencyId; + bool _isProforma = false; + List _lines = []; + + // وضعیت + bool _isLoading = false; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _service = DocumentService(widget.apiClient); + _currencyId = widget.currencyId ?? 1; // پیش‌فرض ریال + _documentDate = DateTime.now(); + + // اگر حالت ویرایش است، مقادیر را بارگذاری کن + if (widget.document != null) { + _loadDocumentData(); + } else { + // خط خالی برای شروع + _lines = [ + DocumentLineEdit(), + DocumentLineEdit(), + ]; + } + } + + /// بارگذاری اطلاعات سند برای ویرایش + Future _loadDocumentData() async { + final doc = widget.document!; + + _codeController.text = doc.code; + _descriptionController.text = doc.description ?? ''; + _documentDate = doc.documentDate; + _currencyId = doc.currencyId; + _isProforma = doc.isProforma; + + // تبدیل سطرهای سند به DocumentLineEdit + if (doc.lines != null && doc.lines!.isNotEmpty) { + // بارگذاری Account برای هر سطر + final accountService = AccountService(client: widget.apiClient); + final loadedLines = []; + + setState(() { + _isLoading = true; + }); + + try { + for (final line in doc.lines!) { + Account? account; + try { + // بارگذاری حساب از API + if (line.accountId != null) { + final accountData = await accountService.getAccount( + businessId: widget.businessId, + accountId: line.accountId!, + ); + account = Account.fromJson(accountData); + } + } catch (e) { + print('خطا در بارگذاری حساب ${line.accountId}: $e'); + // در صورت خطا، یک حساب خالی با ID می‌سازیم + account = Account( + id: line.accountId, + businessId: widget.businessId, + code: 'خطا', + name: 'خطا در بارگذاری', + accountType: 'asset', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + } + + loadedLines.add(DocumentLineEdit( + account: account, + detail: { + if (line.personId != null) 'person_id': line.personId, + if (line.productId != null) 'product_id': line.productId, + if (line.bankAccountId != null) 'bank_account_id': line.bankAccountId, + if (line.cashRegisterId != null) 'cash_register_id': line.cashRegisterId, + if (line.pettyCashId != null) 'petty_cash_id': line.pettyCashId, + if (line.checkId != null) 'check_id': line.checkId, + }, + debit: line.debit, + credit: line.credit, + description: line.description, + quantity: line.quantity, + )); + } + + if (mounted) { + setState(() { + _lines = loadedLines; + _isLoading = false; + }); + } + } catch (e) { + print('خطا در بارگذاری سطرهای سند: $e'); + if (mounted) { + setState(() { + _lines = [ + DocumentLineEdit(), + DocumentLineEdit(), + ]; + _isLoading = false; + }); + } + } + } else { + _lines = [ + DocumentLineEdit(), + DocumentLineEdit(), + ]; + } + } + + @override + void dispose() { + _codeController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + /// ذخیره سند + Future _saveDocument() async { + if (!_formKey.currentState!.validate()) { + return; + } + + // بررسی تاریخ + if (_documentDate == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('تاریخ سند الزامی است')), + ); + return; + } + + // بررسی حداقل 2 سطر + if (_lines.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند باید حداقل 2 سطر داشته باشد')), + ); + return; + } + + // بررسی اینکه تمام سطرها حساب داشته باشند + for (int i = 0; i < _lines.length; i++) { + if (_lines[i].account == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('سطر ${i + 1} باید حساب داشته باشد')), + ); + return; + } + } + + setState(() => _isSaving = true); + + try { + if (widget.document == null) { + // ایجاد سند جدید + await _createDocument(); + } else { + // ویرایش سند + await _updateDocument(); + } + + if (mounted) { + Navigator.of(context).pop(true); // بازگشت با موفقیت + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.document == null + ? 'سند با موفقیت ایجاد شد' + : 'سند با موفقیت ویرایش شد'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + /// ایجاد سند جدید + Future _createDocument() async { + final request = CreateManualDocumentRequest( + code: _codeController.text.isEmpty ? null : _codeController.text, + documentDate: _documentDate!, + fiscalYearId: widget.fiscalYearId, + currencyId: _currencyId!, + isProforma: _isProforma, + description: _descriptionController.text.isEmpty + ? null + : _descriptionController.text, + lines: _lines.map((line) => line.toRequest()).toList(), + ); + + await _service.createManualDocument( + businessId: widget.businessId, + request: request, + ); + } + + /// ویرایش سند + Future _updateDocument() async { + final request = UpdateManualDocumentRequest( + code: _codeController.text.isEmpty ? null : _codeController.text, + documentDate: _documentDate, + currencyId: _currencyId, + isProforma: _isProforma, + description: _descriptionController.text.isEmpty + ? null + : _descriptionController.text, + lines: _lines.map((line) => line.toRequest()).toList(), + ); + + await _service.updateManualDocument( + documentId: widget.document!.id, + request: request, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEditMode = widget.document != null; + + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.95, + height: MediaQuery.of(context).size.height * 0.95, + constraints: const BoxConstraints(maxWidth: 1400), + child: Column( + children: [ + // هدر + _buildHeader(theme, isEditMode), + + // محتوای فرم + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // بخش اطلاعات هدر سند + _buildHeaderSection(theme), + + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 24), + + // بخش سطرهای سند + DocumentLinesEditor( + businessId: widget.businessId, + initialLines: _lines, + onChanged: (lines) { + setState(() { + _lines = lines; + }); + }, + ), + ], + ), + ), + ), + ), + + // فوتر (دکمه‌های ذخیره و انصراف) + _buildFooter(theme), + ], + ), + ), + ); + } + + /// ساخت هدر دیالوگ + Widget _buildHeader(ThemeData theme, bool isEditMode) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + child: Row( + children: [ + Icon( + isEditMode ? Icons.edit_document : Icons.add_box, + size: 28, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + isEditMode ? 'ویرایش سند حسابداری' : 'ایجاد سند حسابداری جدید', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + color: theme.colorScheme.onPrimaryContainer, + ), + ], + ), + ); + } + + /// ساخت بخش اطلاعات هدر سند + Widget _buildHeaderSection(ThemeData theme) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات سند', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + // شماره سند + Expanded( + flex: 2, + child: TextFormField( + controller: _codeController, + decoration: const InputDecoration( + labelText: 'شماره سند', + hintText: 'خودکار', + border: OutlineInputBorder(), + helperText: 'اختیاری - اگر خالی باشد خودکار تولید می‌شود', + ), + ), + ), + const SizedBox(width: 16), + + // تاریخ سند + Expanded( + flex: 2, + child: DateInputField( + calendarController: widget.calendarController, + value: _documentDate, + onChanged: (date) { + setState(() { + _documentDate = date; + }); + }, + labelText: 'تاریخ سند', + hintText: 'انتخاب تاریخ', + ), + ), + const SizedBox(width: 16), + + // ارز (ساده شده - در آینده از API بیاید) + Expanded( + flex: 2, + child: DropdownButtonFormField( + value: _currencyId, + decoration: const InputDecoration( + labelText: 'ارز', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 1, child: Text('ریال')), + DropdownMenuItem(value: 2, child: Text('دلار')), + DropdownMenuItem(value: 3, child: Text('یورو')), + ], + onChanged: (value) { + setState(() { + _currencyId = value; + }); + }, + ), + ), + const SizedBox(width: 16), + + // چک‌باکس پیش‌فاکتور + Expanded( + flex: 1, + child: CheckboxListTile( + title: const Text('پیش‌فاکتور'), + value: _isProforma, + onChanged: (value) { + setState(() { + _isProforma = value ?? false; + }); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + const SizedBox(height: 16), + + // توضیحات سند + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'توضیحات سند', + hintText: 'توضیحات کلی در مورد این سند...', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + ), + ); + } + + /// ساخت فوتر دیالوگ + Widget _buildFooter(ThemeData theme) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: theme.dividerColor, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // دکمه انصراف + OutlinedButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text('انصراف'), + ), + ), + const SizedBox(width: 12), + + // دکمه ذخیره + ElevatedButton.icon( + onPressed: _isSaving ? null : _saveDocument, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save), + label: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text(_isSaving ? 'در حال ذخیره...' : 'ذخیره سند'), + ), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + ), + ], + ), + ); + } +} + diff --git a/hesabixUI/hesabix_ui/lib/widgets/document/document_line_editor.dart b/hesabixUI/hesabix_ui/lib/widgets/document/document_line_editor.dart new file mode 100644 index 0000000..d4ac9e9 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/document/document_line_editor.dart @@ -0,0 +1,630 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hesabix_ui/models/account_model.dart'; +import 'package:hesabix_ui/models/document_model.dart'; +import 'package:hesabix_ui/widgets/document/detail_selector_widget.dart'; +import 'package:hesabix_ui/widgets/invoice/account_tree_combobox_widget.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart'; + +/// مدل داخلی برای نگهداری اطلاعات یک سطر سند در حین ویرایش +class DocumentLineEdit { + Account? account; + Map? detail; // person_id, product_id, etc. + double debit; + double credit; + String? description; + double? quantity; + + DocumentLineEdit({ + this.account, + this.detail, + this.debit = 0, + this.credit = 0, + this.description, + this.quantity, + }); + + /// تبدیل به DocumentLineCreateRequest + DocumentLineCreateRequest toRequest() { + if (account == null) { + throw Exception('حساب نباید خالی باشد'); + } + return DocumentLineCreateRequest( + accountId: account!.id!, + personId: detail?['person_id'], + productId: detail?['product_id'], + bankAccountId: detail?['bank_account_id'], + cashRegisterId: detail?['cash_register_id'], + pettyCashId: detail?['petty_cash_id'], + checkId: detail?['check_id'], + quantity: quantity, + debit: debit, + credit: credit, + description: description, + ); + } + + /// کپی + DocumentLineEdit copy() { + return DocumentLineEdit( + account: account, + detail: detail != null ? Map.from(detail!) : null, + debit: debit, + credit: credit, + description: description, + quantity: quantity, + ); + } +} + +/// ویجت ویرایشگر سطرهای سند +class DocumentLinesEditor extends StatefulWidget { + final int businessId; + final List initialLines; + final ValueChanged> onChanged; + + const DocumentLinesEditor({ + super.key, + required this.businessId, + required this.initialLines, + required this.onChanged, + }); + + @override + State createState() => _DocumentLinesEditorState(); +} + +class _DocumentLinesEditorState extends State { + late List _lines; + + @override + void initState() { + super.initState(); + _lines = widget.initialLines.map((line) => line.copy()).toList(); + if (_lines.isEmpty) { + _addNewLine(); + _addNewLine(); + } + } + + void _addNewLine() { + setState(() { + _lines.add(DocumentLineEdit()); + }); + _notifyChanged(); + } + + void _removeLine(int index) { + if (_lines.length <= 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('سند باید حداقل 2 سطر داشته باشد')), + ); + return; + } + + setState(() { + _lines.removeAt(index); + }); + _notifyChanged(); + } + + void _notifyChanged() { + widget.onChanged(_lines); + } + + /// محاسبه جمع بدهکار + double get _totalDebit { + return _lines.fold(0.0, (sum, line) => sum + line.debit); + } + + /// محاسبه جمع بستانکار + double get _totalCredit { + return _lines.fold(0.0, (sum, line) => sum + line.credit); + } + + /// محاسبه مانده (تفاوت) + double get _balance { + return _totalDebit - _totalCredit; + } + + /// آیا سند متوازن است؟ + bool get _isBalanced { + return _balance.abs() < 0.01; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // هدر + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'سطرهای سند', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: _addNewLine, + icon: const Icon(Icons.add), + label: const Text('افزودن سطر'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + + // جدول سطرها + Container( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + // هدر جدول + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withOpacity(0.3), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(7), + ), + ), + child: Row( + children: [ + SizedBox( + width: 40, + child: Text( + '#', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + Expanded( + flex: 3, + child: Text( + 'حساب', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Text( + 'تفضیل', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: Text( + 'بدهکار', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: Text( + 'بستانکار', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Text( + 'توضیحات', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 48, + child: Text( + 'عملیات', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + + // سطرهای جدول + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _lines.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: theme.dividerColor, + ), + itemBuilder: (context, index) { + return _buildLineRow(index); + }, + ), + ], + ), + ), + + const SizedBox(height: 16), + + // خلاصه و جمع + _buildSummary(theme), + ], + ); + } + + /// ساخت یک سطر از جدول + Widget _buildLineRow(int index) { + final line = _lines[index]; + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // شماره ردیف + SizedBox( + width: 40, + child: Center( + child: CircleAvatar( + radius: 14, + backgroundColor: theme.colorScheme.primary.withOpacity(0.2), + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ), + ), + + // حساب + Expanded( + flex: 3, + child: AccountTreeComboboxWidget( + businessId: widget.businessId, + selectedAccount: line.account, + onChanged: (account) { + setState(() { + line.account = account; + // ریست کردن تفضیل وقتی حساب عوض می‌شود + line.detail = null; + }); + _notifyChanged(); + }, + label: '', + hintText: 'انتخاب حساب', + isRequired: true, + ), + ), + const SizedBox(width: 8), + + // تفضیل + Expanded( + flex: 2, + child: DetailSelectorWidget( + businessId: widget.businessId, + selectedAccount: line.account, + detailType: _getAccountDetailType(line.account), + selectedDetailId: line.detail?['person_id'] ?? + line.detail?['product_id'] ?? + line.detail?['bank_account_id'], + onChanged: (detail) { + setState(() { + line.detail = detail; + }); + _notifyChanged(); + }, + label: '', + ), + ), + const SizedBox(width: 8), + + // بدهکار + SizedBox( + width: 120, + child: TextFormField( + initialValue: line.debit > 0 ? line.debit.toString() : '', + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + isDense: true, + ), + keyboardType: TextInputType.number, + textAlign: TextAlign.left, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), + ], + onChanged: (value) { + line.debit = double.tryParse(value) ?? 0; + // اگر بدهکار وارد شد، بستانکار را صفر کن + if (line.debit > 0) { + line.credit = 0; + } + setState(() {}); + _notifyChanged(); + }, + ), + ), + const SizedBox(width: 8), + + // بستانکار + SizedBox( + width: 120, + child: TextFormField( + initialValue: line.credit > 0 ? line.credit.toString() : '', + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + isDense: true, + ), + keyboardType: TextInputType.number, + textAlign: TextAlign.left, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), + ], + onChanged: (value) { + line.credit = double.tryParse(value) ?? 0; + // اگر بستانکار وارد شد، بدهکار را صفر کن + if (line.credit > 0) { + line.debit = 0; + } + setState(() {}); + _notifyChanged(); + }, + ), + ), + const SizedBox(width: 8), + + // توضیحات + Expanded( + flex: 2, + child: TextFormField( + initialValue: line.description, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'توضیحات', + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + isDense: true, + ), + maxLines: 1, + onChanged: (value) { + line.description = value.isEmpty ? null : value; + _notifyChanged(); + }, + ), + ), + const SizedBox(width: 8), + + // دکمه حذف + SizedBox( + width: 48, + child: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + tooltip: 'حذف سطر', + onPressed: () => _removeLine(index), + ), + ), + ], + ), + ); + } + + /// ساخت بخش خلاصه + Widget _buildSummary(ThemeData theme) { + return Card( + elevation: 2, + color: _isBalanced + ? theme.colorScheme.primaryContainer.withOpacity(0.3) + : Colors.orange.shade50, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // جمع بدهکار + Column( + children: [ + const Text( + 'جمع بدهکار', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + formatWithThousands(_totalDebit.toInt()), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.red, + fontFamily: 'monospace', + ), + textDirection: TextDirection.ltr, + ), + ], + ), + + Container( + width: 2, + height: 40, + color: theme.dividerColor, + ), + + // جمع بستانکار + Column( + children: [ + const Text( + 'جمع بستانکار', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + formatWithThousands(_totalCredit.toInt()), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.green, + fontFamily: 'monospace', + ), + textDirection: TextDirection.ltr, + ), + ], + ), + + Container( + width: 2, + height: 40, + color: theme.dividerColor, + ), + + // مانده + Column( + children: [ + const Text( + 'مانده', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + formatWithThousands(_balance.abs().toInt()), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: _isBalanced ? Colors.green : Colors.orange, + fontFamily: 'monospace', + ), + textDirection: TextDirection.ltr, + ), + const SizedBox(width: 8), + Icon( + _isBalanced ? Icons.check_circle : Icons.warning, + color: _isBalanced ? Colors.green : Colors.orange, + size: 20, + ), + ], + ), + ], + ), + + // وضعیت + if (!_isBalanced) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + '⚠️ سند متوازن نیست', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + '✓ سند متوازن است', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } + + /// تشخیص نوع تفضیل بر اساس حساب + /// TODO: باید از API یا از metadata حساب بیاید + String? _getAccountDetailType(Account? account) { + if (account == null) return null; + + // این یک پیاده‌سازی ساده است + // در واقع باید از account.detailType یا API استفاده شود + final accountName = account.name.toLowerCase(); + + if (accountName.contains('دریافتنی') || + accountName.contains('پرداختنی') || + accountName.contains('مشتری') || + accountName.contains('تامین')) { + return 'person'; + } + + if (accountName.contains('موجودی') || + accountName.contains('کالا') || + accountName.contains('انبار')) { + return 'product'; + } + + if (accountName.contains('بانک')) { + return 'bank_account'; + } + + if (accountName.contains('صندوق')) { + return 'cash_register'; + } + + if (accountName.contains('تنخواه')) { + return 'petty_cash'; + } + + if (accountName.contains('چک')) { + return 'check'; + } + + return null; // حساب نیاز به تفضیل ندارد + } +} + diff --git a/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_details_dialog.dart new file mode 100644 index 0000000..7d1a4bf --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_details_dialog.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/expense_income_document.dart'; +import 'package:hesabix_ui/services/expense_income_service.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; +import 'package:hesabix_ui/core/date_utils.dart' show HesabixDateUtils; +import 'dart:html' as html; + +/// دیالوگ مشاهده جزئیات سند هزینه/درآمد +class ExpenseIncomeDetailsDialog extends StatefulWidget { + final ExpenseIncomeDocument document; + final CalendarController calendarController; + final int businessId; + final ApiClient apiClient; + + const ExpenseIncomeDetailsDialog({ + super.key, + required this.document, + required this.calendarController, + required this.businessId, + required this.apiClient, + }); + + @override + State createState() => _ExpenseIncomeDetailsDialogState(); +} + +class _ExpenseIncomeDetailsDialogState extends State { + bool _isGeneratingPdf = false; + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final doc = widget.document; + + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000, maxHeight: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // هدر دیالوگ + _buildHeader(t, doc), + + // محتوای اصلی + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // اطلاعات کلی سند + _buildDocumentInfo(t, doc), + + const SizedBox(height: 24), + + // خطوط حساب‌ها + _buildItemLines(t, doc), + + const SizedBox(height: 24), + + // خطوط طرف‌حساب‌ها + _buildCounterpartyLines(t, doc), + ], + ), + ), + ), + + // دکمه‌های پایین + _buildFooter(t), + ], + ), + ), + ); + } + + Widget _buildHeader(AppLocalizations t, ExpenseIncomeDocument doc) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'جزئیات سند ${doc.documentTypeName}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 4), + Text( + 'کد سند: ${doc.code}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + tooltip: 'بستن', + ), + ], + ), + ); + } + + Widget _buildDocumentInfo(AppLocalizations t, ExpenseIncomeDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'اطلاعات کلی سند', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + _buildInfoRow('نوع سند', doc.documentTypeName), + _buildInfoRow('تاریخ سند', HesabixDateUtils.formatForDisplay(doc.documentDate, widget.calendarController.isJalali)), + _buildInfoRow('تاریخ ثبت', HesabixDateUtils.formatForDisplay(doc.registeredAt, widget.calendarController.isJalali)), + _buildInfoRow('ارز', doc.currencyCode ?? 'نامشخص'), + _buildInfoRow('ایجادکننده', doc.createdByName ?? 'نامشخص'), + _buildInfoRow('مبلغ کل', formatWithThousands(doc.totalAmount) + ' ریال'), + if (doc.description != null && doc.description!.isNotEmpty) + _buildInfoRow('توضیحات', doc.description!), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } + + Widget _buildItemLines(AppLocalizations t, ExpenseIncomeDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'خطوط حساب‌ها (${doc.itemLinesCount})', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (doc.itemLines.isEmpty) + const Text('هیچ خط حسابی یافت نشد') + else + ...doc.itemLines.map((line) => _buildItemLineItem(line)), + ], + ), + ), + ); + } + + Widget _buildItemLineItem(ItemLine line) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + line.accountName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + 'کد: ${line.accountCode}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (line.description != null && line.description!.isNotEmpty) + Text( + line.description!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Text( + formatWithThousands(line.amount) + ' ریال', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildCounterpartyLines(AppLocalizations t, ExpenseIncomeDocument doc) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'خطوط طرف‌حساب‌ها (${doc.counterpartyLinesCount})', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (doc.counterpartyLines.isEmpty) + const Text('هیچ خط طرف‌حسابی یافت نشد') + else + ...doc.counterpartyLines.map((line) => _buildCounterpartyLineItem(line)), + ], + ), + ), + ); + } + + Widget _buildCounterpartyLineItem(CounterpartyLine line) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + line.displayName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + 'نوع: ${line.transactionTypeName}', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + 'تاریخ: ${HesabixDateUtils.formatForDisplay(line.transactionDate, widget.calendarController.isJalali)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + formatWithThousands(line.amount) + ' ریال', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (line.commission != null && line.commission! > 0) + Text( + 'کارمزد: ${formatWithThousands(line.commission!)} ریال', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ], + ), + if (line.description != null && line.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + line.description!, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ); + } + + Widget _buildFooter(AppLocalizations t) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.close), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _isGeneratingPdf ? null : _generatePdf, + icon: _isGeneratingPdf + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.picture_as_pdf), + label: Text(_isGeneratingPdf ? 'در حال تولید...' : 'خروجی PDF'), + ), + ], + ), + ); + } + + Future _generatePdf() async { + setState(() { + _isGeneratingPdf = true; + }); + + try { + // ایجاد PDF از سند + final service = ExpenseIncomeService(widget.apiClient); + final pdfBytes = await service.generatePdf(widget.document.id); + + // ذخیره فایل + await _savePdfFile(pdfBytes, widget.document.code); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('فایل PDF با موفقیت تولید شد'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('خطا در تولید PDF: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isGeneratingPdf = false; + }); + } + } + } + + Future _savePdfFile(List bytes, String filename) async { + try { + // استفاده از dart:html برای دانلود فایل در وب + final blob = html.Blob([bytes]); + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..setAttribute('download', filename.endsWith('.pdf') ? filename : '$filename.pdf') + ..click(); + html.Url.revokeObjectUrl(url); + + print('✅ PDF downloaded successfully: $filename'); + } catch (e) { + print('❌ Error downloading PDF: $e'); + rethrow; + } + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_form_dialog.dart new file mode 100644 index 0000000..dc5532e --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/expense_income/expense_income_form_dialog.dart @@ -0,0 +1,928 @@ +import 'package:flutter/material.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; +import 'package:hesabix_ui/core/calendar_controller.dart'; +import 'package:hesabix_ui/core/api_client.dart'; +import 'package:hesabix_ui/models/expense_income_document.dart'; +import 'package:hesabix_ui/models/account_model.dart'; +import 'package:hesabix_ui/models/person_model.dart'; +import 'package:hesabix_ui/models/business_dashboard_models.dart'; +import 'package:hesabix_ui/services/expense_income_service.dart'; +import 'package:hesabix_ui/widgets/date_input_field.dart'; +import 'package:hesabix_ui/widgets/banking/currency_picker_widget.dart'; +import 'package:hesabix_ui/widgets/invoice/person_combobox_widget.dart'; +import 'package:hesabix_ui/widgets/invoice/account_combobox_widget.dart'; +import 'package:hesabix_ui/utils/number_formatters.dart' show formatWithThousands; + +/// دیالوگ ایجاد/ویرایش سند هزینه/درآمد +class ExpenseIncomeFormDialog extends StatefulWidget { + final int businessId; + final CalendarController calendarController; + final bool isIncome; + final BusinessWithPermission? businessInfo; + final ApiClient apiClient; + final ExpenseIncomeDocument? initialDocument; + + const ExpenseIncomeFormDialog({ + super.key, + required this.businessId, + required this.calendarController, + required this.isIncome, + this.businessInfo, + required this.apiClient, + this.initialDocument, + }); + + @override + State createState() => _ExpenseIncomeFormDialogState(); +} + +class _ExpenseIncomeFormDialogState extends State { + final _formKey = GlobalKey(); + + late DateTime _docDate; + late bool _isIncome; + int? _selectedCurrencyId; + final TextEditingController _descriptionController = TextEditingController(); + final List<_ItemLine> _itemLines = <_ItemLine>[]; + final List<_CounterpartyLine> _counterpartyLines = <_CounterpartyLine>[]; + + @override + void initState() { + super.initState(); + final initial = widget.initialDocument; + if (initial != null) { + // حالت ویرایش: پرکردن اولیه از سند + _isIncome = initial.isIncome; + _docDate = initial.documentDate; + _selectedCurrencyId = initial.currencyId; + _descriptionController.text = initial.description ?? ''; + + // تبدیل خطوط آیتم‌ها + _itemLines.clear(); + for (final line in initial.itemLines) { + _itemLines.add( + _ItemLine( + accountId: line.accountId.toString(), + accountName: line.accountName, + amount: line.amount, + description: line.description, + ), + ); + } + + // تبدیل خطوط طرف‌حساب‌ها + _counterpartyLines.clear(); + for (final line in initial.counterpartyLines) { + _counterpartyLines.add( + _CounterpartyLine( + transactionType: TransactionType.fromValue(line.transactionType) ?? TransactionType.bank, + amount: line.amount, + transactionDate: line.transactionDate, + description: line.description, + commission: line.commission, + bankAccountId: line.bankAccountId?.toString(), + bankAccountName: line.bankAccountName, + cashRegisterId: line.cashRegisterId?.toString(), + cashRegisterName: line.cashRegisterName, + pettyCashId: line.pettyCashId?.toString(), + pettyCashName: line.pettyCashName, + checkId: line.checkId?.toString(), + checkNumber: line.checkNumber, + personId: line.personId?.toString(), + personName: line.personName, + ), + ); + } + } else { + // حالت ایجاد + _docDate = DateTime.now(); + _isIncome = widget.isIncome; + _selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id; + } + } + + @override + void dispose() { + _descriptionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final sumItems = _itemLines.fold(0, (p, e) => p + e.amount); + final sumCounterparties = _counterpartyLines.fold(0, (p, e) => p + e.amount); + final diff = sumItems - sumCounterparties; + + return Dialog( + insetPadding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200, maxHeight: 800), + 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( + 'هزینه و درآمد', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + if (widget.initialDocument == null) + SegmentedButton( + segments: [ + ButtonSegment(value: false, label: Text('هزینه')), + ButtonSegment(value: true, label: Text('درآمد')), + ], + selected: {_isIncome}, + onSelectionChanged: (s) => setState(() => _isIncome = 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: 'انتخاب ارز', + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'توضیحات کلی سند', + hintText: 'توضیحات اختیاری...', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ), + const Divider(height: 1), + Expanded( + child: Row( + children: [ + Expanded( + child: _ItemLinesPanel( + businessId: widget.businessId, + lines: _itemLines, + onChanged: (ls) => setState(() { + _itemLines.clear(); + _itemLines.addAll(ls); + }), + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: _CounterpartyLinesPanel( + businessId: widget.businessId, + lines: _counterpartyLines, + onChanged: (ls) => setState(() { + _counterpartyLines.clear(); + _counterpartyLines.addAll(ls); + }), + ), + ), + ], + ), + ), + 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: 'حساب‌ها', value: sumItems), + _TotalChip(label: 'طرف‌حساب‌ها', value: sumCounterparties), + _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 && _itemLines.isNotEmpty && _counterpartyLines.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 = ExpenseIncomeService(widget.apiClient); + + // تبدیل itemLines به فرمت مورد نیاز API + final itemLinesData = _itemLines.map((line) => { + 'account_id': int.parse(line.accountId!), + 'amount': line.amount, + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + }).toList(); + + // تبدیل counterpartyLines به فرمت مورد نیاز API + final counterpartyLinesData = _counterpartyLines.map((line) => { + 'transaction_type': line.transactionType.value, + 'amount': line.amount, + 'transaction_date': line.transactionDate.toIso8601String(), + if (line.commission != null && line.commission! > 0) + 'commission': line.commission, + if (line.description != null && line.description!.isNotEmpty) + 'description': line.description, + // اطلاعات اضافی بر اساس نوع تراکنش + if (line.transactionType == TransactionType.bank) ...{ + if (line.bankAccountId != null) 'bank_account_id': int.parse(line.bankAccountId!), + if (line.bankAccountName != null) 'bank_account_name': line.bankAccountName, + }, + if (line.transactionType == TransactionType.cashRegister) ...{ + if (line.cashRegisterId != null) 'cash_register_id': int.parse(line.cashRegisterId!), + if (line.cashRegisterName != null) 'cash_register_name': line.cashRegisterName, + }, + if (line.transactionType == TransactionType.pettyCash) ...{ + if (line.pettyCashId != null) 'petty_cash_id': int.parse(line.pettyCashId!), + if (line.pettyCashName != null) 'petty_cash_name': line.pettyCashName, + }, + if (line.transactionType == TransactionType.check) ...{ + if (line.checkId != null) 'check_id': int.parse(line.checkId!), + if (line.checkNumber != null) 'check_number': line.checkNumber, + }, + if (line.transactionType == TransactionType.person) ...{ + if (line.personId != null) 'person_id': int.parse(line.personId!), + if (line.personName != null) 'person_name': line.personName, + }, + }).toList(); + + // اگر initialDocument وجود دارد، حالت ویرایش + if (widget.initialDocument != null) { + await service.update( + documentId: widget.initialDocument!.id, + documentDate: _docDate, + currencyId: _selectedCurrencyId!, + description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, + itemLines: itemLinesData.map((data) => ItemLineData( + accountId: data['account_id'] as int, + amount: (data['amount'] as num).toDouble(), + description: data['description'] as String?, + )).toList(), + counterpartyLines: counterpartyLinesData.map((data) => CounterpartyLineData( + transactionType: TransactionType.fromValue(data['transaction_type'] as String) ?? TransactionType.bank, + amount: (data['amount'] as num).toDouble(), + transactionDate: DateTime.parse(data['transaction_date'] as String), + description: data['description'] as String?, + commission: data['commission'] != null ? (data['commission'] as num).toDouble() : null, + bankAccountId: data['bank_account_id'] as int?, + bankAccountName: data['bank_account_name'] as String?, + cashRegisterId: data['cash_register_id'] as int?, + cashRegisterName: data['cash_register_name'] as String?, + pettyCashId: data['petty_cash_id'] as int?, + pettyCashName: data['petty_cash_name'] as String?, + checkId: data['check_id'] as int?, + checkNumber: data['check_number'] as String?, + personId: data['person_id'] as int?, + personName: data['person_name'] as String?, + )).toList(), + ); + } else { + // ایجاد سند جدید + await service.create( + businessId: widget.businessId, + documentType: _isIncome ? 'income' : 'expense', + documentDate: _docDate, + currencyId: _selectedCurrencyId!, + description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, + itemLines: itemLinesData.map((data) => ItemLineData( + accountId: data['account_id'] as int, + amount: (data['amount'] as num).toDouble(), + description: data['description'] as String?, + )).toList(), + counterpartyLines: counterpartyLinesData.map((data) => CounterpartyLineData( + transactionType: TransactionType.fromValue(data['transaction_type'] as String) ?? TransactionType.bank, + amount: (data['amount'] as num).toDouble(), + transactionDate: DateTime.parse(data['transaction_date'] as String), + description: data['description'] as String?, + commission: data['commission'] != null ? (data['commission'] as num).toDouble() : null, + bankAccountId: data['bank_account_id'] as int?, + bankAccountName: data['bank_account_name'] as String?, + cashRegisterId: data['cash_register_id'] as int?, + cashRegisterName: data['cash_register_name'] as String?, + pettyCashId: data['petty_cash_id'] as int?, + pettyCashName: data['petty_cash_name'] as String?, + checkId: data['check_id'] as int?, + checkNumber: data['check_number'] as String?, + personId: data['person_id'] as int?, + personName: data['person_name'] as String?, + )).toList(), + ); + } + + if (!mounted) return; + + // بستن dialog loading + Navigator.pop(context); + + // بستن dialog اصلی با موفقیت + Navigator.pop(context, true); + + // نمایش پیام موفقیت + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.initialDocument != null + ? 'سند با موفقیت ویرایش شد' + : (_isIncome ? 'سند درآمد با موفقیت ثبت شد' : 'سند هزینه با موفقیت ثبت شد'), + ), + 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 _ItemLinesPanel extends StatefulWidget { + final int businessId; + final List<_ItemLine> lines; + final ValueChanged> onChanged; + + const _ItemLinesPanel({ + required this.businessId, + required this.lines, + required this.onChanged, + }); + + @override + State<_ItemLinesPanel> createState() => _ItemLinesPanelState(); +} + +class _ItemLinesPanelState extends State<_ItemLinesPanel> { + @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('حساب‌های هزینه/درآمد', style: Theme.of(context).textTheme.titleMedium)), + IconButton( + onPressed: () { + final newLines = List<_ItemLine>.from(widget.lines); + newLines.add(_ItemLine.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 _ItemLineTile( + businessId: widget.businessId, + line: line, + onChanged: (l) { + final newLines = List<_ItemLine>.from(widget.lines); + newLines[i] = l; + widget.onChanged(newLines); + }, + onDelete: () { + final newLines = List<_ItemLine>.from(widget.lines); + newLines.removeAt(i); + widget.onChanged(newLines); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _ItemLineTile extends StatefulWidget { + final int businessId; + final _ItemLine line; + final ValueChanged<_ItemLine> onChanged; + final VoidCallback onDelete; + + const _ItemLineTile({ + required this.businessId, + required this.line, + required this.onChanged, + required this.onDelete, + }); + + @override + State<_ItemLineTile> createState() => _ItemLineTileState(); +} + +class _ItemLineTileState extends State<_ItemLineTile> { + 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: AccountComboboxWidget( + businessId: widget.businessId, + selectedAccount: widget.line.accountId != null + ? Account( + id: int.tryParse(widget.line.accountId!), + businessId: widget.businessId, + name: widget.line.accountName ?? '', + code: '', + accountType: '', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ) + : null, + onChanged: (opt) { + widget.onChanged(widget.line.copyWith( + accountId: opt?.id?.toString(), + accountName: opt?.name + )); + }, + label: 'حساب', + 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 _CounterpartyLinesPanel extends StatefulWidget { + final int businessId; + final List<_CounterpartyLine> lines; + final ValueChanged> onChanged; + + const _CounterpartyLinesPanel({ + required this.businessId, + required this.lines, + required this.onChanged, + }); + + @override + State<_CounterpartyLinesPanel> createState() => _CounterpartyLinesPanelState(); +} + +class _CounterpartyLinesPanelState extends State<_CounterpartyLinesPanel> { + @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('طرف‌حساب‌ها', style: Theme.of(context).textTheme.titleMedium)), + IconButton( + onPressed: () { + final newLines = List<_CounterpartyLine>.from(widget.lines); + newLines.add(_CounterpartyLine.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 _CounterpartyLineTile( + businessId: widget.businessId, + line: line, + onChanged: (l) { + final newLines = List<_CounterpartyLine>.from(widget.lines); + newLines[i] = l; + widget.onChanged(newLines); + }, + onDelete: () { + final newLines = List<_CounterpartyLine>.from(widget.lines); + newLines.removeAt(i); + widget.onChanged(newLines); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _CounterpartyLineTile extends StatefulWidget { + final int businessId; + final _CounterpartyLine line; + final ValueChanged<_CounterpartyLine> onChanged; + final VoidCallback onDelete; + + const _CounterpartyLineTile({ + required this.businessId, + required this.line, + required this.onChanged, + required this.onDelete, + }); + + @override + State<_CounterpartyLineTile> createState() => _CounterpartyLineTileState(); +} + +class _CounterpartyLineTileState extends State<_CounterpartyLineTile> { + 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: DropdownButtonFormField( + value: widget.line.transactionType, + decoration: const InputDecoration( + labelText: 'نوع تراکنش', + ), + items: TransactionType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(type.displayName), + ); + }).toList(), + onChanged: (type) { + if (type != null) { + widget.onChanged(widget.line.copyWith(transactionType: type)); + } + }, + ), + ), + const SizedBox(width: 8), + + // مبلغ + SizedBox( + width: 150, + child: TextFormField( + controller: _amountController, + decoration: InputDecoration( + labelText: t.amount, + ), + keyboardType: TextInputType.number, + 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), + + // فیلدهای اضافی بر اساس نوع تراکنش + _buildTransactionTypeFields(), + + 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() + )), + ), + ], + ), + ), + ); + } + + Widget _buildTransactionTypeFields() { + switch (widget.line.transactionType) { + case TransactionType.person: + return 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: (person) { + widget.onChanged(widget.line.copyWith( + personId: person?.id?.toString(), + personName: person?.aliasName, + )); + }, + label: 'شخص', + hintText: 'انتخاب شخص', + isRequired: true, + ), + ), + ], + ); + + default: + return const SizedBox.shrink(); + } + } +} + +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 _ItemLine { + final String? accountId; + final String? accountName; + final double amount; + final String? description; + + const _ItemLine({this.accountId, this.accountName, required this.amount, this.description}); + + factory _ItemLine.empty() => const _ItemLine(amount: 0); + + _ItemLine copyWith({String? accountId, String? accountName, double? amount, String? description}) { + return _ItemLine( + accountId: accountId ?? this.accountId, + accountName: accountName ?? this.accountName, + amount: amount ?? this.amount, + description: description ?? this.description, + ); + } +} + +class _CounterpartyLine { + final TransactionType transactionType; + final double amount; + final DateTime transactionDate; + final String? description; + final double? commission; + + // فیلدهای اختیاری بر اساس نوع تراکنش + final String? bankAccountId; + final String? bankAccountName; + final String? cashRegisterId; + final String? cashRegisterName; + final String? pettyCashId; + final String? pettyCashName; + final String? checkId; + final String? checkNumber; + final String? personId; + final String? personName; + + const _CounterpartyLine({ + required this.transactionType, + required this.amount, + required this.transactionDate, + this.description, + this.commission, + this.bankAccountId, + this.bankAccountName, + this.cashRegisterId, + this.cashRegisterName, + this.pettyCashId, + this.pettyCashName, + this.checkId, + this.checkNumber, + this.personId, + this.personName, + }); + + factory _CounterpartyLine.empty() => _CounterpartyLine( + transactionType: TransactionType.bank, + amount: 0, + transactionDate: DateTime.now(), + ); + + _CounterpartyLine copyWith({ + TransactionType? transactionType, + double? amount, + DateTime? transactionDate, + String? description, + double? commission, + String? bankAccountId, + String? bankAccountName, + String? cashRegisterId, + String? cashRegisterName, + String? pettyCashId, + String? pettyCashName, + String? checkId, + String? checkNumber, + String? personId, + String? personName, + }) { + return _CounterpartyLine( + transactionType: transactionType ?? this.transactionType, + amount: amount ?? this.amount, + transactionDate: transactionDate ?? this.transactionDate, + description: description ?? this.description, + commission: commission ?? this.commission, + bankAccountId: bankAccountId ?? this.bankAccountId, + bankAccountName: bankAccountName ?? this.bankAccountName, + cashRegisterId: cashRegisterId ?? this.cashRegisterId, + cashRegisterName: cashRegisterName ?? this.cashRegisterName, + pettyCashId: pettyCashId ?? this.pettyCashId, + pettyCashName: pettyCashName ?? this.pettyCashName, + checkId: checkId ?? this.checkId, + checkNumber: checkNumber ?? this.checkNumber, + personId: personId ?? this.personId, + personName: personName ?? this.personName, + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/account_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_combobox_widget.dart new file mode 100644 index 0000000..4184abe --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_combobox_widget.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../models/account_model.dart'; +import '../../services/account_service.dart'; + +class AccountComboboxWidget extends StatefulWidget { + final int businessId; + final Account? selectedAccount; + final ValueChanged onChanged; + final String label; + final String hintText; + final bool isRequired; + + const AccountComboboxWidget({ + super.key, + required this.businessId, + this.selectedAccount, + required this.onChanged, + this.label = 'حساب', + this.hintText = 'انتخاب حساب', + this.isRequired = false, + }); + + @override + State createState() => _AccountComboboxWidgetState(); +} + +class _AccountComboboxWidgetState extends State { + final AccountService _accountService = AccountService(); + final TextEditingController _searchController = TextEditingController(); + Timer? _debounceTimer; + + List _accounts = []; + bool _isLoading = false; + bool _isSearching = false; + + @override + void initState() { + super.initState(); + _searchController.text = widget.selectedAccount?.name ?? ''; + _loadAccounts(); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadAccounts() async { + setState(() { + _isLoading = true; + }); + + try { + final response = await _accountService.getAccounts(businessId: widget.businessId); + final items = (response['items'] as List?) + ?.map((item) => Account.fromJson(item as Map)) + .toList() ?? []; + + setState(() { + _accounts = items; + }); + } catch (e) { + print('خطا در لود کردن حساب‌ها: $e'); + setState(() { + _accounts = []; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _onQueryChanged(String query) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + _performSearch(query); + }); + } + + Future _performSearch(String query) async { + if (_isSearching) return; + + setState(() { + _isSearching = true; + }); + + try { + final response = await _accountService.searchAccounts( + businessId: widget.businessId, + searchQuery: query.isEmpty ? null : query, + limit: 50, + ); + + final items = (response['items'] as List?) + ?.map((item) => Account.fromJson(item as Map)) + .toList() ?? []; + + if (mounted) { + setState(() { + _accounts = items; + }); + } + } catch (e) { + print('خطا در جستجوی حساب‌ها: $e'); + if (mounted) { + setState(() { + _accounts = []; + }); + } + } finally { + if (mounted) { + setState(() { + _isSearching = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: _searchController, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hintText, + suffixIcon: _isLoading || _isSearching + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + readOnly: true, + validator: widget.isRequired + ? (value) { + if (value == null || value.isEmpty) { + return '${widget.label} الزامی است'; + } + return null; + } + : null, + onTap: () => _showAccountSelectionDialog(), + ); + } + + void _showAccountSelectionDialog() { + showDialog( + context: context, + builder: (context) => _AccountSelectionDialog( + accounts: _accounts, + selectedAccount: widget.selectedAccount, + onAccountSelected: (account) { + widget.onChanged(account); + _searchController.text = account?.name ?? ''; + Navigator.pop(context); + }, + ), + ); + } +} + +class _AccountSelectionDialog extends StatefulWidget { + final List accounts; + final Account? selectedAccount; + final ValueChanged onAccountSelected; + + const _AccountSelectionDialog({ + required this.accounts, + this.selectedAccount, + required this.onAccountSelected, + }); + + @override + State<_AccountSelectionDialog> createState() => _AccountSelectionDialogState(); +} + +class _AccountSelectionDialogState extends State<_AccountSelectionDialog> { + String _searchQuery = ''; + List _filteredAccounts = []; + + @override + void initState() { + super.initState(); + _filteredAccounts = widget.accounts; + } + + void _filterAccounts(String query) { + setState(() { + _searchQuery = query; + if (query.isEmpty) { + _filteredAccounts = widget.accounts; + } else { + _filteredAccounts = widget.accounts + .where((account) => + account.name.toLowerCase().contains(query.toLowerCase()) || + account.code.toLowerCase().contains(query.toLowerCase())) + .toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 500), + child: Column( + children: [ + // هدر دیالوگ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'انتخاب حساب', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close), + tooltip: 'بستن', + ), + ], + ), + ), + + // فیلد جستجو + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + decoration: const InputDecoration( + labelText: 'جستجو', + hintText: 'نام یا کد حساب...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: _filterAccounts, + ), + ), + + // لیست حساب‌ها + Expanded( + child: _filteredAccounts.isEmpty + ? Center( + child: Text( + _searchQuery.isEmpty ? 'هیچ حسابی یافت نشد' : 'نتیجه‌ای یافت نشد', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + itemCount: _filteredAccounts.length, + itemBuilder: (context, index) { + final account = _filteredAccounts[index]; + final isSelected = widget.selectedAccount?.id == account.id; + + return ListTile( + title: Text(account.name), + subtitle: Text('کد: ${account.code}'), + selected: isSelected, + onTap: () { + widget.onAccountSelected(account); + }, + trailing: isSelected + ? Icon( + Icons.check, + color: theme.colorScheme.primary, + ) + : null, + ); + }, + ), + ), + + // دکمه‌های پایین + Container( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + widget.onAccountSelected(null); + Navigator.pop(context); + }, + child: const Text('پاک کردن انتخاب'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart index 74fd110..4a0db18 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/account_tree_combobox_widget.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; +import '../../models/account_model.dart'; import '../../models/account_tree_node.dart'; import '../../services/account_service.dart'; +/// ویجت انتخاب حساب با ساختار درختی +/// فقط حساب‌هایی که فرزند ندارند (leaf nodes) قابل انتخاب هستند class AccountTreeComboboxWidget extends StatefulWidget { final int businessId; - final AccountTreeNode? selectedAccount; - final ValueChanged onChanged; + final Account? selectedAccount; + final ValueChanged onChanged; final String label; final String hintText; final bool isRequired; @@ -26,16 +29,25 @@ class AccountTreeComboboxWidget extends StatefulWidget { class _AccountTreeComboboxWidgetState extends State { final AccountService _accountService = AccountService(); - List _accounts = []; + final TextEditingController _searchController = TextEditingController(); + + List _accountTree = []; bool _isLoading = false; @override void initState() { super.initState(); - _loadAccounts(); + _searchController.text = widget.selectedAccount?.displayName ?? ''; + _loadAccountsTree(); } - Future _loadAccounts() async { + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadAccountsTree() async { setState(() { _isLoading = true; }); @@ -47,12 +59,12 @@ class _AccountTreeComboboxWidgetState extends State { .toList() ?? []; setState(() { - _accounts = items; + _accountTree = items; }); } catch (e) { - print('خطا در لود کردن حساب‌ها: $e'); + print('خطا در لود کردن درخت حساب‌ها: $e'); setState(() { - _accounts = []; + _accountTree = []; }); } finally { setState(() { @@ -61,92 +73,47 @@ class _AccountTreeComboboxWidgetState extends State { } } - @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // لیبل - if (widget.label.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Text( - widget.label, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), + return TextFormField( + controller: _searchController, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hintText, + suffixIcon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), ), - if (widget.isRequired) - Text( - ' *', - style: TextStyle( - color: theme.colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - - // فیلد انتخاب - InkWell( - onTap: _isLoading ? null : _showAccountDialog, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outline), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.account_balance_wallet, - color: theme.colorScheme.onSurfaceVariant, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.selectedAccount?.toString() ?? widget.hintText, - style: theme.textTheme.bodyMedium?.copyWith( - color: widget.selectedAccount != null - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant, - ), - ), - ), - if (_isLoading) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - Icon( - Icons.arrow_drop_down, - color: theme.colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ], + ) + : const Icon(Icons.account_tree), + border: const OutlineInputBorder(), + ), + readOnly: true, + validator: widget.isRequired + ? (value) { + if (value == null || value.isEmpty) { + return '${widget.label} الزامی است'; + } + return null; + } + : null, + onTap: () => _showAccountTreeDialog(), ); } - void _showAccountDialog() { + void _showAccountTreeDialog() { showDialog( context: context, - builder: (context) => AccountSelectionDialog( - accounts: _accounts, + builder: (context) => _AccountTreeDialog( + accountTree: _accountTree, selectedAccount: widget.selectedAccount, onAccountSelected: (account) { widget.onChanged(account); + _searchController.text = account?.displayName ?? ''; Navigator.pop(context); }, ), @@ -154,55 +121,112 @@ class _AccountTreeComboboxWidgetState extends State { } } -class AccountSelectionDialog extends StatefulWidget { - final List accounts; - final AccountTreeNode? selectedAccount; - final ValueChanged onAccountSelected; +/// دیالوگ انتخاب حساب با ساختار درختی +class _AccountTreeDialog extends StatefulWidget { + final List accountTree; + final Account? selectedAccount; + final ValueChanged onAccountSelected; - const AccountSelectionDialog({ - super.key, - required this.accounts, + const _AccountTreeDialog({ + required this.accountTree, this.selectedAccount, required this.onAccountSelected, }); @override - State createState() => _AccountSelectionDialogState(); + State<_AccountTreeDialog> createState() => _AccountTreeDialogState(); } -class _AccountSelectionDialogState extends State { +class _AccountTreeDialogState extends State<_AccountTreeDialog> { + final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; - List _filteredAccounts = []; - final Set _expandedNodes = {}; + final Set _expandedNodes = {}; @override void initState() { super.initState(); - _filteredAccounts = widget.accounts; - // همه گره‌های سطح اول را به صورت پیش‌فرض باز کن - _expandedNodes.addAll(widget.accounts.map((account) => account.id)); + // باز کردن خودکار مسیر به حساب انتخاب شده + if (widget.selectedAccount != null) { + _expandToNode(widget.accountTree, widget.selectedAccount!.id!); + } } - void _filterAccounts(String query) { + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// باز کردن مسیر تا یک نود خاص + bool _expandToNode(List nodes, int targetId) { + for (final node in nodes) { + if (node.id == targetId) { + return true; + } + if (node.children.isNotEmpty) { + if (_expandToNode(node.children, targetId)) { + setState(() { + _expandedNodes.add(node.id); + }); + return true; + } + } + } + return false; + } + + /// فیلتر کردن درخت بر اساس جستجو + List _filterTree(List nodes) { + if (_searchQuery.isEmpty) { + return nodes; + } + + final List filtered = []; + final query = _searchQuery.toLowerCase(); + + for (final node in nodes) { + final matchesSearch = node.name.toLowerCase().contains(query) || + node.code.toLowerCase().contains(query); + + final filteredChildren = _filterTree(node.children); + + if (matchesSearch || filteredChildren.isNotEmpty) { + filtered.add(AccountTreeNode( + id: node.id, + code: node.code, + name: node.name, + accountType: node.accountType, + parentId: node.parentId, + children: filteredChildren, + )); + + // باز کردن خودکار نودهای دارای نتیجه جستجو + if (filteredChildren.isNotEmpty) { + _expandedNodes.add(node.id); + } + } + } + + return filtered; + } + + void _toggleExpanded(int nodeId) { setState(() { - _searchQuery = query; - if (query.isEmpty) { - _filteredAccounts = widget.accounts; + if (_expandedNodes.contains(nodeId)) { + _expandedNodes.remove(nodeId); } else { - _filteredAccounts = widget.accounts - .expand((account) => account.searchAccounts(query)) - .where((account) => !account.hasChildren) // فقط حساب‌های بدون فرزند - .toList(); + _expandedNodes.add(nodeId); } }); } - void _expandAll() { + void _expandAll(List nodes) { setState(() { - _expandedNodes.clear(); - // همه گره‌هایی که فرزند دارند را باز کن - for (final account in widget.accounts) { - _addAllExpandableNodes(account); + for (final node in nodes) { + if (node.children.isNotEmpty) { + _expandedNodes.add(node.id); + _expandAll(node.children); + } } }); } @@ -213,31 +237,21 @@ class _AccountSelectionDialogState extends State { }); } - void _addAllExpandableNodes(AccountTreeNode account) { - if (account.hasChildren) { - _expandedNodes.add(account.id); - for (final child in account.children) { - _addAllExpandableNodes(child); - } - } - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); + final filteredTree = _filterTree(widget.accountTree); return Dialog( - child: Container( - width: 600, - height: 500, - margin: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700, maxHeight: 600), child: Column( children: [ - // هدر + // هدر دیالوگ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.primary, + color: theme.colorScheme.primaryContainer, borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), @@ -246,99 +260,116 @@ class _AccountSelectionDialogState extends State { child: Row( children: [ Icon( - Icons.account_balance_wallet, - color: theme.colorScheme.onPrimary, - size: 24, + Icons.account_tree, + color: theme.colorScheme.onPrimaryContainer, ), - const SizedBox(width: 8), - Text( - 'انتخاب حساب', - style: theme.textTheme.titleLarge?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, + const SizedBox(width: 12), + Expanded( + child: Text( + 'انتخاب حساب (درختی)', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), ), ), - const Spacer(), IconButton( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close), - color: theme.colorScheme.onPrimary, + tooltip: 'بستن', ), ], ), ), - // جستجو و دکمه‌های کنترل + // فیلد جستجو و دکمه‌های باز/بسته کردن Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.all(16), child: Column( children: [ TextField( - decoration: InputDecoration( - hintText: 'جستجو در حساب‌ها...', - prefixIcon: const Icon(Icons.search), - border: const OutlineInputBorder(), + controller: _searchController, + decoration: const InputDecoration( + labelText: 'جستجو', + hintText: 'نام یا کد حساب...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), ), - onChanged: _filterAccounts, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _expandAll(filteredTree), + icon: const Icon(Icons.unfold_more, size: 18), + label: const Text('باز کردن همه'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _collapseAll, + icon: const Icon(Icons.unfold_less, size: 18), + label: const Text('بستن همه'), + ), + ], ), - if (_searchQuery.isEmpty) ...[ - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton.icon( - onPressed: _expandAll, - icon: const Icon(Icons.expand_more), - label: const Text('همه را باز کن'), - ), - TextButton.icon( - onPressed: _collapseAll, - icon: const Icon(Icons.expand_less), - label: const Text('همه را ببند'), - ), - ], - ), - ], ], ), ), - // لیست حساب‌ها + const Divider(height: 1), + + // درخت حساب‌ها Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - child: _searchQuery.isEmpty - ? _buildTreeView() - : _buildSearchResults(), - ), + child: filteredTree.isEmpty + ? Center( + child: Text( + _searchQuery.isEmpty ? 'هیچ حسابی یافت نشد' : 'نتیجه‌ای یافت نشد', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView( + padding: const EdgeInsets.all(8), + children: _buildTreeNodes(filteredTree, 0), + ), ), - // دکمه‌ها + const Divider(height: 1), + + // دکمه‌های پایین Container( padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - ), child: Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('انصراف'), - ), - const SizedBox(width: 8), - if (widget.selectedAccount != null) - TextButton( - onPressed: () { - widget.onAccountSelected(null); - Navigator.pop(context); - }, - child: const Text('حذف انتخاب'), + Text( + 'فقط حساب‌های انتهایی قابل انتخاب هستند', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, ), + ), + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('انصراف'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + widget.onAccountSelected(null); + }, + child: const Text('پاک کردن'), + ), + ], + ), ], ), ), @@ -348,141 +379,160 @@ class _AccountSelectionDialogState extends State { ); } - Widget _buildTreeView() { - return ListView.builder( - itemCount: widget.accounts.length, - itemBuilder: (context, index) { - return _buildAccountNode(widget.accounts[index], 0); - }, - ); - } - - Widget _buildSearchResults() { - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filteredAccounts.length, - itemBuilder: (context, index) { - final account = _filteredAccounts[index]; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - tileColor: account.id == widget.selectedAccount?.id - ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - leading: Icon( - Icons.account_balance_wallet, - color: account.id == widget.selectedAccount?.id - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - title: Text(account.name), - subtitle: Text('کد: ${account.code}'), - trailing: account.id == widget.selectedAccount?.id - ? Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ) - : null, - onTap: () => widget.onAccountSelected(account), - ), - ); - }, - ); - } - - Widget _buildAccountNode(AccountTreeNode account, int level) { - final theme = Theme.of(context); - final isSelected = account.id == widget.selectedAccount?.id; - final canSelect = !account.hasChildren; - final isExpanded = _expandedNodes.contains(account.id); + List _buildTreeNodes(List nodes, int level) { + final List widgets = []; - return Column( - children: [ - Container( - margin: EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - child: ListTile( - contentPadding: EdgeInsets.symmetric( - horizontal: 16 + (level * 24), - vertical: 8, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - tileColor: isSelected - ? theme.colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - leading: account.hasChildren - ? IconButton( - icon: Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, - color: theme.colorScheme.onSurfaceVariant, - ), - onPressed: () { - setState(() { - if (isExpanded) { - _expandedNodes.remove(account.id); - } else { - _expandedNodes.add(account.id); - } - }); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 24, - minHeight: 24, - ), - ) - : Icon( - Icons.account_balance_wallet, - color: canSelect - ? (isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant) - : theme.colorScheme.outline, - ), - title: Text( - account.name, - style: TextStyle( - color: canSelect - ? theme.colorScheme.onSurface - : theme.colorScheme.outline, - fontWeight: account.hasChildren ? FontWeight.bold : FontWeight.normal, - ), - ), - subtitle: Text( - 'کد: ${account.code}', - style: TextStyle( - color: canSelect - ? theme.colorScheme.onSurfaceVariant - : theme.colorScheme.outline, - ), - ), - trailing: isSelected - ? Icon( - Icons.check_circle, + for (final node in nodes) { + final isExpanded = _expandedNodes.contains(node.id); + final hasChildren = node.children.isNotEmpty; + final isSelected = widget.selectedAccount?.id == node.id; + final isSelectable = node.isSelectable; + + widgets.add( + _TreeNodeWidget( + node: node, + level: level, + isExpanded: isExpanded, + hasChildren: hasChildren, + isSelected: isSelected, + isSelectable: isSelectable, + onTap: isSelectable + ? () => widget.onAccountSelected(node.toAccount()) + : null, + onToggleExpand: hasChildren ? () => _toggleExpanded(node.id) : null, + ), + ); + + if (isExpanded && hasChildren) { + widgets.addAll(_buildTreeNodes(node.children, level + 1)); + } + } + + return widgets; + } +} + +/// ویجت نمایش یک نود در درخت +class _TreeNodeWidget extends StatelessWidget { + final AccountTreeNode node; + final int level; + final bool isExpanded; + final bool hasChildren; + final bool isSelected; + final bool isSelectable; + final VoidCallback? onTap; + final VoidCallback? onToggleExpand; + + const _TreeNodeWidget({ + required this.node, + required this.level, + required this.isExpanded, + required this.hasChildren, + required this.isSelected, + required this.isSelectable, + this.onTap, + this.onToggleExpand, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final indent = level * 24.0; + + return InkWell( + onTap: isSelectable ? onTap : onToggleExpand, + child: Container( + padding: EdgeInsets.only( + right: indent + 8, + left: 8, + top: 8, + bottom: 8, + ), + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primaryContainer.withOpacity(0.5) + : null, + border: Border( + right: isSelected + ? BorderSide( color: theme.colorScheme.primary, + width: 3, ) - : null, - onTap: canSelect ? () => widget.onAccountSelected(account) : null, + : BorderSide.none, ), ), - // نمایش فرزندان فقط اگر گره باز باشد - if (account.hasChildren && isExpanded) - ...account.children.map((child) => _buildAccountNode(child, level + 1)), - // خط جداکننده بین حساب‌های مختلف (فقط برای سطح اول) - if (level == 0 && account != widget.accounts.last) - Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.2), + child: Row( + children: [ + // آیکون باز/بسته کردن + SizedBox( + width: 24, + child: hasChildren + ? IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + iconSize: 20, + onPressed: onToggleExpand, + icon: Icon( + isExpanded ? Icons.expand_more : Icons.chevron_left, + color: theme.colorScheme.onSurface, + ), + ) + : null, ), - ), - ], + + const SizedBox(width: 8), + + // آیکون حساب + Icon( + hasChildren ? Icons.folder : Icons.receipt_long, + size: 20, + color: isSelectable + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + + const SizedBox(width: 12), + + // اطلاعات حساب + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.name, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: hasChildren ? FontWeight.bold : FontWeight.normal, + color: isSelectable + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + 'کد: ${node.code}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // نشانگر انتخاب یا غیرفعال بودن + if (isSelected) + Icon( + Icons.check_circle, + color: theme.colorScheme.primary, + size: 20, + ) + else if (!isSelectable) + Icon( + Icons.lock_outline, + color: theme.colorScheme.onSurfaceVariant, + size: 18, + ), + ], + ), + ), ); } } 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 a34c518..73d7511 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/invoice_transactions_widget.dart @@ -838,10 +838,22 @@ class _TransactionDialogState extends State { Widget _buildAccountFields() { return AccountTreeComboboxWidget( businessId: widget.businessId, - selectedAccount: _selectedAccount, + selectedAccount: _selectedAccount?.toAccount(), onChanged: (account) { setState(() { - _selectedAccount = account; + // تبدیل Account به AccountTreeNode - فقط id را نگه می‌داریم + // برای استفاده کامل، باید از tree اصلی پیدا شود + if (account != null) { + _selectedAccount = AccountTreeNode( + id: account.id!, + code: account.code, + name: account.name, + accountType: account.accountType, + parentId: account.parentId, + ); + } else { + _selectedAccount = null; + } }); }, label: 'حساب *', diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart index 863fc84..003b60d 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/product/product_form_dialog.dart @@ -5,6 +5,7 @@ import '../../controllers/product_form_controller.dart'; import 'sections/product_basic_info_section.dart'; import 'sections/product_pricing_inventory_section.dart'; import 'sections/product_tax_section.dart'; +import 'sections/product_bom_section.dart'; class ProductFormDialog extends StatefulWidget { final int businessId; @@ -83,7 +84,7 @@ class _ProductFormDialogState extends State { Widget _buildFormContent() { return DefaultTabController( - length: 3, + length: 4, child: SizedBox( height: MediaQuery.of(context).size.height > 800 ? 700 : 600, child: Column( @@ -99,6 +100,7 @@ class _ProductFormDialogState extends State { _buildBasicInfoTab(), _buildPricingInventoryTab(), _buildTaxTab(), + _buildBomTab(), ], ), ), @@ -118,6 +120,7 @@ class _ProductFormDialogState extends State { Tab(text: t.productGeneralInfo), Tab(text: t.pricingAndInventory), Tab(text: t.tax), + const Tab(text: 'فرمول تولید'), ], ); } @@ -168,6 +171,17 @@ class _ProductFormDialogState extends State { ); } + Widget _buildBomTab() { + final productId = widget.product != null ? widget.product!['id'] as int? : null; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: ProductBomSection( + businessId: widget.businessId, + productId: productId, + ), + ); + } + Widget _buildErrorMessage() { return Container( margin: const EdgeInsets.only(top: 16), diff --git a/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart new file mode 100644 index 0000000..03014f5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/product/sections/product_bom_section.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import '../../../services/bom_service.dart'; +import '../../../models/bom_models.dart'; + +class ProductBomSection extends StatefulWidget { + final int businessId; + final int? productId; + + const ProductBomSection({super.key, required this.businessId, required this.productId}); + + @override + State createState() => _ProductBomSectionState(); +} + +class _ProductBomSectionState extends State { + final BomService _service = BomService(); + bool _loading = true; + String? _error; + List _items = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + if (widget.productId == null) { + setState(() { + _loading = false; + }); + return; + } + try { + setState(() { + _loading = true; + _error = null; + }); + final items = await _service.list(businessId: widget.businessId, productId: widget.productId); + if (!mounted) return; + setState(() { + _items = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (widget.productId == null) { + return _buildDisabledState(); + } + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text(_error!, style: TextStyle(color: Colors.red.shade700))); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text('فرمول‌های تولید', style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + FilledButton.icon( + onPressed: _showCreateDialog, + icon: const Icon(Icons.add), + label: const Text('افزودن فرمول'), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: _items.isEmpty + ? const Center(child: Text('هنوز فرمولی تعریف نشده است')) + : ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, idx) { + final bom = _items[idx]; + return ListTile( + leading: Icon(bom.isDefault ? Icons.star : Icons.blur_on, color: bom.isDefault ? Colors.orange : null), + title: Text('${bom.name} (v${bom.version})'), + subtitle: Text('وضعیت: ${bom.status} | بازده: ${bom.yieldPercent ?? 0}٪ | پرت: ${bom.wastagePercent ?? 0}٪'), + trailing: Wrap(spacing: 8, children: [ + IconButton( + tooltip: 'انفجار فرمول', + icon: const Icon(Icons.auto_awesome), + onPressed: () => _explode(bom), + ), + IconButton( + tooltip: 'ویرایش', + icon: const Icon(Icons.edit), + onPressed: () => _showEditDialog(bom), + ), + IconButton( + tooltip: 'حذف', + icon: const Icon(Icons.delete_outline), + onPressed: () => _delete(bom), + ), + ]), + ); + }, + ), + ), + ], + ); + } + + Widget _buildDisabledState() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('برای مدیریت فرمول تولید، ابتدا کالا را ذخیره کنید.'), + ); + } + + Future _explode(ProductBOM bom) async { + try { + final result = await _service.explode( + businessId: widget.businessId, + bomId: bom.id, + quantity: 1, + ); + if (!mounted) return; + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('خروجی انفجار فرمول (برای ۱ واحد)'), + content: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('مواد موردنیاز:'), + const SizedBox(height: 8), + SizedBox( + height: 160, + child: ListView.builder( + itemCount: result.items.length, + itemBuilder: (ctx, i) { + final it = result.items[i]; + return Text('- ${it.componentProductId} × ${it.requiredQty} ${it.uom ?? ''}'); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('بستن')), + ], + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } + + Future _showCreateDialog() async { + final controller = TextEditingController(); + final nameController = TextEditingController(); + bool isDefault = false; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('افزودن فرمول تولید'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: nameController, decoration: const InputDecoration(labelText: 'عنوان')), + TextField(controller: controller, decoration: const InputDecoration(labelText: 'نسخه (مثلاً v1)')), + const SizedBox(height: 8), + StatefulBuilder(builder: (ctx, setSt) { + return CheckboxListTile( + value: isDefault, + onChanged: (v) => setSt(() => isDefault = v ?? false), + title: const Text('به عنوان پیش‌فرض تنظیم شود'), + controlAffinity: ListTileControlAffinity.leading, + ); + }) + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')), + ], + ), + ); + if (ok != true) return; + try { + final created = await _service.create( + businessId: widget.businessId, + payload: { + 'product_id': widget.productId, + 'version': controller.text.trim().isEmpty ? 'v1' : controller.text.trim(), + 'name': nameController.text.trim().isEmpty ? 'BOM' : nameController.text.trim(), + 'is_default': isDefault, + 'items': >[], + 'outputs': >[], + 'operations': >[], + }, + ); + if (!mounted) return; + setState(() { + _items = [created, ..._items]; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } + + Future _showEditDialog(ProductBOM bom) async { + final controller = TextEditingController(text: bom.version); + final nameController = TextEditingController(text: bom.name); + bool isDefault = bom.isDefault; + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('ویرایش فرمول تولید'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: nameController, decoration: const InputDecoration(labelText: 'عنوان')), + TextField(controller: controller, decoration: const InputDecoration(labelText: 'نسخه')), + const SizedBox(height: 8), + StatefulBuilder(builder: (ctx, setSt) { + return CheckboxListTile( + value: isDefault, + onChanged: (v) => setSt(() => isDefault = v ?? false), + title: const Text('پیش‌فرض'), + controlAffinity: ListTileControlAffinity.leading, + ); + }) + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('ذخیره')), + ], + ), + ); + if (ok != true) return; + try { + final updated = await _service.update( + businessId: widget.businessId, + bomId: bom.id!, + payload: { + 'version': controller.text.trim(), + 'name': nameController.text.trim(), + 'is_default': isDefault, + }, + ); + if (!mounted) return; + setState(() { + _items = _items.map((e) => e.id == updated.id ? updated : e).toList(); + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } + + Future _delete(ProductBOM bom) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('حذف فرمول'), + content: Text('آیا از حذف «${bom.name}» مطمئن هستید؟'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('انصراف')), + FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('حذف')), + ], + ), + ); + if (ok != true) return; + try { + await _service.delete(businessId: widget.businessId, bomId: bom.id!); + if (!mounted) return; + setState(() { + _items = _items.where((e) => e.id != bom.id).toList(); + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('خطا: $e'))); + } + } +} + + + + diff --git a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart index de098e7..2786e9e 100644 --- a/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/transfer/transfer_details_dialog.dart @@ -1,15 +1,53 @@ import 'package:flutter/material.dart'; +import '../../core/date_utils.dart'; +import '../../core/calendar_controller.dart'; class TransferDetailsDialog extends StatelessWidget { final Map document; - const TransferDetailsDialog({super.key, required this.document}); + final CalendarController calendarController; + const TransferDetailsDialog({ + super.key, + required this.document, + required this.calendarController, + }); + + String _typeFa(String? t) { + switch (t) { + case 'bank': + return 'بانک'; + case 'cash_register': + return 'صندوق'; + case 'petty_cash': + return 'تنخواه'; + default: + return t ?? ''; + } + } + + String _formatSourceDestination(String? type, String? name) { + final typeFa = _typeFa(type); + final nameStr = name ?? ''; + if (typeFa.isEmpty && nameStr.isEmpty) return ''; + return '$typeFa $nameStr'.trim(); + } @override Widget build(BuildContext context) { final lines = List>.from(document['account_lines'] as List? ?? const []); final code = document['code'] as String? ?? ''; - final date = document['document_date'] as String? ?? ''; + final dateStr = document['document_date'] as String? ?? ''; + final date = DateTime.tryParse(dateStr); final total = (document['total_amount'] as num?)?.toStringAsFixed(0) ?? '0'; + + // Get source and destination info + final sourceType = document['source_type'] as String?; + final sourceName = document['source_name'] as String?; + final destinationType = document['destination_type'] as String?; + final destinationName = document['destination_name'] as String?; + + final sourceText = _formatSourceDestination(sourceType, sourceName); + final destinationText = _formatSourceDestination(destinationType, destinationName); + return Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800, maxHeight: 600), @@ -29,11 +67,54 @@ class TransferDetailsDialog extends StatelessWidget { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Chip(label: Text('تاریخ: $date')), - const SizedBox(width: 8), - Chip(label: Text('مبلغ کل: $total')), + Row( + children: [ + Chip(label: Text('تاریخ: ${HesabixDateUtils.formatForDisplay(date, calendarController.isJalali)}')), + const SizedBox(width: 8), + Chip(label: Text('مبلغ کل: $total ریال')), + ], + ), + const SizedBox(height: 8), + if (sourceText.isNotEmpty || destinationText.isNotEmpty) ...[ + Row( + children: [ + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('مبدا', style: TextStyle(fontWeight: FontWeight.bold)), + Text(sourceText.isNotEmpty ? sourceText : 'نامشخص'), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward), + const SizedBox(width: 8), + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('مقصد', style: TextStyle(fontWeight: FontWeight.bold)), + Text(destinationText.isNotEmpty ? destinationText : 'نامشخص'), + ], + ), + ), + ), + ), + ], + ), + ], ], ), ), @@ -49,11 +130,28 @@ class TransferDetailsDialog extends StatelessWidget { final side = (l['side'] as String?) ?? ''; final isCommission = (l['is_commission_line'] as bool?) ?? false; final amount = (l['amount'] as num?)?.toStringAsFixed(0) ?? ''; + + String sideText = ''; + if (isCommission) { + sideText = 'کارمزد'; + } else { + switch (side) { + case 'source': + sideText = 'مبدا'; + break; + case 'destination': + sideText = 'مقصد'; + break; + default: + sideText = side; + } + } + return ListTile( leading: Icon(isCommission ? Icons.receipt_long : Icons.account_balance_wallet), title: Text(name), - subtitle: Text('کد: $code • سمت: ${isCommission ? 'کارمزد' : side}'), - trailing: Text(amount), + subtitle: Text('کد: $code • سمت: $sideText'), + trailing: Text('$amount ریال'), ); }, ),