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:
2026-03-06 16:24:11 +01:00
parent 82bf46725b
commit b87df4a3e5
69 changed files with 1729 additions and 1831 deletions
+1
View File
@@ -0,0 +1 @@
# Domain: billing
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PricingTier(Base):
__tablename__ = "pricing_tiers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category_key: Mapped[str] = mapped_column(String(100), nullable=False)
quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal")
price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier")
__table_args__ = (
UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"),
Index("ix_pricing_tiers_category_key", "category_key"),
)
+4
View File
@@ -0,0 +1,4 @@
# Re-export from original router.
from app.api.routers.pricing import router
__all__ = ["router"]
+183
View File
@@ -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