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

359 lines
13 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.

from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, desc, func, select
from sqlalchemy.orm import Session
from adapters.db.models.business import Business
from adapters.db.models.business_currency_rate import BusinessCurrencyRate
from adapters.db.models.currency import BusinessCurrency, Currency
from app.core.responses import ApiError
def _to_utc_aware(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _get_business_default_currency_id(db: Session, business_id: int) -> int:
b = db.get(Business, int(business_id))
if not b:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
if b.default_currency_id is None:
raise ApiError("BUSINESS_CURRENCY", "ارز اصلی کسب‌وکار تعریف نشده است", http_status=400)
return int(b.default_currency_id)
def _is_currency_secondary_for_business(
db: Session, business_id: int, currency_id: int, default_currency_id: int
) -> bool:
"""فقط ارزهای غیرپایه‌ای که در business_currencies فعال‌اند (نه ارز اصلی)."""
if int(currency_id) == int(default_currency_id):
return False
q = select(BusinessCurrency.id).where(
and_(
BusinessCurrency.business_id == int(business_id),
BusinessCurrency.currency_id == int(currency_id),
)
).limit(1)
return db.execute(q).scalar_one_or_none() is not None
def assert_currency_allows_rate(db: Session, business_id: int, currency_id: int) -> None:
dcid = _get_business_default_currency_id(db, business_id)
if int(currency_id) == int(dcid):
raise ApiError("CURRENCY_IS_BASE", "برای ارز اصلی کسب‌وکار نرخ تسعیر ثبت نمی‌شود (همیشه ۱)", http_status=400)
if not _is_currency_secondary_for_business(db, business_id, int(currency_id), dcid):
raise ApiError("CURRENCY_NOT_ALLOWED", "این ارز برای این کسب‌وکار فعال نیست", http_status=400)
def _row_to_dict(db: Session, row: BusinessCurrencyRate) -> Dict[str, Any]:
cur = row.currency
if cur is None:
cur = db.get(Currency, row.currency_id)
return {
"id": row.id,
"business_id": row.business_id,
"currency_id": row.currency_id,
"currency": {
"id": cur.id,
"code": cur.code,
"title": cur.title,
"symbol": cur.symbol,
}
if cur
else None,
"effective_at": row.effective_at,
"rate": row.rate,
"note": row.note,
"created_by_user_id": row.created_by_user_id,
}
def list_business_currency_rates(
db: Session,
business_id: int,
*,
currency_id: Optional[int] = None,
skip: int = 0,
take: int = 50,
) -> Tuple[List[Dict[str, Any]], int]:
_ = _get_business_default_currency_id(db, business_id)
wheres = [BusinessCurrencyRate.business_id == int(business_id)]
if currency_id is not None:
wheres.append(BusinessCurrencyRate.currency_id == int(currency_id))
total = int(
db.execute(select(func.count()).select_from(BusinessCurrencyRate).where(*wheres)).scalar() or 0
)
q = (
select(BusinessCurrencyRate)
.where(*wheres)
.order_by(desc(BusinessCurrencyRate.effective_at), desc(BusinessCurrencyRate.id))
.offset(max(0, int(skip)))
.limit(min(200, max(1, int(take))))
)
rows = db.execute(q).scalars().all()
return [_row_to_dict(db, r) for r in rows], total
def get_by_id_for_business(db: Session, business_id: int, rate_id: int) -> BusinessCurrencyRate:
row = db.get(BusinessCurrencyRate, int(rate_id))
if not row or int(row.business_id) != int(business_id):
raise ApiError("NOT_FOUND", "نرخ یافت نشد", http_status=404)
return row
def create_business_currency_rate(
db: Session, business_id: int, user_id: int, data: Dict[str, Any]
) -> Dict[str, Any]:
currency_id = data.get("currency_id")
if currency_id is None:
raise ApiError("CURRENCY_ID_REQUIRED", "currency_id الزامی است", http_status=400)
assert_currency_allows_rate(db, business_id, int(currency_id))
raw_effective = data.get("effective_at")
if raw_effective is None:
raise ApiError("EFFECTIVE_AT_REQUIRED", "زمان مؤثر الزامی است", http_status=400)
if isinstance(raw_effective, str):
# fromisoformat handles Z
s = raw_effective.replace("Z", "+00:00")
effective_at = datetime.fromisoformat(s)
else:
effective_at = raw_effective
if not isinstance(effective_at, datetime):
raise ApiError("EFFECTIVE_AT_INVALID", "زمان مؤثر نامعتبر است", http_status=400)
effective_at = _to_utc_aware(effective_at)
rate = data.get("rate")
if rate is None:
raise ApiError("RATE_REQUIRED", "نرخ الزامی است", http_status=400)
rate_dec = Decimal(str(rate))
if rate_dec <= 0:
raise ApiError("RATE_INVALID", "نرخ باید بزرگ‌تر از صفر باشد", http_status=400)
now = datetime.now(timezone.utc)
note = data.get("note")
row = BusinessCurrencyRate(
business_id=int(business_id),
currency_id=int(currency_id),
effective_at=effective_at,
rate=rate_dec,
note=(str(note) if note is not None and str(note).strip() != "" else None),
created_by_user_id=int(user_id),
created_at=now,
updated_at=now,
)
db.add(row)
db.flush()
db.refresh(row)
return _row_to_dict(db, row)
def update_business_currency_rate(
db: Session, business_id: int, user_id: int, rate_id: int, data: Dict[str, Any]
) -> Dict[str, Any]:
row = get_by_id_for_business(db, business_id, rate_id)
# اجازه تغییر currency به ارز معتبر دیگر
if "currency_id" in data and data["currency_id"] is not None:
assert_currency_allows_rate(db, business_id, int(data["currency_id"]))
row.currency_id = int(data["currency_id"])
if "effective_at" in data and data["effective_at"] is not None:
raw = data["effective_at"]
if isinstance(raw, str):
s = raw.replace("Z", "+00:00")
eff = datetime.fromisoformat(s)
else:
eff = raw
if not isinstance(eff, datetime):
raise ApiError("EFFECTIVE_AT_INVALID", "زمان مؤثر نامعتبر است", http_status=400)
row.effective_at = _to_utc_aware(eff)
if "rate" in data and data["rate"] is not None:
r = Decimal(str(data["rate"]))
if r <= 0:
raise ApiError("RATE_INVALID", "نرخ باید بزرگ‌تر از صفر باشد", http_status=400)
row.rate = r
if "note" in data:
n = data.get("note")
row.note = (str(n) if n is not None and str(n).strip() != "" else None)
row.updated_at = datetime.now(timezone.utc)
db.flush()
db.refresh(row)
return _row_to_dict(db, row)
def delete_business_currency_rate(db: Session, business_id: int, rate_id: int) -> None:
row = get_by_id_for_business(db, business_id, rate_id)
db.delete(row)
def resolve_rate_to_base(
db: Session, business_id: int, currency_id: int, as_of: datetime
) -> Dict[str, Any]:
"""نرخ تبدیل ۱ واحد `currency_id` به واحد ارز پایه، در لحظه as_of (آخرین نرخ با effective_at <= as_of)."""
b = db.get(Business, int(business_id))
if not b:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
if b.default_currency_id is None:
raise ApiError("BUSINESS_CURRENCY", "ارز اصلی کسب‌وکار تعریف نشده است", http_status=400)
dcid = int(b.default_currency_id)
as_of_utc = _to_utc_aware(as_of)
if int(currency_id) == dcid:
return {
"base_currency_id": dcid,
"currency_id": dcid,
"rate": Decimal(1),
"effective_at": as_of_utc,
"rate_row_id": None,
}
assert_currency_allows_rate(db, business_id, int(currency_id))
q = (
select(BusinessCurrencyRate)
.where(
and_(
BusinessCurrencyRate.business_id == int(business_id),
BusinessCurrencyRate.currency_id == int(currency_id),
BusinessCurrencyRate.effective_at <= as_of_utc,
)
)
.order_by(desc(BusinessCurrencyRate.effective_at), desc(BusinessCurrencyRate.id))
.limit(1)
)
row = db.execute(q).scalar_one_or_none()
if not row:
raise ApiError("RATE_NOT_FOUND", "نرخی برای این ارز و زمان یافت نشد", http_status=404)
return {
"base_currency_id": dcid,
"currency_id": int(currency_id),
"rate": row.rate,
"effective_at": row.effective_at,
"rate_row_id": row.id,
}
def resolve_rate_to_base_or_one(
db: Session, business_id: int, currency_id: int, as_of: datetime
) -> Decimal:
"""
مثل resolve_rate_to_base اما در صورت نبود نرخ، خطای ارز غیرمجاز، یا نبود ارز اصلی → ۱ (تسعیر یک‌به‌یک).
برای داشبورد و تجمیع‌های غیرحساس که نباید خطا دهند.
"""
try:
res = resolve_rate_to_base(db, business_id, int(currency_id), as_of)
r = res.get("rate")
return r if isinstance(r, Decimal) else Decimal(str(r))
except Exception:
return Decimal(1)
def list_latest_rates_for_business(db: Session, business_id: int) -> Dict[str, Any]:
"""آخرین نرخ تسعیر هر ارز فرعی (نوار ابزار). فقط چندارزی."""
from app.services.fx_rate_provider_service import assert_multi_currency
assert_multi_currency(db, business_id)
b = db.get(Business, int(business_id))
if not b or b.default_currency_id is None:
raise ApiError("BUSINESS_CURRENCY", "ارز اصلی کسب‌وکار تعریف نشده است", http_status=400)
default_id = int(b.default_currency_id)
base = db.get(Currency, default_id)
secondary = db.execute(
select(Currency)
.join(BusinessCurrency, BusinessCurrency.currency_id == Currency.id)
.where(
BusinessCurrency.business_id == int(business_id),
Currency.id != default_id,
)
.order_by(Currency.code)
).scalars().all()
now = datetime.now(timezone.utc)
items: List[Dict[str, Any]] = []
for cur in secondary:
q = (
select(BusinessCurrencyRate)
.where(
and_(
BusinessCurrencyRate.business_id == int(business_id),
BusinessCurrencyRate.currency_id == int(cur.id),
)
)
.order_by(desc(BusinessCurrencyRate.effective_at), desc(BusinessCurrencyRate.id))
.limit(1)
)
row = db.execute(q).scalar_one_or_none()
age_hours = None
if row and row.effective_at is not None:
eff = row.effective_at
if eff.tzinfo is None:
eff = eff.replace(tzinfo=timezone.utc)
age_hours = round((now - eff).total_seconds() / 3600.0, 2)
items.append(
{
"currency_id": cur.id,
"currency_code": cur.code,
"currency_title": cur.title,
"currency_symbol": cur.symbol,
"rate": str(row.rate) if row else None,
"rate_row_id": row.id if row else None,
"effective_at": row.effective_at if row else None,
"note": row.note if row else None,
"age_hours": age_hours,
"missing": row is None,
}
)
return {
"is_multi_currency": True,
"base_currency": {
"id": base.id if base else default_id,
"code": base.code if base else None,
"title": base.title if base else None,
"symbol": base.symbol if base else None,
},
"items": items,
"missing_count": sum(1 for i in items if i["missing"]),
"as_of": now,
}
def bulk_create_business_currency_rates(
db: Session, business_id: int, user_id: int, payload: Dict[str, Any]
) -> Dict[str, Any]:
"""ثبت چند نرخ دستی یکجا (نوار ابزار)."""
from app.services.fx_rate_provider_service import assert_multi_currency
assert_multi_currency(db, business_id)
items = payload.get("items") or []
if not isinstance(items, list) or not items:
raise ApiError("FX_BULK_ITEMS_REQUIRED", "لیست items الزامی است", http_status=400)
note_default = payload.get("note")
raw_eff = payload.get("effective_at")
if raw_eff is None:
effective_at = datetime.now(timezone.utc)
elif isinstance(raw_eff, str):
effective_at = _to_utc_aware(datetime.fromisoformat(raw_eff.replace("Z", "+00:00")))
elif isinstance(raw_eff, datetime):
effective_at = _to_utc_aware(raw_eff)
else:
raise ApiError("EFFECTIVE_AT_INVALID", "زمان مؤثر نامعتبر است", http_status=400)
created: List[Dict[str, Any]] = []
for raw in items:
if not isinstance(raw, dict):
continue
body = {
"currency_id": raw.get("currency_id"),
"rate": raw.get("rate"),
"effective_at": effective_at,
"note": raw.get("note") if raw.get("note") is not None else note_default,
}
created.append(create_business_currency_rate(db, business_id, user_id, body))
# P6: همگام‌سازی اختیاری قیمت پایه کالاهایی که auto_update دارند
product_sync: Dict[str, Any] = {}
try:
from app.services.product_fx_price_service import sync_business_products_base_from_fx
product_sync = sync_business_products_base_from_fx(db, business_id, only_auto=True)
except Exception:
product_sync = {"updated_count": 0, "error": "sync_skipped"}
return {"created": created, "count": len(created), "product_price_sync": product_sync}