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

243 lines
8.1 KiB
Python
Executable file
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.

"""
تسعیر ارز روی extra_info سند فاکتور: snap نرخ نسبت به ارز اصلی کسب‌وکار.
سیاست از فیلد businesses.fx_revaluation_policy (و در نبود، پیش‌فرض کد) خوانده می‌شود.
"""
from __future__ import annotations
from datetime import date, datetime, time, timezone
from decimal import Decimal
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from adapters.db.models.business import Business
from app.core.responses import ApiError
from app.services.business_currency_rate_service import (
_to_utc_aware,
assert_currency_allows_rate,
get_by_id_for_business,
resolve_rate_to_base,
)
# کلیدهای مجاز JSON سیاست
DEFAULT_FX_REVALUATION_POLICY: Dict[str, str] = {
# document_date: از تاریخ سند (با document_date_effective) | registered_at: زمان ثبت سند
"as_of_source": "document_date",
# اگر as_of_source=document_date: لحظه مؤثر در همان روز (UTC)
"document_date_effective": "end_of_day", # start_of_day | noon | end_of_day
# block: بدون نرخ ثبت ممنوع | allow_without_fx: بدون بلوک، fx ممکن است ناقص باشد
"when_no_rate": "block",
# D2: نمایش نرخ در UI — as_base | irr | toman (فقط نمایش؛ نرخ ذخیره‌شده دست‌نخورده)
"rate_display_unit": "as_base",
}
def get_fx_revaluation_policy(business: Business) -> Dict[str, str]:
"""ادغام سیاست ذخیره‌شده با پیش‌فرض."""
raw = getattr(business, "fx_revaluation_policy", None) or {}
if not isinstance(raw, dict):
raw = {}
out = dict(DEFAULT_FX_REVALUATION_POLICY)
for k, v in raw.items():
if k in DEFAULT_FX_REVALUATION_POLICY and v is not None and str(v).strip() != "":
out[k] = str(v).strip()
try:
_validate_policy(out)
except Exception:
return dict(DEFAULT_FX_REVALUATION_POLICY)
return out
def _validate_policy(p: Dict[str, str]) -> None:
if p.get("as_of_source") not in ("document_date", "registered_at"):
raise ValueError("as_of_source نامعتبر")
if p.get("document_date_effective") not in ("start_of_day", "noon", "end_of_day"):
raise ValueError("document_date_effective نامعتبر")
if p.get("when_no_rate") not in ("block", "allow_without_fx"):
raise ValueError("when_no_rate نامعتبر")
if p.get("rate_display_unit") not in ("as_base", "irr", "toman"):
raise ValueError("rate_display_unit نامعتبر")
def display_rate_factor_for_unit(*, base_currency_code: Optional[str], display_unit: str) -> Decimal:
"""
ضریب نمایشی D2: نرخ ذخیره‌شده همیشه نسبت به ارز پایه است.
- as_base: ۱
- toman روی پایه IRR: ÷۱۰
- irr روی پایه IRT/TOMAN: ×۱۰
"""
unit = (display_unit or "as_base").strip().lower()
code = (base_currency_code or "").strip().upper()
if unit == "as_base":
return Decimal(1)
base_is_irr = code in ("IRR", "RIAL", "")
base_is_toman = code in ("IRT", "TMN", "TOMAN")
if unit == "toman":
return Decimal("0.1") if base_is_irr else Decimal(1)
if unit == "irr":
return Decimal(10) if base_is_toman else Decimal(1)
return Decimal(1)
def apply_rate_display_unit(rate: Any, *, base_currency_code: Optional[str], display_unit: str) -> Optional[Decimal]:
try:
r = Decimal(str(rate))
except Exception:
return None
return r * display_rate_factor_for_unit(
base_currency_code=base_currency_code,
display_unit=display_unit,
)
def validate_and_normalize_fx_revaluation_policy_payload(raw: Any) -> Optional[Dict[str, str]]:
"""برای API ویرایش کسب‌وکار: اعتبارسنجی و برگرداندن دیکشنار نهایی یا None."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError("fx_revaluation_policy باید شیء JSON باشد")
out = dict(DEFAULT_FX_REVALUATION_POLICY)
for k, v in raw.items():
if k in DEFAULT_FX_REVALUATION_POLICY and v is not None and str(v).strip() != "":
out[k] = str(v).strip()
_validate_policy(out)
return out
def compute_fx_as_of_utc(
document_date: date,
registered_at_utc: datetime,
policy: Dict[str, str],
) -> datetime:
"""لحظه as_of طبق سیاست (UTC)."""
reg = _to_utc_aware(registered_at_utc)
if policy.get("as_of_source") == "registered_at":
return reg
eff = policy.get("document_date_effective") or "end_of_day"
if eff == "start_of_day":
tm = time(0, 0, 0)
elif eff == "noon":
tm = time(12, 0, 0)
else:
tm = time(23, 59, 59, 999999)
return datetime.combine(document_date, tm, tzinfo=timezone.utc)
def _rate_row_to_fx_dict(
*,
base_id: int,
currency_id: int,
rate: Decimal,
effective_at: datetime,
rate_row_id: Optional[int],
mode: str,
) -> Dict[str, Any]:
return {
"base_currency_id": base_id,
"document_currency_id": currency_id,
"rate": str(rate),
"effective_at": effective_at.isoformat() if effective_at else None,
"rate_row_id": rate_row_id,
"mode": mode, # auto | selected | base
}
def apply_fx_revaluation_to_invoice_extra(
db: Session,
business: Business,
*,
document_currency_id: int,
document_date: date,
extra_info: Dict[str, Any],
data: Dict[str, Any],
user_can_select_fx_rate: bool,
registered_at_utc: datetime,
) -> Dict[str, Any]:
"""
روی نسخه extra_info (شبیه) کار می‌کند و کلید 'fx' را اضافه/به‌روز می‌کند.
"""
extra: Dict[str, Any] = dict(extra_info) if extra_info else {}
business_id = int(business.id)
base_id = business.default_currency_id
if not base_id:
# بدون ارز اصلی، تسعیر معنا ندارد
extra["fx"] = {
"skipped": True,
"reason": "no_default_currency",
}
return extra
if int(document_currency_id) == int(base_id):
extra["fx"] = _rate_row_to_fx_dict(
base_id=int(base_id),
currency_id=int(document_currency_id),
rate=Decimal(1),
effective_at=_to_utc_aware(registered_at_utc),
rate_row_id=None,
mode="base",
)
return extra
policy = get_fx_revaluation_policy(business)
as_of = compute_fx_as_of_utc(document_date, registered_at_utc, policy)
assert_currency_allows_rate(db, business_id, int(document_currency_id))
fx_rate_id: Optional[int] = None
if user_can_select_fx_rate and data:
if data.get("fx_rate_id") is not None:
try:
fx_rate_id = int(data["fx_rate_id"])
except (TypeError, ValueError):
fx_rate_id = None
# اگر ارسال شده اما مجوز انتخاب نیست: نادیده بگیر
if not user_can_select_fx_rate:
fx_rate_id = None
if fx_rate_id is not None:
row = get_by_id_for_business(db, business_id, int(fx_rate_id))
if int(row.currency_id) != int(document_currency_id):
raise ApiError("FX_RATE_MISMATCH", "نرخ انتخاب‌شده با ارز سند سازگار نیست", http_status=400)
if _to_utc_aware(row.effective_at) > as_of:
raise ApiError(
"FX_RATE_INVALID",
"زمان مؤثر نرخ انتخاب‌شده بعد از لحظه مرجع تسعیر سند است",
http_status=400,
)
extra["fx"] = _rate_row_to_fx_dict(
base_id=int(base_id),
currency_id=int(document_currency_id),
rate=row.rate,
effective_at=row.effective_at,
rate_row_id=row.id,
mode="selected",
)
return extra
# خودکار: نزدیک‌ترین نرخ (آخرین با effective_at <= as_of)
try:
res = resolve_rate_to_base(db, business_id, int(document_currency_id), as_of)
extra["fx"] = _rate_row_to_fx_dict(
base_id=int(res["base_currency_id"]),
currency_id=int(res["currency_id"]),
rate=res["rate"] if isinstance(res["rate"], Decimal) else Decimal(str(res["rate"])),
effective_at=res["effective_at"] if isinstance(res["effective_at"], datetime) else _to_utc_aware(as_of),
rate_row_id=res.get("rate_row_id"),
mode="auto",
)
except ApiError as e:
err = getattr(e, "detail", None)
code = None
if isinstance(err, dict) and isinstance(err.get("error"), dict):
code = err["error"].get("code")
if code == "RATE_NOT_FOUND" and policy.get("when_no_rate") == "allow_without_fx":
extra["fx"] = {
"skipped": True,
"reason": "no_rate",
"as_of": as_of.isoformat(),
"document_currency_id": int(document_currency_id),
"base_currency_id": int(base_id),
}
return extra
raise
return extra