Implement display artifacts, pricing integrity, draft base and publish preview bundle

This commit is contained in:
greebo
2026-03-19 17:58:17 +03:00
parent 85fb2f4bb9
commit c91c5abf15
35 changed files with 3283 additions and 302 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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"],
)

View File

@@ -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}

View File

@@ -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],
)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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(

View File

@@ -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()

View File

@@ -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(),
)

View File

@@ -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())

View File

@@ -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

View File

@@ -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()

View File

@@ -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,15 +45,12 @@ 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(
cloned = SchemeGroupRecord(
group_record_id=uuid4().hex,
scheme_id=row.scheme_id,
scheme_version_id=target_scheme_version_id,
@@ -69,6 +59,110 @@ async def clone_scheme_version_groups(
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()

View File

@@ -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

View File

@@ -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,15 +45,12 @@ 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(
cloned = SchemeSectorRecord(
sector_record_id=uuid4().hex,
scheme_id=row.scheme_id,
scheme_version_id=target_scheme_version_id,
@@ -69,6 +59,110 @@ async def clone_scheme_version_sectors(
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()

View File

@@ -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",
)

View File

@@ -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]

View File

@@ -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]

View File

@@ -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

View File

@@ -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"

View File

@@ -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(),
}

View File

@@ -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

View File

@@ -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}",
)

View File

@@ -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

View File

@@ -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]

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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,
},
}

View File

@@ -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,
}

View File

@@ -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,
},
}

View File

@@ -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