Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/document_line_fx_service.py

456 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
تسعیر خطوط سند به ارز پایه (P2.5) و غنی‌سازی جمع فاکتور (P2).
اصول:
- debit/credit همیشه به ارز سند می‌مانند (مانده حساب بانکی/صندوق به ارز همان حساب).
- debit_base/credit_base معادل ارز پایه برای گزارش‌گیری، مانده شخص، بستن سال.
- ستون‌ها nullable؛ اسناد قدیمی با fallback (fx سند → resolve نرخ → ارز پایه) خوانده می‌شوند.
- کسب‌وکار تک‌ارزی: rate=1 و base=amount (یا NULL؛ هر دو از نظر معنایی یکسان‌اند اگر ارز سند=پایه).
"""
from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from adapters.db.models.business import Business
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from app.core.responses import ApiError
from app.services.currency_quant import get_currency_quant_and_round
logger = logging.getLogger(__name__)
def _d(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else 0))
except Exception:
return Decimal(0)
def parse_fx_rate_from_extra(extra_info: Any) -> Optional[Decimal]:
"""نرخ قفل‌شده از extra_info.fx؛ در صورت skip یا نامعتبر → None."""
if not isinstance(extra_info, dict):
return None
fx = extra_info.get("fx")
if not isinstance(fx, dict) or fx.get("skipped"):
return None
raw = fx.get("rate")
if raw is None:
return None
try:
rate = Decimal(str(raw))
except Exception:
return None
if rate <= 0:
return None
return rate
def quantize_money(amount: Decimal, quant: Decimal, *, round_on: bool) -> Decimal:
if not round_on:
return amount
return amount.quantize(quant, rounding=ROUND_HALF_UP)
def compute_base_amounts(
*,
debit: Decimal,
credit: Decimal,
rate: Decimal,
base_quant: Decimal,
round_on: bool,
) -> Tuple[Decimal, Decimal]:
"""debit/credit × rate → مبالغ پایه گردشده طبق اعشار ارز پایه."""
r = rate if rate > 0 else Decimal(1)
debit_base = quantize_money(_d(debit) * r, base_quant, round_on=round_on)
credit_base = quantize_money(_d(credit) * r, base_quant, round_on=round_on)
return debit_base, credit_base
def resolve_document_fx_rate(
db: Session,
document: Document,
*,
business: Optional[Business] = None,
allow_infer: bool = True,
fallback_one_on_missing: bool = True,
) -> Tuple[Optional[Decimal], str]:
"""
نرخ تبدیل ۱ واحد ارز سند → ارز پایه.
Returns: (rate, source) که source یکی از:
snapshot | base | inferred | missing | no_default_currency
"""
biz = business or db.get(Business, int(document.business_id))
if not biz or not biz.default_currency_id:
return None, "no_default_currency"
base_id = int(biz.default_currency_id)
doc_currency_id = int(document.currency_id or 0)
if doc_currency_id == base_id:
return Decimal(1), "base"
snap = parse_fx_rate_from_extra(document.extra_info)
if snap is not None:
return snap, "snapshot"
if not allow_infer:
return (Decimal(1), "missing") if fallback_one_on_missing else (None, "missing")
from app.services.business_currency_rate_service import resolve_rate_to_base, _to_utc_aware
from app.services.invoice_fx_revaluation import compute_fx_as_of_utc, get_fx_revaluation_policy
policy = get_fx_revaluation_policy(biz)
reg = document.registered_at or datetime.now(timezone.utc)
if reg.tzinfo is None:
reg = reg.replace(tzinfo=timezone.utc)
reg = _to_utc_aware(reg)
as_of = compute_fx_as_of_utc(document.document_date, reg, policy)
try:
res = resolve_rate_to_base(db, int(document.business_id), doc_currency_id, as_of)
rate = res["rate"] if isinstance(res["rate"], Decimal) else Decimal(str(res["rate"]))
if rate > 0:
return rate, "inferred"
except ApiError:
pass
try:
fallback_as_of = datetime.combine(
document.document_date, time(23, 59, 59, 999999), tzinfo=timezone.utc
)
res = resolve_rate_to_base(db, int(document.business_id), doc_currency_id, fallback_as_of)
rate = res["rate"] if isinstance(res["rate"], Decimal) else Decimal(str(res["rate"]))
if rate > 0:
return rate, "inferred"
except ApiError as e:
logger.warning(
"document_line_fx: no rate business=%s document=%s currency=%s: %s",
document.business_id,
document.id,
doc_currency_id,
e,
)
if fallback_one_on_missing:
return Decimal(1), "missing"
return None, "missing"
def stamp_document_lines_fx_base(
db: Session,
document: Document,
*,
business: Optional[Business] = None,
only_missing: bool = False,
allow_infer: bool = True,
) -> int:
"""
پر کردن exchange_rate / debit_base / credit_base برای همه خطوط یک سند.
only_missing=True → فقط خطوطی که هنوز base ندارند (مناسب backfill).
Returns: تعداد خطوط به‌روزشده.
"""
biz = business or db.get(Business, int(document.business_id))
if not biz or not biz.default_currency_id:
return 0
rate, _source = resolve_document_fx_rate(
db,
document,
business=biz,
allow_infer=allow_infer,
# V2-P8: هرگز base را با نرخ جعلی ۱ برای اسناد ارزی ناقص پر نکن
fallback_one_on_missing=False,
)
if rate is None:
return 0
base_quant, round_on = get_currency_quant_and_round(db, int(biz.default_currency_id))
lines: List[DocumentLine] = (
db.query(DocumentLine).filter(DocumentLine.document_id == int(document.id)).all()
)
updated = 0
for line in lines:
if only_missing and line.debit_base is not None and line.credit_base is not None:
continue
debit_base, credit_base = compute_base_amounts(
debit=_d(line.debit),
credit=_d(line.credit),
rate=rate,
base_quant=base_quant,
round_on=round_on,
)
line.exchange_rate = rate
line.debit_base = debit_base
line.credit_base = credit_base
updated += 1
return updated
def line_side_amount_to_base(
db: Session,
document: Document,
line: DocumentLine,
amount: Any,
*,
side: str,
rate_cache: Dict[int, Decimal],
base_currency_by_business: Dict[int, Optional[int]],
) -> Decimal:
"""
تبدیل یک طرف خط (debit یا credit) به ارز پایه با اولویت فیلدهای ذخیره‌شده.
side: 'debit' | 'credit'
"""
amt = _d(amount)
if amt == 0:
return Decimal(0)
if side == "debit" and line.debit_base is not None and _d(line.debit) == amt:
return _d(line.debit_base)
if side == "credit" and line.credit_base is not None and _d(line.credit) == amt:
return _d(line.credit_base)
bid = int(document.business_id)
if bid not in base_currency_by_business:
bz = db.get(Business, bid)
base_currency_by_business[bid] = (
int(bz.default_currency_id) if bz and bz.default_currency_id else None
)
base_id = base_currency_by_business[bid]
if base_id is None:
return amt
# نرخ خط ذخیره‌شده
if line.exchange_rate is not None and _d(line.exchange_rate) > 0:
quant, round_on = get_currency_quant_and_round(db, int(base_id))
return quantize_money(amt * _d(line.exchange_rate), quant, round_on=round_on)
# نرخ سند (کش‌شده) یا resolve
doc_id = int(document.id)
if doc_id in rate_cache:
rate = rate_cache[doc_id]
else:
rate, _src = resolve_document_fx_rate(
db, document, allow_infer=True, fallback_one_on_missing=True
)
rate = rate if rate is not None else Decimal(1)
rate_cache[doc_id] = rate
quant, round_on = get_currency_quant_and_round(db, int(base_id))
return quantize_money(amt * rate, quant, round_on=round_on)
def build_invoice_dual_totals(
db: Session,
document: Document,
*,
business: Optional[Business] = None,
) -> Dict[str, Any]:
"""
P2: ساختار totals دوگانه برای پاسخ API فاکتور.
- totals.foreign: همان جمع‌های ارز سند (از extra_info.totals)
- totals.base: معادل ارز پایه (اگر fx معتبر باشد)
- fx: snapshot یا خلاصه وضعیت
"""
biz = business or db.get(Business, int(document.business_id))
extra = document.extra_info if isinstance(document.extra_info, dict) else {}
raw_totals = extra.get("totals") if isinstance(extra.get("totals"), dict) else {}
fx = extra.get("fx") if isinstance(extra.get("fx"), dict) else None
foreign = {
"gross": float(_d(raw_totals.get("gross"))),
"discount": float(_d(raw_totals.get("discount"))),
"tax": float(_d(raw_totals.get("tax"))),
"net": float(_d(raw_totals.get("net"))),
"adjustments_net": float(_d(raw_totals.get("adjustments_net"))),
"adjustments_tax": float(_d(raw_totals.get("adjustments_tax"))),
"currency_id": int(document.currency_id) if document.currency_id else None,
}
# قابل پرداخت ≈ net + adj_net + adj_tax (هم‌راستا با UI)
payable_foreign = (
_d(foreign["net"]) + _d(foreign["adjustments_net"]) + _d(foreign["adjustments_tax"])
)
foreign["payable"] = float(payable_foreign)
out: Dict[str, Any] = {
"foreign": foreign,
"base": None,
"fx": fx,
"is_foreign_currency": False,
"show_dual": False,
}
if not biz or not biz.default_currency_id:
return out
base_id = int(biz.default_currency_id)
doc_cur = int(document.currency_id or 0)
out["is_foreign_currency"] = doc_cur != base_id
rate, source = resolve_document_fx_rate(
db, document, business=biz, allow_infer=True, fallback_one_on_missing=False
)
# برای ارز پایه همیشه dual لازم نیست
if doc_cur == base_id:
base_quant, round_on = get_currency_quant_and_round(db, base_id)
base_block = {
k: float(quantize_money(_d(v), base_quant, round_on=round_on))
for k, v in foreign.items()
if k != "currency_id"
}
base_block["currency_id"] = base_id
base_block["rate"] = 1.0
base_block["rate_source"] = "base"
out["base"] = base_block
out["show_dual"] = False
return out
if rate is None:
out["show_dual"] = False
return out
base_quant, round_on = get_currency_quant_and_round(db, base_id)
def _to_base(v: Any) -> float:
return float(quantize_money(_d(v) * rate, base_quant, round_on=round_on))
out["base"] = {
"gross": _to_base(foreign["gross"]),
"discount": _to_base(foreign["discount"]),
"tax": _to_base(foreign["tax"]),
"net": _to_base(foreign["net"]),
"adjustments_net": _to_base(foreign["adjustments_net"]),
"adjustments_tax": _to_base(foreign["adjustments_tax"]),
"payable": _to_base(foreign["payable"]),
"currency_id": base_id,
"rate": float(rate),
"rate_source": source,
}
# فقط وقتی ارز ≠ پایه و نرخ معتبر: نمایش دوگانه (Gate در UI هم MC را چک می‌کند)
out["show_dual"] = True
return out
def backfill_document_lines_fx_base(
db: Session,
*,
business_id: Optional[int] = None,
batch_size: int = 200,
only_with_fx_snapshot: bool = True,
max_documents: Optional[int] = None,
commit_every_batch: bool = True,
) -> Dict[str, int]:
"""
مهاجرت نرم لایه ۲: پر کردن base برای اسنادی که fx دارند (پیش‌فرض).
idempotent با only_missing روی خطوط.
از keyset روی document.id استفاده می‌کند (نه OFFSET) تا روی دیتابیس بزرگ مقیاس‌پذیر باشد.
"""
from sqlalchemy import text
processed = 0
updated_docs = 0
updated_lines = 0
skipped = 0
last_id = 0
while True:
if max_documents is not None and processed >= max_documents:
break
# انتخاب دسته‌ای: فقط اسنادی که هنوز حداقل یک خط بدون base دارند
params: Dict[str, Any] = {"last_id": last_id, "lim": int(batch_size)}
biz_clause = ""
if business_id is not None:
biz_clause = "AND d.business_id = :business_id"
params["business_id"] = int(business_id)
if only_with_fx_snapshot:
# اسناد با snapshot نرخ، یا ارز سند = ارز پایه کسب‌وکار
sql = text(
f"""
SELECT d.id
FROM documents d
JOIN businesses b ON b.id = d.business_id
WHERE d.id > :last_id
{biz_clause}
AND EXISTS (
SELECT 1 FROM document_lines dl
WHERE dl.document_id = d.id
AND (dl.debit_base IS NULL OR dl.credit_base IS NULL)
)
AND (
b.default_currency_id IS NOT NULL
AND (
d.currency_id = b.default_currency_id
OR (
d.extra_info IS NOT NULL
AND (d.extra_info::jsonb) ? 'fx'
AND COALESCE(((d.extra_info::jsonb)->'fx'->>'skipped')::boolean, false) = false
AND ((d.extra_info::jsonb)->'fx'->>'rate') IS NOT NULL
)
)
)
ORDER BY d.id
LIMIT :lim
"""
)
else:
sql = text(
f"""
SELECT d.id
FROM documents d
WHERE d.id > :last_id
{biz_clause}
AND EXISTS (
SELECT 1 FROM document_lines dl
WHERE dl.document_id = d.id
AND (dl.debit_base IS NULL OR dl.credit_base IS NULL)
)
ORDER BY d.id
LIMIT :lim
"""
)
ids = [int(r[0]) for r in db.execute(sql, params).fetchall()]
if not ids:
break
for doc_id in ids:
if max_documents is not None and processed >= max_documents:
break
doc = db.get(Document, doc_id)
if not doc:
skipped += 1
continue
processed += 1
last_id = doc_id
biz = db.get(Business, int(doc.business_id))
n = stamp_document_lines_fx_base(
db,
doc,
business=biz,
only_missing=True,
allow_infer=not only_with_fx_snapshot,
)
if n:
updated_docs += 1
updated_lines += n
else:
skipped += 1
db.flush()
if commit_every_batch:
db.commit()
return {
"processed_documents": processed,
"updated_documents": updated_docs,
"updated_lines": updated_lines,
"skipped_documents": skipped,
}