From 4d7a31409c2eaa87b2f61299a3d7ffbe407711d3 Mon Sep 17 00:00:00 2001 From: Babak Alizadeh Date: Thu, 16 Oct 2025 13:02:03 +0330 Subject: [PATCH] progress in recipies --- hesabixAPI/adapters/api/v1/fiscal_years.py | 71 ++++ .../adapters/api/v1/receipts_payments.py | 40 ++ .../db/repositories/fiscal_year_repo.py | 14 + hesabixAPI/app/main.py | 2 + .../app/services/receipt_payment_service.py | 352 ++++++++++++++++++ hesabixAPI/locales/en/LC_MESSAGES/messages.mo | Bin 8134 -> 8908 bytes hesabixAPI/locales/en/LC_MESSAGES/messages.po | 32 ++ hesabixAPI/locales/fa/LC_MESSAGES/messages.mo | Bin 10791 -> 11798 bytes hesabixAPI/locales/fa/LC_MESSAGES/messages.po | 32 ++ hesabixUI/hesabix_ui/lib/core/api_client.dart | 11 + .../lib/core/fiscal_year_controller.dart | 33 ++ .../dashboard/business_dashboard_page.dart | 62 ++- .../business/receipts_payments_list_page.dart | 322 ++++++++++++++-- .../services/business_dashboard_service.dart | 19 +- .../lib/services/receipt_payment_service.dart | 22 ++ .../lib/widgets/fiscal_year_switcher.dart | 48 +++ 16 files changed, 1022 insertions(+), 38 deletions(-) create mode 100644 hesabixAPI/adapters/api/v1/fiscal_years.py create mode 100644 hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart create mode 100644 hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart diff --git a/hesabixAPI/adapters/api/v1/fiscal_years.py b/hesabixAPI/adapters/api/v1/fiscal_years.py new file mode 100644 index 0000000..2962135 --- /dev/null +++ b/hesabixAPI/adapters/api/v1/fiscal_years.py @@ -0,0 +1,71 @@ +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.db.repositories.fiscal_year_repo import FiscalYearRepository + + +router = APIRouter(prefix="/business", tags=["fiscal-years"]) + + +@router.get("/{business_id}/fiscal-years") +@require_business_access("business_id") +def list_fiscal_years( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + repo = FiscalYearRepository(db) + + # اطمینان از دسترسی کاربر به کسب و کار + if not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) + + items = repo.list_by_business(business_id) + + data = [ + { + "id": fy.id, + "title": fy.title, + "start_date": fy.start_date, + "end_date": fy.end_date, + "is_current": fy.is_last, + } + for fy in items + ] + + return success_response(data=format_datetime_fields({"items": data}, request), request=request, message="FISCAL_YEARS_LIST_FETCHED") + + +@router.get("/{business_id}/fiscal-years/current") +@require_business_access("business_id") +def get_current_fiscal_year( + request: Request, + business_id: int, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + repo = FiscalYearRepository(db) + + if not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", f"No access to business {business_id}", http_status=403) + + fy = repo.get_current_for_business(business_id) + if not fy: + return success_response(data=None, request=request, message="NO_CURRENT_FISCAL_YEAR") + + data = { + "id": fy.id, + "title": fy.title, + "start_date": fy.start_date, + "end_date": fy.end_date, + "is_current": fy.is_last, + } + return success_response(data=format_datetime_fields(data, request), request=request, message="FISCAL_YEAR_CURRENT_FETCHED") + + diff --git a/hesabixAPI/adapters/api/v1/receipts_payments.py b/hesabixAPI/adapters/api/v1/receipts_payments.py index 1d1b609..6535c67 100644 --- a/hesabixAPI/adapters/api/v1/receipts_payments.py +++ b/hesabixAPI/adapters/api/v1/receipts_payments.py @@ -21,6 +21,7 @@ from app.services.receipt_payment_service import ( get_receipt_payment, list_receipts_payments, delete_receipt_payment, + update_receipt_payment, ) from adapters.db.models.business import Business @@ -67,6 +68,14 @@ async def list_receipts_payments_endpoint( except Exception: pass + # دریافت fiscal_year_id از هدر برای اولویت دادن به انتخاب کاربر + try: + fy_header = request.headers.get("X-Fiscal-Year-ID") + if fy_header: + query_dict["fiscal_year_id"] = int(fy_header) + except Exception: + pass + result = list_receipts_payments(db, business_id, query_dict) result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])] @@ -208,6 +217,37 @@ async def delete_receipt_payment_endpoint( ) +@router.put( + "/receipts-payments/{document_id}", + summary="ویرایش سند دریافت/پرداخت", + description="به‌روزرسانی یک سند دریافت یا پرداخت", +) +async def update_receipt_payment_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), +): + """ویرایش سند""" + # دریافت سند برای بررسی دسترسی + result = get_receipt_payment(db, document_id) + if not result: + raise ApiError("DOCUMENT_NOT_FOUND", "Receipt/Payment document not found", http_status=404) + + business_id = result.get("business_id") + if business_id and not ctx.can_access_business(business_id): + raise ApiError("FORBIDDEN", "Access denied", http_status=403) + + updated = update_receipt_payment(db, document_id, ctx.get_user_id(), body) + return success_response( + data=format_datetime_fields(updated, request), + request=request, + message="RECEIPT_PAYMENT_UPDATED", + ) + + @router.post( "/businesses/{business_id}/receipts-payments/export/excel", summary="خروجی Excel لیست اسناد دریافت و پرداخت", diff --git a/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py index b4cf059..71e27a2 100644 --- a/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py +++ b/hesabixAPI/adapters/db/repositories/fiscal_year_repo.py @@ -34,4 +34,18 @@ class FiscalYearRepository(BaseRepository[FiscalYear]): self.db.refresh(fiscal_year) return fiscal_year + def list_by_business(self, business_id: int) -> list[FiscalYear]: + """لیست سال‌های مالی یک کسب‌وکار بر اساس business_id""" + from sqlalchemy import select + + stmt = select(FiscalYear).where(FiscalYear.business_id == business_id).order_by(FiscalYear.start_date.desc()) + return list(self.db.execute(stmt).scalars().all()) + + def get_current_for_business(self, business_id: int) -> FiscalYear | None: + """دریافت سال مالی جاری یک کسب و کار (بر اساس is_last)""" + from sqlalchemy import select + + stmt = select(FiscalYear).where(FiscalYear.business_id == business_id, FiscalYear.is_last == True) # noqa: E712 + return self.db.execute(stmt).scalars().first() + diff --git a/hesabixAPI/app/main.py b/hesabixAPI/app/main.py index dc3aca0..8080bca 100644 --- a/hesabixAPI/app/main.py +++ b/hesabixAPI/app/main.py @@ -31,6 +31,7 @@ from adapters.api.v1.support.statuses import router as support_statuses_router from adapters.api.v1.admin.file_storage import router as admin_file_storage_router from adapters.api.v1.admin.email_config import router as admin_email_config_router from adapters.api.v1.receipts_payments import router as receipts_payments_router +from adapters.api.v1.fiscal_years import router as fiscal_years_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 @@ -307,6 +308,7 @@ def create_app() -> FastAPI: application.include_router(tax_units_router, prefix=settings.api_v1_prefix) application.include_router(tax_types_router, prefix=settings.api_v1_prefix) application.include_router(receipts_payments_router, prefix=settings.api_v1_prefix) + application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix) # Support endpoints application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support") diff --git a/hesabixAPI/app/services/receipt_payment_service.py b/hesabixAPI/app/services/receipt_payment_service.py index b27e85f..1537f01 100644 --- a/hesabixAPI/app/services/receipt_payment_service.py +++ b/hesabixAPI/app/services/receipt_payment_service.py @@ -583,6 +583,22 @@ def list_receipts_payments( Document.document_type.in_([DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT]) ) ) + + # فیلتر بر اساس سال مالی (از query یا پیش فرض سال جاری) + fiscal_year_id = query.get("fiscal_year_id") + if fiscal_year_id is not None: + try: + fiscal_year_id = int(fiscal_year_id) + except (TypeError, ValueError): + fiscal_year_id = None + if fiscal_year_id is None: + try: + fiscal_year = _get_current_fiscal_year(db, business_id) + fiscal_year_id = fiscal_year.id + except Exception: + fiscal_year_id = None + if fiscal_year_id is not None: + q = q.filter(Document.fiscal_year_id == fiscal_year_id) # فیلتر بر اساس نوع doc_type = query.get("document_type") @@ -653,6 +669,61 @@ def delete_receipt_payment(db: Session, document_id: int) -> bool: if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT): return False + + # 1) جلوگیری از حذف در سال مالی غیر جاری + try: + fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first() + if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True: + raise ApiError( + "FISCAL_YEAR_LOCKED", + "سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد", + http_status=409, + ) + except ApiError: + # عبور خطای آگاهانه + raise + except Exception: + # اگر به هر دلیل نتوانستیم وضعیت سال مالی را بررسی کنیم، حذف را متوقف نکن + pass + + # 2) جلوگیری از حذف در صورت قفل بودن سند (براساس extra_info یا developer_settings) + try: + locked_flags = [] + if isinstance(document.extra_info, dict): + locked_flags.append(bool(document.extra_info.get("locked"))) + locked_flags.append(bool(document.extra_info.get("is_locked"))) + if isinstance(document.developer_settings, dict): + locked_flags.append(bool(document.developer_settings.get("locked"))) + locked_flags.append(bool(document.developer_settings.get("is_locked"))) + if any(locked_flags): + raise ApiError( + "DOCUMENT_LOCKED", + "این سند قفل است و قابل حذف نمی‌باشد", + http_status=409, + ) + except ApiError: + raise + except Exception: + pass + + # 3) جلوگیری از حذف اگر خطوط سند به چک مرتبط باشند + try: + has_related_checks = db.query(DocumentLine).filter( + and_( + DocumentLine.document_id == document.id, + DocumentLine.check_id.isnot(None), + ) + ).first() is not None + if has_related_checks: + raise ApiError( + "DOCUMENT_REFERENCED", + "این سند دارای اقلام مرتبط با چک است و قابل حذف نمی‌باشد", + http_status=409, + ) + except ApiError: + raise + except Exception: + pass db.delete(document) db.commit() @@ -660,6 +731,287 @@ def delete_receipt_payment(db: Session, document_id: int) -> bool: return True +def update_receipt_payment( + db: Session, + document_id: int, + user_id: int, + data: Dict[str, Any] +) -> Dict[str, Any]: + """به‌روزرسانی سند دریافت/پرداخت - استراتژی Full-Replace خطوط""" + document = db.query(Document).filter(Document.id == document_id).first() + if document is None: + raise ApiError("DOCUMENT_NOT_FOUND", "Document not found", http_status=404) + + if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT): + raise ApiError("INVALID_DOCUMENT_TYPE", "Invalid document type", http_status=400) + + # 1) محدودیت‌های سال مالی/قفل/وابستگی مشابه حذف + try: + fiscal_year = db.query(FiscalYear).filter(FiscalYear.id == document.fiscal_year_id).first() + if fiscal_year is not None and getattr(fiscal_year, "is_last", False) is not True: + raise ApiError( + "FISCAL_YEAR_LOCKED", + "سند متعلق به سال مالی جاری نیست و قابل ویرایش نمی‌باشد", + http_status=409, + ) + except ApiError: + raise + except Exception: + pass + + try: + locked_flags = [] + if isinstance(document.extra_info, dict): + locked_flags.append(bool(document.extra_info.get("locked"))) + locked_flags.append(bool(document.extra_info.get("is_locked"))) + if isinstance(document.developer_settings, dict): + locked_flags.append(bool(document.developer_settings.get("locked"))) + locked_flags.append(bool(document.developer_settings.get("is_locked"))) + if any(locked_flags): + raise ApiError( + "DOCUMENT_LOCKED", + "این سند قفل است و قابل ویرایش نمی‌باشد", + http_status=409, + ) + except ApiError: + raise + except Exception: + pass + + try: + has_related_checks = db.query(DocumentLine).filter( + and_( + DocumentLine.document_id == document.id, + DocumentLine.check_id.isnot(None), + ) + ).first() is not None + if has_related_checks: + raise ApiError( + "DOCUMENT_REFERENCED", + "این سند دارای اقلام مرتبط با چک است و قابل ویرایش نمی‌باشد", + http_status=409, + ) + except ApiError: + raise + except Exception: + pass + + # 2) اعتبارسنجی ورودی‌ها (مشابه create) + document_date = _parse_iso_date(data.get("document_date", document.document_date)) + currency_id = data.get("currency_id", document.currency_id) + if not currency_id: + raise ApiError("CURRENCY_REQUIRED", "currency_id is required", http_status=400) + currency = db.query(Currency).filter(Currency.id == int(currency_id)).first() + if not currency: + raise ApiError("CURRENCY_NOT_FOUND", "Currency not found", http_status=404) + + person_lines = data.get("person_lines", []) + account_lines = data.get("account_lines", []) + if not isinstance(person_lines, list) or not person_lines: + raise ApiError("PERSON_LINES_REQUIRED", "At least one person line is required", http_status=400) + if not isinstance(account_lines, list) or not account_lines: + raise ApiError("ACCOUNT_LINES_REQUIRED", "At least one account line is required", http_status=400) + + person_total = sum(float(line.get("amount", 0)) for line in person_lines) + account_total = sum(float(line.get("amount", 0)) for line in account_lines) + if abs(person_total - account_total) > 0.01: + raise ApiError("UNBALANCED_AMOUNTS", "Totals must be balanced", http_status=400) + + # 3) اعمال تغییرات در سند (بدون تغییر code و document_type) + document.document_date = document_date + document.currency_id = int(currency_id) + if isinstance(data.get("extra_info"), dict) or data.get("extra_info") is None: + document.extra_info = data.get("extra_info") + + # تعیین نوع دریافت/پرداخت برای محاسبات بدهکار/بستانکار + is_receipt = (document.document_type == DOCUMENT_TYPE_RECEIPT) + + # حذف خطوط فعلی و ایجاد مجدد + db.query(DocumentLine).filter(DocumentLine.document_id == document.id).delete(synchronize_session=False) + + # خطوط شخص + for person_line in person_lines: + person_id = person_line.get("person_id") + if not person_id: + continue + amount = Decimal(str(person_line.get("amount", 0))) + if amount <= 0: + continue + description = (person_line.get("description") or "").strip() or None + person_account = _get_person_account(db, document.business_id, int(person_id), is_receivable=is_receipt) + debit_amount = amount if not is_receipt else Decimal(0) + credit_amount = amount if is_receipt else Decimal(0) + line = DocumentLine( + document_id=document.id, + account_id=person_account.id, + person_id=int(person_id), + quantity=person_line.get("quantity"), + debit=debit_amount, + credit=credit_amount, + description=description, + extra_info={ + "person_id": int(person_id), + "person_name": person_line.get("person_name"), + }, + ) + db.add(line) + + # خطوط حساب‌ها + کارمزدها + total_commission = Decimal(0) + for i, account_line in enumerate(account_lines): + amount = Decimal(str(account_line.get("amount", 0))) + if amount <= 0: + continue + description = (account_line.get("description") or "").strip() or None + transaction_type = account_line.get("transaction_type") + transaction_date = account_line.get("transaction_date") + commission = account_line.get("commission") + if commission: + total_commission += Decimal(str(commission)) + + # انتخاب حساب بر اساس transaction_type یا account_id + account = None + if transaction_type == "bank": + account = _get_fixed_account_by_code(db, "10203") + elif transaction_type == "cash_register": + account = _get_fixed_account_by_code(db, "10202") + elif transaction_type == "petty_cash": + account = _get_fixed_account_by_code(db, "10201") + elif transaction_type == "check": + account = _get_fixed_account_by_code(db, "10403" if is_receipt else "20202") + elif transaction_type == "person": + account = _get_fixed_account_by_code(db, "20201") + elif account_line.get("account_id"): + account = db.query(Account).filter( + and_( + Account.id == int(account_line.get("account_id")), + or_(Account.business_id == document.business_id, Account.business_id == None), + ) + ).first() + if not account: + raise ApiError("ACCOUNT_NOT_FOUND", "Account not found for transaction_type", http_status=404) + + extra_info: Dict[str, Any] = {} + if transaction_type: + extra_info["transaction_type"] = transaction_type + if transaction_date: + extra_info["transaction_date"] = transaction_date + if commission: + extra_info["commission"] = float(commission) + if transaction_type == "bank": + if account_line.get("bank_id"): + extra_info["bank_id"] = account_line.get("bank_id") + if account_line.get("bank_name"): + extra_info["bank_name"] = account_line.get("bank_name") + elif transaction_type == "cash_register": + if account_line.get("cash_register_id"): + extra_info["cash_register_id"] = account_line.get("cash_register_id") + if account_line.get("cash_register_name"): + extra_info["cash_register_name"] = account_line.get("cash_register_name") + elif transaction_type == "petty_cash": + if account_line.get("petty_cash_id"): + extra_info["petty_cash_id"] = account_line.get("petty_cash_id") + if account_line.get("petty_cash_name"): + extra_info["petty_cash_name"] = account_line.get("petty_cash_name") + elif transaction_type == "check": + if account_line.get("check_id"): + extra_info["check_id"] = account_line.get("check_id") + if account_line.get("check_number"): + extra_info["check_number"] = account_line.get("check_number") + + debit_amount = amount if is_receipt else Decimal(0) + credit_amount = amount if not is_receipt else Decimal(0) + + bank_account_id = None + if transaction_type == "bank" and account_line.get("bank_id"): + try: + bank_account_id = int(account_line.get("bank_id")) + except Exception: + bank_account_id = None + + person_id_for_line = None + if transaction_type == "person" and account_line.get("person_id"): + try: + person_id_for_line = int(account_line.get("person_id")) + except Exception: + person_id_for_line = None + + line = DocumentLine( + document_id=document.id, + account_id=account.id, + person_id=person_id_for_line, + bank_account_id=bank_account_id, + cash_register_id=account_line.get("cash_register_id"), + petty_cash_id=account_line.get("petty_cash_id"), + check_id=account_line.get("check_id"), + quantity=account_line.get("quantity"), + debit=debit_amount, + credit=credit_amount, + description=description, + extra_info=extra_info if extra_info else None, + ) + db.add(line) + + # خطوط کارمزد + if total_commission > 0: + for i, account_line in enumerate(account_lines): + commission = account_line.get("commission") + if not commission or Decimal(str(commission)) <= 0: + continue + commission_amount = Decimal(str(commission)) + transaction_type = account_line.get("transaction_type") + commission_account_code = None + if transaction_type == "bank": + commission_account_code = "10203" + elif transaction_type == "cash_register": + commission_account_code = "10202" + elif transaction_type == "petty_cash": + commission_account_code = "10201" + elif transaction_type == "check": + commission_account_code = "10403" if is_receipt else "20202" + elif transaction_type == "person": + commission_account_code = "20201" + if commission_account_code: + commission_account = _get_fixed_account_by_code(db, commission_account_code) + commission_debit = commission_amount if not is_receipt else Decimal(0) + commission_credit = commission_amount if is_receipt else Decimal(0) + db.add(DocumentLine( + document_id=document.id, + account_id=commission_account.id, + bank_account_id=account_line.get("bank_id"), + cash_register_id=account_line.get("cash_register_id"), + petty_cash_id=account_line.get("petty_cash_id"), + check_id=account_line.get("check_id"), + debit=commission_debit, + credit=commission_credit, + description=f"کارمزد تراکنش {transaction_type}", + extra_info={ + "transaction_type": transaction_type, + "commission": float(commission_amount), + "is_commission_line": True, + "original_transaction_index": i, + }, + )) + commission_service_account = _get_fixed_account_by_code(db, "70902") + commission_service_debit = commission_amount if is_receipt else Decimal(0) + commission_service_credit = commission_amount if not is_receipt else Decimal(0) + db.add(DocumentLine( + document_id=document.id, + account_id=commission_service_account.id, + debit=commission_service_debit, + credit=commission_service_credit, + description="کارمزد خدمات بانکی", + extra_info={ + "commission": float(commission_amount), + "is_commission_line": True, + "original_transaction_index": i, + "commission_type": "banking_service", + }, + )) + + db.commit() + db.refresh(document) + return document_to_dict(db, document) def document_to_dict(db: Session, document: Document) -> Dict[str, Any]: """تبدیل سند به دیکشنری""" # دریافت خطوط سند diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.mo b/hesabixAPI/locales/en/LC_MESSAGES/messages.mo index 330c26b7a1118ed9a454e16f01032c2102d0f36b..64becaca9fbbeeee66c1c05834d2a17ff48d8a67 100644 GIT binary patch delta 2848 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 yo#Rf4ItgbON{7A*d!q&`Ks&KTSB>gX4j>Rtk diff --git a/hesabixAPI/locales/en/LC_MESSAGES/messages.po b/hesabixAPI/locales/en/LC_MESSAGES/messages.po index b1ea560..1c57cb1 100644 --- a/hesabixAPI/locales/en/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/en/LC_MESSAGES/messages.po @@ -399,3 +399,35 @@ msgstr "Storage connection test - to be implemented" msgid "TEST_STORAGE_CONFIG_ERROR" msgstr "Error testing storage connection" + +# Receipts & Payments +msgid "RECEIPTS_PAYMENTS_LIST_FETCHED" +msgstr "Receipts & payments list retrieved successfully" + +msgid "RECEIPT_PAYMENT_CREATED" +msgstr "Receipt/Payment created successfully" + +msgid "RECEIPT_PAYMENT_DETAILS" +msgstr "Receipt/Payment details" + +msgid "RECEIPT_PAYMENT_DELETED" +msgstr "Receipt/Payment deleted successfully" + +# Common errors for receipts/payments +msgid "DOCUMENT_NOT_FOUND" +msgstr "Document not found" + +msgid "FORBIDDEN" +msgstr "Access denied" + +msgid "FISCAL_YEAR_LOCKED" +msgstr "Document does not belong to the current fiscal year and cannot be deleted" + +msgid "DOCUMENT_LOCKED" +msgstr "This document is locked and cannot be deleted" + +msgid "DOCUMENT_REFERENCED" +msgstr "This document has dependencies (e.g., check) and cannot be deleted" + +msgid "RECEIPT_PAYMENT_UPDATED" +msgstr "Receipt/Payment updated successfully" diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo b/hesabixAPI/locales/fa/LC_MESSAGES/messages.mo index bb7b7385dbbd919ce8a38b89fe2490bea57e42d9..fbe40b24c0ecfadd52f26d5ed247527ef36b5477 100644 GIT binary patch delta 3142 zcma*oeN5F=9LMnk5(0_}LWcNoQG5WO^MO!7FZW&oML_PwhM}ysv}U$iv*mQVVnA-; znh>P+;G8AGG}LQt)+&~3=H}XTxqd%L=lG{|JviEWSZ?yltrQyKyA8V>0WT(_Dk zKZ1JZYf%e7hbnbFD&9fsDeGBO-0qRoUpwiuH-_@2DZp6N#MxFaYJm_k)NHbDLp|eH zPysuvpIN)DeW-B>Y+rG+P#amAMEyNnte}H2W)rIP+ffTPS=&*S>cT1L;xxR4N@yBU zH9rSc@*>py$5EBoYVWt8DtQ={z_BnFdUhY7O8Gg);TNcY7w|spMUDFo6`;?ye@6ux zLfLrVOcJUR6HxIM;ZV#)#r0ZOpx%{mkP8J~kJ`zT$ct}Yv;*o;3%+XGt*B4LLDYhW ztr1khU8ukpkm8tcQ1O36#rX|2?-r7H*bL_d(1K%81E!)T%tGxv$C`(l=)+(XZ#&MX z{SMD79~~^lG1Pq_K8Ew~b@bpl)M>en1vr8-E!O#8&4n^;LT#Olfw^`d%;}YD0d3XYI@h8*=D2uO?5~;)hwqUr3iykhtKqhB| zv1T<+#b;0@Y{8{?7H4D3M80A;AL%lC@F6^oS$GXKeza%ck(J{l+Uro2+<}VI?xFtb zaOlvnilRPVoQ4|k6mrY##d17t`xA)vAnja?!>93K+=2!8vF%s6)E|$_a5XN+J*diD zoE#o_X2UbtJsk!7(X-r$I^Pj2#2(~*Gn2@Y983;sr&Xv8yoCBc;V7z-Uti(Cu?b`OYs08{@ zr{LFFk^OPcMlUS$Rjvw@hH6U6d@BQf;~#97mW65x%PLE6>#7J81}XxjzJNaxH|&q7 z^x~k;TT)XM@Ky}YE-b4kDE9jUrIDz_z0qM`ptw8~tSR?aX^CJ>NpX;%fsn6=l_~=L zw+HXm_$mV4&>i~yfs(+T`$OL1lHh;hRF?Z~oXEbBSK`A?TTi3g;Bg{ugVX78ce=H1 zoyTc&B2I_L-9-o2N7Qx9Iquea+y=M4r?GcOqjQ8Cr<_h>XZAEYZEme5H4JqBBd*iQ z=o4Ctpo*yY-JLy+3q8}_dS*8CFW=qJ-JW&V)mes_kr&chhJ*)mu6H_|({7!+(?g~& zvlZgidHTcFv7mEQYis3(f!X)CuTHH*(h*Iq?~iiwuNcOyjUA7u$Y=|-@vBwJCmRrE*A8iR;+8O)*!E%yH41C!%<{hr(Rd4AvLc}`oT zbD7tl9J|l>`;Pzd{6}I{`v31xoY_ci^6cj5kYRe#_xO1IjP~>ybXT67z5aet>PxA8`WZ$F4oVAhj1_K7NW?_;wtD?Wjz2 zVIp2c&G#FoGQU0LA&k+ymO5sk-e@NB@)kiYs2Nl63sit@sDK@)H$3Iqf5tS*caV=g z=Sy4g4z-{}zO=v$^a4DT@}Q0tu3-^IQ(leQ%VyNXTTm(7jSAT1{K75EYAMc%vm z6jrJ6lThQzopp)iUlXjPf{(R0ccAueKWe~f)B=COFy2KS%Gao~6QrOxA!jKn19g~# z&6tb3kRjHCe5`jQ`By6MP*Q-uQEwO^pXx9Xm7!wP0?JWavH+E#kCB6K%TfKCa0qTj z_4^7n-gD*cs5n0$yKe1jP|A*@0$#$Qco`M=hVw4!3_L^yeu8?Fm&iG^A*4y|NvH|O zyK)xlMo&deSnRAuE!eB$K?9qRShgND!Plrj+fV~{p;mqXHDRY~_fh@Nqu#vNc@;J8 zCe}xp{f@sswhTXmk7?rZ&B^N7{&oy zilah<^=q++ax2DRAI`^{s7$7X2kRSA^=q&SPhd4Z4U>PRqJTV?;5y94!>B#Fh`P5U zSavOzBFDtGAWgOh^`d>ffR=l*ZenVjR8-&$94U#|QbwSX8llyhfsd475D^JxE%_%~7htKn_YenL`z(C--68sm3|mIeHe Ia<2sb1EGG{uK)l5 diff --git a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po index 179ad4d..5c9ce92 100644 --- a/hesabixAPI/locales/fa/LC_MESSAGES/messages.po +++ b/hesabixAPI/locales/fa/LC_MESSAGES/messages.po @@ -422,3 +422,35 @@ msgid "TEST_STORAGE_CONFIG_ERROR" msgstr "خطا در تست اتصال" +# Receipts & Payments +msgid "RECEIPTS_PAYMENTS_LIST_FETCHED" +msgstr "لیست اسناد دریافت و پرداخت با موفقیت دریافت شد" + +msgid "RECEIPT_PAYMENT_CREATED" +msgstr "سند دریافت/پرداخت با موفقیت ایجاد شد" + +msgid "RECEIPT_PAYMENT_DETAILS" +msgstr "جزئیات سند دریافت/پرداخت" + +msgid "RECEIPT_PAYMENT_DELETED" +msgstr "سند دریافت/پرداخت با موفقیت حذف شد" + +# Common errors for receipts/payments +msgid "DOCUMENT_NOT_FOUND" +msgstr "سند یافت نشد" + +msgid "FORBIDDEN" +msgstr "دسترسی مجاز نیست" + +msgid "FISCAL_YEAR_LOCKED" +msgstr "سند متعلق به سال مالی جاری نیست و قابل حذف نمی‌باشد" + +msgid "DOCUMENT_LOCKED" +msgstr "این سند قفل است و قابل حذف نمی‌باشد" + +msgid "DOCUMENT_REFERENCED" +msgstr "این سند دارای وابستگی (مانند چک) است و قابل حذف نیست" + +msgid "RECEIPT_PAYMENT_UPDATED" +msgstr "سند دریافت/پرداخت با موفقیت ویرایش شد" + diff --git a/hesabixUI/hesabix_ui/lib/core/api_client.dart b/hesabixUI/hesabix_ui/lib/core/api_client.dart index 1a78f46..e221704 100644 --- a/hesabixUI/hesabix_ui/lib/core/api_client.dart +++ b/hesabixUI/hesabix_ui/lib/core/api_client.dart @@ -23,6 +23,7 @@ class ApiClient { static Locale? _currentLocale; static AuthStore? _authStore; static CalendarController? _calendarController; + static ValueNotifier? _fiscalYearId; static void setCurrentLocale(Locale locale) { _currentLocale = locale; @@ -36,6 +37,11 @@ class ApiClient { _calendarController = controller; } + // Fiscal Year binding (allows UI to update selected fiscal year globally) + static void bindFiscalYear(ValueNotifier fiscalYearId) { + _fiscalYearId = fiscalYearId; + } + ApiClient._(this._dio); factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) { @@ -71,6 +77,11 @@ class ApiClient { if (calendarType != null && calendarType.isNotEmpty) { options.headers['X-Calendar-Type'] = calendarType; } + // Inject Fiscal Year header if provided + final fyId = _fiscalYearId?.value; + if (fyId != null && fyId > 0) { + options.headers['X-Fiscal-Year-ID'] = fyId.toString(); + } // Inject X-Business-ID header when request targets a specific business try { final uri = options.uri; diff --git a/hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart b/hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart new file mode 100644 index 0000000..af516b5 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class FiscalYearController extends ChangeNotifier { + static const String _prefsKey = 'selected_fiscal_year_id'; + + int? _fiscalYearId; + int? get fiscalYearId => _fiscalYearId; + + FiscalYearController._(this._fiscalYearId); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt(_prefsKey); + return FiscalYearController._(id); + } + + Future setFiscalYearId(int? id) async { + if (_fiscalYearId == id) return; + _fiscalYearId = id; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + if (id == null) { + await prefs.remove(_prefsKey); + } else { + await prefs.setInt(_prefsKey, id); + } + } +} + + diff --git a/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart index 8542ae7..e35f40b 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/dashboard/business_dashboard_page.dart @@ -3,6 +3,8 @@ import 'package:hesabix_ui/l10n/app_localizations.dart'; import '../../../services/business_dashboard_service.dart'; import '../../../core/api_client.dart'; import '../../../models/business_dashboard_models.dart'; +import '../../../core/fiscal_year_controller.dart'; +import '../../../widgets/fiscal_year_switcher.dart'; class BusinessDashboardPage extends StatefulWidget { final int businessId; @@ -14,7 +16,8 @@ class BusinessDashboardPage extends StatefulWidget { } class _BusinessDashboardPageState extends State { - final BusinessDashboardService _service = BusinessDashboardService(ApiClient()); + late final FiscalYearController _fiscalController; + late final BusinessDashboardService _service; BusinessDashboardResponse? _dashboardData; bool _loading = true; String? _error; @@ -22,7 +25,20 @@ class _BusinessDashboardPageState extends State { @override void initState() { super.initState(); - _loadDashboard(); + _init(); + } + + Future _init() async { + _fiscalController = await FiscalYearController.load(); + _service = BusinessDashboardService(ApiClient(), fiscalYearController: _fiscalController); + ApiClient.bindFiscalYear(ValueNotifier(_fiscalController.fiscalYearId)); + _fiscalController.addListener(() { + // به‌روزرسانی هدر سراسری + ApiClient.bindFiscalYear(ValueNotifier(_fiscalController.fiscalYearId)); + // رفرش داشبورد + _loadDashboard(); + }); + await _loadDashboard(); } Future _loadDashboard() async { @@ -85,9 +101,45 @@ class _BusinessDashboardPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - t.businessDashboard, - style: Theme.of(context).textTheme.headlineMedium, + Row( + children: [ + Expanded( + child: Text( + t.businessDashboard, + style: Theme.of(context).textTheme.headlineMedium, + ), + ), + FutureBuilder>>( + future: _service.listFiscalYears(widget.businessId), + builder: (context, snapshot) { + final items = snapshot.data ?? const >[]; + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)); + } + if (items.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.timeline, size: 16), + const SizedBox(width: 6), + FiscalYearSwitcher( + controller: _fiscalController, + fiscalYears: items, + onChanged: () => _loadDashboard(), + ), + ], + ), + ); + }, + ), + ], ), const SizedBox(height: 16), if (_dashboardData != null) ...[ diff --git a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart index 12cba06..4c6d933 100644 --- a/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/receipts_payments_list_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.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'; @@ -43,7 +44,9 @@ class _ReceiptsPaymentsListPageState extends State { String? _selectedDocumentType; DateTime? _fromDate; DateTime? _toDate; - int _refreshKey = 0; // کلید برای تازه‌سازی جدول + // کلید کنترل جدول برای دسترسی به selection و refresh + final GlobalKey _tableKey = GlobalKey(); + int _selectedCount = 0; // تعداد سطرهای انتخاب‌شده @override void initState() { @@ -53,9 +56,17 @@ class _ReceiptsPaymentsListPageState extends State { /// تازه‌سازی داده‌های جدول void _refreshData() { - setState(() { - _refreshKey++; // تغییر کلید باعث rebuild شدن جدول می‌شود - }); + final state = _tableKey.currentState; + if (state != null) { + try { + // استفاده از متد عمومی refresh در ویجت جدول + // نوت: دسترسی دینامیک چون State کلاس خصوصی است + // ignore: avoid_dynamic_calls + (state as dynamic).refresh(); + return; + } catch (_) {} + } + if (mounted) setState(() {}); } @override @@ -79,7 +90,7 @@ class _ReceiptsPaymentsListPageState extends State { child: Padding( padding: const EdgeInsets.all(8.0), child: DataTableWidget( - key: ValueKey(_refreshKey), + key: _tableKey, config: _buildTableConfig(t), fromJson: (json) => ReceiptPaymentDocument.fromJson(json), calendarController: widget.calendarController, @@ -223,6 +234,21 @@ class _ReceiptsPaymentsListPageState extends State { title: t.receiptsAndPayments, excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel', pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/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, if (_selectedDocumentType != null) 'document_type': _selectedDocumentType, @@ -336,6 +362,11 @@ class _ReceiptsPaymentsListPageState extends State { showPdfExport: true, defaultPageSize: 20, pageSizeOptions: [10, 20, 50, 100], + onRowSelectionChanged: (rows) { + setState(() { + _selectedCount = rows.length; + }); + }, additionalParams: { if (_selectedDocumentType != null) 'document_type': _selectedDocumentType, if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(), @@ -379,13 +410,39 @@ class _ReceiptsPaymentsListPageState extends State { } /// ویرایش سند - void _onEdit(ReceiptPaymentDocument document) { - // TODO: باز کردن صفحه ویرایش سند - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('ویرایش سند ${document.code}'), - ), - ); + void _onEdit(ReceiptPaymentDocument 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: (_) => BulkSettlementDialog( + businessId: widget.businessId, + calendarController: widget.calendarController, + isReceipt: fullDoc.isReceipt, + 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')), + ); + } } /// حذف سند @@ -394,7 +451,7 @@ class _ReceiptsPaymentsListPageState extends State { context: context, builder: (context) => AlertDialog( title: const Text('تأیید حذف'), - content: Text('آیا از حذف سند ${document.code} اطمینان دارید؟'), + content: Text('حذف سند ${document.code} غیرقابل بازگشت است. آیا ادامه می‌دهید؟'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -415,30 +472,168 @@ class _ReceiptsPaymentsListPageState extends State { /// انجام عملیات حذف Future _performDelete(ReceiptPaymentDocument 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('خطا در حذف سند: $e'), + 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, + ), + ); + } + } } class BulkSettlementDialog extends StatefulWidget { @@ -447,6 +642,7 @@ class BulkSettlementDialog extends StatefulWidget { final bool isReceipt; final BusinessWithPermission? businessInfo; final ApiClient apiClient; + final ReceiptPaymentDocument? initialDocument; const BulkSettlementDialog({ super.key, required this.businessId, @@ -454,6 +650,7 @@ class BulkSettlementDialog extends StatefulWidget { required this.isReceipt, this.businessInfo, required this.apiClient, + this.initialDocument, }); @override @@ -472,10 +669,59 @@ class _BulkSettlementDialogState extends State { @override void initState() { super.initState(); - _docDate = DateTime.now(); - _isReceipt = widget.isReceipt; - // اگر ارز پیشفرض موجود است، آن را انتخاب کن، در غیر این صورت null بگذار تا CurrencyPickerWidget خودکار انتخاب کند - _selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id; + final initial = widget.initialDocument; + if (initial != null) { + // حالت ویرایش: پرکردن اولیه از سند + _isReceipt = initial.isReceipt; + _docDate = initial.documentDate; + _selectedCurrencyId = initial.currencyId; + // تبدیل خطوط اشخاص + _personLines.clear(); + for (final pl in initial.personLines) { + _personLines.add( + _PersonLine( + personId: pl.personId?.toString(), + personName: pl.personName, + amount: pl.amount, + description: pl.description, + ), + ); + } + // تبدیل خطوط حساب‌ها (حذف خطوط کارمزد) + _centerTransactions.clear(); + for (final al in initial.accountLines) { + final isCommission = (al.extraInfo != null && (al.extraInfo!['is_commission_line'] == true)); + if (isCommission) continue; + final t = TransactionType.fromValue(al.transactionType ?? '') ?? TransactionType.person; + _centerTransactions.add( + InvoiceTransaction( + id: al.id.toString(), + type: t, + bankId: al.extraInfo?['bank_id']?.toString(), + bankName: al.extraInfo?['bank_name']?.toString(), + cashRegisterId: al.extraInfo?['cash_register_id']?.toString(), + cashRegisterName: al.extraInfo?['cash_register_name']?.toString(), + pettyCashId: al.extraInfo?['petty_cash_id']?.toString(), + pettyCashName: al.extraInfo?['petty_cash_name']?.toString(), + checkId: al.extraInfo?['check_id']?.toString(), + checkNumber: al.extraInfo?['check_number']?.toString(), + personId: al.extraInfo?['person_id']?.toString(), + personName: al.extraInfo?['person_name']?.toString(), + accountId: al.accountId.toString(), + accountName: al.accountName, + transactionDate: al.transactionDate ?? _docDate, + amount: al.amount, + commission: al.commission, + description: al.description, + ), + ); + } + } else { + // حالت ایجاد + _docDate = DateTime.now(); + _isReceipt = widget.isReceipt; + _selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id; + } } @override @@ -504,14 +750,15 @@ class _BulkSettlementDialogState extends State { style: Theme.of(context).textTheme.titleLarge, ), ), - SegmentedButton( + if (widget.initialDocument == null) + SegmentedButton( segments: [ ButtonSegment(value: true, label: Text(t.receipts)), ButtonSegment(value: false, label: Text(t.payments)), ], selected: {_isReceipt}, onSelectionChanged: (s) => setState(() => _isReceipt = s.first), - ), + ), const SizedBox(width: 12), SizedBox( width: 200, @@ -664,15 +911,26 @@ class _BulkSettlementDialogState extends State { }, }).toList(); - // ارسال به سرور - await service.createReceiptPayment( - businessId: widget.businessId, - documentType: _isReceipt ? 'receipt' : 'payment', - documentDate: _docDate, - currencyId: _selectedCurrencyId!, - personLines: personLinesData, - accountLines: accountLinesData, - ); + // اگر initialDocument وجود دارد، حالت ویرایش + if (widget.initialDocument != null) { + await service.updateReceiptPayment( + documentId: widget.initialDocument!.id, + documentDate: _docDate, + currencyId: _selectedCurrencyId!, + personLines: personLinesData, + accountLines: accountLinesData, + ); + } else { + // ایجاد سند جدید + await service.createReceiptPayment( + businessId: widget.businessId, + documentType: _isReceipt ? 'receipt' : 'payment', + documentDate: _docDate, + currencyId: _selectedCurrencyId!, + personLines: personLinesData, + accountLines: accountLinesData, + ); + } if (!mounted) return; @@ -686,9 +944,9 @@ class _BulkSettlementDialogState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - _isReceipt - ? 'سند دریافت با موفقیت ثبت شد' - : 'سند پرداخت با موفقیت ثبت شد', + widget.initialDocument != null + ? 'سند با موفقیت ویرایش شد' + : (_isReceipt ? 'سند دریافت با موفقیت ثبت شد' : 'سند پرداخت با موفقیت ثبت شد'), ), backgroundColor: Colors.green, ), diff --git a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart index 266a5f3..f12c693 100644 --- a/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/business_dashboard_service.dart @@ -1,17 +1,23 @@ import 'package:dio/dio.dart'; import '../core/api_client.dart'; import '../models/business_dashboard_models.dart'; +import '../core/fiscal_year_controller.dart'; class BusinessDashboardService { final ApiClient _apiClient; + final FiscalYearController? fiscalYearController; - BusinessDashboardService(this._apiClient); + BusinessDashboardService(this._apiClient, {this.fiscalYearController}); /// دریافت داشبورد کسب و کار Future getDashboard(int businessId) async { try { final response = await _apiClient.post>( '/api/v1/business/$businessId/dashboard', + options: Options(headers: { + if (fiscalYearController?.fiscalYearId != null) + 'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(), + }), ); if (response.data?['success'] == true) { @@ -62,6 +68,10 @@ class BusinessDashboardService { try { final response = await _apiClient.post>( '/api/v1/business/$businessId/statistics', + options: Options(headers: { + if (fiscalYearController?.fiscalYearId != null) + 'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(), + }), ); if (response.data?['success'] == true) { @@ -82,6 +92,13 @@ class BusinessDashboardService { } } + Future>> listFiscalYears(int businessId) async { + final res = await _apiClient.get>('/api/v1/business/$businessId/fiscal-years'); + final data = res.data as Map; + final items = (data['data']?['items'] as List?) ?? const []; + return items.cast>(); + } + /// دریافت لیست کسب و کارهای کاربر (مالک + عضو) Future> getUserBusinesses() async { try { diff --git a/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart index d7266b6..96836fb 100644 --- a/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/receipt_payment_service.dart @@ -98,6 +98,28 @@ class ReceiptPaymentService { ); } + /// ویرایش سند دریافت/پرداخت + Future> updateReceiptPayment({ + required int documentId, + required DateTime documentDate, + required int currencyId, + required List> personLines, + required List> accountLines, + Map? extraInfo, + }) async { + final response = await _apiClient.put( + '/receipts-payments/$documentId', + data: { + 'document_date': documentDate.toIso8601String(), + 'currency_id': currencyId, + 'person_lines': personLines, + 'account_lines': accountLines, + if (extraInfo != null) 'extra_info': extraInfo, + }, + ); + return response.data['data'] as Map; + } + /// ایجاد سند دریافت /// /// این متد یک wrapper ساده برای createReceiptPayment است diff --git a/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart b/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart new file mode 100644 index 0000000..cfcf4b2 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import '../core/fiscal_year_controller.dart'; +import 'package:hesabix_ui/l10n/app_localizations.dart'; + +class FiscalYearSwitcher extends StatelessWidget { + final FiscalYearController controller; + final List> fiscalYears; // [{id, title, start_date, end_date, is_current}] + final VoidCallback? onChanged; // برای رفرش دیتای داشبورد بعد از تغییر + + const FiscalYearSwitcher({super.key, required this.controller, required this.fiscalYears, this.onChanged}); + + @override + Widget build(BuildContext context) { + final t = AppLocalizations.of(context); + final int? selectedId = controller.fiscalYearId ?? _currentDefaultId(); + + return DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedId, + icon: const Icon(Icons.expand_more, size: 18), + items: fiscalYears.map((fy) { + final id = fy['id'] as int; + final title = (fy['title'] as String?) ?? id.toString(); + return DropdownMenuItem( + value: id, + child: Text(title, overflow: TextOverflow.ellipsis), + ); + }).toList(), + onChanged: (id) async { + await controller.setFiscalYearId(id); + onChanged?.call(); + }, + hint: const Text('سال مالی'), + ), + ); + } + + int? _currentDefaultId() { + try { + final current = fiscalYears.firstWhere((e) => e['is_current'] == true, orElse: () => {}); + return (current['id'] as int?); + } catch (_) { + return null; + } + } +} + +