refactor(B1): migrate to domain-driven project structure
Move all models/schemas/services/routers into app/domains/. Keep backward-compat shims in old locations for imports. Preserves domains/rendering/tasks.py from Phase A. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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.domains.billing.models 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."""
|
||||
# 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."""
|
||||
if output_type_id is not None:
|
||||
from app.domains.rendering.models 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."""
|
||||
from app.domains.products.models 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 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.domains.orders.models import Order, OrderLine
|
||||
from app.domains.rendering.models import OutputType
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user