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 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.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.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.schemes import router as schemes_router
from app.api.routes.structure import router as structure_router from app.api.routes.structure import router as structure_router
from app.api.routes.system import router as system_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(pricing_router)
router.include_router(test_mode_router) router.include_router(test_mode_router)
router.include_router(audit_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.core.config import settings
from app.repositories.audit import create_audit_event from app.repositories.audit import create_audit_event
@@ -12,33 +14,44 @@ from app.repositories.pricing import (
update_price_rule, update_price_rule,
update_pricing_category, 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.schemes import get_scheme_record_by_scheme_id
from app.repositories.scheme_versions import get_current_scheme_version
from app.schemas.pricing import ( from app.schemas.pricing import (
DeleteResponse,
PriceRuleCreateRequest, PriceRuleCreateRequest,
PriceRuleCreateResponse,
PriceRuleItem, PriceRuleItem,
PriceRuleUpdateRequest, PriceRuleUpdateRequest,
PriceRuleUpdateResponse, PricingBundleResponse,
PricingCategoryCreateRequest, PricingCategoryCreateRequest,
PricingCategoryCreateResponse,
PricingCategoryItem, PricingCategoryItem,
PricingCategoryUpdateRequest, PricingCategoryUpdateRequest,
PricingCategoryUpdateResponse,
SchemePricingResponse,
) )
from app.security.auth import require_api_key from app.security.auth import require_api_key
router = APIRouter() router = APIRouter()
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse) async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None:
async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key)): scheme = await get_scheme_record_by_scheme_id(scheme_id)
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) categories = await list_pricing_categories(scheme_id)
rules = await list_price_rules(scheme_id) rules = await list_price_rules(scheme_id)
return SchemePricingResponse( return PricingBundleResponse(
categories=[ categories=[
PricingCategoryItem( PricingCategoryItem(
pricing_category_id=row.pricing_category_id, 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, pricing_category_id=row.pricing_category_id,
target_type=row.target_type, target_type=row.target_type,
target_ref=row.target_ref, target_ref=row.target_ref,
amount=row.amount, amount=str(row.amount),
currency=row.currency, currency=row.currency,
created_at=row.created_at.isoformat(), 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( async def create_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
payload: PricingCategoryCreateRequest, payload: PricingCategoryCreateRequest,
role: str = Depends(require_api_key), role: str = Depends(require_api_key),
): ):
await get_scheme_record_by_scheme_id(scheme_id)
pricing_category_id = await create_pricing_category( pricing_category_id = await create_pricing_category(
scheme_id=scheme_id, scheme_id=scheme_id,
name=payload.name, name=payload.name,
code=payload.code, code=payload.code,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.category.created", event_type="pricing.category.created",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code}, details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
)
return PricingCategoryCreateResponse(
pricing_category_id=pricing_category_id,
scheme_id=scheme_id,
name=payload.name,
code=payload.code,
) )
return {
"pricing_category_id": pricing_category_id,
"scheme_id": scheme_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( async def update_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
pricing_category_id: str, pricing_category_id: str,
@@ -105,53 +120,71 @@ async def update_pricing_category_endpoint(
name=payload.name, name=payload.name,
code=payload.code, code=payload.code,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.category.updated", event_type="pricing.category.updated",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, object_ref=pricing_category_id,
details={"name": payload.name, "code": payload.code}, details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
)
return PricingCategoryUpdateResponse(
pricing_category_id=row.pricing_category_id,
scheme_id=row.scheme_id,
name=row.name,
code=row.code,
) )
return {
"pricing_category_id": row.pricing_category_id,
"scheme_id": row.scheme_id,
"name": row.name,
"code": row.code,
}
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse)
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
async def delete_pricing_category_endpoint( async def delete_pricing_category_endpoint(
scheme_id: str, scheme_id: str,
pricing_category_id: str, pricing_category_id: str,
role: str = Depends(require_api_key), 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( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.category.deleted", event_type="pricing.category.deleted",
object_type="pricing_category", object_type="pricing_category",
object_ref=pricing_category_id, 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( async def create_price_rule_endpoint(
scheme_id: str, scheme_id: str,
payload: PriceRuleCreateRequest, payload: PriceRuleCreateRequest,
role: str = Depends(require_api_key), 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( price_rule_id = await create_price_rule(
scheme_id=scheme_id, scheme_id=scheme_id,
pricing_category_id=payload.pricing_category_id, pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type, target_type=payload.target_type,
target_ref=payload.target_ref, target_ref=payload.target_ref,
amount=payload.amount, amount=amount,
currency=payload.currency, currency=payload.currency,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.rule.created", event_type="pricing.rule.created",
@@ -161,37 +194,49 @@ async def create_price_rule_endpoint(
"pricing_category_id": payload.pricing_category_id, "pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type, "target_type": payload.target_type,
"target_ref": payload.target_ref, "target_ref": payload.target_ref,
"amount": str(payload.amount), "amount": payload.amount,
"currency": payload.currency, "currency": payload.currency,
"snapshot": snapshot,
}, },
) )
return PriceRuleCreateResponse(
price_rule_id=price_rule_id, return {
scheme_id=scheme_id, "price_rule_id": price_rule_id,
pricing_category_id=payload.pricing_category_id, "scheme_id": scheme_id,
target_type=payload.target_type, "pricing_category_id": payload.pricing_category_id,
target_ref=payload.target_ref, "target_type": payload.target_type,
amount=payload.amount, "target_ref": payload.target_ref,
currency=payload.currency, "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( async def update_price_rule_endpoint(
scheme_id: str, scheme_id: str,
price_rule_id: str, price_rule_id: str,
payload: PriceRuleUpdateRequest, payload: PriceRuleUpdateRequest,
role: str = Depends(require_api_key), 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( row = await update_price_rule(
scheme_id=scheme_id, scheme_id=scheme_id,
price_rule_id=price_rule_id, price_rule_id=price_rule_id,
pricing_category_id=payload.pricing_category_id, pricing_category_id=payload.pricing_category_id,
target_type=payload.target_type, target_type=payload.target_type,
target_ref=payload.target_ref, target_ref=payload.target_ref,
amount=payload.amount, amount=amount,
currency=payload.currency, currency=payload.currency,
) )
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
await create_audit_event( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.rule.updated", event_type="pricing.rule.updated",
@@ -201,33 +246,41 @@ async def update_price_rule_endpoint(
"pricing_category_id": payload.pricing_category_id, "pricing_category_id": payload.pricing_category_id,
"target_type": payload.target_type, "target_type": payload.target_type,
"target_ref": payload.target_ref, "target_ref": payload.target_ref,
"amount": str(payload.amount), "amount": payload.amount,
"currency": payload.currency, "currency": payload.currency,
"snapshot": snapshot,
}, },
) )
return PriceRuleUpdateResponse(
price_rule_id=row.price_rule_id, return {
scheme_id=row.scheme_id, "price_rule_id": row.price_rule_id,
pricing_category_id=row.pricing_category_id, "scheme_id": row.scheme_id,
target_type=row.target_type, "pricing_category_id": row.pricing_category_id,
target_ref=row.target_ref, "target_type": row.target_type,
amount=row.amount, "target_ref": row.target_ref,
currency=row.currency, "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( async def delete_price_rule_endpoint(
scheme_id: str, scheme_id: str,
price_rule_id: str, price_rule_id: str,
role: str = Depends(require_api_key), 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( await create_audit_event(
scheme_id=scheme_id, scheme_id=scheme_id,
event_type="pricing.rule.deleted", event_type="pricing.rule.deleted",
object_type="price_rule", object_type="price_rule",
object_ref=price_rule_id, 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, count_scheme_records,
get_scheme_record_by_scheme_id, get_scheme_record_by_scheme_id,
list_scheme_records, list_scheme_records,
publish_scheme,
rollback_scheme_to_version, rollback_scheme_to_version,
unpublish_scheme, unpublish_scheme,
) )
@@ -34,6 +33,8 @@ from app.schemas.scheme_versions import (
SchemeVersionListResponse, SchemeVersionListResponse,
) )
from app.security.auth import require_api_key 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() 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)): async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)):
row = await publish_scheme(scheme_id) return await publish_current_draft_scheme(scheme_id=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,
)
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse) @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.core.config import settings
from app.repositories.pricing import find_effective_price_rule 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_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_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats
from app.repositories.scheme_sectors import list_scheme_version_sectors 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 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: if version.display_svg_status == "ready" and version.display_svg_storage_path:
path = Path(version.display_svg_storage_path) path = Path(version.display_svg_storage_path)
if path.exists() and path.is_file(): if path.exists() and path.is_file():
return path.read_bytes(), path 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 not sanitized_path.exists() or not sanitized_path.is_file():
if version.display_svg_status == "pending": if version.display_svg_status == "pending":
raise HTTPException( raise HTTPException(
@@ -106,6 +123,16 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload)
) )
display_path = Path(display_path_str) 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( await update_scheme_version_display_artifact(
scheme_version_id=version.scheme_version_id, scheme_version_id=version.scheme_version_id,
display_svg_storage_path=display_path_str, 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), 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 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) scheme, version, upload = await _load_current_context(scheme_id)
if resolved_mode == settings.svg_display_mode: 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" filename = f"{scheme.name or scheme.scheme_id}.{resolved_mode}.svg"
return FileResponse(path=display_path, media_type="image/svg+xml", filename=filename) 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(): if not sanitized_path.exists() or not sanitized_path.is_file():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) scheme, version, upload = await _load_current_context(scheme_id)
if resolved_mode == settings.svg_display_mode: 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) meta = _parse_svg_meta_from_bytes(display_bytes)
generated_at = version.display_svg_generated_at generated_at = version.display_svg_generated_at
else: 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(): if not sanitized_path.exists() or not sanitized_path.is_file():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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() sanitized_bytes = sanitized_path.read_bytes()
try: try:
display_bytes, meta = generate_display_svg(sanitized_bytes, resolved_mode) display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode)
except Exception: except Exception:
logger.exception( logger.exception(
"display_svg.meta_on_demand failed scheme_id=%s scheme_version_id=%s mode=%s", "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 fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from app.core.config import settings 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_groups import replace_scheme_version_groups
from app.repositories.scheme_seats import replace_scheme_version_seats from app.repositories.scheme_seats import replace_scheme_version_seats
from app.repositories.scheme_sectors import replace_scheme_version_sectors 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, 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) normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path)
await replace_scheme_version_sectors( await replace_scheme_version_sectors(

View File

@@ -74,5 +74,9 @@ class Settings(BaseSettings):
def storage_display_dir(self) -> str: def storage_display_dir(self) -> str:
return f"{self.storage_root_dir}/display" return f"{self.storage_root_dir}/display"
@property
def storage_preview_dir(self) -> str:
return f"{self.storage_root_dir}/preview"
settings = Settings() 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 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( async def create_pricing_category(
*, *,
scheme_id: str, scheme_id: str,
@@ -96,6 +124,13 @@ async def create_price_rule(
price_rule_id = uuid4().hex price_rule_id = uuid4().hex
async with AsyncSessionLocal() as session: 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( row = PriceRuleRecord(
price_rule_id=price_rule_id, price_rule_id=price_rule_id,
scheme_id=scheme_id, scheme_id=scheme_id,
@@ -136,6 +171,14 @@ async def update_price_rule(
detail="Price rule not found", 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.pricing_category_id = pricing_category_id
row.target_type = target_type row.target_type = target_type
row.target_ref = target_ref 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 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.db.session import AsyncSessionLocal
from app.models.scheme_group import SchemeGroupRecord from app.models.scheme_group import SchemeGroupRecord
from app.models.scheme_seat import SchemeSeatRecord
async def replace_scheme_version_groups( async def replace_scheme_version_groups(
@@ -14,37 +15,29 @@ async def replace_scheme_version_groups(
groups: list[dict], groups: list[dict],
) -> None: ) -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await session.execute( existing_result = await session.execute(
delete(SchemeGroupRecord).where( select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == scheme_version_id)
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: for item in groups:
row = SchemeGroupRecord( 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_id=scheme_id,
scheme_version_id=scheme_version_id, scheme_version_id=scheme_version_id,
element_id=item.get("id"), element_id=item.get("id"),
group_id=item.get("group_id"), group_id=item.get("group_id"),
name=item.get("group_id") or item.get("id") or "unnamed-group", name=item.get("group_id"),
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), classes_raw=str(item.get("classes")),
) )
session.add(row) session.add(row)
await session.commit() 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( async def clone_scheme_version_groups(
*, *,
source_scheme_version_id: str, source_scheme_version_id: str,
@@ -52,15 +45,12 @@ async def clone_scheme_version_groups(
) -> None: ) -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(SchemeGroupRecord).where( select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == source_scheme_version_id)
SchemeGroupRecord.scheme_version_id == source_scheme_version_id
)
) )
rows = list(result.scalars().all()) rows = list(result.scalars().all())
for row in rows: for row in rows:
session.add( cloned = SchemeGroupRecord(
SchemeGroupRecord(
group_record_id=uuid4().hex, group_record_id=uuid4().hex,
scheme_id=row.scheme_id, scheme_id=row.scheme_id,
scheme_version_id=target_scheme_version_id, scheme_version_id=target_scheme_version_id,
@@ -69,6 +59,110 @@ async def clone_scheme_version_groups(
name=row.name, name=row.name,
classes_raw=row.classes_raw, classes_raw=row.classes_raw,
) )
) session.add(cloned)
await session.commit() 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 fastapi import HTTPException, status
from sqlalchemy import asc, delete, select from sqlalchemy import asc, select
from app.db.session import AsyncSessionLocal from app.db.session import AsyncSessionLocal
from app.models.scheme_seat import SchemeSeatRecord from app.models.scheme_seat import SchemeSeatRecord
@@ -15,15 +12,17 @@ async def replace_scheme_version_seats(
seats: list[dict], seats: list[dict],
) -> None: ) -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await session.execute( existing_result = await session.execute(
delete(SchemeSeatRecord).where( select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == scheme_version_id)
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: for item in seats:
row = SchemeSeatRecord( 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_id=scheme_id,
scheme_version_id=scheme_version_id, scheme_version_id=scheme_version_id,
element_id=item.get("id"), element_id=item.get("id"),
@@ -33,7 +32,7 @@ async def replace_scheme_version_seats(
row_label=item.get("row"), row_label=item.get("row"),
seat_number=item.get("seat_number"), seat_number=item.get("seat_number"),
tag=item.get("tag"), tag=item.get("tag"),
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), classes_raw=str(item.get("classes")),
x=item.get("x"), x=item.get("x"),
y=item.get("y"), y=item.get("y"),
cx=item.get("cx"), cx=item.get("cx"),
@@ -46,12 +45,48 @@ async def replace_scheme_version_seats(
await session.commit() 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 def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(SchemeSeatRecord) select(SchemeSeatRecord)
.where(SchemeSeatRecord.scheme_version_id == scheme_version_id) .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()) return list(result.scalars().all())
@@ -79,40 +114,199 @@ async def get_scheme_version_seat_by_seat_id(
return row return row
async def clone_scheme_version_seats( async def update_scheme_version_seat_by_record_id(
*, *,
source_scheme_version_id: str, scheme_version_id: str,
target_scheme_version_id: str, seat_record_id: str,
) -> None: 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: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(SchemeSeatRecord).where( 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()) rows = list(result.scalars().all())
for row in rows: for row in rows:
session.add( row.sector_id = new_sector_id
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,
)
)
await session.commit() 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 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.db.session import AsyncSessionLocal
from app.models.scheme_sector import SchemeSectorRecord from app.models.scheme_sector import SchemeSectorRecord
from app.models.scheme_seat import SchemeSeatRecord
async def replace_scheme_version_sectors( async def replace_scheme_version_sectors(
@@ -14,37 +15,29 @@ async def replace_scheme_version_sectors(
sectors: list[dict], sectors: list[dict],
) -> None: ) -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await session.execute( existing_result = await session.execute(
delete(SchemeSectorRecord).where( select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == scheme_version_id)
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: for item in sectors:
row = SchemeSectorRecord( 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_id=scheme_id,
scheme_version_id=scheme_version_id, scheme_version_id=scheme_version_id,
element_id=item.get("id"), element_id=item.get("id"),
sector_id=item.get("sector_id"), sector_id=item.get("sector_id"),
name=item.get("sector_id") or item.get("id") or "unnamed-sector", name=item.get("sector_id"),
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False), classes_raw=str(item.get("classes")),
) )
session.add(row) session.add(row)
await session.commit() 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( async def clone_scheme_version_sectors(
*, *,
source_scheme_version_id: str, source_scheme_version_id: str,
@@ -52,15 +45,12 @@ async def clone_scheme_version_sectors(
) -> None: ) -> None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
select(SchemeSectorRecord).where( select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == source_scheme_version_id)
SchemeSectorRecord.scheme_version_id == source_scheme_version_id
)
) )
rows = list(result.scalars().all()) rows = list(result.scalars().all())
for row in rows: for row in rows:
session.add( cloned = SchemeSectorRecord(
SchemeSectorRecord(
sector_record_id=uuid4().hex, sector_record_id=uuid4().hex,
scheme_id=row.scheme_id, scheme_id=row.scheme_id,
scheme_version_id=target_scheme_version_id, scheme_version_id=target_scheme_version_id,
@@ -69,6 +59,110 @@ async def clone_scheme_version_sectors(
name=row.name, name=row.name,
classes_raw=row.classes_raw, classes_raw=row.classes_raw,
) )
) session.add(cloned)
await session.commit() 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 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): class PricingCategoryCreateRequest(BaseModel):
name: str name: str = Field(..., min_length=1, max_length=255)
code: str | None = None code: str | None = Field(default=None, max_length=128)
class PricingCategoryUpdateRequest(BaseModel): 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 name: str
code: str | None = None code: str | None
created_at: str
class PricingCategoryCreateResponse(BaseModel): class PricingCategoryCreateResponse(BaseModel):
@@ -28,98 +51,41 @@ class PricingCategoryUpdateResponse(BaseModel):
code: str | None code: str | None
class DeleteResponse(BaseModel):
status: str
class PriceRuleCreateRequest(BaseModel): class PriceRuleCreateRequest(BaseModel):
pricing_category_id: str | None = None pricing_category_id: str | None = Field(default=None, max_length=32)
target_type: str target_type: str = Field(..., pattern="^(seat|group|sector)$")
target_ref: str target_ref: str = Field(..., min_length=1, max_length=128)
amount: Decimal amount: Decimal
currency: str = "RUB" currency: str = Field(default="RUB", min_length=3, max_length=8)
@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")
@field_validator("amount") @field_validator("amount")
@classmethod @classmethod
def validate_amount(cls, value: Decimal) -> Decimal: def validate_amount(cls, value: Decimal) -> Decimal:
if value < Decimal("0.00"): return _validate_decimal_amount(value)
raise ValueError("Сумма не может быть отрицательной")
if value.quantize(Decimal("0.01")) != value:
raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой")
return value
class PriceRuleUpdateRequest(BaseModel): class PriceRuleUpdateRequest(BaseModel):
pricing_category_id: str | None = None pricing_category_id: str | None = Field(default=None, max_length=32)
target_type: str target_type: str = Field(..., pattern="^(seat|group|sector)$")
target_ref: str target_ref: str = Field(..., min_length=1, max_length=128)
amount: Decimal amount: Decimal
currency: str = "RUB" currency: str = Field(default="RUB", min_length=3, max_length=8)
@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")
@field_validator("amount") @field_validator("amount")
@classmethod @classmethod
def validate_amount(cls, value: Decimal) -> Decimal: def validate_amount(cls, value: Decimal) -> Decimal:
if value < Decimal("0.00"): return _validate_decimal_amount(value)
raise ValueError("Сумма не может быть отрицательной")
if value.quantize(Decimal("0.01")) != value:
raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой") class PriceRuleItem(BaseModel):
return value 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): class PriceRuleCreateResponse(BaseModel):
@@ -142,30 +108,6 @@ class PriceRuleUpdateResponse(BaseModel):
currency: str 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): class EffectiveSeatPriceResponse(BaseModel):
scheme_id: str scheme_id: str
scheme_version_id: str scheme_version_id: str
@@ -175,5 +117,15 @@ class EffectiveSeatPriceResponse(BaseModel):
matched_rule_level: str matched_rule_level: str
matched_target_ref: str matched_target_ref: str
pricing_category_id: str | None pricing_category_id: str | None
amount: Decimal amount: Decimal | str
currency: 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