Files
svg-backend/backend/app/api/routes/pricing_diagnostics.py
greebo a266f56ddd feat(backend): harden draft, pricing and publish contracts
- unify typed API errors across draft, pricing and publish flows
- add stale draft and publish-state mutation guards
- add publish readiness contract and guarded publish flow
- add sellability reason codes to test seat preview
- add pricing diagnostics and strengthen snapshot/publish lifecycle consistency
2026-03-19 20:58:14 +03:00

197 lines
6.7 KiB
Python

from fastapi import APIRouter, Depends
from app.core.config import settings
from app.repositories.pricing import find_effective_price_rule
from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats
from app.repositories.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id
from app.schemas.pricing_diagnostics import PricingRuleDiagnosticsResponse
from app.security.auth import require_api_key
from app.services.pricing_rule_diagnostics import build_pricing_rule_diagnostics
router = APIRouter()
@router.get(
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/diagnostics",
response_model=PricingRuleDiagnosticsResponse,
)
async def get_pricing_rule_diagnostics(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
payload = await build_pricing_rule_diagnostics(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
)
return PricingRuleDiagnosticsResponse(**payload)
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/coverage")
async def get_pricing_coverage(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
seats = await list_scheme_version_seats(version.scheme_version_id)
priced = 0
unpriced = 0
for seat in seats:
if not seat.seat_id:
unpriced += 1
continue
try:
await find_effective_price_rule(
scheme_id=scheme.scheme_id,
seat_id=seat.seat_id,
group_id=seat.group_id,
sector_id=seat.sector_id,
)
priced += 1
except Exception:
unpriced += 1
total = len(seats)
coverage_percent = round((priced / total) * 100, 2) if total else 100.0
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"total_seats": total,
"priced_seats": priced,
"unpriced_seats": unpriced,
"coverage_percent": coverage_percent,
}
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats")
async def get_unpriced_seats(
scheme_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
seats = await list_scheme_version_seats(version.scheme_version_id)
items: list[dict] = []
for seat in seats:
if not seat.seat_id:
items.append(
{
"seat_record_id": seat.seat_record_id,
"seat_id": seat.seat_id,
"element_id": seat.element_id,
"sector_id": seat.sector_id,
"group_id": seat.group_id,
"row_label": seat.row_label,
"seat_number": seat.seat_number,
"reason_code": "missing_seat_id",
"reason_message": "Seat has no seat_id and cannot be priced.",
}
)
continue
try:
await find_effective_price_rule(
scheme_id=scheme.scheme_id,
seat_id=seat.seat_id,
group_id=seat.group_id,
sector_id=seat.sector_id,
)
except Exception:
items.append(
{
"seat_record_id": seat.seat_record_id,
"seat_id": seat.seat_id,
"element_id": seat.element_id,
"sector_id": seat.sector_id,
"group_id": seat.group_id,
"row_label": seat.row_label,
"seat_number": seat.seat_number,
"reason_code": "no_price_rule",
"reason_message": "No effective price rule was found for this seat.",
}
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"total": len(items),
"items": items,
}
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/explain/{{seat_id}}")
async def explain_seat_pricing(
scheme_id: str,
seat_id: str,
role: str = Depends(require_api_key),
):
scheme = await get_scheme_record_by_scheme_id(scheme_id)
version = await get_current_scheme_version(
scheme_id=scheme.scheme_id,
current_version_number=scheme.current_version_number,
)
seat = await get_scheme_version_seat_by_seat_id(
scheme_version_id=version.scheme_version_id,
seat_id=seat_id,
)
try:
matched_rule_level, rule = await find_effective_price_rule(
scheme_id=scheme.scheme_id,
seat_id=seat.seat_id,
group_id=seat.group_id,
sector_id=seat.sector_id,
)
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"seat_id": seat.seat_id,
"element_id": seat.element_id,
"sector_id": seat.sector_id,
"group_id": seat.group_id,
"row_label": seat.row_label,
"seat_number": seat.seat_number,
"has_price": True,
"reason_code": "ok",
"reason_message": "Effective price rule resolved successfully.",
"matched_rule": {
"matched_rule_level": matched_rule_level,
"matched_target_ref": rule["target_ref"],
"pricing_category_id": rule["pricing_category_id"],
"amount": str(rule["amount"]),
"currency": rule["currency"],
},
}
except Exception:
return {
"scheme_id": scheme.scheme_id,
"scheme_version_id": version.scheme_version_id,
"seat_id": seat.seat_id,
"element_id": seat.element_id,
"sector_id": seat.sector_id,
"group_id": seat.group_id,
"row_label": seat.row_label,
"seat_number": seat.seat_number,
"has_price": False,
"reason_code": "no_price_rule",
"reason_message": "No effective price rule was found for this seat.",
"matched_rule": None,
}