arc/hesabixAPI/app/services/received_loan_facility_accounting.py
2026-05-14 01:30:20 +03:30

335 lines
9.7 KiB
Python
Executable file

"""ثبت اسناد حسابداری برای تسهیلات دریافتی (نوع سند manual — قابل حذف و ویرایش مثل اسناد دستی)."""
from __future__ import annotations
import logging
from datetime import datetime, date, timezone
from decimal import Decimal
from typing import Any, Dict, List
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.business import Business
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
from app.services.document_monetization_service import (
ensure_document_policy_allows_creation,
process_document_usage_for_document,
)
from app.services.document_numbering_service import generate_document_code
from app.services.document_service import invalidate_documents_cache
from app.core.received_loan_facility_accounts import (
RECEIVED_LOAN_PRINCIPAL_PAYABLE_CODE,
LOAN_BANKING_INTEREST_EXPENSE_CODE,
LOAN_BANKING_LATE_FEE_EXPENSE_CODE,
)
logger = logging.getLogger(__name__)
DOCUMENT_TYPE_RECEIVED_LOAN_FACILITY = "received_loan_facility"
BANK_LEDGER_ACCOUNT_CODE = "10203"
DOCUMENT_SOURCE_RECEIVED_LOAN_FACILITY = "received_loan_facility"
def _append_loan_document_fx_extra(
db: Session,
business_id: int,
*,
document_currency_id: int,
document_date: date,
extra_info: Dict[str, Any],
registered_at_utc: datetime,
) -> Dict[str, Any]:
"""همان کلید fx فاکتور روی سند تسهیلات برای ماندهٔ پایه و بستن سال."""
base = dict(extra_info) if extra_info else {}
business = db.get(Business, int(business_id))
if not business:
return base
from app.services.invoice_fx_revaluation import apply_fx_revaluation_to_invoice_extra
return apply_fx_revaluation_to_invoice_extra(
db,
business,
document_currency_id=int(document_currency_id),
document_date=document_date,
extra_info=base,
data={},
user_can_select_fx_rate=False,
registered_at_utc=registered_at_utc,
)
def _get_fixed_chart_account(db: Session, account_code: str) -> Account:
row = db.query(Account).filter(
Account.business_id.is_(None), # noqa: E711
Account.code == str(account_code),
).first()
if not row:
raise ApiError(
"LOAN_CHART_ACCOUNT_NOT_FOUND",
f"Chart account code {account_code} not found for loan posting",
http_status=500,
)
return row
def _resolve_fiscal_year(db: Session, business_id: int, ref_date: date) -> FiscalYear:
fy = (
db.query(FiscalYear)
.filter(
FiscalYear.business_id == business_id,
FiscalYear.start_date <= ref_date,
FiscalYear.end_date >= ref_date,
)
.first()
)
if not fy:
raise ApiError("NO_FISCAL_YEAR", "No fiscal year covers this transaction date", http_status=400)
return fy
def _assert_fiscal_year_open_for_posting(fy: FiscalYear) -> None:
"""فقط سال مالی جاری (دارای پرچم آخرین) می‌پذیرد ثبت؛ قفل از دیدگاه عملیاتی شبیه اسناد فاکتور."""
if getattr(fy, "is_last", False) is not True:
raise ApiError(
"FISCAL_YEAR_LOCKED",
"سال مالی شامل این تاریخ بسته شده یا غیرفعال است؛ تنها برای سال مالی جاری می‌توانید سند ثبت کنید",
http_status=409,
)
def _create_balanced_manual_document(
db: Session,
*,
business_id: int,
user_id: int,
currency_id: int,
document_date: date,
description: str | None,
extra_info: Dict[str, Any],
lines_payload: List[Dict[str, Any]],
) -> Document:
registered_at_utc = datetime.now(timezone.utc)
fiscal_year = _resolve_fiscal_year(db, business_id, document_date)
_assert_fiscal_year_open_for_posting(fiscal_year)
final_extra_info = _append_loan_document_fx_extra(
db,
business_id,
document_currency_id=int(currency_id),
document_date=document_date,
extra_info=extra_info,
registered_at_utc=registered_at_utc,
)
td = sum(Decimal(str(x.get("debit", 0) or 0)) for x in lines_payload).quantize(Decimal("0.01"))
tc = sum(Decimal(str(x.get("credit", 0) or 0)) for x in lines_payload).quantize(Decimal("0.01"))
if td != tc or td <= 0:
raise ApiError(
"LOAN_ACCOUNTING_LINES_UNBALANCED",
"Loan accounting lines not balanced",
http_status=400,
)
ensure_document_policy_allows_creation(
db,
business_id,
document_type=DOCUMENT_TYPE_RECEIVED_LOAN_FACILITY,
document_date=document_date,
amount=td,
)
document: Document | None = None
for _attempt in range(8):
doc_code = generate_document_code(db, business_id, DOCUMENT_TYPE_RECEIVED_LOAN_FACILITY, document_date)
candidate = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=DOCUMENT_TYPE_RECEIVED_LOAN_FACILITY,
document_date=document_date,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=description,
extra_info=final_extra_info,
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
except IntegrityError as exc:
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
continue
raise
else:
document = candidate
break
if document is None:
raise ApiError("DOCUMENT_CODE_RACE", "Failed to allocate document code", http_status=409)
for ln in lines_payload:
db.add(DocumentLine(document_id=document.id, **ln))
db.flush()
try:
process_document_usage_for_document(db, document.id)
except Exception as e:
logger.warning("loan_doc_monetization_skip", extra={"error": str(e), "document_id": document.id})
return document
def post_disbursement_document(
db: Session,
business_id: int,
user_id: int,
*,
title: str,
principal_amount: Decimal,
facility_currency_id: int,
bank_account_id: int,
contract_date: date,
facility_id: int,
) -> Document:
bank_ledger = _get_fixed_chart_account(db, BANK_LEDGER_ACCOUNT_CODE)
principal_acc = _get_fixed_chart_account(db, RECEIVED_LOAN_PRINCIPAL_PAYABLE_CODE)
p_amt = Decimal(str(principal_amount)).quantize(Decimal("0.01"))
if p_amt <= 0:
raise ApiError("INVALID_AMOUNT", "Disbursement amount must be positive", http_status=400)
desc = f"دریافت تسهیلات — {title}"
extra = {
"source": DOCUMENT_SOURCE_RECEIVED_LOAN_FACILITY,
"facility_id": facility_id,
"kind": "disbursement",
}
return _create_balanced_manual_document(
db,
business_id=business_id,
user_id=user_id,
currency_id=int(facility_currency_id),
document_date=contract_date,
description=desc,
extra_info=extra,
lines_payload=[
{
"account_id": bank_ledger.id,
"debit": p_amt,
"credit": Decimal("0"),
"bank_account_id": int(bank_account_id),
"description": "واریز نقد از محل دریافت تسهیلات",
},
{
"account_id": principal_acc.id,
"debit": Decimal("0"),
"credit": p_amt,
"description": "اصل بدهی تسهیلات دریافتی",
},
],
)
def post_installment_payment_document(
db: Session,
business_id: int,
user_id: int,
*,
title: str,
facility_currency_id: int,
facility_id: int,
installment_id: int,
payment_id: int,
payment_date: date,
principal_part: Decimal,
interest_part: Decimal,
penalty_part: Decimal,
bank_account_id: int | None,
) -> Document | None:
pri = Decimal(str(principal_part)).quantize(Decimal("0.01"))
inte = Decimal(str(interest_part)).quantize(Decimal("0.01"))
pen = Decimal(str(penalty_part)).quantize(Decimal("0.01"))
total = (pri + inte + pen).quantize(Decimal("0.01"))
if total <= 0:
return None
if bank_account_id is None:
raise ApiError(
"LOAN_BANK_REQUIRED_FOR_PAYMENT_DOCUMENT",
"Bank account required for installment payment accounting",
http_status=400,
)
bank_ledger = _get_fixed_chart_account(db, BANK_LEDGER_ACCOUNT_CODE)
principal_acc = _get_fixed_chart_account(db, RECEIVED_LOAN_PRINCIPAL_PAYABLE_CODE)
interest_exp = _get_fixed_chart_account(db, LOAN_BANKING_INTEREST_EXPENSE_CODE)
penalty_exp = _get_fixed_chart_account(db, LOAN_BANKING_LATE_FEE_EXPENSE_CODE)
lines_payload: List[Dict[str, Any]] = []
if pri > 0:
lines_payload.append(
{
"account_id": principal_acc.id,
"debit": pri,
"credit": Decimal("0"),
"description": "پرداخت اصل قسط",
},
)
if inte > 0:
lines_payload.append(
{
"account_id": interest_exp.id,
"debit": inte,
"credit": Decimal("0"),
"description": "بهره تسهیلات",
},
)
if pen > 0:
lines_payload.append(
{
"account_id": penalty_exp.id,
"debit": pen,
"credit": Decimal("0"),
"description": "جریمه دیرکرد / وجه التزام",
},
)
lines_payload.append(
{
"account_id": bank_ledger.id,
"debit": Decimal("0"),
"credit": total,
"bank_account_id": int(bank_account_id),
"description": f"پرداخت قسط — {title}",
},
)
extra = {
"source": DOCUMENT_SOURCE_RECEIVED_LOAN_FACILITY,
"facility_id": facility_id,
"installment_id": installment_id,
"payment_id": payment_id,
"kind": "installment_payment",
}
return _create_balanced_manual_document(
db,
business_id=business_id,
user_id=user_id,
currency_id=int(facility_currency_id),
document_date=payment_date,
description=f"پرداخت قسط تسهیلات — {title}",
extra_info=extra,
lines_payload=lines_payload,
)
def notify_document_cache_manual(db: Session, document: Document) -> None:
try:
invalidate_documents_cache(
business_id=document.business_id,
fiscal_year_id=document.fiscal_year_id,
document_id=document.id,
document_type=DOCUMENT_TYPE_RECEIVED_LOAN_FACILITY,
)
except Exception as e:
logger.warning("loan_doc_cache_invalidate_failed", extra={"error": str(e)})