progress in recipies
This commit is contained in:
parent
4c9283ab98
commit
4d7a31409c
71
hesabixAPI/adapters/api/v1/fiscal_years.py
Normal file
71
hesabixAPI/adapters/api/v1/fiscal_years.py
Normal file
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,6 +21,7 @@ from app.services.receipt_payment_service import (
|
||||||
get_receipt_payment,
|
get_receipt_payment,
|
||||||
list_receipts_payments,
|
list_receipts_payments,
|
||||||
delete_receipt_payment,
|
delete_receipt_payment,
|
||||||
|
update_receipt_payment,
|
||||||
)
|
)
|
||||||
from adapters.db.models.business import Business
|
from adapters.db.models.business import Business
|
||||||
|
|
||||||
|
|
@ -67,6 +68,14 @@ async def list_receipts_payments_endpoint(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = list_receipts_payments(db, business_id, query_dict)
|
||||||
result["items"] = [format_datetime_fields(item, request) for item in result.get("items", [])]
|
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(
|
@router.post(
|
||||||
"/businesses/{business_id}/receipts-payments/export/excel",
|
"/businesses/{business_id}/receipts-payments/export/excel",
|
||||||
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
|
summary="خروجی Excel لیست اسناد دریافت و پرداخت",
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,18 @@ class FiscalYearRepository(BaseRepository[FiscalYear]):
|
||||||
self.db.refresh(fiscal_year)
|
self.db.refresh(fiscal_year)
|
||||||
return 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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.i18n import negotiate_locale, Translator
|
||||||
from app.core.error_handlers import register_error_handlers
|
from app.core.error_handlers import register_error_handlers
|
||||||
from app.core.smart_normalizer import smart_normalize_json, SmartNormalizerConfig
|
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_units_router, prefix=settings.api_v1_prefix)
|
||||||
application.include_router(tax_types_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(receipts_payments_router, prefix=settings.api_v1_prefix)
|
||||||
|
application.include_router(fiscal_years_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
# Support endpoints
|
# Support endpoints
|
||||||
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
application.include_router(support_tickets_router, prefix=f"{settings.api_v1_prefix}/support")
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,22 @@ def list_receipts_payments(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# فیلتر بر اساس سال مالی (از 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")
|
doc_type = query.get("document_type")
|
||||||
if doc_type:
|
if doc_type:
|
||||||
|
|
@ -654,12 +670,348 @@ def delete_receipt_payment(db: Session, document_id: int) -> bool:
|
||||||
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
if document.document_type not in (DOCUMENT_TYPE_RECEIPT, DOCUMENT_TYPE_PAYMENT):
|
||||||
return False
|
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.delete(document)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return True
|
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]:
|
def document_to_dict(db: Session, document: Document) -> Dict[str, Any]:
|
||||||
"""تبدیل سند به دیکشنری"""
|
"""تبدیل سند به دیکشنری"""
|
||||||
# دریافت خطوط سند
|
# دریافت خطوط سند
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -399,3 +399,35 @@ msgstr "Storage connection test - to be implemented"
|
||||||
|
|
||||||
msgid "TEST_STORAGE_CONFIG_ERROR"
|
msgid "TEST_STORAGE_CONFIG_ERROR"
|
||||||
msgstr "Error testing storage connection"
|
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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -422,3 +422,35 @@ msgid "TEST_STORAGE_CONFIG_ERROR"
|
||||||
msgstr "خطا در تست اتصال"
|
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 "سند دریافت/پرداخت با موفقیت ویرایش شد"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class ApiClient {
|
||||||
static Locale? _currentLocale;
|
static Locale? _currentLocale;
|
||||||
static AuthStore? _authStore;
|
static AuthStore? _authStore;
|
||||||
static CalendarController? _calendarController;
|
static CalendarController? _calendarController;
|
||||||
|
static ValueNotifier<int?>? _fiscalYearId;
|
||||||
|
|
||||||
static void setCurrentLocale(Locale locale) {
|
static void setCurrentLocale(Locale locale) {
|
||||||
_currentLocale = locale;
|
_currentLocale = locale;
|
||||||
|
|
@ -36,6 +37,11 @@ class ApiClient {
|
||||||
_calendarController = controller;
|
_calendarController = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fiscal Year binding (allows UI to update selected fiscal year globally)
|
||||||
|
static void bindFiscalYear(ValueNotifier<int?> fiscalYearId) {
|
||||||
|
_fiscalYearId = fiscalYearId;
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient._(this._dio);
|
ApiClient._(this._dio);
|
||||||
|
|
||||||
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
|
factory ApiClient({String? baseUrl, ApiClientOptions options = const ApiClientOptions()}) {
|
||||||
|
|
@ -71,6 +77,11 @@ class ApiClient {
|
||||||
if (calendarType != null && calendarType.isNotEmpty) {
|
if (calendarType != null && calendarType.isNotEmpty) {
|
||||||
options.headers['X-Calendar-Type'] = calendarType;
|
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
|
// Inject X-Business-ID header when request targets a specific business
|
||||||
try {
|
try {
|
||||||
final uri = options.uri;
|
final uri = options.uri;
|
||||||
|
|
|
||||||
33
hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart
Normal file
33
hesabixUI/hesabix_ui/lib/core/fiscal_year_controller.dart
Normal file
|
|
@ -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<FiscalYearController> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final id = prefs.getInt(_prefsKey);
|
||||||
|
return FiscalYearController._(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3,6 +3,8 @@ import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import '../../../services/business_dashboard_service.dart';
|
import '../../../services/business_dashboard_service.dart';
|
||||||
import '../../../core/api_client.dart';
|
import '../../../core/api_client.dart';
|
||||||
import '../../../models/business_dashboard_models.dart';
|
import '../../../models/business_dashboard_models.dart';
|
||||||
|
import '../../../core/fiscal_year_controller.dart';
|
||||||
|
import '../../../widgets/fiscal_year_switcher.dart';
|
||||||
|
|
||||||
class BusinessDashboardPage extends StatefulWidget {
|
class BusinessDashboardPage extends StatefulWidget {
|
||||||
final int businessId;
|
final int businessId;
|
||||||
|
|
@ -14,7 +16,8 @@ class BusinessDashboardPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
final BusinessDashboardService _service = BusinessDashboardService(ApiClient());
|
late final FiscalYearController _fiscalController;
|
||||||
|
late final BusinessDashboardService _service;
|
||||||
BusinessDashboardResponse? _dashboardData;
|
BusinessDashboardResponse? _dashboardData;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
@ -22,7 +25,20 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
_fiscalController = await FiscalYearController.load();
|
||||||
|
_service = BusinessDashboardService(ApiClient(), fiscalYearController: _fiscalController);
|
||||||
|
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
|
||||||
|
_fiscalController.addListener(() {
|
||||||
|
// بهروزرسانی هدر سراسری
|
||||||
|
ApiClient.bindFiscalYear(ValueNotifier<int?>(_fiscalController.fiscalYearId));
|
||||||
|
// رفرش داشبورد
|
||||||
_loadDashboard();
|
_loadDashboard();
|
||||||
|
});
|
||||||
|
await _loadDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDashboard() async {
|
Future<void> _loadDashboard() async {
|
||||||
|
|
@ -85,10 +101,46 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
t.businessDashboard,
|
t.businessDashboard,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
|
future: _service.listFiscalYears(widget.businessId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final items = snapshot.data ?? const <Map<String, dynamic>>[];
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
if (_dashboardData != null) ...[
|
if (_dashboardData != null) ...[
|
||||||
_buildBusinessInfo(_dashboardData!.businessInfo),
|
_buildBusinessInfo(_dashboardData!.businessInfo),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
import 'package:hesabix_ui/core/calendar_controller.dart';
|
import 'package:hesabix_ui/core/calendar_controller.dart';
|
||||||
import 'package:hesabix_ui/core/auth_store.dart';
|
import 'package:hesabix_ui/core/auth_store.dart';
|
||||||
|
|
@ -43,7 +44,9 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
String? _selectedDocumentType;
|
String? _selectedDocumentType;
|
||||||
DateTime? _fromDate;
|
DateTime? _fromDate;
|
||||||
DateTime? _toDate;
|
DateTime? _toDate;
|
||||||
int _refreshKey = 0; // کلید برای تازهسازی جدول
|
// کلید کنترل جدول برای دسترسی به selection و refresh
|
||||||
|
final GlobalKey _tableKey = GlobalKey();
|
||||||
|
int _selectedCount = 0; // تعداد سطرهای انتخابشده
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -53,9 +56,17 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
|
|
||||||
/// تازهسازی دادههای جدول
|
/// تازهسازی دادههای جدول
|
||||||
void _refreshData() {
|
void _refreshData() {
|
||||||
setState(() {
|
final state = _tableKey.currentState;
|
||||||
_refreshKey++; // تغییر کلید باعث rebuild شدن جدول میشود
|
if (state != null) {
|
||||||
});
|
try {
|
||||||
|
// استفاده از متد عمومی refresh در ویجت جدول
|
||||||
|
// نوت: دسترسی دینامیک چون State کلاس خصوصی است
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
(state as dynamic).refresh();
|
||||||
|
return;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -79,7 +90,7 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: DataTableWidget<ReceiptPaymentDocument>(
|
child: DataTableWidget<ReceiptPaymentDocument>(
|
||||||
key: ValueKey(_refreshKey),
|
key: _tableKey,
|
||||||
config: _buildTableConfig(t),
|
config: _buildTableConfig(t),
|
||||||
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
|
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
|
||||||
calendarController: widget.calendarController,
|
calendarController: widget.calendarController,
|
||||||
|
|
@ -223,6 +234,21 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
title: t.receiptsAndPayments,
|
title: t.receiptsAndPayments,
|
||||||
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
excelEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/excel',
|
||||||
pdfEndpoint: '/businesses/${widget.businessId}/receipts-payments/export/pdf',
|
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: () => {
|
getExportParams: () => {
|
||||||
'business_id': widget.businessId,
|
'business_id': widget.businessId,
|
||||||
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||||
|
|
@ -336,6 +362,11 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
showPdfExport: true,
|
showPdfExport: true,
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
pageSizeOptions: [10, 20, 50, 100],
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
onRowSelectionChanged: (rows) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCount = rows.length;
|
||||||
|
});
|
||||||
|
},
|
||||||
additionalParams: {
|
additionalParams: {
|
||||||
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
if (_selectedDocumentType != null) 'document_type': _selectedDocumentType,
|
||||||
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
if (_fromDate != null) 'from_date': _fromDate!.toIso8601String(),
|
||||||
|
|
@ -379,13 +410,39 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ویرایش سند
|
/// ویرایش سند
|
||||||
void _onEdit(ReceiptPaymentDocument document) {
|
void _onEdit(ReceiptPaymentDocument document) async {
|
||||||
// TODO: باز کردن صفحه ویرایش سند
|
try {
|
||||||
|
// دریافت جزئیات کامل سند
|
||||||
|
final fullDoc = await _service.getById(document.id);
|
||||||
|
if (fullDoc == null) {
|
||||||
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(content: Text('سند یافت نشد')),
|
||||||
content: Text('ویرایش سند ${document.code}'),
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
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<ReceiptsPaymentsListPage> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('تأیید حذف'),
|
title: const Text('تأیید حذف'),
|
||||||
content: Text('آیا از حذف سند ${document.code} اطمینان دارید؟'),
|
content: Text('حذف سند ${document.code} غیرقابل بازگشت است. آیا ادامه میدهید؟'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
|
|
@ -415,30 +472,168 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
||||||
/// انجام عملیات حذف
|
/// انجام عملیات حذف
|
||||||
Future<void> _performDelete(ReceiptPaymentDocument document) async {
|
Future<void> _performDelete(ReceiptPaymentDocument document) async {
|
||||||
try {
|
try {
|
||||||
|
// نمایش لودینگ هنگام حذف
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
|
||||||
final success = await _service.delete(document.id);
|
final success = await _service.delete(document.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
Navigator.pop(context); // بستن لودینگ
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('سند ${document.code} با موفقیت حذف شد'),
|
content: Text('سند ${document.code} با موفقیت حذف شد'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setState(() {
|
||||||
|
_selectedCount = 0; // پاکسازی شمارنده انتخاب پس از حذف
|
||||||
|
});
|
||||||
|
_refreshData();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
throw Exception('خطا در حذف سند');
|
throw Exception('خطا در حذف سند');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
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<String, dynamic>) ? data['detail'] : null;
|
||||||
|
if (detail is Map<String, dynamic>) {
|
||||||
|
final err = detail['error'];
|
||||||
|
if (err is Map<String, dynamic>) {
|
||||||
|
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('خطا در حذف سند: $e'),
|
content: Text(message),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// حذف گروهی اسناد انتخابشده
|
||||||
|
Future<void> _onBulkDelete() async {
|
||||||
|
// استخراج آیتمهای انتخابشده از جدول
|
||||||
|
final state = _tableKey.currentState;
|
||||||
|
if (state == null) return;
|
||||||
|
|
||||||
|
List<dynamic> selectedItems = const [];
|
||||||
|
try {
|
||||||
|
// ignore: avoid_dynamic_calls
|
||||||
|
selectedItems = (state as dynamic).getSelectedItems();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (selectedItems.isEmpty) return;
|
||||||
|
|
||||||
|
// نگاشت به مدل و شناسهها
|
||||||
|
final docs = selectedItems.cast<ReceiptPaymentDocument>();
|
||||||
|
final ids = docs.map((d) => d.id).toList();
|
||||||
|
final codes = docs.map((d) => d.code).toList();
|
||||||
|
|
||||||
|
// تایید کاربر
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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 {
|
class BulkSettlementDialog extends StatefulWidget {
|
||||||
|
|
@ -447,6 +642,7 @@ class BulkSettlementDialog extends StatefulWidget {
|
||||||
final bool isReceipt;
|
final bool isReceipt;
|
||||||
final BusinessWithPermission? businessInfo;
|
final BusinessWithPermission? businessInfo;
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
final ReceiptPaymentDocument? initialDocument;
|
||||||
const BulkSettlementDialog({
|
const BulkSettlementDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.businessId,
|
required this.businessId,
|
||||||
|
|
@ -454,6 +650,7 @@ class BulkSettlementDialog extends StatefulWidget {
|
||||||
required this.isReceipt,
|
required this.isReceipt,
|
||||||
this.businessInfo,
|
this.businessInfo,
|
||||||
required this.apiClient,
|
required this.apiClient,
|
||||||
|
this.initialDocument,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -472,11 +669,60 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
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();
|
_docDate = DateTime.now();
|
||||||
_isReceipt = widget.isReceipt;
|
_isReceipt = widget.isReceipt;
|
||||||
// اگر ارز پیشفرض موجود است، آن را انتخاب کن، در غیر این صورت null بگذار تا CurrencyPickerWidget خودکار انتخاب کند
|
|
||||||
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
|
_selectedCurrencyId = widget.businessInfo?.defaultCurrency?.id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -504,6 +750,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.initialDocument == null)
|
||||||
SegmentedButton<bool>(
|
SegmentedButton<bool>(
|
||||||
segments: [
|
segments: [
|
||||||
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
|
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
|
||||||
|
|
@ -664,7 +911,17 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
},
|
},
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// ارسال به سرور
|
// اگر initialDocument وجود دارد، حالت ویرایش
|
||||||
|
if (widget.initialDocument != null) {
|
||||||
|
await service.updateReceiptPayment(
|
||||||
|
documentId: widget.initialDocument!.id,
|
||||||
|
documentDate: _docDate,
|
||||||
|
currencyId: _selectedCurrencyId!,
|
||||||
|
personLines: personLinesData,
|
||||||
|
accountLines: accountLinesData,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// ایجاد سند جدید
|
||||||
await service.createReceiptPayment(
|
await service.createReceiptPayment(
|
||||||
businessId: widget.businessId,
|
businessId: widget.businessId,
|
||||||
documentType: _isReceipt ? 'receipt' : 'payment',
|
documentType: _isReceipt ? 'receipt' : 'payment',
|
||||||
|
|
@ -673,6 +930,7 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
personLines: personLinesData,
|
personLines: personLinesData,
|
||||||
accountLines: accountLinesData,
|
accountLines: accountLinesData,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|
@ -686,9 +944,9 @@ class _BulkSettlementDialogState extends State<BulkSettlementDialog> {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
_isReceipt
|
widget.initialDocument != null
|
||||||
? 'سند دریافت با موفقیت ثبت شد'
|
? 'سند با موفقیت ویرایش شد'
|
||||||
: 'سند پرداخت با موفقیت ثبت شد',
|
: (_isReceipt ? 'سند دریافت با موفقیت ثبت شد' : 'سند پرداخت با موفقیت ثبت شد'),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import '../core/api_client.dart';
|
import '../core/api_client.dart';
|
||||||
import '../models/business_dashboard_models.dart';
|
import '../models/business_dashboard_models.dart';
|
||||||
|
import '../core/fiscal_year_controller.dart';
|
||||||
|
|
||||||
class BusinessDashboardService {
|
class BusinessDashboardService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
|
final FiscalYearController? fiscalYearController;
|
||||||
|
|
||||||
BusinessDashboardService(this._apiClient);
|
BusinessDashboardService(this._apiClient, {this.fiscalYearController});
|
||||||
|
|
||||||
/// دریافت داشبورد کسب و کار
|
/// دریافت داشبورد کسب و کار
|
||||||
Future<BusinessDashboardResponse> getDashboard(int businessId) async {
|
Future<BusinessDashboardResponse> getDashboard(int businessId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
'/api/v1/business/$businessId/dashboard',
|
'/api/v1/business/$businessId/dashboard',
|
||||||
|
options: Options(headers: {
|
||||||
|
if (fiscalYearController?.fiscalYearId != null)
|
||||||
|
'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data?['success'] == true) {
|
if (response.data?['success'] == true) {
|
||||||
|
|
@ -62,6 +68,10 @@ class BusinessDashboardService {
|
||||||
try {
|
try {
|
||||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||||
'/api/v1/business/$businessId/statistics',
|
'/api/v1/business/$businessId/statistics',
|
||||||
|
options: Options(headers: {
|
||||||
|
if (fiscalYearController?.fiscalYearId != null)
|
||||||
|
'X-Fiscal-Year-ID': fiscalYearController!.fiscalYearId.toString(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data?['success'] == true) {
|
if (response.data?['success'] == true) {
|
||||||
|
|
@ -82,6 +92,13 @@ class BusinessDashboardService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> listFiscalYears(int businessId) async {
|
||||||
|
final res = await _apiClient.get<Map<String, dynamic>>('/api/v1/business/$businessId/fiscal-years');
|
||||||
|
final data = res.data as Map<String, dynamic>;
|
||||||
|
final items = (data['data']?['items'] as List?) ?? const [];
|
||||||
|
return items.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
/// دریافت لیست کسب و کارهای کاربر (مالک + عضو)
|
/// دریافت لیست کسب و کارهای کاربر (مالک + عضو)
|
||||||
Future<List<BusinessWithPermission>> getUserBusinesses() async {
|
Future<List<BusinessWithPermission>> getUserBusinesses() async {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,28 @@ class ReceiptPaymentService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ویرایش سند دریافت/پرداخت
|
||||||
|
Future<Map<String, dynamic>> updateReceiptPayment({
|
||||||
|
required int documentId,
|
||||||
|
required DateTime documentDate,
|
||||||
|
required int currencyId,
|
||||||
|
required List<Map<String, dynamic>> personLines,
|
||||||
|
required List<Map<String, dynamic>> accountLines,
|
||||||
|
Map<String, dynamic>? 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// ایجاد سند دریافت
|
/// ایجاد سند دریافت
|
||||||
///
|
///
|
||||||
/// این متد یک wrapper ساده برای createReceiptPayment است
|
/// این متد یک wrapper ساده برای createReceiptPayment است
|
||||||
|
|
|
||||||
48
hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart
Normal file
48
hesabixUI/hesabix_ui/lib/widgets/fiscal_year_switcher.dart
Normal file
|
|
@ -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<Map<String, dynamic>> 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<int>(
|
||||||
|
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<int>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue