diff --git a/backend/app/api/routes/pricing.py b/backend/app/api/routes/pricing.py index 88e2652..abd38f9 100644 --- a/backend/app/api/routes/pricing.py +++ b/backend/app/api/routes/pricing.py @@ -1,6 +1,6 @@ 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.repositories.audit import create_audit_event @@ -14,45 +14,37 @@ from app.repositories.pricing import ( update_price_rule, update_pricing_category, ) -from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot from app.schemas.pricing import ( - DeleteResponse, PriceRuleCreateRequest, - PriceRuleCreateResponse, PriceRuleItem, PriceRuleUpdateRequest, - PriceRuleUpdateResponse, PricingBundleResponse, PricingCategoryCreateRequest, - PricingCategoryCreateResponse, PricingCategoryItem, PricingCategoryUpdateRequest, - PricingCategoryUpdateResponse, ) from app.security.auth import require_api_key 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() -async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None: - context = await validate_expected_draft_version_if_provided( +async def _require_current_draft( + scheme_id: str, + expected_scheme_version_id: str | None, +): + return await get_current_draft_context( scheme_id=scheme_id, - expected_scheme_version_id=None, - ) - 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, + expected_scheme_version_id=expected_scheme_version_id, ) @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) 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( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", - response_model=PricingCategoryCreateResponse, -) +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories") async def create_pricing_category_endpoint( scheme_id: str, payload: PricingCategoryCreateRequest, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) pricing_category_id = await create_pricing_category( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, name=payload.name, code=payload.code, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.created", object_type="pricing_category", 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( - pricing_category_id=pricing_category_id, - scheme_id=scheme_id, - name=payload.name, - code=payload.code, - ) + return { + "pricing_category_id": pricing_category_id, + "scheme_id": scheme.scheme_id, + "name": payload.name, + "code": payload.code, + } -@router.put( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", - response_model=PricingCategoryUpdateResponse, -) +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}") async def update_pricing_category_endpoint( scheme_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), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) row = await update_pricing_category( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, pricing_category_id=pricing_category_id, name=payload.name, code=payload.code, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.updated", object_type="pricing_category", 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( - pricing_category_id=row.pricing_category_id, - scheme_id=row.scheme_id, - name=row.name, - code=row.code, - ) + return { + "pricing_category_id": row.pricing_category_id, + "scheme_id": row.scheme_id, + "name": row.name, + "code": row.code, + } -@router.delete( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", - response_model=DeleteResponse, -) +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}") async def delete_pricing_category_endpoint( scheme_id: str, pricing_category_id: str, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) await delete_pricing_category( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, pricing_category_id=pricing_category_id, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.category.deleted", object_type="pricing_category", object_ref=pricing_category_id, - details={"snapshot": snapshot}, + details={"scheme_version_id": version.scheme_version_id}, ) - return DeleteResponse( - deleted=True, - pricing_category_id=pricing_category_id, - ) + return { + "deleted": True, + "pricing_category_id": pricing_category_id, + } -@router.post( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", - response_model=PriceRuleCreateResponse, -) +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules") async def create_price_rule_endpoint( scheme_id: str, payload: PriceRuleCreateRequest, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) @@ -217,49 +202,45 @@ async def create_price_rule_endpoint( raise_unprocessable( code="invalid_amount", message="Некорректная сумма", - amount=payload.amount, + details={"amount": payload.amount}, ) price_rule_id = await create_price_rule( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, target_ref=payload.target_ref, amount=amount, currency=payload.currency, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.created", object_type="price_rule", object_ref=price_rule_id, details={ + "scheme_version_id": version.scheme_version_id, "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, - "amount": payload.amount, + "amount": str(amount), "currency": payload.currency, - "snapshot": snapshot, }, ) - return PriceRuleCreateResponse( - price_rule_id=price_rule_id, - scheme_id=scheme_id, - pricing_category_id=payload.pricing_category_id, - target_type=payload.target_type, - target_ref=payload.target_ref, - amount=payload.amount, - currency=payload.currency, - ) + return { + "price_rule_id": price_rule_id, + "scheme_id": scheme.scheme_id, + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": str(amount), + "currency": payload.currency, + } -@router.put( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", - response_model=PriceRuleUpdateResponse, -) +@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}") async def update_price_rule_endpoint( scheme_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), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) @@ -278,11 +259,11 @@ async def update_price_rule_endpoint( raise_unprocessable( code="invalid_amount", message="Некорректная сумма", - amount=payload.amount, + details={"amount": payload.amount}, ) row = await update_price_rule( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, price_rule_id=price_rule_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, @@ -290,64 +271,59 @@ async def update_price_rule_endpoint( amount=amount, currency=payload.currency, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.updated", object_type="price_rule", object_ref=price_rule_id, details={ - "pricing_category_id": payload.pricing_category_id, - "target_type": payload.target_type, - "target_ref": payload.target_ref, - "amount": payload.amount, - "currency": payload.currency, - "snapshot": snapshot, + "scheme_version_id": version.scheme_version_id, + "pricing_category_id": row.pricing_category_id, + "target_type": row.target_type, + "target_ref": row.target_ref, + "amount": str(row.amount), + "currency": row.currency, }, ) - return PriceRuleUpdateResponse( - price_rule_id=row.price_rule_id, - scheme_id=row.scheme_id, - pricing_category_id=row.pricing_category_id, - target_type=row.target_type, - target_ref=row.target_ref, - amount=str(row.amount), - currency=row.currency, - ) + return { + "price_rule_id": row.price_rule_id, + "scheme_id": row.scheme_id, + "pricing_category_id": row.pricing_category_id, + "target_type": row.target_type, + "target_ref": row.target_ref, + "amount": str(row.amount), + "currency": row.currency, + } -@router.delete( - f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", - response_model=DeleteResponse, -) +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}") async def delete_price_rule_endpoint( scheme_id: str, price_rule_id: str, expected_scheme_version_id: str | None = Query(default=None), role: str = Depends(require_api_key), ): - await validate_expected_draft_version_if_provided( + scheme, version = await _require_current_draft( scheme_id=scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) await delete_price_rule( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, price_rule_id=price_rule_id, ) - snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) await create_audit_event( - scheme_id=scheme_id, + scheme_id=scheme.scheme_id, event_type="pricing.rule.deleted", object_type="price_rule", object_ref=price_rule_id, - details={"snapshot": snapshot}, + details={"scheme_version_id": version.scheme_version_id}, ) - return DeleteResponse( - deleted=True, - price_rule_id=price_rule_id, - ) + return { + "deleted": True, + "price_rule_id": price_rule_id, + } diff --git a/backend/app/api/routes/pricing_diagnostics.py b/backend/app/api/routes/pricing_diagnostics.py index ee5d3b5..956747e 100644 --- a/backend/app/api/routes/pricing_diagnostics.py +++ b/backend/app/api/routes/pricing_diagnostics.py @@ -1,26 +1,38 @@ -from fastapi import APIRouter, Depends, HTTPException, status +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 ( - ExplainMatchedRule, - ExplainSeatPriceResponse, - PricingCoverageResponse, - UnpricedSeatItem, - UnpricedSeatListResponse, -) +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/coverage", - response_model=PricingCoverageResponse, + 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), @@ -32,12 +44,12 @@ async def get_pricing_coverage( ) seats = await list_scheme_version_seats(version.scheme_version_id) - priced_seats = 0 - unpriced_seats = 0 + priced = 0 + unpriced = 0 for seat in seats: if not seat.seat_id: - unpriced_seats += 1 + unpriced += 1 continue try: @@ -47,29 +59,24 @@ async def get_pricing_coverage( group_id=seat.group_id, sector_id=seat.sector_id, ) - priced_seats += 1 - except HTTPException as exc: - if exc.status_code != status.HTTP_404_NOT_FOUND: - raise - unpriced_seats += 1 + priced += 1 + except Exception: + unpriced += 1 - total_seats = len(seats) - coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 100.0 + total = len(seats) + coverage_percent = round((priced / total) * 100, 2) if total else 100.0 - return PricingCoverageResponse( - 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, - ) + 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", - response_model=UnpricedSeatListResponse, -) +@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), @@ -81,22 +88,21 @@ async def get_unpriced_seats( ) seats = await list_scheme_version_seats(version.scheme_version_id) - items: list[UnpricedSeatItem] = [] - + items: list[dict] = [] for seat in seats: if not 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="missing_seat_id", - reason_message="Seat has no seat_id, so price resolution is not possible.", - ) + { + "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 @@ -107,36 +113,31 @@ async def get_unpriced_seats( group_id=seat.group_id, sector_id=seat.sector_id, ) - except HTTPException as exc: - if exc.status_code != status.HTTP_404_NOT_FOUND: - raise + except Exception: 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="no_price_rule", - reason_message="No effective price rule was found for this seat.", - ) + { + "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 UnpricedSeatListResponse( - scheme_id=scheme.scheme_id, - scheme_version_id=version.scheme_version_id, - total=len(items), - items=items, - ) + 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}}", - response_model=ExplainSeatPriceResponse, -) -async def explain_seat_price( +@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), @@ -151,22 +152,6 @@ async def explain_seat_price( 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: matched_rule_level, rule = await find_effective_price_rule( scheme_id=scheme.scheme_id, @@ -174,42 +159,38 @@ async def explain_seat_price( group_id=seat.group_id, sector_id=seat.sector_id, ) - matched_rule = ExplainMatchedRule( - 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"], - ) - return ExplainSeatPriceResponse( - 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, - ) - except HTTPException as exc: - if exc.status_code != status.HTTP_404_NOT_FOUND: - raise - - return ExplainSeatPriceResponse( - 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, - ) + 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, + } diff --git a/backend/app/schemas/pricing_diagnostics.py b/backend/app/schemas/pricing_diagnostics.py index a452df3..ab3c8eb 100644 --- a/backend/app/schemas/pricing_diagnostics.py +++ b/backend/app/schemas/pricing_diagnostics.py @@ -1,52 +1,55 @@ from pydantic import BaseModel -class PricingCoverageResponse(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 +class PricingCategoryMutationResponse(BaseModel): 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 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_version_id: str - seat_id: str - 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: ExplainMatchedRule | None + summary: PricingRuleDiagnosticsSummary + items: list[PricingRuleDiagnosticsItem] diff --git a/backend/app/services/api_errors.py b/backend/app/services/api_errors.py index b9a9a46..5f89ba2 100644 --- a/backend/app/services/api_errors.py +++ b/backend/app/services/api_errors.py @@ -3,12 +3,17 @@ from __future__ import annotations 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 = { "code": code, "message": message, } - if details: + if details is not None: payload["details"] = details 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 = { "code": code, "message": message, } - if details: - payload["details"] = details + if details is not None: + payload.update(details) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/app/services/draft_guard.py b/backend/app/services/draft_guard.py index 1a67251..72829eb 100644 --- a/backend/app/services/draft_guard.py +++ b/backend/app/services/draft_guard.py @@ -1,5 +1,3 @@ -from fastapi import HTTPException, status - from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.schemes import get_scheme_record_by_scheme_id 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": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Current scheme version is not editable because it is not in draft state", + raise_conflict( + code="draft_not_editable", + 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: diff --git a/backend/app/services/pricing_rule_diagnostics.py b/backend/app/services/pricing_rule_diagnostics.py new file mode 100644 index 0000000..fd17e25 --- /dev/null +++ b/backend/app/services/pricing_rule_diagnostics.py @@ -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, + }