diff --git a/backend/app/api/routes/pricing.py b/backend/app/api/routes/pricing.py index a18d2c6..88e2652 100644 --- a/backend/app/api/routes/pricing.py +++ b/backend/app/api/routes/pricing.py @@ -30,6 +30,7 @@ from app.schemas.pricing import ( 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 router = APIRouter() @@ -213,9 +214,10 @@ async def create_price_rule_endpoint( try: amount = Decimal(payload.amount) except Exception: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Некорректная сумма", + raise_unprocessable( + code="invalid_amount", + message="Некорректная сумма", + amount=payload.amount, ) price_rule_id = await create_price_rule( @@ -273,9 +275,10 @@ async def update_price_rule_endpoint( try: amount = Decimal(payload.amount) except Exception: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Некорректная сумма", + raise_unprocessable( + code="invalid_amount", + message="Некорректная сумма", + amount=payload.amount, ) row = await update_price_rule( diff --git a/backend/app/services/api_errors.py b/backend/app/services/api_errors.py new file mode 100644 index 0000000..9b68979 --- /dev/null +++ b/backend/app/services/api_errors.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from fastapi import HTTPException, status + + +def raise_conflict(*, code: str, message: str, **extra) -> None: + detail = { + "code": code, + "message": message, + **extra, + } + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=detail, + ) + + +def raise_unprocessable(*, code: str, message: str, **extra) -> None: + detail = { + "code": code, + "message": message, + **extra, + } + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=detail, + ) + + +def raise_not_found(*, code: str, message: str, **extra) -> None: + detail = { + "code": code, + "message": message, + **extra, + } + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) diff --git a/backend/app/services/draft_guard.py b/backend/app/services/draft_guard.py index f70287b..84b5a00 100644 --- a/backend/app/services/draft_guard.py +++ b/backend/app/services/draft_guard.py @@ -1,7 +1,6 @@ -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 def build_stale_draft_version_detail( @@ -28,19 +27,19 @@ 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", + 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: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=build_stale_draft_version_detail( - expected_scheme_version_id=expected_scheme_version_id, - actual_scheme_version_id=version.scheme_version_id, - ), - ) + raise_conflict(**build_stale_draft_version_detail( + expected_scheme_version_id=expected_scheme_version_id, + actual_scheme_version_id=version.scheme_version_id, + )) return scheme, version diff --git a/backend/app/services/editor_validation.py b/backend/app/services/editor_validation.py index eaccb4e..7b65d81 100644 --- a/backend/app/services/editor_validation.py +++ b/backend/app/services/editor_validation.py @@ -1,24 +1,19 @@ from __future__ import annotations -from fastapi import HTTPException, status - 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 +from app.services.api_errors import raise_unprocessable def _raise_uniqueness_error(message: str, detail: dict | None = None) -> None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=detail or {"code": "editor_uniqueness_error", "message": message}, - ) + payload = detail or {"code": "editor_uniqueness_error", "message": message} + raise_unprocessable(**payload) def _raise_reference_error(message: str, detail: dict | None = None) -> None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=detail or {"code": "editor_reference_error", "message": message}, - ) + payload = detail or {"code": "editor_reference_error", "message": message} + raise_unprocessable(**payload) async def validate_single_seat_patch_uniqueness( diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index b11eefb..28953e6 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -1,9 +1,8 @@ -from fastapi import HTTPException, status - 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 @@ -23,9 +22,11 @@ async def publish_current_draft_scheme( ) if not validation["summary"]["is_publishable"]: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Scheme is not publishable in current state", + 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"], ) snapshot = await replace_scheme_version_pricing_snapshot( diff --git a/backend/app/services/remap_service.py b/backend/app/services/remap_service.py index ec3e41b..0ecbab0 100644 --- a/backend/app/services/remap_service.py +++ b/backend/app/services/remap_service.py @@ -1,11 +1,10 @@ from __future__ import annotations -from fastapi import HTTPException, status - from app.repositories.scheme_seats import ( bulk_remap_scheme_version_seats, list_scheme_version_seats, ) +from app.services.api_errors import raise_unprocessable from app.services.editor_validation import validate_remap_target_references @@ -35,9 +34,9 @@ async def preview_remap( to_group_id: str | None, ) -> list[dict]: if not any([seat_record_ids, from_sector_id, from_group_id]): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="At least one remap filter must be provided", + raise_unprocessable( + code="remap_filter_required", + message="At least one remap filter must be provided", ) await validate_remap_target_references( diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index 09483f1..ecd3887 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -164,3 +164,18 @@ Additional draft read-side stale checks: - GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}?expected_scheme_version_id={current_version_id} -> 200 - same endpoints with stale expected_scheme_version_id -> 409 + +## 11. Typed error contract + +Validate representative failures return JSON detail objects with: +- code +- message + +Recommended checks: +- stale_draft_version -> 409 +- stale_current_version -> 409 +- duplicate_sector_id / duplicate_group_id -> 422 +- unknown_sector_id / unknown_group_id -> 422 +- invalid_amount -> 422 +- publish_validation_failed -> 409 +