233 lines
7.1 KiB
Python
233 lines
7.1 KiB
Python
"""Pricing service — price lookup and order price computation.
|
|
|
|
Price resolution cascade for order lines:
|
|
1. OutputType's linked pricing_tier (if active) → use its price_per_item
|
|
2. Product's category_key → look up PricingTier by category
|
|
3. "default" category tier → global fallback
|
|
4. None if nothing configured
|
|
"""
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select, update as sql_update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.models.pricing_tier import PricingTier
|
|
|
|
|
|
async def get_price_for(
|
|
db: AsyncSession,
|
|
category_key: str,
|
|
quality_level: str = "Normal",
|
|
) -> Decimal | None:
|
|
"""Return price_per_item for the given category + quality level.
|
|
|
|
Falls back to category_key='default' if no exact match is found.
|
|
Returns None if nothing is configured.
|
|
"""
|
|
# 1. Exact match
|
|
result = await db.execute(
|
|
select(PricingTier).where(
|
|
PricingTier.category_key == category_key,
|
|
PricingTier.quality_level == quality_level,
|
|
PricingTier.is_active.is_(True),
|
|
)
|
|
)
|
|
tier = result.scalar_one_or_none()
|
|
if tier is not None:
|
|
return tier.price_per_item
|
|
|
|
if category_key == "default":
|
|
return None
|
|
|
|
# 2. Fallback: default category
|
|
result = await db.execute(
|
|
select(PricingTier).where(
|
|
PricingTier.category_key == "default",
|
|
PricingTier.quality_level == quality_level,
|
|
PricingTier.is_active.is_(True),
|
|
)
|
|
)
|
|
tier = result.scalar_one_or_none()
|
|
return tier.price_per_item if tier is not None else None
|
|
|
|
|
|
async def resolve_line_price(
|
|
db: AsyncSession,
|
|
output_type_id: str | None,
|
|
product_category_key: str | None,
|
|
) -> Decimal | None:
|
|
"""Resolve the unit price for a single order line using the cascade.
|
|
|
|
1. OutputType's linked pricing_tier (if active)
|
|
2. Product's category_key → PricingTier by category
|
|
3. "default" category tier → global fallback
|
|
4. None
|
|
"""
|
|
if output_type_id is not None:
|
|
from app.models.output_type import OutputType
|
|
result = await db.execute(
|
|
select(OutputType)
|
|
.options(selectinload(OutputType.pricing_tier))
|
|
.where(OutputType.id == output_type_id)
|
|
)
|
|
ot = result.scalar_one_or_none()
|
|
if ot and ot.pricing_tier and ot.pricing_tier.is_active:
|
|
return ot.pricing_tier.price_per_item
|
|
|
|
# Step 2+3: category lookup with default fallback
|
|
cat = product_category_key or "default"
|
|
return await get_price_for(db, cat)
|
|
|
|
|
|
async def estimate_order_price(
|
|
db: AsyncSession,
|
|
lines: list[dict[str, Any]],
|
|
) -> dict:
|
|
"""Estimate price for a list of prospective order lines.
|
|
|
|
Each line dict should have: product_id, output_type_id.
|
|
Returns {total, line_count, breakdown: [{output_type_id, product_id, unit_price}], has_unpriced}.
|
|
"""
|
|
from app.models.product import Product
|
|
|
|
breakdown: list[dict] = []
|
|
total = Decimal("0.00")
|
|
has_unpriced = False
|
|
|
|
for line in lines:
|
|
product_id = line.get("product_id")
|
|
output_type_id = line.get("output_type_id")
|
|
|
|
# Get product category
|
|
cat = None
|
|
if product_id:
|
|
prod_result = await db.execute(
|
|
select(Product).where(Product.id == product_id)
|
|
)
|
|
prod = prod_result.scalar_one_or_none()
|
|
if prod:
|
|
cat = prod.category_key
|
|
|
|
price = await resolve_line_price(db, output_type_id, cat)
|
|
|
|
breakdown.append({
|
|
"output_type_id": str(output_type_id) if output_type_id else None,
|
|
"product_id": str(product_id) if product_id else None,
|
|
"unit_price": float(price) if price is not None else None,
|
|
})
|
|
|
|
if price is not None:
|
|
total += price
|
|
else:
|
|
has_unpriced = True
|
|
|
|
return {
|
|
"total": float(total),
|
|
"line_count": len(lines),
|
|
"breakdown": breakdown,
|
|
"has_unpriced": has_unpriced,
|
|
}
|
|
|
|
|
|
async def compute_order_estimated_price(
|
|
db: AsyncSession,
|
|
order,
|
|
items,
|
|
quality_level: str = "Normal",
|
|
) -> Decimal | None:
|
|
"""Compute estimated price for an order based on rendering items.
|
|
|
|
Returns None if no pricing is configured, or Decimal('0.00') if there
|
|
are no rendering items.
|
|
"""
|
|
rendering_count = sum(1 for i in items if i.medias_rendering)
|
|
if rendering_count == 0:
|
|
return Decimal("0.00")
|
|
|
|
# Resolve category from template
|
|
category_key = "default"
|
|
if order.template_id is not None:
|
|
from app.models.template import Template
|
|
tmpl_result = await db.execute(
|
|
select(Template).where(Template.id == order.template_id)
|
|
)
|
|
tmpl = tmpl_result.scalar_one_or_none()
|
|
if tmpl and tmpl.category_key:
|
|
category_key = tmpl.category_key
|
|
|
|
unit_price = await get_price_for(db, category_key, quality_level)
|
|
if unit_price is None:
|
|
return None
|
|
|
|
return unit_price * rendering_count
|
|
|
|
|
|
async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
|
|
"""Re-fetch order + lines, resolve per-line prices, snapshot to unit_price, update order total."""
|
|
from app.models.order import Order
|
|
from app.models.order_line import OrderLine
|
|
from app.models.output_type import OutputType
|
|
from app.models.product import Product
|
|
|
|
order_result = await db.execute(select(Order).where(Order.id == order_id))
|
|
order = order_result.scalar_one_or_none()
|
|
if order is None:
|
|
return None
|
|
|
|
lines_result = await db.execute(
|
|
select(OrderLine)
|
|
.options(
|
|
selectinload(OrderLine.output_type).selectinload(OutputType.pricing_tier),
|
|
selectinload(OrderLine.product),
|
|
)
|
|
.where(
|
|
OrderLine.order_id == order_id,
|
|
OrderLine.output_type_id.is_not(None),
|
|
)
|
|
)
|
|
lines = lines_result.scalars().all()
|
|
|
|
if not lines:
|
|
await db.execute(
|
|
sql_update(Order)
|
|
.where(Order.id == order_id)
|
|
.values(estimated_price=Decimal("0.00"))
|
|
)
|
|
await db.commit()
|
|
return Decimal("0.00")
|
|
|
|
total = Decimal("0.00")
|
|
any_priced = False
|
|
|
|
for line in lines:
|
|
# Cascade: 1) OT pricing tier, 2) product category, 3) default
|
|
price = None
|
|
if line.output_type and line.output_type.pricing_tier and line.output_type.pricing_tier.is_active:
|
|
price = line.output_type.pricing_tier.price_per_item
|
|
else:
|
|
cat = line.product.category_key if line.product else None
|
|
price = await get_price_for(db, cat or "default")
|
|
|
|
# Snapshot to line
|
|
await db.execute(
|
|
sql_update(OrderLine)
|
|
.where(OrderLine.id == line.id)
|
|
.values(unit_price=price)
|
|
)
|
|
|
|
if price is not None:
|
|
total += price
|
|
any_priced = True
|
|
|
|
new_price = total if any_priced else None
|
|
|
|
await db.execute(
|
|
sql_update(Order)
|
|
.where(Order.id == order_id)
|
|
.values(estimated_price=new_price)
|
|
)
|
|
await db.commit()
|
|
return new_price
|