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

343 lines
13 KiB
Python
Executable file

"""
اضافات و کسورات فاکتور (فروش/خرید): اعتبارسنجی، ادغام در جمع فاکتور، و خطوط حسابداری.
فقط برای invoice_sales و invoice_purchase؛ در پیش‌فاکتور فقط در extra_info ذخیره می‌شود.
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Tuple, Optional
from sqlalchemy import or_
from sqlalchemy.orm import Session
from adapters.db.models.account import Account
from adapters.db.models.document_line import DocumentLine
from app.core.responses import ApiError
INVOICE_ADJUSTMENTS_KEY = "invoice_adjustments"
INVOICE_SALES = "invoice_sales"
INVOICE_PURCHASE = "invoice_purchase"
_ADJUSTMENT_SUPPORTED = frozenset({INVOICE_SALES, INVOICE_PURCHASE})
_KIND_ADD = "addition"
_KIND_DED = "deduction"
def _money_quant(x: Decimal) -> Decimal:
return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def total_with_tax_from_totals_dict(totals: Dict[str, Any]) -> Decimal:
gross = Decimal(str(totals.get("gross", 0)))
discount = Decimal(str(totals.get("discount", 0)))
tax = Decimal(str(totals.get("tax", 0)))
adj_n = Decimal(str(totals.get("adjustments_net", 0)))
adj_t = Decimal(str(totals.get("adjustments_tax", 0)))
return _money_quant(gross - discount + tax + adj_n + adj_t)
def payable_total_from_totals_dict(totals: Dict[str, Any]) -> Decimal:
"""
مبلغ قابل پرداخت/دریافت فاکتور (مبنای مانده و سقف تخصیص دریافت/پرداخت).
net = جمع خالص ردیف‌های کالا/خدمت (پس از تخفیف، شامل مالیات خطی).
adjustments_net / adjustments_tax = اضافات و کسورات فاکتور با علامت.
"""
if not isinstance(totals, dict):
return Decimal(0)
if "net" in totals:
net = Decimal(str(totals.get("net", 0)))
adj_n = Decimal(str(totals.get("adjustments_net", 0)))
adj_t = Decimal(str(totals.get("adjustments_tax", 0)))
return _money_quant(net + adj_n + adj_t)
return total_with_tax_from_totals_dict(totals)
def payable_total_from_extra_info(extra_info: Any) -> Optional[Decimal]:
if not isinstance(extra_info, dict):
return None
totals = extra_info.get("totals")
if not isinstance(totals, dict):
return None
if "net" not in totals and "gross" not in totals:
return None
return payable_total_from_totals_dict(totals)
def _validate_account_for_business(db: Session, business_id: int, account_id: int) -> Account:
acc = (
db.query(Account)
.filter(
Account.id == int(account_id),
or_(Account.business_id == business_id, Account.business_id.is_(None)), # noqa: E711
)
.first()
)
if not acc:
raise ApiError(
"ADJUSTMENT_ACCOUNT_NOT_FOUND",
f"حساب اضافات/کسورات با شناسه {account_id} یافت نشد",
http_status=404,
)
return acc
def normalize_invoice_adjustments(
db: Session,
business_id: int,
invoice_type: str,
raw: Any,
) -> List[Dict[str, Any]]:
if raw is None or raw == []:
return []
if invoice_type not in _ADJUSTMENT_SUPPORTED:
raise ApiError(
"INVOICE_ADJUSTMENTS_NOT_ALLOWED",
"اضافات و کسورات فقط برای فاکتور فروش یا خرید مجاز است",
http_status=400,
)
if not isinstance(raw, list):
raise ApiError("INVALID_INVOICE_ADJUSTMENTS", "invoice_adjustments باید آرایه باشد", http_status=400)
out: List[Dict[str, Any]] = []
for i, row in enumerate(raw):
if not isinstance(row, dict):
raise ApiError("INVALID_INVOICE_ADJUSTMENTS", f"ردیف {i + 1} نامعتبر است", http_status=400)
kind = str(row.get("kind") or "").strip().lower()
if kind not in (_KIND_ADD, _KIND_DED):
raise ApiError(
"INVALID_ADJUSTMENT_KIND",
f"ردیف {i + 1}: kind باید addition یا deduction باشد",
http_status=400,
)
try:
amount = Decimal(str(row.get("amount", 0) or 0))
except Exception as exc:
raise ApiError("INVALID_ADJUSTMENT_AMOUNT", f"ردیف {i + 1}: مبلغ نامعتبر", http_status=400) from exc
if amount <= 0:
raise ApiError("INVALID_ADJUSTMENT_AMOUNT", f"ردیف {i + 1}: مبلغ باید مثبت باشد", http_status=400)
tax_rate = row.get("tax_rate")
tr = Decimal(0)
if tax_rate is not None and str(tax_rate).strip() != "":
try:
tr = Decimal(str(tax_rate))
except Exception as exc:
raise ApiError("INVALID_ADJUSTMENT_TAX", f"ردیف {i + 1}: نرخ مالیات نامعتبر", http_status=400) from exc
if tr < 0 or tr > 100:
raise ApiError("INVALID_ADJUSTMENT_TAX", f"ردیف {i + 1}: نرخ مالیات باید بین 0 و 100 باشد", http_status=400)
tax_amt = _money_quant(amount * tr / Decimal(100)) if tr > 0 else Decimal(0)
total = _money_quant(amount + tax_amt)
aid = row.get("account_id")
if aid is None:
raise ApiError("ADJUSTMENT_ACCOUNT_REQUIRED", f"ردیف {i + 1}: انتخاب حساب الزامی است", http_status=400)
try:
acc_id = int(aid)
except Exception as exc:
raise ApiError("ADJUSTMENT_ACCOUNT_REQUIRED", f"ردیف {i + 1}: شناسه حساب نامعتبر", http_status=400) from exc
_validate_account_for_business(db, business_id, acc_id)
desc = row.get("description")
desc_s = str(desc).strip() if desc is not None else ""
source = row.get("source")
source_s = str(source).strip() if source is not None else ""
exclude_raw = row.get("exclude_from_profit", False)
exclude_from_profit = (
exclude_raw is True
or str(exclude_raw).strip().lower() in {"1", "true", "yes", "on"}
)
signed_net = amount if kind == _KIND_ADD else -amount
signed_tax = tax_amt if kind == _KIND_ADD else -tax_amt
normalized_row = {
"kind": kind,
"amount": float(amount),
"tax_rate": float(tr),
"tax_amount": float(tax_amt),
"total": float(total),
"account_id": acc_id,
"description": desc_s or None,
"_signed_net": signed_net,
"_signed_tax": signed_tax,
}
if source_s:
normalized_row["source"] = source_s[:80]
if exclude_from_profit:
normalized_row["exclude_from_profit"] = True
out.append(normalized_row)
return out
def summarize_normalized_adjustments(rows: List[Dict[str, Any]]) -> Tuple[Decimal, Decimal]:
net = Decimal(0)
tax = Decimal(0)
for r in rows:
net += Decimal(str(r["_signed_net"]))
tax += Decimal(str(r["_signed_tax"]))
return _money_quant(net), _money_quant(tax)
def merge_invoice_adjustments_into_header_extra(
db: Session,
business_id: int,
invoice_type: str,
header_extra: Dict[str, Any],
) -> None:
"""اعتبارسنجی، نرمال‌سازی invoice_adjustments و قرار دادن adjustments_net/tax در totals."""
raw = header_extra.get(INVOICE_ADJUSTMENTS_KEY)
totals = dict(header_extra.get("totals") or {})
if not raw:
totals.setdefault("adjustments_net", 0.0)
totals.setdefault("adjustments_tax", 0.0)
header_extra.pop(INVOICE_ADJUSTMENTS_KEY, None)
header_extra["totals"] = totals
return
normalized = normalize_invoice_adjustments(db, business_id, invoice_type, raw)
adj_net, adj_tax = summarize_normalized_adjustments(normalized)
for r in normalized:
r.pop("_signed_net", None)
r.pop("_signed_tax", None)
header_extra[INVOICE_ADJUSTMENTS_KEY] = normalized
totals["adjustments_net"] = float(adj_net)
totals["adjustments_tax"] = float(adj_tax)
header_extra["totals"] = totals
def add_adjustment_document_lines(
db: Session,
*,
business_id: int,
document_id: int,
invoice_type: str,
accounts: Dict[str, Any],
header_extra: Dict[str, Any],
) -> None:
"""خطوط حسابداری اضافات/کسورات؛ فقط برای فاکتور قطعی فراخوانی شود."""
rows_raw = header_extra.get(INVOICE_ADJUSTMENTS_KEY) or []
if not rows_raw:
return
if invoice_type not in _ADJUSTMENT_SUPPORTED:
return
rows = normalize_invoice_adjustments(db, business_id, invoice_type, rows_raw)
if not rows:
return
vat_out = accounts["vat_out"]
vat_in = accounts["vat_in"]
for r in rows:
kind = r["kind"]
net = Decimal(str(r["amount"]))
tax_amt = Decimal(str(r["tax_amount"]))
acc_id = int(r["account_id"])
desc = (r.get("description") or "").strip() or ("اضافات/کسورات فاکتور")
def _extra(kind_value: str) -> Dict[str, Any]:
extra: Dict[str, Any] = {"invoice_adjustment": True, "kind": kind_value}
if r.get("source"):
extra["source"] = r.get("source")
if r.get("exclude_from_profit"):
extra["exclude_from_profit"] = True
return extra
if invoice_type == INVOICE_SALES:
if kind == _KIND_ADD:
db.add(
DocumentLine(
document_id=document_id,
account_id=acc_id,
debit=Decimal(0),
credit=net,
description=desc,
extra_info=_extra("addition"),
)
)
if tax_amt > 0:
db.add(
DocumentLine(
document_id=document_id,
account_id=vat_out.id,
debit=Decimal(0),
credit=tax_amt,
description=f"مالیات خروجی — {desc}",
extra_info=_extra("addition_vat"),
)
)
else:
db.add(
DocumentLine(
document_id=document_id,
account_id=acc_id,
debit=net,
credit=Decimal(0),
description=desc,
extra_info=_extra("deduction"),
)
)
if tax_amt > 0:
db.add(
DocumentLine(
document_id=document_id,
account_id=vat_out.id,
debit=tax_amt,
credit=Decimal(0),
description=f"تعدیل مالیات خروجی — {desc}",
extra_info=_extra("deduction_vat"),
)
)
elif invoice_type == INVOICE_PURCHASE:
if kind == _KIND_ADD:
db.add(
DocumentLine(
document_id=document_id,
account_id=acc_id,
debit=net,
credit=Decimal(0),
description=desc,
extra_info=_extra("addition"),
)
)
if tax_amt > 0:
db.add(
DocumentLine(
document_id=document_id,
account_id=vat_in.id,
debit=tax_amt,
credit=Decimal(0),
description=f"مالیات ورودی — {desc}",
extra_info=_extra("addition_vat"),
)
)
else:
db.add(
DocumentLine(
document_id=document_id,
account_id=acc_id,
debit=Decimal(0),
credit=net,
description=desc,
extra_info=_extra("deduction"),
)
)
if tax_amt > 0:
db.add(
DocumentLine(
document_id=document_id,
account_id=vat_in.id,
debit=Decimal(0),
credit=tax_amt,
description=f"تعدیل مالیات ورودی — {desc}",
extra_info=_extra("deduction_vat"),
)
)