feat(backend): add pricing coverage, unpriced seats, and explain endpoints
add backend endpoints for pricing coverage analysis and unpriced seat inspection add explain endpoint to make effective pricing decisions traceable improve pricing diagnostics for admin and editor workflows
This commit is contained in:
@@ -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.audit import router as audit_router
|
||||||
from app.api.routes.editor import router as editor_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 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.publish import router as publish_router
|
||||||
from app.api.routes.schemes import router as schemes_router
|
from app.api.routes.schemes import router as schemes_router
|
||||||
from app.api.routes.structure import router as structure_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(schemes_router)
|
||||||
router.include_router(structure_router)
|
router.include_router(structure_router)
|
||||||
router.include_router(pricing_router)
|
router.include_router(pricing_router)
|
||||||
|
router.include_router(pricing_diagnostics_router)
|
||||||
router.include_router(test_mode_router)
|
router.include_router(test_mode_router)
|
||||||
router.include_router(audit_router)
|
router.include_router(audit_router)
|
||||||
router.include_router(admin_router)
|
router.include_router(admin_router)
|
||||||
|
|||||||
210
backend/app/api/routes/pricing_diagnostics.py
Normal file
210
backend/app/api/routes/pricing_diagnostics.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
52
backend/app/schemas/pricing_diagnostics.py
Normal file
52
backend/app/schemas/pricing_diagnostics.py
Normal file
@@ -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
|
||||||
@@ -43,6 +43,11 @@
|
|||||||
- PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
- PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}
|
||||||
- DELETE /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
|
## app/api/routes/test_mode.py
|
||||||
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}
|
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}
|
||||||
|
|
||||||
@@ -57,11 +62,11 @@
|
|||||||
|
|
||||||
## app/api/routes/editor.py
|
## app/api/routes/editor.py
|
||||||
- GET /api/v1/schemes/{scheme_id}/draft/structure
|
- 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/validation
|
||||||
- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}
|
- 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/sectors/records/{sector_record_id}
|
||||||
- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_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/sectors
|
||||||
- POST /api/v1/schemes/{scheme_id}/draft/groups
|
- POST /api/v1/schemes/{scheme_id}/draft/groups
|
||||||
- DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user