diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index ac92e97..6b2c32a 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -4,6 +4,7 @@ from app.api.routes.admin import router as admin_router from app.api.routes.audit import router as audit_router from app.api.routes.editor import router as editor_router from app.api.routes.pricing import router as pricing_router +from app.api.routes.pricing_diagnostics import router as pricing_diagnostics_router from app.api.routes.publish import router as publish_router from app.api.routes.schemes import router as schemes_router from app.api.routes.structure import router as structure_router @@ -17,6 +18,7 @@ router.include_router(uploads_router) router.include_router(schemes_router) router.include_router(structure_router) router.include_router(pricing_router) +router.include_router(pricing_diagnostics_router) router.include_router(test_mode_router) router.include_router(audit_router) router.include_router(admin_router) diff --git a/backend/app/api/routes/pricing_diagnostics.py b/backend/app/api/routes/pricing_diagnostics.py new file mode 100644 index 0000000..6887ba9 --- /dev/null +++ b/backend/app/api/routes/pricing_diagnostics.py @@ -0,0 +1,210 @@ +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 ( + PricingCoverageSummaryResponse, + PricingExplainMatchedRule, + PricingExplainResponse, + UnpricedSeatItem, + UnpricedSeatsResponse, +) +from app.security.auth import require_api_key + +router = APIRouter() + + +def _build_unpriced_reason(*, seat_id: str | None) -> tuple[str, str]: + if not seat_id: + return ( + "missing_seat_id", + "Seat has no seat_id, so price resolution is impossible.", + ) + return ( + "no_price_rule", + "No effective price rule was found for this seat.", + ) + + +@router.get( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/coverage", + response_model=PricingCoverageSummaryResponse, +) +async def get_pricing_coverage_summary( + 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_seats = 0 + unpriced_seats = 0 + + for seat in seats: + if not seat.seat_id: + unpriced_seats += 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_seats += 1 + except Exception: + unpriced_seats += 1 + + total_seats = len(seats) + coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 0.0 + + return PricingCoverageSummaryResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + total_seats=total_seats, + priced_seats=priced_seats, + unpriced_seats=unpriced_seats, + coverage_percent=coverage_percent, + ) + + +@router.get( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats", + response_model=UnpricedSeatsResponse, +) +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[UnpricedSeatItem] = [] + + for seat in seats: + if seat.seat_id: + 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, + ) + continue + except Exception: + pass + + reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) + items.append( + UnpricedSeatItem( + 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=reason_code, + reason_message=reason_message, + ) + ) + + return UnpricedSeatsResponse( + 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}}", + response_model=PricingExplainResponse, +) +async def explain_pricing_for_seat( + 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, + ) + + if not seat.seat_id: + reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) + return PricingExplainResponse( + 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=reason_code, + reason_message=reason_message, + matched_rule=None, + ) + + 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 PricingExplainResponse( + 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=PricingExplainMatchedRule( + 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: + reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) + return PricingExplainResponse( + 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=reason_code, + reason_message=reason_message, + matched_rule=None, + ) diff --git a/backend/app/schemas/pricing_diagnostics.py b/backend/app/schemas/pricing_diagnostics.py new file mode 100644 index 0000000..5374e72 --- /dev/null +++ b/backend/app/schemas/pricing_diagnostics.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel + + +class PricingExplainMatchedRule(BaseModel): + matched_rule_level: str + matched_target_ref: str + pricing_category_id: str + amount: str + currency: str + + +class PricingExplainResponse(BaseModel): + scheme_id: str + scheme_version_id: str + seat_id: str | None + element_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + has_price: bool + reason_code: str + reason_message: str + matched_rule: PricingExplainMatchedRule | None + + +class UnpricedSeatItem(BaseModel): + seat_record_id: str + seat_id: str | None + element_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + reason_code: str + reason_message: str + + +class UnpricedSeatsResponse(BaseModel): + scheme_id: str + scheme_version_id: str + total: int + items: list[UnpricedSeatItem] + + +class PricingCoverageSummaryResponse(BaseModel): + scheme_id: str + scheme_version_id: str + total_seats: int + priced_seats: int + unpriced_seats: int + coverage_percent: float diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 2f7a3f9..30998af 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -43,6 +43,11 @@ - PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id} - DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id} +## app/api/routes/pricing_diagnostics.py +- GET /api/v1/schemes/{scheme_id}/pricing/coverage +- GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats +- GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} + ## app/api/routes/test_mode.py - GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} @@ -57,11 +62,11 @@ ## app/api/routes/editor.py - GET /api/v1/schemes/{scheme_id}/draft/structure +- GET /api/v1/schemes/{scheme_id}/draft/compare-preview - GET /api/v1/schemes/{scheme_id}/draft/validation - GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} - GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} - GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -- GET /api/v1/schemes/{scheme_id}/draft/compare-preview - POST /api/v1/schemes/{scheme_id}/draft/sectors - POST /api/v1/schemes/{scheme_id}/draft/groups - DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} @@ -84,5 +89,3 @@ - This file is an operational route index, not a generated OpenAPI export. - Update this map in the same change set when adding, removing, renaming, or moving routes. - Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows. - -- Test mode response now includes reason_code and reason_message for sellability diagnostics.