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,
|
||||
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 لیست اسناد دریافت و پرداخت",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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):
|
||||
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()
|
||||
|
||||
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]:
|
||||
"""تبدیل سند به دیکشنری"""
|
||||
# دریافت خطوط سند
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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 "سند دریافت/پرداخت با موفقیت ویرایش شد"
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class ApiClient {
|
|||
static Locale? _currentLocale;
|
||||
static AuthStore? _authStore;
|
||||
static CalendarController? _calendarController;
|
||||
static ValueNotifier<int?>? _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<int?> 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;
|
||||
|
|
|
|||
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 '../../../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<BusinessDashboardPage> {
|
||||
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<BusinessDashboardPage> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDashboard();
|
||||
_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();
|
||||
});
|
||||
await _loadDashboard();
|
||||
}
|
||||
|
||||
Future<void> _loadDashboard() async {
|
||||
|
|
@ -85,9 +101,45 @@ class _BusinessDashboardPageState extends State<BusinessDashboardPage> {
|
|||
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<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),
|
||||
if (_dashboardData != null) ...[
|
||||
|
|
|
|||
|
|
@ -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<ReceiptsPaymentsListPage> {
|
|||
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<ReceiptsPaymentsListPage> {
|
|||
|
||||
/// تازهسازی دادههای جدول
|
||||
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<ReceiptsPaymentsListPage> {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: DataTableWidget<ReceiptPaymentDocument>(
|
||||
key: ValueKey(_refreshKey),
|
||||
key: _tableKey,
|
||||
config: _buildTableConfig(t),
|
||||
fromJson: (json) => ReceiptPaymentDocument.fromJson(json),
|
||||
calendarController: widget.calendarController,
|
||||
|
|
@ -223,6 +234,21 @@ class _ReceiptsPaymentsListPageState extends State<ReceiptsPaymentsListPage> {
|
|||
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<ReceiptsPaymentsListPage> {
|
|||
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<ReceiptsPaymentsListPage> {
|
|||
}
|
||||
|
||||
/// ویرایش سند
|
||||
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<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,
|
||||
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<ReceiptsPaymentsListPage> {
|
|||
/// انجام عملیات حذف
|
||||
Future<void> _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<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(
|
||||
SnackBar(
|
||||
content: Text('خطا در حذف سند: $e'),
|
||||
content: Text(message),
|
||||
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 {
|
||||
|
|
@ -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<BulkSettlementDialog> {
|
|||
@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<BulkSettlementDialog> {
|
|||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
SegmentedButton<bool>(
|
||||
if (widget.initialDocument == null)
|
||||
SegmentedButton<bool>(
|
||||
segments: [
|
||||
ButtonSegment<bool>(value: true, label: Text(t.receipts)),
|
||||
ButtonSegment<bool>(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<BulkSettlementDialog> {
|
|||
},
|
||||
}).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<BulkSettlementDialog> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isReceipt
|
||||
? 'سند دریافت با موفقیت ثبت شد'
|
||||
: 'سند پرداخت با موفقیت ثبت شد',
|
||||
widget.initialDocument != null
|
||||
? 'سند با موفقیت ویرایش شد'
|
||||
: (_isReceipt ? 'سند دریافت با موفقیت ثبت شد' : 'سند پرداخت با موفقیت ثبت شد'),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<BusinessDashboardResponse> getDashboard(int businessId) async {
|
||||
try {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'/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<Map<String, dynamic>>(
|
||||
'/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<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 {
|
||||
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 است
|
||||
|
|
|
|||
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