diff --git a/backend/app/api/routes/publish.py b/backend/app/api/routes/publish.py index ee8376c..bb81398 100644 --- a/backend/app/api/routes/publish.py +++ b/backend/app/api/routes/publish.py @@ -11,9 +11,11 @@ from app.schemas.publish_preview import ( RemapPreviewResponse, RemapPreviewSeatItem, ) +from app.schemas.publish_readiness import PublishReadinessResponse from app.security.auth import require_api_key from app.services.draft_guard import get_current_draft_context from app.services.publish_preview import get_or_build_publish_preview_bundle +from app.services.publish_readiness import build_publish_readiness from app.services.remap_service import apply_remap, preview_remap router = APIRouter() @@ -26,7 +28,7 @@ async def create_draft_pricing_snapshot( role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( - scheme_id=scheme_id, + scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) result = await replace_scheme_version_pricing_snapshot( @@ -49,6 +51,35 @@ async def create_draft_pricing_snapshot( } +@router.get( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-readiness", + response_model=PublishReadinessResponse, +) +async def get_draft_publish_readiness( + scheme_id: str, + expected_scheme_version_id: str | None = Query(default=None), + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context( + scheme_id, + expected_scheme_version_id=expected_scheme_version_id, + ) + readiness = await build_publish_readiness( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + require_full_pricing_coverage=settings.publish_require_full_pricing_coverage, + ) + return PublishReadinessResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + status=version.status, + validation_summary=readiness["validation_summary"], + pricing_coverage=readiness["pricing_coverage"], + snapshot=readiness["snapshot"], + readiness=readiness["readiness"], + ) + + @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse) async def get_publish_preview( scheme_id: str, @@ -58,7 +89,7 @@ async def get_publish_preview( role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( - scheme_id=scheme_id, + scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) bundle = await get_or_build_publish_preview_bundle( @@ -86,7 +117,7 @@ async def preview_draft_remap( role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( - scheme_id=scheme_id, + scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) items = await preview_remap( @@ -114,7 +145,7 @@ async def apply_draft_remap( role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( - scheme_id=scheme_id, + scheme_id, expected_scheme_version_id=expected_scheme_version_id, ) items = await apply_remap( diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index 466169d..7c43f39 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -1,4 +1,4 @@ -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 @@ -146,10 +146,8 @@ async def create_next_scheme_version_endpoint( current_version_number=current_scheme.current_version_number, ) - if ( - expected_current_scheme_version_id is not None - and expected_current_scheme_version_id != current_version.scheme_version_id - ): + if expected_current_scheme_version_id and expected_current_scheme_version_id != current_version.scheme_version_id: + from fastapi import HTTPException, status raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d928fc2..f3e1761 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -36,6 +36,7 @@ class Settings(BaseSettings): storage_root_dir: str = "/data" publish_preview_retention_per_variant: int = 2 + publish_require_full_pricing_coverage: bool = False model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/app/schemas/publish_readiness.py b/backend/app/schemas/publish_readiness.py new file mode 100644 index 0000000..82b738b --- /dev/null +++ b/backend/app/schemas/publish_readiness.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class PublishReadinessResponse(BaseModel): + scheme_id: str + scheme_version_id: str + status: str + validation_summary: dict + pricing_coverage: dict + snapshot: dict + readiness: dict diff --git a/backend/app/services/api_errors.py b/backend/app/services/api_errors.py index 9b68979..2881e92 100644 --- a/backend/app/services/api_errors.py +++ b/backend/app/services/api_errors.py @@ -1,39 +1,40 @@ -from __future__ import annotations - from fastapi import HTTPException, status -def raise_conflict(*, code: str, message: str, **extra) -> None: - detail = { +def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None: + payload = { "code": code, "message": message, - **extra, } + if details: + payload.update(details) + raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=detail, + detail=payload, ) -def raise_unprocessable(*, code: str, message: str, **extra) -> None: - detail = { +def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None: + payload = { "code": code, "message": message, - **extra, } + if details: + payload.update(details) + raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=detail, + detail=payload, ) -def raise_not_found(*, code: str, message: str, **extra) -> None: - detail = { - "code": code, - "message": message, - **extra, - } +def raise_publish_not_ready(*, reason: str, details: dict) -> None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=detail, + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "publish_not_ready", + "message": reason, + "details": details, + }, ) diff --git a/backend/app/services/publish_readiness.py b/backend/app/services/publish_readiness.py new file mode 100644 index 0000000..fcc18ca --- /dev/null +++ b/backend/app/services/publish_readiness.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from app.repositories.scheme_seats import list_scheme_version_seats +from app.repositories.scheme_version_pricing import ( + find_effective_snapshot_price_rule, + list_scheme_version_snapshot_categories, + list_scheme_version_snapshot_rules, +) +from app.services.scheme_validation import build_scheme_validation_report + + +async def build_publish_readiness( + *, + scheme_id: str, + scheme_version_id: str, + require_full_pricing_coverage: bool, +) -> dict: + validation = await build_scheme_validation_report( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + ) + + seats = await list_scheme_version_seats(scheme_version_id) + snapshot_categories = await list_scheme_version_snapshot_categories(scheme_version_id) + snapshot_rules = await list_scheme_version_snapshot_rules(scheme_version_id) + + snapshot_available = len(snapshot_categories) > 0 or len(snapshot_rules) > 0 + + priced_seats = 0 + unpriced_seats = 0 + + for seat in seats: + if not seat.seat_id: + unpriced_seats += 1 + continue + + if not snapshot_available: + unpriced_seats += 1 + continue + + try: + await find_effective_snapshot_price_rule( + scheme_version_id=scheme_version_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 + + validation_publishable = bool(validation["summary"]["is_publishable"]) + full_pricing_coverage = unpriced_seats == 0 + pricing_gate_passed = full_pricing_coverage if require_full_pricing_coverage else True + + is_ready_to_publish = ( + validation_publishable + and snapshot_available + and pricing_gate_passed + ) + + return { + "validation_summary": validation["summary"], + "pricing_coverage": { + "total_seats": total_seats, + "priced_seats": priced_seats, + "unpriced_seats": unpriced_seats, + "coverage_percent": coverage_percent, + }, + "snapshot": { + "available": snapshot_available, + "categories_count": len(snapshot_categories), + "rules_count": len(snapshot_rules), + }, + "readiness": { + "validation_publishable": validation_publishable, + "snapshot_available": snapshot_available, + "require_full_pricing_coverage": require_full_pricing_coverage, + "full_pricing_coverage": full_pricing_coverage, + "pricing_gate_passed": pricing_gate_passed, + "is_ready_to_publish": is_ready_to_publish, + }, + } diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 28953e6..1d72015 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -1,9 +1,10 @@ +from app.core.config import settings from app.repositories.audit import create_audit_event from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot -from app.services.draft_guard import get_current_draft_context -from app.repositories.schemes import publish_scheme -from app.services.api_errors import raise_conflict -from app.services.scheme_validation import build_scheme_validation_report +from app.repositories.scheme_versions import get_current_scheme_version +from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme +from app.services.api_errors import raise_publish_not_ready +from app.services.publish_readiness import build_publish_readiness async def publish_current_draft_scheme( @@ -11,22 +12,29 @@ async def publish_current_draft_scheme( scheme_id: str, expected_scheme_version_id: str | None = None, ) -> dict: - scheme, version = await get_current_draft_context( - scheme_id=scheme_id, - expected_scheme_version_id=expected_scheme_version_id, - ) - - validation = await build_scheme_validation_report( + scheme = await get_scheme_record_by_scheme_id(scheme_id) + version = await get_current_scheme_version( scheme_id=scheme.scheme_id, - scheme_version_id=version.scheme_version_id, + current_version_number=scheme.current_version_number, ) - if not validation["summary"]["is_publishable"]: - raise_conflict( - code="publish_validation_failed", - message="Scheme is not publishable in current state", - scheme_version_id=version.scheme_version_id, - validation_summary=validation["summary"], + if scheme.status != "draft" or version.status != "draft": + raise_publish_not_ready( + reason="Current scheme version is not publishable because it is not in draft state", + details={ + "scheme_status": scheme.status, + "scheme_version_status": version.status, + "scheme_version_id": version.scheme_version_id, + }, + ) + + if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id: + raise_publish_not_ready( + reason="Draft scheme version is stale. Reload current draft state before publishing.", + details={ + "expected_scheme_version_id": expected_scheme_version_id, + "actual_scheme_version_id": version.scheme_version_id, + }, ) snapshot = await replace_scheme_version_pricing_snapshot( @@ -34,6 +42,18 @@ async def publish_current_draft_scheme( scheme_version_id=version.scheme_version_id, ) + readiness = await build_publish_readiness( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + require_full_pricing_coverage=settings.publish_require_full_pricing_coverage, + ) + + if not readiness["readiness"]["is_ready_to_publish"]: + raise_publish_not_ready( + reason="Scheme is not ready to publish", + details=readiness, + ) + published_row = await publish_scheme(scheme.scheme_id) await create_audit_event( @@ -46,6 +66,7 @@ async def publish_current_draft_scheme( "status": published_row.status, "pricing_snapshot": snapshot, "scheme_version_id": version.scheme_version_id, + "publish_readiness": readiness, }, ) @@ -56,5 +77,5 @@ async def publish_current_draft_scheme( "current_version_number": published_row.current_version_number, "published_at": published_row.published_at.isoformat() if published_row.published_at else None, "pricing_snapshot": snapshot, - "validation_summary": validation["summary"], + "publish_readiness": readiness, } diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 30998af..10efbc3 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -89,3 +89,6 @@ - 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. + +## app/api/routes/publish.py +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness