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
This commit is contained in:
greebo
2026-03-19 20:58:14 +03:00
parent ac3a62f108
commit a266f56ddd
6 changed files with 368 additions and 297 deletions

View File

@@ -1,6 +1,6 @@
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, Query
from app.core.config import settings from app.core.config import settings
from app.repositories.audit import create_audit_event from app.repositories.audit import create_audit_event
@@ -14,45 +14,37 @@ from app.repositories.pricing import (
update_price_rule, update_price_rule,
update_pricing_category, update_pricing_category,
) )
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
from app.schemas.pricing import ( from app.schemas.pricing import (
DeleteResponse,
PriceRuleCreateRequest, PriceRuleCreateRequest,
PriceRuleCreateResponse,
PriceRuleItem, PriceRuleItem,
PriceRuleUpdateRequest, PriceRuleUpdateRequest,
PriceRuleUpdateResponse,
PricingBundleResponse, PricingBundleResponse,
PricingCategoryCreateRequest, PricingCategoryCreateRequest,
PricingCategoryCreateResponse,
PricingCategoryItem, PricingCategoryItem,
PricingCategoryUpdateRequest, PricingCategoryUpdateRequest,
PricingCategoryUpdateResponse,
) )
from app.security.auth import require_api_key from app.security.auth import require_api_key
from app.services.api_errors import raise_unprocessable from app.services.api_errors import raise_unprocessable
from app.services.draft_guard import validate_expected_draft_version_if_provided from app.services.draft_guard import get_current_draft_context
router = APIRouter() router = APIRouter()
async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None: async def _require_current_draft(
context = await validate_expected_draft_version_if_provided( scheme_id: str,
expected_scheme_version_id: str | None,
):
return await get_current_draft_context(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=None, expected_scheme_version_id=expected_scheme_version_id,
)
if context is None:
return None
scheme, version = context
return await replace_scheme_version_pricing_snapshot(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
) )
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key)): async def get_pricing_bundle(
scheme_id: str,
role: str = Depends(require_api_key),
):
categories = await list_pricing_categories(scheme_id) categories = await list_pricing_categories(scheme_id)
rules = await list_price_rules(scheme_id) rules = await list_price_rules(scheme_id)
@@ -83,48 +75,45 @@ async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key
) )
@router.post( @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories",
response_model=PricingCategoryCreateResponse,
)
async def create_pricing_category_endpoint( async def create_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
payload: PricingCategoryCreateRequest, payload: PricingCategoryCreateRequest,
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
pricing_category_id = await create_pricing_category( pricing_category_id = await create_pricing_category(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
name=payload.name, name=payload.name,
code=payload.code, code=payload.code,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.category.created", event_type="pricing.category.created",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, details={
"scheme_version_id": version.scheme_version_id,
"name": payload.name,
"code": payload.code,
},
) )
return PricingCategoryCreateResponse( return {
pricing_category_id=pricing_category_id, "pricing_category_id": pricing_category_id,
scheme_id=scheme_id, "scheme_id": scheme.scheme_id,
name=payload.name, "name": payload.name,
code=payload.code, "code": payload.code,
) }
@router.put( @router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}",
response_model=PricingCategoryUpdateResponse,
)
async def update_pricing_category_endpoint( async def update_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
pricing_category_id: str, pricing_category_id: str,
@@ -132,81 +121,77 @@ async def update_pricing_category_endpoint(
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
row = await update_pricing_category( row = await update_pricing_category(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
pricing_category_id=pricing_category_id, pricing_category_id=pricing_category_id,
name=payload.name, name=payload.name,
code=payload.code, code=payload.code,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.category.updated", event_type="pricing.category.updated",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, details={
"scheme_version_id": version.scheme_version_id,
"name": row.name,
"code": row.code,
},
) )
return PricingCategoryUpdateResponse( return {
pricing_category_id=row.pricing_category_id, "pricing_category_id": row.pricing_category_id,
scheme_id=row.scheme_id, "scheme_id": row.scheme_id,
name=row.name, "name": row.name,
code=row.code, "code": row.code,
) }
@router.delete( @router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}",
response_model=DeleteResponse,
)
async def delete_pricing_category_endpoint( async def delete_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
pricing_category_id: str, pricing_category_id: str,
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
await delete_pricing_category( await delete_pricing_category(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
pricing_category_id=pricing_category_id, pricing_category_id=pricing_category_id,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.category.deleted", event_type="pricing.category.deleted",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, object_ref=pricing_category_id,
details={"snapshot": snapshot}, details={"scheme_version_id": version.scheme_version_id},
) )
return DeleteResponse( return {
deleted=True, "deleted": True,
pricing_category_id=pricing_category_id, "pricing_category_id": pricing_category_id,
) }
@router.post( @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules",
response_model=PriceRuleCreateResponse,
)
async def create_price_rule_endpoint( async def create_price_rule_endpoint(
scheme_id: str, scheme_id: str,
payload: PriceRuleCreateRequest, payload: PriceRuleCreateRequest,
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
@@ -217,49 +202,45 @@ async def create_price_rule_endpoint(
raise_unprocessable( raise_unprocessable(
code="invalid_amount", code="invalid_amount",
message="Некорректная сумма", message="Некорректная сумма",
amount=payload.amount, details={"amount": payload.amount},
) )
price_rule_id = await create_price_rule( price_rule_id = await create_price_rule(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
pricing_category_id=payload.pricing_category_id, pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type, target_type=payload.target_type,
target_ref=payload.target_ref, target_ref=payload.target_ref,
amount=amount, amount=amount,
currency=payload.currency, currency=payload.currency,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.rule.created", event_type="pricing.rule.created",
object_type="price_rule", object_type="price_rule",
object_ref=price_rule_id, object_ref=price_rule_id,
details={ details={
"scheme_version_id": version.scheme_version_id,
"pricing_category_id": payload.pricing_category_id, "pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type, "target_type": payload.target_type,
"target_ref": payload.target_ref, "target_ref": payload.target_ref,
"amount": payload.amount, "amount": str(amount),
"currency": payload.currency, "currency": payload.currency,
"snapshot": snapshot,
}, },
) )
return PriceRuleCreateResponse( return {
price_rule_id=price_rule_id, "price_rule_id": price_rule_id,
scheme_id=scheme_id, "scheme_id": scheme.scheme_id,
pricing_category_id=payload.pricing_category_id, "pricing_category_id": payload.pricing_category_id,
target_type=payload.target_type, "target_type": payload.target_type,
target_ref=payload.target_ref, "target_ref": payload.target_ref,
amount=payload.amount, "amount": str(amount),
currency=payload.currency, "currency": payload.currency,
) }
@router.put( @router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}",
response_model=PriceRuleUpdateResponse,
)
async def update_price_rule_endpoint( async def update_price_rule_endpoint(
scheme_id: str, scheme_id: str,
price_rule_id: str, price_rule_id: str,
@@ -267,7 +248,7 @@ async def update_price_rule_endpoint(
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
@@ -278,11 +259,11 @@ async def update_price_rule_endpoint(
raise_unprocessable( raise_unprocessable(
code="invalid_amount", code="invalid_amount",
message="Некорректная сумма", message="Некорректная сумма",
amount=payload.amount, details={"amount": payload.amount},
) )
row = await update_price_rule( row = await update_price_rule(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
price_rule_id=price_rule_id, price_rule_id=price_rule_id,
pricing_category_id=payload.pricing_category_id, pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type, target_type=payload.target_type,
@@ -290,64 +271,59 @@ async def update_price_rule_endpoint(
amount=amount, amount=amount,
currency=payload.currency, currency=payload.currency,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.rule.updated", event_type="pricing.rule.updated",
object_type="price_rule", object_type="price_rule",
object_ref=price_rule_id, object_ref=price_rule_id,
details={ details={
"pricing_category_id": payload.pricing_category_id, "scheme_version_id": version.scheme_version_id,
"target_type": payload.target_type, "pricing_category_id": row.pricing_category_id,
"target_ref": payload.target_ref, "target_type": row.target_type,
"amount": payload.amount, "target_ref": row.target_ref,
"currency": payload.currency, "amount": str(row.amount),
"snapshot": snapshot, "currency": row.currency,
}, },
) )
return PriceRuleUpdateResponse( return {
price_rule_id=row.price_rule_id, "price_rule_id": row.price_rule_id,
scheme_id=row.scheme_id, "scheme_id": row.scheme_id,
pricing_category_id=row.pricing_category_id, "pricing_category_id": row.pricing_category_id,
target_type=row.target_type, "target_type": row.target_type,
target_ref=row.target_ref, "target_ref": row.target_ref,
amount=str(row.amount), "amount": str(row.amount),
currency=row.currency, "currency": row.currency,
) }
@router.delete( @router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}",
response_model=DeleteResponse,
)
async def delete_price_rule_endpoint( async def delete_price_rule_endpoint(
scheme_id: str, scheme_id: str,
price_rule_id: str, price_rule_id: str,
expected_scheme_version_id: str | None = Query(default=None), expected_scheme_version_id: str | None = Query(default=None),
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await validate_expected_draft_version_if_provided( scheme, version = await _require_current_draft(
scheme_id=scheme_id, scheme_id=scheme_id,
expected_scheme_version_id=expected_scheme_version_id, expected_scheme_version_id=expected_scheme_version_id,
) )
await delete_price_rule( await delete_price_rule(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
price_rule_id=price_rule_id, price_rule_id=price_rule_id,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme.scheme_id,
event_type="pricing.rule.deleted", event_type="pricing.rule.deleted",
object_type="price_rule", object_type="price_rule",
object_ref=price_rule_id, object_ref=price_rule_id,
details={"snapshot": snapshot}, details={"scheme_version_id": version.scheme_version_id},
) )
return DeleteResponse( return {
deleted=True, "deleted": True,
price_rule_id=price_rule_id, "price_rule_id": price_rule_id,
) }

View File

@@ -1,26 +1,38 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
from app.core.config import settings from app.core.config import settings
from app.repositories.pricing import find_effective_price_rule 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_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.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id from app.repositories.schemes import get_scheme_record_by_scheme_id
from app.schemas.pricing_diagnostics import ( from app.schemas.pricing_diagnostics import PricingRuleDiagnosticsResponse
ExplainMatchedRule,
ExplainSeatPriceResponse,
PricingCoverageResponse,
UnpricedSeatItem,
UnpricedSeatListResponse,
)
from app.security.auth import require_api_key from app.security.auth import require_api_key
from app.services.pricing_rule_diagnostics import build_pricing_rule_diagnostics
router = APIRouter() router = APIRouter()
@router.get( @router.get(
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/coverage", f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/diagnostics",
response_model=PricingCoverageResponse, 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( async def get_pricing_coverage(
scheme_id: str, scheme_id: str,
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
@@ -32,12 +44,12 @@ async def get_pricing_coverage(
) )
seats = await list_scheme_version_seats(version.scheme_version_id) seats = await list_scheme_version_seats(version.scheme_version_id)
priced_seats = 0 priced = 0
unpriced_seats = 0 unpriced = 0
for seat in seats: for seat in seats:
if not seat.seat_id: if not seat.seat_id:
unpriced_seats += 1 unpriced += 1
continue continue
try: try:
@@ -47,29 +59,24 @@ async def get_pricing_coverage(
group_id=seat.group_id, group_id=seat.group_id,
sector_id=seat.sector_id, sector_id=seat.sector_id,
) )
priced_seats += 1 priced += 1
except HTTPException as exc: except Exception:
if exc.status_code != status.HTTP_404_NOT_FOUND: unpriced += 1
raise
unpriced_seats += 1
total_seats = len(seats) total = len(seats)
coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 100.0 coverage_percent = round((priced / total) * 100, 2) if total else 100.0
return PricingCoverageResponse( return {
scheme_id=scheme.scheme_id, "scheme_id": scheme.scheme_id,
scheme_version_id=version.scheme_version_id, "scheme_version_id": version.scheme_version_id,
total_seats=total_seats, "total_seats": total,
priced_seats=priced_seats, "priced_seats": priced,
unpriced_seats=unpriced_seats, "unpriced_seats": unpriced,
coverage_percent=coverage_percent, "coverage_percent": coverage_percent,
) }
@router.get( @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats",
response_model=UnpricedSeatListResponse,
)
async def get_unpriced_seats( async def get_unpriced_seats(
scheme_id: str, scheme_id: str,
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
@@ -81,22 +88,21 @@ async def get_unpriced_seats(
) )
seats = await list_scheme_version_seats(version.scheme_version_id) seats = await list_scheme_version_seats(version.scheme_version_id)
items: list[UnpricedSeatItem] = [] items: list[dict] = []
for seat in seats: for seat in seats:
if not seat.seat_id: if not seat.seat_id:
items.append( items.append(
UnpricedSeatItem( {
seat_record_id=seat.seat_record_id, "seat_record_id": seat.seat_record_id,
seat_id=seat.seat_id, "seat_id": seat.seat_id,
element_id=seat.element_id, "element_id": seat.element_id,
sector_id=seat.sector_id, "sector_id": seat.sector_id,
group_id=seat.group_id, "group_id": seat.group_id,
row_label=seat.row_label, "row_label": seat.row_label,
seat_number=seat.seat_number, "seat_number": seat.seat_number,
reason_code="missing_seat_id", "reason_code": "missing_seat_id",
reason_message="Seat has no seat_id, so price resolution is not possible.", "reason_message": "Seat has no seat_id and cannot be priced.",
) }
) )
continue continue
@@ -107,36 +113,31 @@ async def get_unpriced_seats(
group_id=seat.group_id, group_id=seat.group_id,
sector_id=seat.sector_id, sector_id=seat.sector_id,
) )
except HTTPException as exc: except Exception:
if exc.status_code != status.HTTP_404_NOT_FOUND:
raise
items.append( items.append(
UnpricedSeatItem( {
seat_record_id=seat.seat_record_id, "seat_record_id": seat.seat_record_id,
seat_id=seat.seat_id, "seat_id": seat.seat_id,
element_id=seat.element_id, "element_id": seat.element_id,
sector_id=seat.sector_id, "sector_id": seat.sector_id,
group_id=seat.group_id, "group_id": seat.group_id,
row_label=seat.row_label, "row_label": seat.row_label,
seat_number=seat.seat_number, "seat_number": seat.seat_number,
reason_code="no_price_rule", "reason_code": "no_price_rule",
reason_message="No effective price rule was found for this seat.", "reason_message": "No effective price rule was found for this seat.",
) }
) )
return UnpricedSeatListResponse( return {
scheme_id=scheme.scheme_id, "scheme_id": scheme.scheme_id,
scheme_version_id=version.scheme_version_id, "scheme_version_id": version.scheme_version_id,
total=len(items), "total": len(items),
items=items, "items": items,
) }
@router.get( @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/explain/{{seat_id}}")
f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/explain/{{seat_id}}", async def explain_seat_pricing(
response_model=ExplainSeatPriceResponse,
)
async def explain_seat_price(
scheme_id: str, scheme_id: str,
seat_id: str, seat_id: str,
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
@@ -151,22 +152,6 @@ async def explain_seat_price(
seat_id=seat_id, seat_id=seat_id,
) )
if not seat.seat_id:
return ExplainSeatPriceResponse(
scheme_id=scheme.scheme_id,
scheme_version_id=version.scheme_version_id,
seat_id=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="missing_seat_id",
reason_message="Seat has no seat_id, so price resolution is not possible.",
matched_rule=None,
)
try: try:
matched_rule_level, rule = await find_effective_price_rule( matched_rule_level, rule = await find_effective_price_rule(
scheme_id=scheme.scheme_id, scheme_id=scheme.scheme_id,
@@ -174,42 +159,38 @@ async def explain_seat_price(
group_id=seat.group_id, group_id=seat.group_id,
sector_id=seat.sector_id, sector_id=seat.sector_id,
) )
matched_rule = ExplainMatchedRule( return {
matched_rule_level=matched_rule_level, "scheme_id": scheme.scheme_id,
matched_target_ref=rule["target_ref"], "scheme_version_id": version.scheme_version_id,
pricing_category_id=rule["pricing_category_id"], "seat_id": seat.seat_id,
amount=str(rule["amount"]), "element_id": seat.element_id,
currency=rule["currency"], "sector_id": seat.sector_id,
) "group_id": seat.group_id,
return ExplainSeatPriceResponse( "row_label": seat.row_label,
scheme_id=scheme.scheme_id, "seat_number": seat.seat_number,
scheme_version_id=version.scheme_version_id, "has_price": True,
seat_id=seat.seat_id, "reason_code": "ok",
element_id=seat.element_id, "reason_message": "Effective price rule resolved successfully.",
sector_id=seat.sector_id, "matched_rule": {
group_id=seat.group_id, "matched_rule_level": matched_rule_level,
row_label=seat.row_label, "matched_target_ref": rule["target_ref"],
seat_number=seat.seat_number, "pricing_category_id": rule["pricing_category_id"],
has_price=True, "amount": str(rule["amount"]),
reason_code="ok", "currency": rule["currency"],
reason_message="Effective price rule resolved successfully.", },
matched_rule=matched_rule, }
) except Exception:
except HTTPException as exc: return {
if exc.status_code != status.HTTP_404_NOT_FOUND: "scheme_id": scheme.scheme_id,
raise "scheme_version_id": version.scheme_version_id,
"seat_id": seat.seat_id,
return ExplainSeatPriceResponse( "element_id": seat.element_id,
scheme_id=scheme.scheme_id, "sector_id": seat.sector_id,
scheme_version_id=version.scheme_version_id, "group_id": seat.group_id,
seat_id=seat.seat_id, "row_label": seat.row_label,
element_id=seat.element_id, "seat_number": seat.seat_number,
sector_id=seat.sector_id, "has_price": False,
group_id=seat.group_id, "reason_code": "no_price_rule",
row_label=seat.row_label, "reason_message": "No effective price rule was found for this seat.",
seat_number=seat.seat_number, "matched_rule": None,
has_price=False, }
reason_code="no_price_rule",
reason_message="No effective price rule was found for this seat.",
matched_rule=None,
)

View File

@@ -1,52 +1,55 @@
from pydantic import BaseModel from pydantic import BaseModel
class PricingCoverageResponse(BaseModel): class PricingCategoryMutationResponse(BaseModel):
scheme_id: str
scheme_version_id: str
total_seats: int
priced_seats: int
unpriced_seats: int
coverage_percent: float
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 UnpricedSeatListResponse(BaseModel):
scheme_id: str
scheme_version_id: str
total: int
items: list[UnpricedSeatItem]
class ExplainMatchedRule(BaseModel):
matched_rule_level: str
matched_target_ref: str
pricing_category_id: str pricing_category_id: str
scheme_id: str
name: str
code: str
class PricingCategoryDeleteResponse(BaseModel):
deleted: bool
pricing_category_id: str
class PriceRuleMutationResponse(BaseModel):
price_rule_id: str
scheme_id: str
pricing_category_id: str
target_type: str
target_ref: str
amount: str amount: str
currency: str currency: str
class ExplainSeatPriceResponse(BaseModel): class PriceRuleDeleteResponse(BaseModel):
deleted: bool
price_rule_id: str
class PricingRuleDiagnosticsItem(BaseModel):
price_rule_id: str
pricing_category_id: str
target_type: str
target_ref: str
amount: str
currency: str
matched_seats_count: int
matched_seat_ids: list[str]
orphan: bool
orphan_reason: str | None
class PricingRuleDiagnosticsSummary(BaseModel):
total_rules: int
orphan_rules_count: int
active_rules_count: int
matched_seats_total: int
class PricingRuleDiagnosticsResponse(BaseModel):
scheme_id: str scheme_id: str
scheme_version_id: str scheme_version_id: str
seat_id: str summary: PricingRuleDiagnosticsSummary
element_id: str | None items: list[PricingRuleDiagnosticsItem]
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: ExplainMatchedRule | None

View File

@@ -3,12 +3,17 @@ from __future__ import annotations
from fastapi import HTTPException, status from fastapi import HTTPException, status
def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None: def raise_conflict(
*,
code: str,
message: str,
details: dict | None = None,
) -> None:
payload: dict = { payload: dict = {
"code": code, "code": code,
"message": message, "message": message,
} }
if details: if details is not None:
payload["details"] = details payload["details"] = details
raise HTTPException( raise HTTPException(
@@ -17,13 +22,18 @@ def raise_conflict(*, code: str, message: str, details: dict | None = None) -> N
) )
def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None: def raise_unprocessable(
*,
code: str,
message: str,
details: dict | None = None,
) -> None:
payload: dict = { payload: dict = {
"code": code, "code": code,
"message": message, "message": message,
} }
if details: if details is not None:
payload["details"] = details payload.update(details)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -1,5 +1,3 @@
from fastapi import HTTPException, status
from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.scheme_versions import get_current_scheme_version
from app.repositories.schemes import get_scheme_record_by_scheme_id from app.repositories.schemes import get_scheme_record_by_scheme_id
from app.services.api_errors import raise_conflict from app.services.api_errors import raise_conflict
@@ -29,9 +27,14 @@ async def get_current_draft_context(
) )
if version.status != "draft" or scheme.status != "draft": if version.status != "draft" or scheme.status != "draft":
raise HTTPException( raise_conflict(
status_code=status.HTTP_409_CONFLICT, code="draft_not_editable",
detail="Current scheme version is not editable because it is not in draft state", message="Current scheme version is not editable because it is not in draft state",
details={
"scheme_status": scheme.status,
"scheme_version_status": version.status,
"actual_scheme_version_id": version.scheme_version_id,
},
) )
if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id: if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id:

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from app.repositories.pricing import list_price_rules
from app.repositories.scheme_groups import list_scheme_version_groups
from app.repositories.scheme_seats import list_scheme_version_seats
from app.repositories.scheme_sectors import list_scheme_version_sectors
async def build_pricing_rule_diagnostics(
*,
scheme_id: str,
scheme_version_id: str,
) -> dict:
rules = await list_price_rules(scheme_id)
seats = await list_scheme_version_seats(scheme_version_id)
sectors = await list_scheme_version_sectors(scheme_version_id)
groups = await list_scheme_version_groups(scheme_version_id)
sector_ids = {row.sector_id for row in sectors if row.sector_id}
group_ids = {row.group_id for row in groups if row.group_id}
seat_ids = {row.seat_id for row in seats if row.seat_id}
items: list[dict] = []
matched_seats_total = 0
orphan_rules_count = 0
for rule in rules:
matched_seat_ids: list[str] = []
orphan = False
orphan_reason: str | None = None
if rule.target_type == "seat":
if rule.target_ref not in seat_ids:
orphan = True
orphan_reason = "target_seat_not_found"
else:
matched_seat_ids = [
seat.seat_id
for seat in seats
if seat.seat_id and seat.seat_id == rule.target_ref
]
elif rule.target_type == "group":
if rule.target_ref not in group_ids:
orphan = True
orphan_reason = "target_group_not_found"
else:
matched_seat_ids = [
seat.seat_id
for seat in seats
if seat.seat_id and seat.group_id == rule.target_ref
]
elif rule.target_type == "sector":
if rule.target_ref not in sector_ids:
orphan = True
orphan_reason = "target_sector_not_found"
else:
matched_seat_ids = [
seat.seat_id
for seat in seats
if seat.seat_id and seat.sector_id == rule.target_ref
]
else:
orphan = True
orphan_reason = "unsupported_target_type"
if orphan:
orphan_rules_count += 1
matched_seats_total += len(matched_seat_ids)
items.append(
{
"price_rule_id": rule.price_rule_id,
"pricing_category_id": rule.pricing_category_id,
"target_type": rule.target_type,
"target_ref": rule.target_ref,
"amount": str(rule.amount),
"currency": rule.currency,
"matched_seats_count": len(matched_seat_ids),
"matched_seat_ids": matched_seat_ids,
"orphan": orphan,
"orphan_reason": orphan_reason,
}
)
return {
"scheme_id": scheme_id,
"scheme_version_id": scheme_version_id,
"summary": {
"total_rules": len(items),
"orphan_rules_count": orphan_rules_count,
"active_rules_count": len(items) - orphan_rules_count,
"matched_seats_total": matched_seats_total,
},
"items": items,
}