118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
"""Backend-authoritative pricing engine.
|
|
|
|
Produces a full, explainable breakdown and is always the source of truth —
|
|
the frontend may display estimates but must never be trusted for financial
|
|
values.
|
|
"""
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
|
|
CHARGE_TYPES = {"fixed", "per_kg", "per_unit", "percentage", "tiered", "fee"}
|
|
|
|
|
|
class PricingEngine:
|
|
def calculate(self, weight_kg, selected_steps, currency: str = "IRR") -> dict:
|
|
weight = Decimal(str(weight_kg))
|
|
lines = []
|
|
subtotal = Decimal("0")
|
|
|
|
# Pass 1 — charge rules.
|
|
for step in selected_steps:
|
|
for rule in step.pricing_rules.filter(active=True, rule_type__in=CHARGE_TYPES).order_by("priority"):
|
|
line = self._apply_charge_rule(rule, weight)
|
|
if line is None:
|
|
continue
|
|
lines.append(line)
|
|
subtotal += Decimal(line["amount"])
|
|
|
|
# Pass 2 — discounts (fixed or percentage of subtotal).
|
|
discounts = Decimal("0")
|
|
for step in selected_steps:
|
|
for rule in step.pricing_rules.filter(active=True, rule_type="discount"):
|
|
amt = Decimal(str(rule.amount))
|
|
if (rule.config or {}).get("percentage"):
|
|
amt = subtotal * amt / Decimal("100")
|
|
discounts += amt
|
|
lines.append(
|
|
self._line(rule, weight, "discount", Decimal("0"), amt)
|
|
)
|
|
|
|
# Pass 3 — taxes on (subtotal - discounts).
|
|
taxes = Decimal("0")
|
|
taxable = subtotal - discounts
|
|
for step in selected_steps:
|
|
for rule in step.pricing_rules.filter(active=True, rule_type="tax"):
|
|
amt = taxable * Decimal(str(rule.amount)) / Decimal("100")
|
|
taxes += amt
|
|
lines.append(self._line(rule, weight, "tax", Decimal("0"), amt))
|
|
|
|
total = subtotal - discounts + taxes
|
|
if total < 0:
|
|
total = Decimal("0")
|
|
|
|
return {
|
|
"currency": currency,
|
|
"lines": lines,
|
|
"subtotal": str(self._q(subtotal)),
|
|
"discounts": str(self._q(discounts)),
|
|
"taxes": str(self._q(taxes)),
|
|
"total": str(self._q(total)),
|
|
}
|
|
|
|
def _q(self, v: Decimal) -> Decimal:
|
|
return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
|
|
|
def _line(self, rule, weight, kind, unit_price, amount, quantity=None) -> dict:
|
|
return {
|
|
"step_key": rule.step.key,
|
|
"rule": rule.name,
|
|
"kind": kind,
|
|
"quantity": str(quantity if quantity is not None else weight),
|
|
"unit_price": str(self._q(unit_price)),
|
|
"amount": str(self._q(amount)),
|
|
}
|
|
|
|
def _apply_charge_rule(self, rule, weight):
|
|
kind = rule.rule_type
|
|
cfg = rule.config or {}
|
|
amount = Decimal(str(rule.amount))
|
|
unit_price = Decimal("0")
|
|
quantity = weight
|
|
if kind == "fixed":
|
|
unit_price = amount
|
|
elif kind == "per_kg":
|
|
unit_price = amount
|
|
amount = amount * weight
|
|
elif kind == "per_unit":
|
|
units = Decimal(str(cfg.get("units", 1)))
|
|
unit_price = amount
|
|
amount = amount * units
|
|
quantity = units
|
|
elif kind == "percentage":
|
|
base = Decimal(str(cfg.get("base", 0)))
|
|
unit_price = amount
|
|
amount = base * amount / Decimal("100")
|
|
elif kind == "tiered":
|
|
unit_price = self._tier_unit_price(cfg.get("tiers", []), weight)
|
|
amount = unit_price * weight
|
|
elif kind == "fee":
|
|
unit_price = Decimal("0")
|
|
else:
|
|
return None
|
|
|
|
min_c = cfg.get("min_charge")
|
|
max_c = cfg.get("max_charge")
|
|
if min_c is not None and amount < Decimal(str(min_c)):
|
|
amount = Decimal(str(min_c))
|
|
if max_c is not None and amount > Decimal(str(max_c)):
|
|
amount = Decimal(str(max_c))
|
|
|
|
return self._line(rule, weight, kind, unit_price, amount, quantity)
|
|
|
|
def _tier_unit_price(self, tiers, weight) -> Decimal:
|
|
best = Decimal("0")
|
|
for tier in tiers or []:
|
|
low = Decimal(str(tier.get("from", 0)))
|
|
if weight >= low:
|
|
best = Decimal(str(tier.get("price", 0)))
|
|
return best
|