From c91c5abf15c24cd27c10ddf100291d6bc9273c76 Mon Sep 17 00:00:00 2001 From: greebo Date: Thu, 19 Mar 2026 17:58:17 +0300 Subject: [PATCH] Implement display artifacts, pricing integrity, draft base and publish preview bundle --- .../20260319_12_create_scheme_artifacts.py | 44 ++ .../20260319_13_price_rules_unique_target.py | 38 ++ ...0319_14_scheme_version_pricing_snapshot.py | 85 +++ backend/app/api/routes/__init__.py | 6 + backend/app/api/routes/admin.py | 162 ++++++ backend/app/api/routes/editor.py | 517 ++++++++++++++++++ backend/app/api/routes/pricing.py | 177 +++--- backend/app/api/routes/publish.py | 132 +++++ backend/app/api/routes/schemes.py | 38 +- backend/app/api/routes/structure.py | 59 +- backend/app/api/routes/uploads.py | 28 + backend/app/core/config.py | 4 + backend/app/models/scheme_artifact.py | 38 ++ backend/app/models/scheme_version_pricing.py | 36 ++ backend/app/repositories/pricing.py | 43 ++ backend/app/repositories/scheme_artifacts.py | 101 ++++ backend/app/repositories/scheme_groups.py | 158 ++++-- backend/app/repositories/scheme_seats.py | 268 +++++++-- backend/app/repositories/scheme_sectors.py | 158 ++++-- .../repositories/scheme_version_pricing.py | 144 +++++ backend/app/schemas/editor.py | 194 +++++++ backend/app/schemas/pricing.py | 168 ++---- backend/app/schemas/publish_preview.py | 50 ++ backend/app/services/baseline_selector.py | 45 ++ backend/app/services/display_regenerator.py | 89 +++ backend/app/services/draft_guard.py | 20 + backend/app/services/editor_validation.py | 96 ++++ backend/app/services/publish_preview.py | 135 +++++ backend/app/services/publish_preview_cache.py | 59 ++ backend/app/services/publish_service.py | 65 +++ backend/app/services/remap_service.py | 95 ++++ backend/app/services/scheme_validation.py | 107 ++++ backend/app/services/structure_diff.py | 130 +++++ backend/app/services/structure_sync.py | 62 +++ backend/docs/roadmap-status.md | 34 ++ 35 files changed, 3283 insertions(+), 302 deletions(-) create mode 100644 backend/alembic/versions/20260319_12_create_scheme_artifacts.py create mode 100644 backend/alembic/versions/20260319_13_price_rules_unique_target.py create mode 100644 backend/alembic/versions/20260319_14_scheme_version_pricing_snapshot.py create mode 100644 backend/app/api/routes/admin.py create mode 100644 backend/app/api/routes/editor.py create mode 100644 backend/app/api/routes/publish.py create mode 100644 backend/app/models/scheme_artifact.py create mode 100644 backend/app/models/scheme_version_pricing.py create mode 100644 backend/app/repositories/scheme_artifacts.py create mode 100644 backend/app/repositories/scheme_version_pricing.py create mode 100644 backend/app/schemas/editor.py create mode 100644 backend/app/schemas/publish_preview.py create mode 100644 backend/app/services/baseline_selector.py create mode 100644 backend/app/services/display_regenerator.py create mode 100644 backend/app/services/draft_guard.py create mode 100644 backend/app/services/editor_validation.py create mode 100644 backend/app/services/publish_preview.py create mode 100644 backend/app/services/publish_preview_cache.py create mode 100644 backend/app/services/publish_service.py create mode 100644 backend/app/services/remap_service.py create mode 100644 backend/app/services/scheme_validation.py create mode 100644 backend/app/services/structure_diff.py create mode 100644 backend/app/services/structure_sync.py create mode 100644 backend/docs/roadmap-status.md diff --git a/backend/alembic/versions/20260319_12_create_scheme_artifacts.py b/backend/alembic/versions/20260319_12_create_scheme_artifacts.py new file mode 100644 index 0000000..7c2d93d --- /dev/null +++ b/backend/alembic/versions/20260319_12_create_scheme_artifacts.py @@ -0,0 +1,44 @@ +"""create scheme_artifacts + +Revision ID: 20260319_12 +Revises: 20260318_11 +Create Date: 2026-03-19 12:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260319_12" +down_revision = "20260318_11" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_artifacts", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("artifact_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("artifact_type", sa.String(length=64), nullable=False), + sa.Column("artifact_variant", sa.String(length=64), nullable=False), + sa.Column("storage_path", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default="ready"), + sa.Column("meta_json", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + + op.create_index("ix_scheme_artifacts_artifact_id", "scheme_artifacts", ["artifact_id"], unique=True) + op.create_index("ix_scheme_artifacts_scheme_id", "scheme_artifacts", ["scheme_id"], unique=False) + op.create_index("ix_scheme_artifacts_scheme_version_id", "scheme_artifacts", ["scheme_version_id"], unique=False) + op.create_index("ix_scheme_artifacts_type_variant", "scheme_artifacts", ["scheme_version_id", "artifact_type", "artifact_variant"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_scheme_artifacts_type_variant", table_name="scheme_artifacts") + op.drop_index("ix_scheme_artifacts_scheme_version_id", table_name="scheme_artifacts") + op.drop_index("ix_scheme_artifacts_scheme_id", table_name="scheme_artifacts") + op.drop_index("ix_scheme_artifacts_artifact_id", table_name="scheme_artifacts") + op.drop_table("scheme_artifacts") diff --git a/backend/alembic/versions/20260319_13_price_rules_unique_target.py b/backend/alembic/versions/20260319_13_price_rules_unique_target.py new file mode 100644 index 0000000..a44e32f --- /dev/null +++ b/backend/alembic/versions/20260319_13_price_rules_unique_target.py @@ -0,0 +1,38 @@ +"""price_rules unique target + +Revision ID: 20260319_13 +Revises: 20260319_12 +Create Date: 2026-03-19 12:40:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260319_13" +down_revision = "20260319_12" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + conn.execute(sa.text(""" + delete from price_rules pr + using price_rules newer + where pr.scheme_id = newer.scheme_id + and pr.target_type = newer.target_type + and pr.target_ref = newer.target_ref + and pr.id < newer.id + """)) + + op.create_unique_constraint( + "uq_price_rules_scheme_target", + "price_rules", + ["scheme_id", "target_type", "target_ref"], + ) + + +def downgrade() -> None: + op.drop_constraint("uq_price_rules_scheme_target", "price_rules", type_="unique") diff --git a/backend/alembic/versions/20260319_14_scheme_version_pricing_snapshot.py b/backend/alembic/versions/20260319_14_scheme_version_pricing_snapshot.py new file mode 100644 index 0000000..9a39d4a --- /dev/null +++ b/backend/alembic/versions/20260319_14_scheme_version_pricing_snapshot.py @@ -0,0 +1,85 @@ +"""scheme version pricing snapshot + +Revision ID: 20260319_14 +Revises: 20260319_13 +Create Date: 2026-03-19 13:40:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260319_14" +down_revision = "20260319_13" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "scheme_version_pricing_categories", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("snapshot_category_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("source_pricing_category_id", sa.String(length=32), nullable=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("code", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index( + "ix_scheme_version_pricing_categories_snapshot_category_id", + "scheme_version_pricing_categories", + ["snapshot_category_id"], + unique=True, + ) + op.create_index( + "ix_scheme_version_pricing_categories_scheme_version_id", + "scheme_version_pricing_categories", + ["scheme_version_id"], + unique=False, + ) + + op.create_table( + "scheme_version_price_rules", + sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column("snapshot_price_rule_id", sa.String(length=32), nullable=False), + sa.Column("scheme_id", sa.String(length=32), nullable=False), + sa.Column("scheme_version_id", sa.String(length=32), nullable=False), + sa.Column("source_price_rule_id", sa.String(length=32), nullable=True), + sa.Column("snapshot_category_id", sa.String(length=32), nullable=True), + sa.Column("target_type", sa.String(length=32), nullable=False), + sa.Column("target_ref", sa.String(length=128), nullable=False), + sa.Column("amount", sa.Numeric(12, 2), nullable=False), + sa.Column("currency", sa.String(length=8), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index( + "ix_scheme_version_price_rules_snapshot_price_rule_id", + "scheme_version_price_rules", + ["snapshot_price_rule_id"], + unique=True, + ) + op.create_index( + "ix_scheme_version_price_rules_scheme_version_id", + "scheme_version_price_rules", + ["scheme_version_id"], + unique=False, + ) + op.create_index( + "ix_scheme_version_price_rules_lookup", + "scheme_version_price_rules", + ["scheme_version_id", "target_type", "target_ref"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_scheme_version_price_rules_lookup", table_name="scheme_version_price_rules") + op.drop_index("ix_scheme_version_price_rules_scheme_version_id", table_name="scheme_version_price_rules") + op.drop_index("ix_scheme_version_price_rules_snapshot_price_rule_id", table_name="scheme_version_price_rules") + op.drop_table("scheme_version_price_rules") + + op.drop_index("ix_scheme_version_pricing_categories_scheme_version_id", table_name="scheme_version_pricing_categories") + op.drop_index("ix_scheme_version_pricing_categories_snapshot_category_id", table_name="scheme_version_pricing_categories") + op.drop_table("scheme_version_pricing_categories") diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 510b283..ac92e97 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,7 +1,10 @@ from fastapi import APIRouter +from app.api.routes.admin import router as admin_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.pricing import router as pricing_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.structure import router as structure_router from app.api.routes.system import router as system_router @@ -16,3 +19,6 @@ router.include_router(structure_router) router.include_router(pricing_router) router.include_router(test_mode_router) router.include_router(audit_router) +router.include_router(admin_router) +router.include_router(editor_router) +router.include_router(publish_router) diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py new file mode 100644 index 0000000..08f23d9 --- /dev/null +++ b/backend/app/api/routes/admin.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, Depends, Query + +from app.core.config import settings +from app.repositories.scheme_artifacts import artifact_exists, list_scheme_artifacts +from app.repositories.scheme_versions import get_current_scheme_version +from app.repositories.schemes import get_scheme_record_by_scheme_id, list_scheme_records +from app.repositories.uploads import get_upload_record_by_upload_id +from app.security.auth import require_api_key +from app.services.display_regenerator import regenerate_display_artifact +from app.services.scheme_validation import build_scheme_validation_report + +router = APIRouter() + + +@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/artifacts") +async def list_current_scheme_artifacts( + 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, + ) + rows = await list_scheme_artifacts(scheme_version_id=version.scheme_version_id) + + return { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "items": [ + { + "artifact_id": row.artifact_id, + "artifact_type": row.artifact_type, + "artifact_variant": row.artifact_variant, + "status": row.status, + "storage_path": row.storage_path, + "meta_json": row.meta_json, + "created_at": row.created_at.isoformat(), + } + for row in rows + ], + "total": len(rows), + } + + +@router.get(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/validation") +async def validate_current_scheme( + 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, + ) + + report = await build_scheme_validation_report( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + + return { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "report": report, + } + + +@router.post(f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/current/display/regenerate") +async def regenerate_current_display( + scheme_id: str, + mode: str = Query(default="passthrough"), + 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, + ) + upload = await get_upload_record_by_upload_id(scheme.source_upload_id) + + return await regenerate_display_artifact( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + upload_id=upload.upload_id, + original_filename=upload.original_filename, + sanitized_storage_path=upload.sanitized_storage_path, + mode=mode, + ) + + +@router.post(f"{settings.api_v1_prefix}/admin/display/backfill") +async def bulk_backfill_display_artifacts( + mode: str = Query(default="passthrough"), + limit: int = Query(default=100, ge=1, le=1000), + only_missing: bool = Query(default=True), + role: str = Depends(require_api_key), +): + schemes = await list_scheme_records(limit=limit, offset=0) + + processed: list[dict] = [] + skipped: list[dict] = [] + failed: list[dict] = [] + + for scheme in schemes: + try: + version = await get_current_scheme_version( + scheme_id=scheme.scheme_id, + current_version_number=scheme.current_version_number, + ) + + if only_missing: + exists = await artifact_exists( + scheme_version_id=version.scheme_version_id, + artifact_type="display_svg", + artifact_variant=mode, + ) + if exists: + skipped.append( + { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "reason": "artifact already exists", + } + ) + continue + + upload = await get_upload_record_by_upload_id(scheme.source_upload_id) + result = await regenerate_display_artifact( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + upload_id=upload.upload_id, + original_filename=upload.original_filename, + sanitized_storage_path=upload.sanitized_storage_path, + mode=mode, + ) + processed.append( + { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "artifact_variant": result["artifact_variant"], + } + ) + except Exception as exc: + failed.append( + { + "scheme_id": scheme.scheme_id, + "reason": f"{exc.__class__.__name__}: {exc}", + } + ) + + return { + "mode": mode, + "limit": limit, + "only_missing": only_missing, + "processed_count": len(processed), + "skipped_count": len(skipped), + "failed_count": len(failed), + "processed": processed, + "skipped": skipped, + "failed": failed, + } diff --git a/backend/app/api/routes/editor.py b/backend/app/api/routes/editor.py new file mode 100644 index 0000000..71eadaf --- /dev/null +++ b/backend/app/api/routes/editor.py @@ -0,0 +1,517 @@ +from fastapi import APIRouter, Depends + +from app.core.config import settings +from app.repositories.audit import create_audit_event +from app.repositories.scheme_groups import ( + create_scheme_version_group, + delete_scheme_version_group_by_record_id, + list_scheme_version_groups, + update_scheme_version_group_by_record_id, +) +from app.repositories.scheme_seats import ( + bulk_update_scheme_version_seats_by_record_id, + cascade_update_seat_group_reference, + cascade_update_seat_sector_reference, + list_scheme_version_seats, + update_scheme_version_seat_by_record_id, +) +from app.repositories.scheme_sectors import ( + create_scheme_version_sector, + delete_scheme_version_sector_by_record_id, + list_scheme_version_sectors, + update_scheme_version_sector_by_record_id, +) +from app.schemas.editor import ( + BulkSeatPatchRequest, + BulkSeatPatchResponse, + BulkSeatPatchResultItem, + CreateGroupRequest, + CreateGroupResponse, + CreateSectorRequest, + CreateSectorResponse, + DeleteEntityResponse, + DraftGroupItem, + DraftSeatItem, + DraftSectorItem, + DraftStructureResponse, + GroupPatchRequest, + GroupPatchResponse, + RepairReferencesResponse, + SeatPatchRequest, + SeatPatchResponse, + SectorPatchRequest, + SectorPatchResponse, + StructureDiffEntityItem, + StructureDiffResponse, +) +from app.security.auth import require_api_key +from app.services.draft_guard import get_current_draft_context +from app.services.editor_validation import ( + validate_bulk_seat_patch_uniqueness, + validate_group_patch_uniqueness, + validate_sector_patch_uniqueness, + validate_single_seat_patch_uniqueness, +) +from app.services.structure_diff import build_structure_diff +from app.services.structure_sync import repair_structure_references + +router = APIRouter() + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/structure", response_model=DraftStructureResponse) +async def get_draft_structure( + scheme_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + seats = await list_scheme_version_seats(version.scheme_version_id) + sectors = await list_scheme_version_sectors(version.scheme_version_id) + groups = await list_scheme_version_groups(version.scheme_version_id) + + return DraftStructureResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + status=version.status, + seats=[ + DraftSeatItem( + seat_record_id=row.seat_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + created_at=row.created_at.isoformat(), + ) + for row in seats + ], + sectors=[ + DraftSectorItem( + sector_record_id=row.sector_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in sectors + ], + groups=[ + DraftGroupItem( + group_record_id=row.group_record_id, + scheme_id=row.scheme_id, + scheme_version_id=row.scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, + created_at=row.created_at.isoformat(), + ) + for row in groups + ], + total_seats=len(seats), + total_sectors=len(sectors), + total_groups=len(groups), + ) + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/compare-preview", response_model=StructureDiffResponse) +async def get_draft_compare_preview( + scheme_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + diff = await build_structure_diff( + scheme_id=scheme.scheme_id, + draft_scheme_version_id=version.scheme_version_id, + ) + return StructureDiffResponse( + scheme_id=scheme.scheme_id, + draft_scheme_version_id=version.scheme_version_id, + baseline_scheme_version_id=diff["baseline_scheme_version_id"], + summary=diff["summary"], + sectors=[StructureDiffEntityItem(**item) for item in diff["sectors"]], + groups=[StructureDiffEntityItem(**item) for item in diff["groups"]], + seats=[StructureDiffEntityItem(**item) for item in diff["seats"]], + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors", response_model=CreateSectorResponse) +async def create_draft_sector( + scheme_id: str, + payload: CreateSectorRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + row = await create_scheme_version_sector( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + element_id=payload.element_id, + sector_id=payload.sector_id, + name=payload.name, + classes_raw=payload.classes_raw, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.sector.created", + object_type="sector", + object_ref=row.sector_record_id, + details={ + "scheme_version_id": version.scheme_version_id, + "sector_id": row.sector_id, + "name": row.name, + }, + ) + + return CreateSectorResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + sector_record_id=row.sector_record_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups", response_model=CreateGroupResponse) +async def create_draft_group( + scheme_id: str, + payload: CreateGroupRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + row = await create_scheme_version_group( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + element_id=payload.element_id, + group_id=payload.group_id, + name=payload.name, + classes_raw=payload.classes_raw, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.group.created", + object_type="group", + object_ref=row.group_record_id, + details={ + "scheme_version_id": version.scheme_version_id, + "group_id": row.group_id, + "name": row.name, + }, + ) + + return CreateGroupResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + group_record_id=row.group_record_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=DeleteEntityResponse) +async def delete_draft_sector( + scheme_id: str, + sector_record_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + await delete_scheme_version_sector_by_record_id( + scheme_version_id=version.scheme_version_id, + sector_record_id=sector_record_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.sector.deleted", + object_type="sector", + object_ref=sector_record_id, + details={"scheme_version_id": version.scheme_version_id}, + ) + + return DeleteEntityResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + deleted=True, + record_id=sector_record_id, + ) + + +@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=DeleteEntityResponse) +async def delete_draft_group( + scheme_id: str, + group_record_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + await delete_scheme_version_group_by_record_id( + scheme_version_id=version.scheme_version_id, + group_record_id=group_record_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.group.deleted", + object_type="group", + object_ref=group_record_id, + details={"scheme_version_id": version.scheme_version_id}, + ) + + return DeleteEntityResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + deleted=True, + record_id=group_record_id, + ) + + +@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/records/{{seat_record_id}}", response_model=SeatPatchResponse) +async def patch_draft_seat( + scheme_id: str, + seat_record_id: str, + payload: SeatPatchRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + await validate_single_seat_patch_uniqueness( + scheme_version_id=version.scheme_version_id, + seat_record_id=seat_record_id, + new_seat_id=payload.seat_id, + ) + + row = await update_scheme_version_seat_by_record_id( + scheme_version_id=version.scheme_version_id, + seat_record_id=seat_record_id, + seat_id=payload.seat_id, + sector_id=payload.sector_id, + group_id=payload.group_id, + row_label=payload.row_label, + seat_number=payload.seat_number, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.seat.updated", + object_type="seat", + object_ref=seat_record_id, + details={ + "scheme_version_id": version.scheme_version_id, + "seat_id": payload.seat_id, + "sector_id": payload.sector_id, + "group_id": payload.group_id, + "row_label": payload.row_label, + "seat_number": payload.seat_number, + }, + ) + + return SeatPatchResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/seats/bulk", response_model=BulkSeatPatchResponse) +async def bulk_patch_draft_seats( + scheme_id: str, + payload: BulkSeatPatchRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + items = [item.model_dump() for item in payload.items] + await validate_bulk_seat_patch_uniqueness( + scheme_version_id=version.scheme_version_id, + items=items, + ) + + rows = await bulk_update_scheme_version_seats_by_record_id( + scheme_version_id=version.scheme_version_id, + items=items, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.seats.bulk_updated", + object_type="seat_bulk", + object_ref=version.scheme_version_id, + details={ + "scheme_version_id": version.scheme_version_id, + "updated_count": len(rows), + }, + ) + + return BulkSeatPatchResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + updated_count=len(rows), + items=[ + BulkSeatPatchResultItem( + seat_record_id=payload.items[idx].seat_record_id, + updated_seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + ) + for idx, row in enumerate(rows) + ], + ) + + +@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/sectors/records/{{sector_record_id}}", response_model=SectorPatchResponse) +async def patch_draft_sector( + scheme_id: str, + sector_record_id: str, + payload: SectorPatchRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + await validate_sector_patch_uniqueness( + scheme_version_id=version.scheme_version_id, + sector_record_id=sector_record_id, + new_sector_id=payload.sector_id, + ) + + row, old_sector_id = await update_scheme_version_sector_by_record_id( + scheme_version_id=version.scheme_version_id, + sector_record_id=sector_record_id, + sector_id=payload.sector_id, + name=payload.name, + ) + cascaded_count = await cascade_update_seat_sector_reference( + scheme_version_id=version.scheme_version_id, + old_sector_id=old_sector_id, + new_sector_id=payload.sector_id, + ) + repair_result = await repair_structure_references( + scheme_version_id=version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.sector.updated", + object_type="sector", + object_ref=sector_record_id, + details={ + "scheme_version_id": version.scheme_version_id, + "sector_id": payload.sector_id, + "name": payload.name, + "cascaded_seats_count": cascaded_count, + "repair_result": repair_result, + }, + ) + + return SectorPatchResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + ) + + +@router.patch(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/groups/records/{{group_record_id}}", response_model=GroupPatchResponse) +async def patch_draft_group( + scheme_id: str, + group_record_id: str, + payload: GroupPatchRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + + await validate_group_patch_uniqueness( + scheme_version_id=version.scheme_version_id, + group_record_id=group_record_id, + new_group_id=payload.group_id, + ) + + row, old_group_id = await update_scheme_version_group_by_record_id( + scheme_version_id=version.scheme_version_id, + group_record_id=group_record_id, + group_id=payload.group_id, + name=payload.name, + ) + cascaded_count = await cascade_update_seat_group_reference( + scheme_version_id=version.scheme_version_id, + old_group_id=old_group_id, + new_group_id=payload.group_id, + ) + repair_result = await repair_structure_references( + scheme_version_id=version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.group.updated", + object_type="group", + object_ref=group_record_id, + details={ + "scheme_version_id": version.scheme_version_id, + "group_id": payload.group_id, + "name": payload.name, + "cascaded_seats_count": cascaded_count, + "repair_result": repair_result, + }, + ) + + return GroupPatchResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/repair-references", response_model=RepairReferencesResponse) +async def repair_draft_references( + scheme_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + result = await repair_structure_references( + scheme_version_id=version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.references.repaired", + object_type="draft_structure", + object_ref=version.scheme_version_id, + details=result, + ) + + return RepairReferencesResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + repaired_sector_refs_count=result["repaired_sector_refs_count"], + repaired_group_refs_count=result["repaired_group_refs_count"], + details=result["details"], + ) diff --git a/backend/app/api/routes/pricing.py b/backend/app/api/routes/pricing.py index 321865f..33d6989 100644 --- a/backend/app/api/routes/pricing.py +++ b/backend/app/api/routes/pricing.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, status from app.core.config import settings from app.repositories.audit import create_audit_event @@ -12,33 +14,44 @@ 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.repositories.schemes import get_scheme_record_by_scheme_id +from app.repositories.scheme_versions import get_current_scheme_version from app.schemas.pricing import ( - DeleteResponse, PriceRuleCreateRequest, - PriceRuleCreateResponse, PriceRuleItem, PriceRuleUpdateRequest, - PriceRuleUpdateResponse, + PricingBundleResponse, PricingCategoryCreateRequest, - PricingCategoryCreateResponse, PricingCategoryItem, PricingCategoryUpdateRequest, - PricingCategoryUpdateResponse, - SchemePricingResponse, ) from app.security.auth import require_api_key router = APIRouter() -@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse) -async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key)): - await get_scheme_record_by_scheme_id(scheme_id) +async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None: + 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, + ) + if scheme.status != "draft" or version.status != "draft": + return None + + 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) +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) - return SchemePricingResponse( + return PricingBundleResponse( categories=[ PricingCategoryItem( pricing_category_id=row.pricing_category_id, @@ -56,7 +69,7 @@ async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key pricing_category_id=row.pricing_category_id, target_type=row.target_type, target_ref=row.target_ref, - amount=row.amount, + amount=str(row.amount), currency=row.currency, created_at=row.created_at.isoformat(), ) @@ -65,34 +78,36 @@ async def get_scheme_pricing(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, role: str = Depends(require_api_key), ): - await get_scheme_record_by_scheme_id(scheme_id) pricing_category_id = await create_pricing_category( scheme_id=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, event_type="pricing.category.created", object_type="pricing_category", object_ref=pricing_category_id, - details={"name": payload.name, "code": payload.code}, - ) - return PricingCategoryCreateResponse( - pricing_category_id=pricing_category_id, - scheme_id=scheme_id, - name=payload.name, - code=payload.code, + details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, ) + return { + "pricing_category_id": pricing_category_id, + "scheme_id": 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, @@ -105,53 +120,71 @@ async def update_pricing_category_endpoint( name=payload.name, code=payload.code, ) + snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + await create_audit_event( scheme_id=scheme_id, event_type="pricing.category.updated", object_type="pricing_category", object_ref=pricing_category_id, - details={"name": payload.name, "code": payload.code}, - ) - return PricingCategoryUpdateResponse( - pricing_category_id=row.pricing_category_id, - scheme_id=row.scheme_id, - name=row.name, - code=row.code, + details={"name": payload.name, "code": payload.code, "snapshot": snapshot}, ) + 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, role: str = Depends(require_api_key), ): - await delete_pricing_category(scheme_id=scheme_id, pricing_category_id=pricing_category_id) + await delete_pricing_category( + scheme_id=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, event_type="pricing.category.deleted", object_type="pricing_category", object_ref=pricing_category_id, - details=None, + details={"snapshot": snapshot}, ) - return DeleteResponse(status="deleted") + + 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, role: str = Depends(require_api_key), ): - await get_scheme_record_by_scheme_id(scheme_id) + try: + amount = Decimal(payload.amount) + except Exception: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Некорректная сумма", + ) + price_rule_id = await create_price_rule( scheme_id=scheme_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, target_ref=payload.target_ref, - amount=payload.amount, + amount=amount, currency=payload.currency, ) + snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + await create_audit_event( scheme_id=scheme_id, event_type="pricing.rule.created", @@ -161,37 +194,49 @@ async def create_price_rule_endpoint( "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, - "amount": str(payload.amount), + "amount": payload.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_id, + "pricing_category_id": payload.pricing_category_id, + "target_type": payload.target_type, + "target_ref": payload.target_ref, + "amount": payload.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, payload: PriceRuleUpdateRequest, role: str = Depends(require_api_key), ): + try: + amount = Decimal(payload.amount) + except Exception: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Некорректная сумма", + ) + row = await update_price_rule( scheme_id=scheme_id, price_rule_id=price_rule_id, pricing_category_id=payload.pricing_category_id, target_type=payload.target_type, target_ref=payload.target_ref, - amount=payload.amount, + amount=amount, currency=payload.currency, ) + snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id) + await create_audit_event( scheme_id=scheme_id, event_type="pricing.rule.updated", @@ -201,33 +246,41 @@ async def update_price_rule_endpoint( "pricing_category_id": payload.pricing_category_id, "target_type": payload.target_type, "target_ref": payload.target_ref, - "amount": str(payload.amount), + "amount": payload.amount, "currency": payload.currency, + "snapshot": snapshot, }, ) - 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=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, role: str = Depends(require_api_key), ): - await delete_price_rule(scheme_id=scheme_id, price_rule_id=price_rule_id) + await delete_price_rule( + scheme_id=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, event_type="pricing.rule.deleted", object_type="price_rule", object_ref=price_rule_id, - details=None, + details={"snapshot": snapshot}, ) - return DeleteResponse(status="deleted") + + return {"deleted": True, "price_rule_id": price_rule_id} diff --git a/backend/app/api/routes/publish.py b/backend/app/api/routes/publish.py new file mode 100644 index 0000000..7976e6a --- /dev/null +++ b/backend/app/api/routes/publish.py @@ -0,0 +1,132 @@ +from fastapi import APIRouter, Depends, Query + +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.schemas.publish_preview import ( + PublishPreviewResponse, + RemapApplyRequest, + RemapApplyResponse, + RemapPreviewRequest, + RemapPreviewResponse, + RemapPreviewSeatItem, +) +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.remap_service import apply_remap, preview_remap + +router = APIRouter() + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/pricing/snapshot") +async def create_draft_pricing_snapshot( + scheme_id: str, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + result = await replace_scheme_version_pricing_snapshot( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.pricing.snapshot.created", + object_type="pricing_snapshot", + object_ref=version.scheme_version_id, + details=result, + ) + + return { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + **result, + } + + +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse) +async def get_publish_preview( + scheme_id: str, + baseline_scheme_version_id: str | None = Query(default=None), + refresh: bool = Query(default=False), + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + bundle = await get_or_build_publish_preview_bundle( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + baseline_override_scheme_version_id=baseline_scheme_version_id, + refresh=refresh, + ) + return PublishPreviewResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + artifacts=bundle["artifacts"], + validation=bundle["validation"], + structure_diff=bundle["structure_diff"], + pricing_coverage=bundle["pricing_coverage"], + summary=bundle["summary"], + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/preview", response_model=RemapPreviewResponse) +async def preview_draft_remap( + scheme_id: str, + payload: RemapPreviewRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + items = await preview_remap( + scheme_version_id=version.scheme_version_id, + seat_record_ids=payload.seat_record_ids, + from_sector_id=payload.from_sector_id, + to_sector_id=payload.to_sector_id, + from_group_id=payload.from_group_id, + to_group_id=payload.to_group_id, + ) + + return RemapPreviewResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + matched_count=len(items), + items=[RemapPreviewSeatItem(**item) for item in items], + ) + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/remap/apply", response_model=RemapApplyResponse) +async def apply_draft_remap( + scheme_id: str, + payload: RemapApplyRequest, + role: str = Depends(require_api_key), +): + scheme, version = await get_current_draft_context(scheme_id) + items = await apply_remap( + scheme_version_id=version.scheme_version_id, + seat_record_ids=payload.seat_record_ids, + from_sector_id=payload.from_sector_id, + to_sector_id=payload.to_sector_id, + from_group_id=payload.from_group_id, + to_group_id=payload.to_group_id, + ) + + await create_audit_event( + scheme_id=scheme.scheme_id, + event_type="draft.remap.applied", + object_type="draft_structure", + object_ref=version.scheme_version_id, + details={ + "matched_count": len(items), + "from_sector_id": payload.from_sector_id, + "to_sector_id": payload.to_sector_id, + "from_group_id": payload.from_group_id, + "to_group_id": payload.to_group_id, + }, + ) + + return RemapApplyResponse( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + updated_count=len(items), + items=[RemapPreviewSeatItem(**item) for item in items], + ) diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index cb44ec2..c8fe25c 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -15,7 +15,6 @@ from app.repositories.schemes import ( count_scheme_records, get_scheme_record_by_scheme_id, list_scheme_records, - publish_scheme, rollback_scheme_to_version, unpublish_scheme, ) @@ -34,6 +33,8 @@ from app.schemas.scheme_versions import ( SchemeVersionListResponse, ) from app.security.auth import require_api_key +from app.services.publish_service import publish_current_draft_scheme +from app.services.scheme_validation import build_scheme_validation_report router = APIRouter() @@ -180,22 +181,27 @@ async def create_next_scheme_version_endpoint( ) -@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse) +@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation") +async def get_publish_validation(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, + ) + report = await build_scheme_validation_report( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + return { + "scheme_id": scheme.scheme_id, + "scheme_version_id": version.scheme_version_id, + "report": report, + } + + +@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish") async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)): - row = await publish_scheme(scheme_id) - await create_audit_event( - scheme_id=row.scheme_id, - event_type="scheme.published", - object_type="scheme", - object_ref=row.scheme_id, - details={"current_version_number": row.current_version_number, "status": row.status}, - ) - return SchemePublishResponse( - scheme_id=row.scheme_id, - status=row.status, - current_version_number=row.current_version_number, - published_at=row.published_at.isoformat() if row.published_at else None, - ) + return await publish_current_draft_scheme(scheme_id=scheme_id) @router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) diff --git a/backend/app/api/routes/structure.py b/backend/app/api/routes/structure.py index 8c6d230..66213a6 100644 --- a/backend/app/api/routes/structure.py +++ b/backend/app/api/routes/structure.py @@ -8,6 +8,7 @@ from lxml import etree from app.core.config import settings from app.repositories.pricing import find_effective_price_rule +from app.repositories.scheme_artifacts import get_latest_scheme_artifact from app.repositories.scheme_groups import list_scheme_version_groups from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats from app.repositories.scheme_sectors import list_scheme_version_sectors @@ -66,13 +67,29 @@ async def _load_current_context(scheme_id: str): return scheme, version, upload -async def _generate_default_display_artifact_if_needed(scheme, version, upload) -> tuple[bytes, Path]: +async def _load_default_display_artifact(scheme, version, upload) -> tuple[bytes, Path]: + artifact = await get_latest_scheme_artifact( + scheme_version_id=version.scheme_version_id, + artifact_type="display_svg", + artifact_variant=settings.svg_display_mode, + ) + if artifact is not None: + path = Path(artifact.storage_path) + if path.exists() and path.is_file(): + return path.read_bytes(), path + if version.display_svg_status == "ready" and version.display_svg_storage_path: path = Path(version.display_svg_storage_path) if path.exists() and path.is_file(): return path.read_bytes(), path - sanitized_path = Path(upload.sanitized_storage_path) + sanitized_artifact = await get_latest_scheme_artifact( + scheme_version_id=version.scheme_version_id, + artifact_type="sanitized_svg", + artifact_variant="source", + ) + sanitized_path = Path(sanitized_artifact.storage_path if sanitized_artifact else upload.sanitized_storage_path) + if not sanitized_path.exists() or not sanitized_path.is_file(): if version.display_svg_status == "pending": raise HTTPException( @@ -106,6 +123,16 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload) ) display_path = Path(display_path_str) + from app.repositories.scheme_artifacts import create_scheme_artifact + await create_scheme_artifact( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + artifact_type="display_svg", + artifact_variant=settings.svg_display_mode, + storage_path=display_path_str, + status="ready", + meta_json=meta, + ) await update_scheme_version_display_artifact( scheme_version_id=version.scheme_version_id, display_svg_storage_path=display_path_str, @@ -113,14 +140,6 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload) display_svg_generated_at=datetime.now(timezone.utc), ) - logger.info( - "display_svg.lazy_generate scheme_id=%s scheme_version_id=%s mode=%s view_box=%s", - scheme.scheme_id, - version.scheme_version_id, - settings.svg_display_mode, - meta.get("view_box"), - ) - return display_bytes, display_path @@ -253,11 +272,16 @@ async def get_scheme_current_display_svg( scheme, version, upload = await _load_current_context(scheme_id) if resolved_mode == settings.svg_display_mode: - display_bytes, display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload) + _display_bytes, display_path = await _load_default_display_artifact(scheme, version, upload) filename = f"{scheme.name or scheme.scheme_id}.{resolved_mode}.svg" return FileResponse(path=display_path, media_type="image/svg+xml", filename=filename) - sanitized_path = Path(upload.sanitized_storage_path) + artifact = await get_latest_scheme_artifact( + scheme_version_id=version.scheme_version_id, + artifact_type="sanitized_svg", + artifact_variant="source", + ) + sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path) if not sanitized_path.exists() or not sanitized_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -292,11 +316,16 @@ async def get_scheme_current_display_svg_meta( scheme, version, upload = await _load_current_context(scheme_id) if resolved_mode == settings.svg_display_mode: - display_bytes, _display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload) + display_bytes, _display_path = await _load_default_display_artifact(scheme, version, upload) meta = _parse_svg_meta_from_bytes(display_bytes) generated_at = version.display_svg_generated_at else: - sanitized_path = Path(upload.sanitized_storage_path) + artifact = await get_latest_scheme_artifact( + scheme_version_id=version.scheme_version_id, + artifact_type="sanitized_svg", + artifact_variant="source", + ) + sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path) if not sanitized_path.exists() or not sanitized_path.is_file(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -305,7 +334,7 @@ async def get_scheme_current_display_svg_meta( sanitized_bytes = sanitized_path.read_bytes() try: - display_bytes, meta = generate_display_svg(sanitized_bytes, resolved_mode) + display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode) except Exception: logger.exception( "display_svg.meta_on_demand failed scheme_id=%s scheme_version_id=%s mode=%s", diff --git a/backend/app/api/routes/uploads.py b/backend/app/api/routes/uploads.py index bf1f409..e9b1a67 100644 --- a/backend/app/api/routes/uploads.py +++ b/backend/app/api/routes/uploads.py @@ -6,6 +6,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from app.core.config import settings +from app.repositories.scheme_artifacts import create_scheme_artifact from app.repositories.scheme_groups import replace_scheme_version_groups from app.repositories.scheme_seats import replace_scheme_version_seats from app.repositories.scheme_sectors import replace_scheme_version_sectors @@ -222,6 +223,33 @@ async def upload_scheme_svg( display_svg_generated_at=display_svg_generated_at, ) + await create_scheme_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type="sanitized_svg", + artifact_variant="source", + storage_path=sanitized_storage_path, + status="ready", + ) + await create_scheme_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type="normalized_json", + artifact_variant="default", + storage_path=normalized_storage_path, + status="ready", + ) + if display_svg_storage_path: + await create_scheme_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type="display_svg", + artifact_variant=settings.svg_display_mode, + storage_path=display_svg_storage_path, + status="ready", + meta_json=display_meta, + ) + normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path) await replace_scheme_version_sectors( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 79efde4..85f7666 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -74,5 +74,9 @@ class Settings(BaseSettings): def storage_display_dir(self) -> str: return f"{self.storage_root_dir}/display" + @property + def storage_preview_dir(self) -> str: + return f"{self.storage_root_dir}/preview" + settings = Settings() diff --git a/backend/app/models/scheme_artifact.py b/backend/app/models/scheme_artifact.py new file mode 100644 index 0000000..36de766 --- /dev/null +++ b/backend/app/models/scheme_artifact.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeArtifactRecord(Base): + __tablename__ = "scheme_artifacts" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + artifact_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + + scheme_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("schemes.scheme_id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + scheme_version_id: Mapped[str] = mapped_column( + String(32), + ForeignKey("scheme_versions.scheme_version_id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + artifact_type: Mapped[str] = mapped_column(String(64), nullable=False) + artifact_variant: Mapped[str] = mapped_column(String(64), nullable=False) + storage_path: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="ready") + meta_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/backend/app/models/scheme_version_pricing.py b/backend/app/models/scheme_version_pricing.py new file mode 100644 index 0000000..27a1984 --- /dev/null +++ b/backend/app/models/scheme_version_pricing.py @@ -0,0 +1,36 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import BigInteger, DateTime, Numeric, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class SchemeVersionPricingCategoryRecord(Base): + __tablename__ = "scheme_version_pricing_categories" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + snapshot_category_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + scheme_id: Mapped[str] = mapped_column(String(32), nullable=False) + scheme_version_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False) + source_pricing_category_id: Mapped[str | None] = mapped_column(String(32), nullable=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + code: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class SchemeVersionPriceRuleRecord(Base): + __tablename__ = "scheme_version_price_rules" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + snapshot_price_rule_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + scheme_id: Mapped[str] = mapped_column(String(32), nullable=False) + scheme_version_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False) + source_price_rule_id: Mapped[str | None] = mapped_column(String(32), nullable=True) + snapshot_category_id: Mapped[str | None] = mapped_column(String(32), nullable=True) + target_type: Mapped[str] = mapped_column(String(32), nullable=False) + target_ref: Mapped[str] = mapped_column(String(128), nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(8), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/repositories/pricing.py b/backend/app/repositories/pricing.py index 0f21554..3a6d13d 100644 --- a/backend/app/repositories/pricing.py +++ b/backend/app/repositories/pricing.py @@ -9,6 +9,34 @@ from app.models.price_rule import PriceRuleRecord from app.models.pricing_category import PricingCategoryRecord +async def _ensure_unique_price_rule_target( + *, + session, + scheme_id: str, + target_type: str, + target_ref: str, + exclude_price_rule_id: str | None = None, +) -> None: + stmt = select(PriceRuleRecord).where( + PriceRuleRecord.scheme_id == scheme_id, + PriceRuleRecord.target_type == target_type, + PriceRuleRecord.target_ref == target_ref, + ) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row is None: + return + + if exclude_price_rule_id and row.price_rule_id == exclude_price_rule_id: + return + + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Pricing rule already exists for this target", + ) + + async def create_pricing_category( *, scheme_id: str, @@ -96,6 +124,13 @@ async def create_price_rule( price_rule_id = uuid4().hex async with AsyncSessionLocal() as session: + await _ensure_unique_price_rule_target( + session=session, + scheme_id=scheme_id, + target_type=target_type, + target_ref=target_ref, + ) + row = PriceRuleRecord( price_rule_id=price_rule_id, scheme_id=scheme_id, @@ -136,6 +171,14 @@ async def update_price_rule( detail="Price rule not found", ) + await _ensure_unique_price_rule_target( + session=session, + scheme_id=scheme_id, + target_type=target_type, + target_ref=target_ref, + exclude_price_rule_id=price_rule_id, + ) + row.pricing_category_id = pricing_category_id row.target_type = target_type row.target_ref = target_ref diff --git a/backend/app/repositories/scheme_artifacts.py b/backend/app/repositories/scheme_artifacts.py new file mode 100644 index 0000000..4bc34c2 --- /dev/null +++ b/backend/app/repositories/scheme_artifacts.py @@ -0,0 +1,101 @@ +from uuid import uuid4 + +from sqlalchemy import asc, desc, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme_artifact import SchemeArtifactRecord + + +async def create_scheme_artifact( + *, + scheme_id: str, + scheme_version_id: str, + artifact_type: str, + artifact_variant: str, + storage_path: str, + status: str = "ready", + meta_json: dict | None = None, +) -> SchemeArtifactRecord: + async with AsyncSessionLocal() as session: + row = SchemeArtifactRecord( + artifact_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type=artifact_type, + artifact_variant=artifact_variant, + storage_path=storage_path, + status=status, + meta_json=meta_json, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +async def list_scheme_artifacts( + *, + scheme_version_id: str, + artifact_type: str | None = None, + artifact_variant: str | None = None, +) -> list[SchemeArtifactRecord]: + async with AsyncSessionLocal() as session: + stmt = select(SchemeArtifactRecord).where( + SchemeArtifactRecord.scheme_version_id == scheme_version_id + ) + + if artifact_type is not None: + stmt = stmt.where(SchemeArtifactRecord.artifact_type == artifact_type) + + if artifact_variant is not None: + stmt = stmt.where(SchemeArtifactRecord.artifact_variant == artifact_variant) + + stmt = stmt.order_by( + asc(SchemeArtifactRecord.created_at), + asc(SchemeArtifactRecord.id), + ) + + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def artifact_exists( + *, + scheme_version_id: str, + artifact_type: str, + artifact_variant: str, +) -> bool: + async with AsyncSessionLocal() as session: + stmt = ( + select(SchemeArtifactRecord.id) + .where(SchemeArtifactRecord.scheme_version_id == scheme_version_id) + .where(SchemeArtifactRecord.artifact_type == artifact_type) + .where(SchemeArtifactRecord.artifact_variant == artifact_variant) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + +async def get_latest_scheme_artifact( + *, + scheme_version_id: str, + artifact_type: str, + artifact_variant: str | None = None, +) -> SchemeArtifactRecord | None: + async with AsyncSessionLocal() as session: + stmt = select(SchemeArtifactRecord).where( + SchemeArtifactRecord.scheme_version_id == scheme_version_id, + SchemeArtifactRecord.artifact_type == artifact_type, + ) + + if artifact_variant is not None: + stmt = stmt.where(SchemeArtifactRecord.artifact_variant == artifact_variant) + + stmt = stmt.order_by( + desc(SchemeArtifactRecord.created_at), + desc(SchemeArtifactRecord.id), + ).limit(1) + + result = await session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/app/repositories/scheme_groups.py b/backend/app/repositories/scheme_groups.py index 1b51afa..4ad3893 100644 --- a/backend/app/repositories/scheme_groups.py +++ b/backend/app/repositories/scheme_groups.py @@ -1,10 +1,11 @@ -import json from uuid import uuid4 -from sqlalchemy import asc, delete, select +from fastapi import HTTPException, status +from sqlalchemy import asc, select from app.db.session import AsyncSessionLocal from app.models.scheme_group import SchemeGroupRecord +from app.models.scheme_seat import SchemeSeatRecord async def replace_scheme_version_groups( @@ -14,37 +15,29 @@ async def replace_scheme_version_groups( groups: list[dict], ) -> None: async with AsyncSessionLocal() as session: - await session.execute( - delete(SchemeGroupRecord).where( - SchemeGroupRecord.scheme_version_id == scheme_version_id - ) + existing_result = await session.execute( + select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == scheme_version_id) ) + existing_rows = list(existing_result.scalars().all()) + + for row in existing_rows: + await session.delete(row) for item in groups: row = SchemeGroupRecord( - group_record_id=uuid4().hex, + group_record_id=item["group_record_id"] if "group_record_id" in item and item["group_record_id"] else uuid4().hex, scheme_id=scheme_id, scheme_version_id=scheme_version_id, element_id=item.get("id"), group_id=item.get("group_id"), - name=item.get("group_id") or item.get("id") or "unnamed-group", - classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + name=item.get("group_id"), + classes_raw=str(item.get("classes")), ) session.add(row) await session.commit() -async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]: - async with AsyncSessionLocal() as session: - result = await session.execute( - select(SchemeGroupRecord) - .where(SchemeGroupRecord.scheme_version_id == scheme_version_id) - .order_by(asc(SchemeGroupRecord.id)) - ) - return list(result.scalars().all()) - - async def clone_scheme_version_groups( *, source_scheme_version_id: str, @@ -52,23 +45,124 @@ async def clone_scheme_version_groups( ) -> None: async with AsyncSessionLocal() as session: result = await session.execute( - select(SchemeGroupRecord).where( - SchemeGroupRecord.scheme_version_id == source_scheme_version_id - ) + select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == source_scheme_version_id) ) rows = list(result.scalars().all()) for row in rows: - session.add( - SchemeGroupRecord( - group_record_id=uuid4().hex, - scheme_id=row.scheme_id, - scheme_version_id=target_scheme_version_id, - element_id=row.element_id, - group_id=row.group_id, - name=row.name, - classes_raw=row.classes_raw, - ) + cloned = SchemeGroupRecord( + group_record_id=uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + group_id=row.group_id, + name=row.name, + classes_raw=row.classes_raw, ) + session.add(cloned) await session.commit() + + +async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeGroupRecord) + .where(SchemeGroupRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeGroupRecord.created_at), asc(SchemeGroupRecord.id)) + ) + return list(result.scalars().all()) + + +async def update_scheme_version_group_by_record_id( + *, + scheme_version_id: str, + group_record_id: str, + group_id: str | None, + name: str | None, +) -> tuple[SchemeGroupRecord, str | None]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeGroupRecord).where( + SchemeGroupRecord.scheme_version_id == scheme_version_id, + SchemeGroupRecord.group_record_id == group_record_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group record not found in current draft version", + ) + + old_group_id = row.group_id + row.group_id = group_id + row.name = name + + await session.commit() + await session.refresh(row) + return row, old_group_id + + +async def create_scheme_version_group( + *, + scheme_id: str, + scheme_version_id: str, + element_id: str | None, + group_id: str, + name: str | None, + classes_raw: str | None, +) -> SchemeGroupRecord: + async with AsyncSessionLocal() as session: + row = SchemeGroupRecord( + group_record_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + element_id=element_id, + group_id=group_id, + name=name, + classes_raw=classes_raw, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +async def delete_scheme_version_group_by_record_id( + *, + scheme_version_id: str, + group_record_id: str, +) -> None: + async with AsyncSessionLocal() as session: + group_result = await session.execute( + select(SchemeGroupRecord).where( + SchemeGroupRecord.scheme_version_id == scheme_version_id, + SchemeGroupRecord.group_record_id == group_record_id, + ) + ) + group = group_result.scalar_one_or_none() + + if group is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group record not found in current draft version", + ) + + if group.group_id: + seats_result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.group_id == group.group_id, + ) + ) + seats = list(seats_result.scalars().all()) + if seats: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot delete group while seats still reference it", + ) + + await session.delete(group) + await session.commit() diff --git a/backend/app/repositories/scheme_seats.py b/backend/app/repositories/scheme_seats.py index f2051da..15acd8a 100644 --- a/backend/app/repositories/scheme_seats.py +++ b/backend/app/repositories/scheme_seats.py @@ -1,8 +1,5 @@ -import json -from uuid import uuid4 - from fastapi import HTTPException, status -from sqlalchemy import asc, delete, select +from sqlalchemy import asc, select from app.db.session import AsyncSessionLocal from app.models.scheme_seat import SchemeSeatRecord @@ -15,15 +12,17 @@ async def replace_scheme_version_seats( seats: list[dict], ) -> None: async with AsyncSessionLocal() as session: - await session.execute( - delete(SchemeSeatRecord).where( - SchemeSeatRecord.scheme_version_id == scheme_version_id - ) + existing_result = await session.execute( + select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == scheme_version_id) ) + existing_rows = list(existing_result.scalars().all()) + + for row in existing_rows: + await session.delete(row) for item in seats: row = SchemeSeatRecord( - seat_record_id=uuid4().hex, + seat_record_id=item["seat_record_id"] if "seat_record_id" in item and item["seat_record_id"] else __import__("uuid").uuid4().hex, scheme_id=scheme_id, scheme_version_id=scheme_version_id, element_id=item.get("id"), @@ -33,7 +32,7 @@ async def replace_scheme_version_seats( row_label=item.get("row"), seat_number=item.get("seat_number"), tag=item.get("tag"), - classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + classes_raw=str(item.get("classes")), x=item.get("x"), y=item.get("y"), cx=item.get("cx"), @@ -46,12 +45,48 @@ async def replace_scheme_version_seats( await session.commit() +async def clone_scheme_version_seats( + *, + source_scheme_version_id: str, + target_scheme_version_id: str, +) -> None: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == source_scheme_version_id) + ) + rows = list(result.scalars().all()) + + for row in rows: + cloned = SchemeSeatRecord( + seat_record_id=__import__("uuid").uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + seat_id=row.seat_id, + sector_id=row.sector_id, + group_id=row.group_id, + row_label=row.row_label, + seat_number=row.seat_number, + tag=row.tag, + classes_raw=row.classes_raw, + x=row.x, + y=row.y, + cx=row.cx, + cy=row.cy, + width=row.width, + height=row.height, + ) + session.add(cloned) + + await session.commit() + + async def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]: async with AsyncSessionLocal() as session: result = await session.execute( select(SchemeSeatRecord) .where(SchemeSeatRecord.scheme_version_id == scheme_version_id) - .order_by(asc(SchemeSeatRecord.id)) + .order_by(asc(SchemeSeatRecord.created_at), asc(SchemeSeatRecord.id)) ) return list(result.scalars().all()) @@ -79,40 +114,199 @@ async def get_scheme_version_seat_by_seat_id( return row -async def clone_scheme_version_seats( +async def update_scheme_version_seat_by_record_id( *, - source_scheme_version_id: str, - target_scheme_version_id: str, -) -> None: + scheme_version_id: str, + seat_record_id: str, + seat_id: str | None, + sector_id: str | None, + group_id: str | None, + row_label: str | None, + seat_number: str | None, +) -> SchemeSeatRecord: async with AsyncSessionLocal() as session: result = await session.execute( select(SchemeSeatRecord).where( - SchemeSeatRecord.scheme_version_id == source_scheme_version_id + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.seat_record_id == seat_record_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Seat record not found in current draft version", + ) + + row.seat_id = seat_id + row.sector_id = sector_id + row.group_id = group_id + row.row_label = row_label + row.seat_number = seat_number + + await session.commit() + await session.refresh(row) + return row + + +async def bulk_update_scheme_version_seats_by_record_id( + *, + scheme_version_id: str, + items: list[dict], +) -> list[SchemeSeatRecord]: + updated_rows: list[SchemeSeatRecord] = [] + + async with AsyncSessionLocal() as session: + for item in items: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.seat_record_id == item["seat_record_id"], + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Seat record not found in current draft version: {item['seat_record_id']}", + ) + + row.seat_id = item.get("seat_id") + row.sector_id = item.get("sector_id") + row.group_id = item.get("group_id") + row.row_label = item.get("row_label") + row.seat_number = item.get("seat_number") + updated_rows.append(row) + + await session.commit() + + for row in updated_rows: + await session.refresh(row) + + return updated_rows + + +async def bulk_remap_scheme_version_seats( + *, + scheme_version_id: str, + items: list[dict], +) -> None: + async with AsyncSessionLocal() as session: + for item in items: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.seat_record_id == item["seat_record_id"], + ) + ) + row = result.scalar_one_or_none() + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Seat record not found in current draft version: {item['seat_record_id']}", + ) + + row.sector_id = item["after_sector_id"] + row.group_id = item["after_group_id"] + + await session.commit() + + +async def cascade_update_seat_sector_reference( + *, + scheme_version_id: str, + old_sector_id: str | None, + new_sector_id: str | None, +) -> int: + if not old_sector_id or old_sector_id == new_sector_id: + return 0 + + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.sector_id == old_sector_id, ) ) rows = list(result.scalars().all()) for row in rows: - session.add( - SchemeSeatRecord( - seat_record_id=uuid4().hex, - scheme_id=row.scheme_id, - scheme_version_id=target_scheme_version_id, - element_id=row.element_id, - seat_id=row.seat_id, - sector_id=row.sector_id, - group_id=row.group_id, - row_label=row.row_label, - seat_number=row.seat_number, - tag=row.tag, - classes_raw=row.classes_raw, - x=row.x, - y=row.y, - cx=row.cx, - cy=row.cy, - width=row.width, - height=row.height, - ) - ) + row.sector_id = new_sector_id await session.commit() + return len(rows) + + +async def cascade_update_seat_group_reference( + *, + scheme_version_id: str, + old_group_id: str | None, + new_group_id: str | None, +) -> int: + if not old_group_id or old_group_id == new_group_id: + return 0 + + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.group_id == old_group_id, + ) + ) + rows = list(result.scalars().all()) + + for row in rows: + row.group_id = new_group_id + + await session.commit() + return len(rows) + + +async def repair_orphan_sector_refs( + *, + scheme_version_id: str, + new_sector_id: str, + orphan_values: list[str], +) -> int: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id + ) + ) + rows = list(result.scalars().all()) + + changed = 0 + for row in rows: + if row.sector_id in orphan_values: + row.sector_id = new_sector_id + changed += 1 + + await session.commit() + return changed + + +async def repair_orphan_group_refs( + *, + scheme_version_id: str, + new_group_id: str, + orphan_values: list[str], +) -> int: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id + ) + ) + rows = list(result.scalars().all()) + + changed = 0 + for row in rows: + if row.group_id in orphan_values: + row.group_id = new_group_id + changed += 1 + + await session.commit() + return changed diff --git a/backend/app/repositories/scheme_sectors.py b/backend/app/repositories/scheme_sectors.py index 5c8517a..fd06685 100644 --- a/backend/app/repositories/scheme_sectors.py +++ b/backend/app/repositories/scheme_sectors.py @@ -1,10 +1,11 @@ -import json from uuid import uuid4 -from sqlalchemy import asc, delete, select +from fastapi import HTTPException, status +from sqlalchemy import asc, select from app.db.session import AsyncSessionLocal from app.models.scheme_sector import SchemeSectorRecord +from app.models.scheme_seat import SchemeSeatRecord async def replace_scheme_version_sectors( @@ -14,37 +15,29 @@ async def replace_scheme_version_sectors( sectors: list[dict], ) -> None: async with AsyncSessionLocal() as session: - await session.execute( - delete(SchemeSectorRecord).where( - SchemeSectorRecord.scheme_version_id == scheme_version_id - ) + existing_result = await session.execute( + select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == scheme_version_id) ) + existing_rows = list(existing_result.scalars().all()) + + for row in existing_rows: + await session.delete(row) for item in sectors: row = SchemeSectorRecord( - sector_record_id=uuid4().hex, + sector_record_id=item["sector_record_id"] if "sector_record_id" in item and item["sector_record_id"] else uuid4().hex, scheme_id=scheme_id, scheme_version_id=scheme_version_id, element_id=item.get("id"), sector_id=item.get("sector_id"), - name=item.get("sector_id") or item.get("id") or "unnamed-sector", - classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), + name=item.get("sector_id"), + classes_raw=str(item.get("classes")), ) session.add(row) await session.commit() -async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]: - async with AsyncSessionLocal() as session: - result = await session.execute( - select(SchemeSectorRecord) - .where(SchemeSectorRecord.scheme_version_id == scheme_version_id) - .order_by(asc(SchemeSectorRecord.id)) - ) - return list(result.scalars().all()) - - async def clone_scheme_version_sectors( *, source_scheme_version_id: str, @@ -52,23 +45,124 @@ async def clone_scheme_version_sectors( ) -> None: async with AsyncSessionLocal() as session: result = await session.execute( - select(SchemeSectorRecord).where( - SchemeSectorRecord.scheme_version_id == source_scheme_version_id - ) + select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == source_scheme_version_id) ) rows = list(result.scalars().all()) for row in rows: - session.add( - SchemeSectorRecord( - sector_record_id=uuid4().hex, - scheme_id=row.scheme_id, - scheme_version_id=target_scheme_version_id, - element_id=row.element_id, - sector_id=row.sector_id, - name=row.name, - classes_raw=row.classes_raw, - ) + cloned = SchemeSectorRecord( + sector_record_id=uuid4().hex, + scheme_id=row.scheme_id, + scheme_version_id=target_scheme_version_id, + element_id=row.element_id, + sector_id=row.sector_id, + name=row.name, + classes_raw=row.classes_raw, ) + session.add(cloned) await session.commit() + + +async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSectorRecord) + .where(SchemeSectorRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeSectorRecord.created_at), asc(SchemeSectorRecord.id)) + ) + return list(result.scalars().all()) + + +async def update_scheme_version_sector_by_record_id( + *, + scheme_version_id: str, + sector_record_id: str, + sector_id: str | None, + name: str | None, +) -> tuple[SchemeSectorRecord, str | None]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeSectorRecord).where( + SchemeSectorRecord.scheme_version_id == scheme_version_id, + SchemeSectorRecord.sector_record_id == sector_record_id, + ) + ) + row = result.scalar_one_or_none() + + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Sector record not found in current draft version", + ) + + old_sector_id = row.sector_id + row.sector_id = sector_id + row.name = name + + await session.commit() + await session.refresh(row) + return row, old_sector_id + + +async def create_scheme_version_sector( + *, + scheme_id: str, + scheme_version_id: str, + element_id: str | None, + sector_id: str, + name: str | None, + classes_raw: str | None, +) -> SchemeSectorRecord: + async with AsyncSessionLocal() as session: + row = SchemeSectorRecord( + sector_record_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + element_id=element_id, + sector_id=sector_id, + name=name, + classes_raw=classes_raw, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +async def delete_scheme_version_sector_by_record_id( + *, + scheme_version_id: str, + sector_record_id: str, +) -> None: + async with AsyncSessionLocal() as session: + sector_result = await session.execute( + select(SchemeSectorRecord).where( + SchemeSectorRecord.scheme_version_id == scheme_version_id, + SchemeSectorRecord.sector_record_id == sector_record_id, + ) + ) + sector = sector_result.scalar_one_or_none() + + if sector is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Sector record not found in current draft version", + ) + + if sector.sector_id: + seats_result = await session.execute( + select(SchemeSeatRecord).where( + SchemeSeatRecord.scheme_version_id == scheme_version_id, + SchemeSeatRecord.sector_id == sector.sector_id, + ) + ) + seats = list(seats_result.scalars().all()) + if seats: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Cannot delete sector while seats still reference it", + ) + + await session.delete(sector) + await session.commit() diff --git a/backend/app/repositories/scheme_version_pricing.py b/backend/app/repositories/scheme_version_pricing.py new file mode 100644 index 0000000..74b3a39 --- /dev/null +++ b/backend/app/repositories/scheme_version_pricing.py @@ -0,0 +1,144 @@ +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import asc, desc, select + +from app.db.session import AsyncSessionLocal +from app.models.scheme_version_pricing import ( + SchemeVersionPriceRuleRecord, + SchemeVersionPricingCategoryRecord, +) +from app.repositories.pricing import list_price_rules, list_pricing_categories + + +async def replace_scheme_version_pricing_snapshot( + *, + scheme_id: str, + scheme_version_id: str, +) -> dict: + categories = await list_pricing_categories(scheme_id) + rules = await list_price_rules(scheme_id) + + async with AsyncSessionLocal() as session: + old_categories = await session.execute( + select(SchemeVersionPricingCategoryRecord).where( + SchemeVersionPricingCategoryRecord.scheme_version_id == scheme_version_id + ) + ) + for row in list(old_categories.scalars().all()): + await session.delete(row) + + old_rules = await session.execute( + select(SchemeVersionPriceRuleRecord).where( + SchemeVersionPriceRuleRecord.scheme_version_id == scheme_version_id + ) + ) + for row in list(old_rules.scalars().all()): + await session.delete(row) + + mapping: dict[str, str] = {} + + for category in categories: + snapshot_category_id = uuid4().hex + mapping[category.pricing_category_id] = snapshot_category_id + session.add( + SchemeVersionPricingCategoryRecord( + snapshot_category_id=snapshot_category_id, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + source_pricing_category_id=category.pricing_category_id, + name=category.name, + code=category.code, + ) + ) + + for rule in rules: + session.add( + SchemeVersionPriceRuleRecord( + snapshot_price_rule_id=uuid4().hex, + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + source_price_rule_id=rule.price_rule_id, + snapshot_category_id=mapping.get(rule.pricing_category_id) if rule.pricing_category_id else None, + target_type=rule.target_type, + target_ref=rule.target_ref, + amount=rule.amount, + currency=rule.currency, + ) + ) + + await session.commit() + + return { + "categories_count": len(categories), + "rules_count": len(rules), + } + + +async def list_scheme_version_snapshot_categories( + scheme_version_id: str, +) -> list[SchemeVersionPricingCategoryRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeVersionPricingCategoryRecord) + .where(SchemeVersionPricingCategoryRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeVersionPricingCategoryRecord.created_at), asc(SchemeVersionPricingCategoryRecord.id)) + ) + return list(result.scalars().all()) + + +async def list_scheme_version_snapshot_rules( + scheme_version_id: str, +) -> list[SchemeVersionPriceRuleRecord]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(SchemeVersionPriceRuleRecord) + .where(SchemeVersionPriceRuleRecord.scheme_version_id == scheme_version_id) + .order_by(asc(SchemeVersionPriceRuleRecord.created_at), asc(SchemeVersionPriceRuleRecord.id)) + ) + return list(result.scalars().all()) + + +async def find_effective_snapshot_price_rule( + *, + scheme_version_id: str, + seat_id: str | None, + group_id: str | None, + sector_id: str | None, +) -> tuple[str, dict]: + async with AsyncSessionLocal() as session: + checks = [ + ("seat", seat_id), + ("group", group_id), + ("sector", sector_id), + ] + + for level, ref in checks: + if not ref: + continue + + result = await session.execute( + select(SchemeVersionPriceRuleRecord) + .where( + SchemeVersionPriceRuleRecord.scheme_version_id == scheme_version_id, + SchemeVersionPriceRuleRecord.target_type == level, + SchemeVersionPriceRuleRecord.target_ref == ref, + ) + .order_by(desc(SchemeVersionPriceRuleRecord.created_at), desc(SchemeVersionPriceRuleRecord.id)) + .limit(1) + ) + row = result.scalar_one_or_none() + if row is not None: + return level, { + "snapshot_price_rule_id": row.snapshot_price_rule_id, + "snapshot_category_id": row.snapshot_category_id, + "target_type": row.target_type, + "target_ref": row.target_ref, + "amount": row.amount, + "currency": row.currency, + } + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No snapshot pricing rule matched current seat", + ) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py new file mode 100644 index 0000000..c2c98b7 --- /dev/null +++ b/backend/app/schemas/editor.py @@ -0,0 +1,194 @@ +from pydantic import BaseModel, Field + + +class DraftSeatItem(BaseModel): + seat_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + seat_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + tag: str | None + classes_raw: str | None + x: float | None + y: float | None + cx: float | None + cy: float | None + width: float | None + height: float | None + created_at: str + + +class DraftSectorItem(BaseModel): + sector_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + sector_id: str | None + name: str | None + classes_raw: str | None + created_at: str + + +class DraftGroupItem(BaseModel): + group_record_id: str + scheme_id: str + scheme_version_id: str + element_id: str | None + group_id: str | None + name: str | None + classes_raw: str | None + created_at: str + + +class DraftStructureResponse(BaseModel): + scheme_id: str + scheme_version_id: str + status: str + seats: list[DraftSeatItem] + sectors: list[DraftSectorItem] + groups: list[DraftGroupItem] + total_seats: int + total_sectors: int + total_groups: int + + +class SeatPatchRequest(BaseModel): + seat_id: str | None = Field(default=None, max_length=128) + sector_id: str | None = Field(default=None, max_length=128) + group_id: str | None = Field(default=None, max_length=128) + row_label: str | None = Field(default=None, max_length=64) + seat_number: str | None = Field(default=None, max_length=64) + + +class SeatPatchResponse(BaseModel): + scheme_id: str + scheme_version_id: str + element_id: str | None + seat_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + + +class BulkSeatPatchItem(BaseModel): + seat_record_id: str = Field(..., max_length=32) + seat_id: str | None = Field(default=None, max_length=128) + sector_id: str | None = Field(default=None, max_length=128) + group_id: str | None = Field(default=None, max_length=128) + row_label: str | None = Field(default=None, max_length=64) + seat_number: str | None = Field(default=None, max_length=64) + + +class BulkSeatPatchRequest(BaseModel): + items: list[BulkSeatPatchItem] = Field(..., min_length=1, max_length=500) + + +class BulkSeatPatchResultItem(BaseModel): + seat_record_id: str + updated_seat_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + + +class BulkSeatPatchResponse(BaseModel): + scheme_id: str + scheme_version_id: str + updated_count: int + items: list[BulkSeatPatchResultItem] + + +class SectorPatchRequest(BaseModel): + sector_id: str | None = Field(default=None, max_length=128) + name: str | None = Field(default=None, max_length=255) + + +class SectorPatchResponse(BaseModel): + scheme_id: str + scheme_version_id: str + element_id: str | None + sector_id: str | None + name: str | None + + +class GroupPatchRequest(BaseModel): + group_id: str | None = Field(default=None, max_length=128) + name: str | None = Field(default=None, max_length=255) + + +class GroupPatchResponse(BaseModel): + scheme_id: str + scheme_version_id: str + element_id: str | None + group_id: str | None + name: str | None + + +class CreateSectorRequest(BaseModel): + element_id: str | None = Field(default=None, max_length=255) + sector_id: str = Field(..., max_length=128) + name: str | None = Field(default=None, max_length=255) + classes_raw: str | None = Field(default=None, max_length=4000) + + +class CreateGroupRequest(BaseModel): + element_id: str | None = Field(default=None, max_length=255) + group_id: str = Field(..., max_length=128) + name: str | None = Field(default=None, max_length=255) + classes_raw: str | None = Field(default=None, max_length=4000) + + +class CreateSectorResponse(BaseModel): + scheme_id: str + scheme_version_id: str + sector_record_id: str + element_id: str | None + sector_id: str + name: str | None + + +class CreateGroupResponse(BaseModel): + scheme_id: str + scheme_version_id: str + group_record_id: str + element_id: str | None + group_id: str + name: str | None + + +class DeleteEntityResponse(BaseModel): + scheme_id: str + scheme_version_id: str + deleted: bool + record_id: str + + +class RepairReferencesResponse(BaseModel): + scheme_id: str + scheme_version_id: str + repaired_sector_refs_count: int + repaired_group_refs_count: int + details: dict + + +class StructureDiffEntityItem(BaseModel): + key: str + status: str + before: dict | None + after: dict | None + + +class StructureDiffResponse(BaseModel): + scheme_id: str + draft_scheme_version_id: str + baseline_scheme_version_id: str | None + summary: dict + sectors: list[StructureDiffEntityItem] + groups: list[StructureDiffEntityItem] + seats: list[StructureDiffEntityItem] diff --git a/backend/app/schemas/pricing.py b/backend/app/schemas/pricing.py index bd56558..f7a80d6 100644 --- a/backend/app/schemas/pricing.py +++ b/backend/app/schemas/pricing.py @@ -1,17 +1,40 @@ from decimal import Decimal, InvalidOperation -from typing import List -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator + + +def _validate_decimal_amount(value: Decimal) -> Decimal: + try: + normalized = Decimal(value) + except (InvalidOperation, TypeError, ValueError) as exc: + raise ValueError("Некорректная сумма") from exc + + if not normalized.is_finite(): + raise ValueError("Некорректная сумма") + + return normalized + + +class DeleteResponse(BaseModel): + status: str class PricingCategoryCreateRequest(BaseModel): - name: str - code: str | None = None + name: str = Field(..., min_length=1, max_length=255) + code: str | None = Field(default=None, max_length=128) class PricingCategoryUpdateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + code: str | None = Field(default=None, max_length=128) + + +class PricingCategoryItem(BaseModel): + pricing_category_id: str + scheme_id: str name: str - code: str | None = None + code: str | None + created_at: str class PricingCategoryCreateResponse(BaseModel): @@ -28,98 +51,41 @@ class PricingCategoryUpdateResponse(BaseModel): code: str | None -class DeleteResponse(BaseModel): - status: str - - class PriceRuleCreateRequest(BaseModel): - pricing_category_id: str | None = None - target_type: str - target_ref: str + pricing_category_id: str | None = Field(default=None, max_length=32) + target_type: str = Field(..., pattern="^(seat|group|sector)$") + target_ref: str = Field(..., min_length=1, max_length=128) amount: Decimal - currency: str = "RUB" - - @field_validator("target_type") - @classmethod - def validate_target_type(cls, value: str) -> str: - allowed = {"sector", "group", "seat"} - if value not in allowed: - raise ValueError("Поле target_type должно быть одним из: sector, group, seat") - return value - - @field_validator("currency") - @classmethod - def validate_currency(cls, value: str) -> str: - if value != "RUB": - raise ValueError("В v1 поддерживается только валюта RUB") - return value - - @field_validator("amount", mode="before") - @classmethod - def parse_amount(cls, value): - if value is None: - raise ValueError("Поле amount обязательно") - text = str(value).strip() - if text == "": - raise ValueError("Поле amount обязательно") - try: - return Decimal(text) - except (InvalidOperation, ValueError): - raise ValueError("Некорректная сумма. Используйте формат 2500.00") + currency: str = Field(default="RUB", min_length=3, max_length=8) @field_validator("amount") @classmethod def validate_amount(cls, value: Decimal) -> Decimal: - if value < Decimal("0.00"): - raise ValueError("Сумма не может быть отрицательной") - if value.quantize(Decimal("0.01")) != value: - raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой") - return value + return _validate_decimal_amount(value) class PriceRuleUpdateRequest(BaseModel): - pricing_category_id: str | None = None - target_type: str - target_ref: str + pricing_category_id: str | None = Field(default=None, max_length=32) + target_type: str = Field(..., pattern="^(seat|group|sector)$") + target_ref: str = Field(..., min_length=1, max_length=128) amount: Decimal - currency: str = "RUB" - - @field_validator("target_type") - @classmethod - def validate_target_type(cls, value: str) -> str: - allowed = {"sector", "group", "seat"} - if value not in allowed: - raise ValueError("Поле target_type должно быть одним из: sector, group, seat") - return value - - @field_validator("currency") - @classmethod - def validate_currency(cls, value: str) -> str: - if value != "RUB": - raise ValueError("В v1 поддерживается только валюта RUB") - return value - - @field_validator("amount", mode="before") - @classmethod - def parse_amount(cls, value): - if value is None: - raise ValueError("Поле amount обязательно") - text = str(value).strip() - if text == "": - raise ValueError("Поле amount обязательно") - try: - return Decimal(text) - except (InvalidOperation, ValueError): - raise ValueError("Некорректная сумма. Используйте формат 2500.00") + currency: str = Field(default="RUB", min_length=3, max_length=8) @field_validator("amount") @classmethod def validate_amount(cls, value: Decimal) -> Decimal: - if value < Decimal("0.00"): - raise ValueError("Сумма не может быть отрицательной") - if value.quantize(Decimal("0.01")) != value: - raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой") - return value + return _validate_decimal_amount(value) + + +class PriceRuleItem(BaseModel): + price_rule_id: str + scheme_id: str + pricing_category_id: str | None + target_type: str + target_ref: str + amount: Decimal | str + currency: str + created_at: str class PriceRuleCreateResponse(BaseModel): @@ -142,30 +108,6 @@ class PriceRuleUpdateResponse(BaseModel): currency: str -class PricingCategoryItem(BaseModel): - pricing_category_id: str - scheme_id: str - name: str - code: str | None - created_at: str - - -class PriceRuleItem(BaseModel): - price_rule_id: str - scheme_id: str - pricing_category_id: str | None - target_type: str - target_ref: str - amount: Decimal - currency: str - created_at: str - - -class SchemePricingResponse(BaseModel): - categories: List[PricingCategoryItem] - rules: List[PriceRuleItem] - - class EffectiveSeatPriceResponse(BaseModel): scheme_id: str scheme_version_id: str @@ -175,5 +117,15 @@ class EffectiveSeatPriceResponse(BaseModel): matched_rule_level: str matched_target_ref: str pricing_category_id: str | None - amount: Decimal + amount: Decimal | str currency: str + + +class SchemePricingResponse(BaseModel): + categories: list[PricingCategoryItem] + rules: list[PriceRuleItem] + + +class PricingBundleResponse(BaseModel): + categories: list[PricingCategoryItem] + rules: list[PriceRuleItem] diff --git a/backend/app/schemas/publish_preview.py b/backend/app/schemas/publish_preview.py new file mode 100644 index 0000000..ea2f07a --- /dev/null +++ b/backend/app/schemas/publish_preview.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, Field + + +class RemapPreviewRequest(BaseModel): + seat_record_ids: list[str] | None = Field(default=None, max_length=500) + from_sector_id: str | None = Field(default=None, max_length=128) + to_sector_id: str | None = Field(default=None, max_length=128) + from_group_id: str | None = Field(default=None, max_length=128) + to_group_id: str | None = Field(default=None, max_length=128) + + +class RemapPreviewSeatItem(BaseModel): + seat_record_id: str + seat_id: str | None + before_sector_id: str | None + after_sector_id: str | None + before_group_id: str | None + after_group_id: str | None + + +class RemapPreviewResponse(BaseModel): + scheme_id: str + scheme_version_id: str + matched_count: int + items: list[RemapPreviewSeatItem] + + +class RemapApplyRequest(BaseModel): + seat_record_ids: list[str] | None = Field(default=None, max_length=500) + from_sector_id: str | None = Field(default=None, max_length=128) + to_sector_id: str | None = Field(default=None, max_length=128) + from_group_id: str | None = Field(default=None, max_length=128) + to_group_id: str | None = Field(default=None, max_length=128) + + +class RemapApplyResponse(BaseModel): + scheme_id: str + scheme_version_id: str + updated_count: int + items: list[RemapPreviewSeatItem] + + +class PublishPreviewResponse(BaseModel): + scheme_id: str + scheme_version_id: str + artifacts: dict + validation: dict + structure_diff: dict + pricing_coverage: dict + summary: dict diff --git a/backend/app/services/baseline_selector.py b/backend/app/services/baseline_selector.py new file mode 100644 index 0000000..7775329 --- /dev/null +++ b/backend/app/services/baseline_selector.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.repositories.scheme_versions import list_scheme_versions + + +async def select_baseline_scheme_version( + *, + scheme_id: str, + draft_scheme_version_id: str, + override_scheme_version_id: str | None = None, +): + versions = await list_scheme_versions(scheme_id=scheme_id, limit=200, offset=0) + + if override_scheme_version_id: + for row in versions: + if row.scheme_version_id == override_scheme_version_id: + if row.scheme_version_id == draft_scheme_version_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Baseline override must differ from current draft scheme version", + ) + return row, "override" + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Baseline override scheme version not found", + ) + + published_candidates = [ + row for row in versions + if row.scheme_version_id != draft_scheme_version_id and row.status == "published" + ] + if published_candidates: + return published_candidates[0], "published" + + previous_candidates = [ + row for row in versions + if row.scheme_version_id != draft_scheme_version_id + ] + if previous_candidates: + return previous_candidates[0], "previous" + + return None, "none" diff --git a/backend/app/services/display_regenerator.py b/backend/app/services/display_regenerator.py new file mode 100644 index 0000000..ab56aa0 --- /dev/null +++ b/backend/app/services/display_regenerator.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import HTTPException, status + +from app.core.config import settings +from app.repositories.scheme_artifacts import create_scheme_artifact +from app.repositories.scheme_versions import update_scheme_version_display_artifact +from app.services.storage import save_display_svg +from app.services.svg_display_processor import ALLOWED_MODES, generate_display_svg + + +logger = logging.getLogger(__name__) + + +async def regenerate_display_artifact( + *, + scheme_id: str, + scheme_version_id: str, + upload_id: str, + original_filename: str, + sanitized_storage_path: str, + mode: str, +) -> dict: + if mode not in ALLOWED_MODES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported display mode: {mode}", + ) + + svg_path = Path(sanitized_storage_path) + if not svg_path.exists() or not svg_path.is_file(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Sanitized SVG not found for current scheme version", + ) + + sanitized_bytes = svg_path.read_bytes() + + try: + display_bytes, meta = generate_display_svg(sanitized_bytes, mode) + except Exception as exc: + logger.exception( + "display_svg.regenerate failed scheme_id=%s scheme_version_id=%s mode=%s", + scheme_id, + scheme_version_id, + mode, + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Current scheme version is not ready for display rendering: {exc.__class__.__name__}", + ) + + storage_path = save_display_svg( + upload_id=upload_id, + filename=original_filename, + content=display_bytes, + ) + + await create_scheme_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type="display_svg", + artifact_variant=mode, + storage_path=storage_path, + status="ready", + meta_json=meta, + ) + + if mode == settings.svg_display_mode: + await update_scheme_version_display_artifact( + scheme_version_id=scheme_version_id, + display_svg_storage_path=storage_path, + display_svg_status="ready", + display_svg_generated_at=datetime.now(timezone.utc), + ) + + return { + "scheme_id": scheme_id, + "scheme_version_id": scheme_version_id, + "artifact_type": "display_svg", + "artifact_variant": mode, + "storage_path": storage_path, + "meta": meta, + "generated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/app/services/draft_guard.py b/backend/app/services/draft_guard.py new file mode 100644 index 0000000..29bded0 --- /dev/null +++ b/backend/app/services/draft_guard.py @@ -0,0 +1,20 @@ +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 + + +async def get_current_draft_context(scheme_id: str): + 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, + ) + + 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", + ) + + return scheme, version diff --git a/backend/app/services/editor_validation.py b/backend/app/services/editor_validation.py new file mode 100644 index 0000000..1958cb4 --- /dev/null +++ b/backend/app/services/editor_validation.py @@ -0,0 +1,96 @@ +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 + + +async def validate_single_seat_patch_uniqueness( + *, + scheme_version_id: str, + seat_record_id: str, + new_seat_id: str | None, +) -> None: + if not new_seat_id: + return + + seats = await list_scheme_version_seats(scheme_version_id) + for seat in seats: + if seat.seat_id == new_seat_id and seat.seat_record_id != seat_record_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"seat_id already exists in draft version: {new_seat_id}", + ) + + +async def validate_bulk_seat_patch_uniqueness( + *, + scheme_version_id: str, + items: list[dict], +) -> None: + seats = await list_scheme_version_seats(scheme_version_id) + existing = {seat.seat_id: seat.seat_record_id for seat in seats if seat.seat_id} + + payload_new_ids = [item.get("seat_id") for item in items if item.get("seat_id")] + duplicates_inside_payload = sorted( + { + seat_id + for seat_id in payload_new_ids + if payload_new_ids.count(seat_id) > 1 + } + ) + if duplicates_inside_payload: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Duplicate seat_id values inside bulk payload: {', '.join(duplicates_inside_payload)}", + ) + + for item in items: + new_seat_id = item.get("seat_id") + seat_record_id = item["seat_record_id"] + + if not new_seat_id: + continue + + existing_record_id = existing.get(new_seat_id) + if existing_record_id and existing_record_id != seat_record_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"seat_id already exists in draft version: {new_seat_id}", + ) + + +async def validate_sector_patch_uniqueness( + *, + scheme_version_id: str, + sector_record_id: str, + new_sector_id: str | None, +) -> None: + if not new_sector_id: + return + + sectors = await list_scheme_version_sectors(scheme_version_id) + for sector in sectors: + if sector.sector_id == new_sector_id and sector.sector_record_id != sector_record_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"sector_id already exists in draft version: {new_sector_id}", + ) + + +async def validate_group_patch_uniqueness( + *, + scheme_version_id: str, + group_record_id: str, + new_group_id: str | None, +) -> None: + if not new_group_id: + return + + groups = await list_scheme_version_groups(scheme_version_id) + for group in groups: + if group.group_id == new_group_id and group.group_record_id != group_record_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"group_id already exists in draft version: {new_group_id}", + ) diff --git a/backend/app/services/publish_preview.py b/backend/app/services/publish_preview.py new file mode 100644 index 0000000..a3a12de --- /dev/null +++ b/backend/app/services/publish_preview.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from app.repositories.scheme_artifacts import list_scheme_artifacts +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.publish_preview_cache import ( + get_latest_publish_preview_artifact, + save_publish_preview_artifact, +) +from app.services.scheme_validation import build_scheme_validation_report +from app.services.structure_diff import build_structure_diff + + +async def build_publish_preview_bundle( + *, + scheme_id: str, + scheme_version_id: str, + baseline_override_scheme_version_id: str | None = None, +) -> dict: + validation = await build_scheme_validation_report( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + ) + structure_diff = await build_structure_diff( + scheme_id=scheme_id, + draft_scheme_version_id=scheme_version_id, + baseline_override_scheme_version_id=baseline_override_scheme_version_id, + ) + artifacts_rows = await list_scheme_artifacts(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) + + priced = 0 + unpriced = 0 + snapshot_available = len(snapshot_rules) > 0 or len(snapshot_categories) > 0 + + for seat in seats: + if not seat.seat_id: + unpriced += 1 + continue + + if not snapshot_available: + unpriced += 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 += 1 + except Exception: + unpriced += 1 + + artifacts = { + "total": len(artifacts_rows), + "items": [ + { + "artifact_id": row.artifact_id, + "artifact_type": row.artifact_type, + "artifact_variant": row.artifact_variant, + "status": row.status, + "storage_path": row.storage_path, + "meta_json": row.meta_json, + "created_at": row.created_at.isoformat(), + } + for row in artifacts_rows + ], + } + + pricing_coverage = { + "snapshot_available": snapshot_available, + "snapshot_categories_count": len(snapshot_categories), + "snapshot_rules_count": len(snapshot_rules), + "total_seats": len(seats), + "priced_seats": priced, + "unpriced_seats": unpriced, + } + + summary = { + "is_publishable": validation["summary"]["is_publishable"], + "has_structure_changes": any(value > 0 for value in structure_diff["summary"].values()), + "has_artifacts": len(artifacts_rows) > 0, + "has_unpriced_seats": unpriced > 0, + "snapshot_available": snapshot_available, + } + + return { + "artifacts": artifacts, + "validation": validation, + "structure_diff": structure_diff, + "pricing_coverage": pricing_coverage, + "summary": summary, + } + + +async def get_or_build_publish_preview_bundle( + *, + scheme_id: str, + scheme_version_id: str, + baseline_override_scheme_version_id: str | None = None, + refresh: bool = False, +) -> dict: + if not refresh: + artifact = await get_latest_publish_preview_artifact( + scheme_version_id=scheme_version_id, + baseline_scheme_version_id=baseline_override_scheme_version_id, + ) + if artifact: + path = Path(artifact.storage_path) + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + + payload = await build_publish_preview_bundle( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + baseline_override_scheme_version_id=baseline_override_scheme_version_id, + ) + await save_publish_preview_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + payload=payload, + baseline_scheme_version_id=payload["structure_diff"]["baseline_scheme_version_id"], + ) + return payload diff --git a/backend/app/services/publish_preview_cache.py b/backend/app/services/publish_preview_cache.py new file mode 100644 index 0000000..3162dc5 --- /dev/null +++ b/backend/app/services/publish_preview_cache.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +from app.core.config import settings +from app.repositories.scheme_artifacts import create_scheme_artifact, list_scheme_artifacts + + +def _preview_storage_dir() -> Path: + path = Path(settings.storage_preview_dir) + path.mkdir(parents=True, exist_ok=True) + return path + + +async def save_publish_preview_artifact( + *, + scheme_id: str, + scheme_version_id: str, + payload: dict, + baseline_scheme_version_id: str | None, +) -> dict: + artifact_dir = _preview_storage_dir() / uuid4().hex + artifact_dir.mkdir(parents=True, exist_ok=True) + storage_path = artifact_dir / "publish-preview.json" + storage_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + artifact = await create_scheme_artifact( + scheme_id=scheme_id, + scheme_version_id=scheme_version_id, + artifact_type="publish_preview", + artifact_variant=baseline_scheme_version_id or "default", + storage_path=str(storage_path), + meta_json={ + "baseline_scheme_version_id": baseline_scheme_version_id, + "summary": payload.get("summary"), + }, + ) + return artifact + + +async def get_latest_publish_preview_artifact( + *, + scheme_version_id: str, + baseline_scheme_version_id: str | None, +): + rows = await list_scheme_artifacts(scheme_version_id=scheme_version_id) + variant = baseline_scheme_version_id or "default" + + matching = [ + row for row in rows + if row.artifact_type == "publish_preview" and row.artifact_variant == variant + ] + if not matching: + return None + + matching.sort(key=lambda row: (row.created_at, row.id), reverse=True) + return matching[0] diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py new file mode 100644 index 0000000..64e70c2 --- /dev/null +++ b/backend/app/services/publish_service.py @@ -0,0 +1,65 @@ +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.repositories.scheme_versions import get_current_scheme_version +from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme +from app.services.scheme_validation import build_scheme_validation_report + + +async def publish_current_draft_scheme( + *, + scheme_id: str, +) -> dict: + 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, + ) + + if scheme.status != "draft" or version.status != "draft": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not publishable because it is not in draft state", + ) + + validation = await build_scheme_validation_report( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + + if not validation["summary"]["is_publishable"]: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Scheme is not publishable in current state", + ) + + snapshot = await replace_scheme_version_pricing_snapshot( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + + published_row = await publish_scheme(scheme.scheme_id) + + await create_audit_event( + scheme_id=published_row.scheme_id, + event_type="scheme.published", + object_type="scheme", + object_ref=published_row.scheme_id, + details={ + "current_version_number": published_row.current_version_number, + "status": published_row.status, + "pricing_snapshot": snapshot, + "scheme_version_id": version.scheme_version_id, + }, + ) + + return { + "scheme_id": published_row.scheme_id, + "scheme_version_id": version.scheme_version_id, + "status": published_row.status, + "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"], + } diff --git a/backend/app/services/remap_service.py b/backend/app/services/remap_service.py new file mode 100644 index 0000000..2f4e69d --- /dev/null +++ b/backend/app/services/remap_service.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from fastapi import HTTPException, status + +from app.repositories.scheme_seats import ( + bulk_remap_scheme_version_seats, + list_scheme_version_seats, +) + + +def _match_seat( + seat, + *, + seat_record_ids: set[str] | None, + from_sector_id: str | None, + from_group_id: str | None, +) -> bool: + if seat_record_ids is not None and seat.seat_record_id not in seat_record_ids: + return False + if from_sector_id is not None and seat.sector_id != from_sector_id: + return False + if from_group_id is not None and seat.group_id != from_group_id: + return False + return True + + +async def preview_remap( + *, + scheme_version_id: str, + seat_record_ids: list[str] | None, + from_sector_id: str | None, + to_sector_id: str | None, + from_group_id: str | None, + 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", + ) + + seats = await list_scheme_version_seats(scheme_version_id) + seat_record_id_set = set(seat_record_ids) if seat_record_ids else None + + matched: list[dict] = [] + for seat in seats: + if not _match_seat( + seat, + seat_record_ids=seat_record_id_set, + from_sector_id=from_sector_id, + from_group_id=from_group_id, + ): + continue + + matched.append( + { + "seat_record_id": seat.seat_record_id, + "seat_id": seat.seat_id, + "before_sector_id": seat.sector_id, + "after_sector_id": to_sector_id if to_sector_id is not None else seat.sector_id, + "before_group_id": seat.group_id, + "after_group_id": to_group_id if to_group_id is not None else seat.group_id, + } + ) + + return matched + + +async def apply_remap( + *, + scheme_version_id: str, + seat_record_ids: list[str] | None, + from_sector_id: str | None, + to_sector_id: str | None, + from_group_id: str | None, + to_group_id: str | None, +) -> list[dict]: + preview_items = await preview_remap( + scheme_version_id=scheme_version_id, + seat_record_ids=seat_record_ids, + from_sector_id=from_sector_id, + to_sector_id=to_sector_id, + from_group_id=from_group_id, + to_group_id=to_group_id, + ) + + if not preview_items: + return [] + + await bulk_remap_scheme_version_seats( + scheme_version_id=scheme_version_id, + items=preview_items, + ) + + return preview_items diff --git a/backend/app/services/scheme_validation.py b/backend/app/services/scheme_validation.py new file mode 100644 index 0000000..91fe9d8 --- /dev/null +++ b/backend/app/services/scheme_validation.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections import Counter + +from app.repositories.pricing import find_effective_price_rule +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_scheme_validation_report( + *, + scheme_id: str, + scheme_version_id: str, +) -> dict: + sectors = await list_scheme_version_sectors(scheme_version_id) + groups = await list_scheme_version_groups(scheme_version_id) + seats = await list_scheme_version_seats(scheme_version_id) + + seat_ids = [row.seat_id for row in seats if row.seat_id] + duplicate_seat_ids = sorted([seat_id for seat_id, count in Counter(seat_ids).items() if count > 1]) + + seats_without_price: list[str] = [] + seats_without_sector_or_group: list[str] = [] + seats_with_missing_contract: list[str] = [] + priced_seats_count = 0 + + for seat in seats: + if not seat.seat_id or not seat.element_id: + seats_with_missing_contract.append(seat.element_id or seat.seat_id or "unknown") + continue + + if not seat.sector_id and not seat.group_id: + seats_without_sector_or_group.append(seat.seat_id) + + try: + await find_effective_price_rule( + scheme_id=scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + priced_seats_count += 1 + except Exception: + seats_without_price.append(seat.seat_id) + + errors: list[dict] = [] + warnings: list[dict] = [] + + if duplicate_seat_ids: + errors.append( + { + "code": "duplicate_seat_ids", + "message": "Duplicate seat_id values found", + "items": duplicate_seat_ids, + } + ) + + if seats_with_missing_contract: + errors.append( + { + "code": "missing_seat_contract", + "message": "Some seats have missing contract fields", + "items": seats_with_missing_contract, + } + ) + + if seats_without_sector_or_group: + warnings.append( + { + "code": "seats_without_sector_or_group", + "message": "Some seats do not belong to sector or group", + "items": sorted(seats_without_sector_or_group), + } + ) + + if seats_without_price: + warnings.append( + { + "code": "seats_without_price", + "message": "Some seats have no pricing rule", + "items": sorted(seats_without_price), + } + ) + + is_publishable = len(errors) == 0 + + return { + "summary": { + "sectors_count": len(sectors), + "groups_count": len(groups), + "seats_count": len(seats), + "priced_seats_count": priced_seats_count, + "unpriced_seats_count": len(seats_without_price), + "duplicate_seat_ids_count": len(duplicate_seat_ids), + "seats_with_missing_contract_count": len(seats_with_missing_contract), + "is_publishable": is_publishable, + }, + "errors": errors, + "warnings": warnings, + "issues": { + "duplicate_seat_ids": duplicate_seat_ids, + "seats_without_price": sorted(seats_without_price), + "seats_without_sector_or_group": sorted(seats_without_sector_or_group), + "seats_with_missing_contract": seats_with_missing_contract, + }, + } diff --git a/backend/app/services/structure_diff.py b/backend/app/services/structure_diff.py new file mode 100644 index 0000000..2d88b4d --- /dev/null +++ b/backend/app/services/structure_diff.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +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.baseline_selector import select_baseline_scheme_version + + +def _serialize_sector(row) -> dict: + return { + "sector_record_id": row.sector_record_id, + "element_id": row.element_id, + "sector_id": row.sector_id, + "name": row.name, + "classes_raw": row.classes_raw, + } + + +def _serialize_group(row) -> dict: + return { + "group_record_id": row.group_record_id, + "element_id": row.element_id, + "group_id": row.group_id, + "name": row.name, + "classes_raw": row.classes_raw, + } + + +def _serialize_seat(row) -> dict: + return { + "seat_record_id": row.seat_record_id, + "element_id": row.element_id, + "seat_id": row.seat_id, + "sector_id": row.sector_id, + "group_id": row.group_id, + "row_label": row.row_label, + "seat_number": row.seat_number, + } + + +def _build_diff(before_map: dict, after_map: dict) -> list[dict]: + keys = sorted(set(before_map.keys()) | set(after_map.keys())) + result: list[dict] = [] + + for key in keys: + before = before_map.get(key) + after = after_map.get(key) + + if before is None and after is not None: + status = "added" + elif before is not None and after is None: + status = "removed" + elif before != after: + status = "changed" + else: + status = "unchanged" + + result.append( + { + "key": key, + "status": status, + "before": before, + "after": after, + } + ) + return result + + +async def build_structure_diff( + *, + scheme_id: str, + draft_scheme_version_id: str, + baseline_override_scheme_version_id: str | None = None, +) -> dict: + baseline, baseline_strategy = await select_baseline_scheme_version( + scheme_id=scheme_id, + draft_scheme_version_id=draft_scheme_version_id, + override_scheme_version_id=baseline_override_scheme_version_id, + ) + + draft_sectors = await list_scheme_version_sectors(draft_scheme_version_id) + draft_groups = await list_scheme_version_groups(draft_scheme_version_id) + draft_seats = await list_scheme_version_seats(draft_scheme_version_id) + + if baseline is None: + baseline_sector_map = {} + baseline_group_map = {} + baseline_seat_map = {} + baseline_scheme_version_id = None + else: + baseline_scheme_version_id = baseline.scheme_version_id + baseline_sector_map = { + row.sector_record_id: _serialize_sector(row) + for row in await list_scheme_version_sectors(baseline.scheme_version_id) + } + baseline_group_map = { + row.group_record_id: _serialize_group(row) + for row in await list_scheme_version_groups(baseline.scheme_version_id) + } + baseline_seat_map = { + row.seat_record_id: _serialize_seat(row) + for row in await list_scheme_version_seats(baseline.scheme_version_id) + } + + draft_sector_map = {row.sector_record_id: _serialize_sector(row) for row in draft_sectors} + draft_group_map = {row.group_record_id: _serialize_group(row) for row in draft_groups} + draft_seat_map = {row.seat_record_id: _serialize_seat(row) for row in draft_seats} + + sector_diff = _build_diff(baseline_sector_map, draft_sector_map) + group_diff = _build_diff(baseline_group_map, draft_group_map) + seat_diff = _build_diff(baseline_seat_map, draft_seat_map) + + return { + "baseline_scheme_version_id": baseline_scheme_version_id, + "baseline_strategy": baseline_strategy, + "summary": { + "sectors_added": sum(1 for item in sector_diff if item["status"] == "added"), + "sectors_removed": sum(1 for item in sector_diff if item["status"] == "removed"), + "sectors_changed": sum(1 for item in sector_diff if item["status"] == "changed"), + "groups_added": sum(1 for item in group_diff if item["status"] == "added"), + "groups_removed": sum(1 for item in group_diff if item["status"] == "removed"), + "groups_changed": sum(1 for item in group_diff if item["status"] == "changed"), + "seats_added": sum(1 for item in seat_diff if item["status"] == "added"), + "seats_removed": sum(1 for item in seat_diff if item["status"] == "removed"), + "seats_changed": sum(1 for item in seat_diff if item["status"] == "changed"), + }, + "sectors": sector_diff, + "groups": group_diff, + "seats": seat_diff, + } diff --git a/backend/app/services/structure_sync.py b/backend/app/services/structure_sync.py new file mode 100644 index 0000000..8d936d7 --- /dev/null +++ b/backend/app/services/structure_sync.py @@ -0,0 +1,62 @@ +from app.repositories.scheme_groups import list_scheme_version_groups +from app.repositories.scheme_seats import ( + list_scheme_version_seats, + repair_orphan_group_refs, + repair_orphan_sector_refs, +) +from app.repositories.scheme_sectors import list_scheme_version_sectors + + +async def repair_structure_references( + *, + scheme_version_id: str, +) -> dict: + sectors = await list_scheme_version_sectors(scheme_version_id) + groups = await list_scheme_version_groups(scheme_version_id) + seats = await list_scheme_version_seats(scheme_version_id) + + valid_sector_ids = {item.sector_id for item in sectors if item.sector_id} + valid_group_ids = {item.group_id for item in groups if item.group_id} + + orphan_sector_values = sorted( + { + row.sector_id + for row in seats + if row.sector_id and row.sector_id not in valid_sector_ids + } + ) + orphan_group_values = sorted( + { + row.group_id + for row in seats + if row.group_id and row.group_id not in valid_group_ids + } + ) + + repaired_sector_refs_count = 0 + repaired_group_refs_count = 0 + + if len(valid_sector_ids) == 1 and orphan_sector_values: + repaired_sector_refs_count = await repair_orphan_sector_refs( + scheme_version_id=scheme_version_id, + new_sector_id=next(iter(valid_sector_ids)), + orphan_values=orphan_sector_values, + ) + + if len(valid_group_ids) == 1 and orphan_group_values: + repaired_group_refs_count = await repair_orphan_group_refs( + scheme_version_id=scheme_version_id, + new_group_id=next(iter(valid_group_ids)), + orphan_values=orphan_group_values, + ) + + return { + "repaired_sector_refs_count": repaired_sector_refs_count, + "repaired_group_refs_count": repaired_group_refs_count, + "details": { + "valid_sector_ids": sorted(valid_sector_ids), + "valid_group_ids": sorted(valid_group_ids), + "orphan_sector_values": orphan_sector_values, + "orphan_group_values": orphan_group_values, + }, + } diff --git a/backend/docs/roadmap-status.md b/backend/docs/roadmap-status.md new file mode 100644 index 0000000..7a6a7a3 --- /dev/null +++ b/backend/docs/roadmap-status.md @@ -0,0 +1,34 @@ +# SVG Backend Roadmap Status + +## Closed or nearly closed + +- artifact registry foundation +- display artifact storage +- display current/svg/display/meta +- backfill display artifacts base +- pricing integrity base + - DB unique constraint + - duplicate handling +- publish validation gate +- versioning/publish base +- audit base +- editable draft foundation +- version-aware pricing snapshot foundation +- publish preview bundle +- baseline override +- preview cache +- auto snapshot rebuild on pricing mutation + +## Notes + +The backend has moved beyond the initial upload/normalize/viewer baseline and now includes the core foundations for: +- display artifact lifecycle +- pricing consistency +- draft/editable version groundwork +- publish-time validation and preview preparation + +Further work remains around: +- full edit API +- maintenance/admin operations +- regression fixtures for problematic SVG files +- broader automated integration coverage