Files
data-entry-app/backend/app/services/ordering_pricing.py
T
2026-06-11 23:56:02 +12:00

202 lines
6.6 KiB
Python

"""Customer-specific pricing engine for the B2B ordering portal.
The backend is the single source of truth for prices — the frontend never
computes a final price. :func:`resolve_price` returns a fully-attributed
:class:`PriceResolution` so every order line, and any future report, can explain
exactly which rule produced the number.
Resolution priority (highest wins):
1. **Quote-only** — the product requires a manual quote, or the customer has a
``quote`` price rule for it. No automatic price.
2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
product (quantity tiers may refine it).
3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
list (quantity tiers may refine it).
4. **Base + discount** — the catalogue list price, optionally reduced by the
customer's default discount percentage.
5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
All prices are GST-exclusive.
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.ordering import (
CatalogueProduct,
CustomerPriceAssignment,
CustomerProductPrice,
PriceListItem,
PriceTier,
)
@dataclass(frozen=True)
class PriceResolution:
unit_price: float | None
price_source: str # one of ordering.PRICE_SOURCES
price_rule_id: int | None
discount_percent: float
requires_quote: bool
label: str
def line_total(self, quantity: float) -> float | None:
if self.unit_price is None:
return None
return round(self.unit_price * quantity, 4)
_SOURCE_LABELS = {
"fixed": "Fixed price",
"contract": "Contract price",
"price_list": "Price list",
"tiered": "Tiered price",
"base": "List price",
"quote": "Quote required",
}
def _best_tier_price(
db: Session,
*,
customer_product_price_id: int | None = None,
price_list_item_id: int | None = None,
quantity: float,
) -> float | None:
"""Return the unit price of the highest qualifying quantity tier, if any."""
stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
if customer_product_price_id is not None:
stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
elif price_list_item_id is not None:
stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
else:
return None
tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
return tiers[0].unit_price if tiers else None
def _quote(label: str = "Quote required") -> PriceResolution:
return PriceResolution(
unit_price=None,
price_source="quote",
price_rule_id=None,
discount_percent=0.0,
requires_quote=True,
label=label,
)
def get_customer_assignment(
db: Session, *, client_account_id: int
) -> CustomerPriceAssignment | None:
return db.scalar(
select(CustomerPriceAssignment).where(
CustomerPriceAssignment.client_account_id == client_account_id
)
)
def resolve_price(
db: Session,
*,
client_account_id: int,
product: CatalogueProduct,
quantity: float,
) -> PriceResolution:
"""Resolve the GST-exclusive unit price for a customer + product + quantity."""
quantity = max(quantity, 0.0)
# 1. Product-level manual-quote flag.
if product.requires_quote:
return _quote()
# 2. Customer-specific fixed/contract/quote rule.
cpp = db.scalar(
select(CustomerProductPrice).where(
CustomerProductPrice.client_account_id == client_account_id,
CustomerProductPrice.product_id == product.id,
CustomerProductPrice.active.is_(True),
)
)
if cpp is not None:
if cpp.rule_type == "quote":
return _quote()
tier_price = _best_tier_price(
db, customer_product_price_id=cpp.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
if cpp.unit_price is not None:
source = "contract" if cpp.rule_type == "contract" else "fixed"
return PriceResolution(
unit_price=round(cpp.unit_price, 4),
price_source=source,
price_rule_id=cpp.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS[source],
)
assignment = get_customer_assignment(db, client_account_id=client_account_id)
# 3. Assigned price list.
if assignment is not None and assignment.price_list_id is not None:
pli = db.scalar(
select(PriceListItem).where(
PriceListItem.price_list_id == assignment.price_list_id,
PriceListItem.product_id == product.id,
)
)
if pli is not None:
tier_price = _best_tier_price(
db, price_list_item_id=pli.id, quantity=quantity
)
if tier_price is not None:
return PriceResolution(
unit_price=round(tier_price, 4),
price_source="tiered",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["tiered"],
)
return PriceResolution(
unit_price=round(pli.unit_price, 4),
price_source="price_list",
price_rule_id=pli.id,
discount_percent=0.0,
requires_quote=False,
label=_SOURCE_LABELS["price_list"],
)
# 4. Base price (+ optional customer discount).
if product.base_price is not None:
discount = assignment.discount_percent if assignment else 0.0
discount = min(max(discount, 0.0), 100.0)
unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
label = _SOURCE_LABELS["base"]
if discount:
label = f"List price less {discount:g}%"
return PriceResolution(
unit_price=unit_price,
price_source="base",
price_rule_id=product.id,
discount_percent=discount,
requires_quote=False,
label=label,
)
# 5. No resolvable price ⇒ quote.
return _quote("No price set — quote required")