Implement display artifacts, pricing integrity, draft base and publish preview bundle
This commit is contained in:
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
162
backend/app/api/routes/admin.py
Normal file
162
backend/app/api/routes/admin.py
Normal 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,
|
||||||
|
}
|
||||||
517
backend/app/api/routes/editor.py
Normal file
517
backend/app/api/routes/editor.py
Normal 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"],
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
|||||||
132
backend/app/api/routes/publish.py
Normal file
132
backend/app/api/routes/publish.py
Normal 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],
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
38
backend/app/models/scheme_artifact.py
Normal file
38
backend/app/models/scheme_artifact.py
Normal 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(),
|
||||||
|
)
|
||||||
36
backend/app/models/scheme_version_pricing.py
Normal file
36
backend/app/models/scheme_version_pricing.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
101
backend/app/repositories/scheme_artifacts.py
Normal file
101
backend/app/repositories/scheme_artifacts.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
144
backend/app/repositories/scheme_version_pricing.py
Normal file
144
backend/app/repositories/scheme_version_pricing.py
Normal 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",
|
||||||
|
)
|
||||||
194
backend/app/schemas/editor.py
Normal file
194
backend/app/schemas/editor.py
Normal 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]
|
||||||
@@ -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]
|
||||||
|
|||||||
50
backend/app/schemas/publish_preview.py
Normal file
50
backend/app/schemas/publish_preview.py
Normal 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
|
||||||
45
backend/app/services/baseline_selector.py
Normal file
45
backend/app/services/baseline_selector.py
Normal 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"
|
||||||
89
backend/app/services/display_regenerator.py
Normal file
89
backend/app/services/display_regenerator.py
Normal 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(),
|
||||||
|
}
|
||||||
20
backend/app/services/draft_guard.py
Normal file
20
backend/app/services/draft_guard.py
Normal 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
|
||||||
96
backend/app/services/editor_validation.py
Normal file
96
backend/app/services/editor_validation.py
Normal 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}",
|
||||||
|
)
|
||||||
135
backend/app/services/publish_preview.py
Normal file
135
backend/app/services/publish_preview.py
Normal 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
|
||||||
59
backend/app/services/publish_preview_cache.py
Normal file
59
backend/app/services/publish_preview_cache.py
Normal 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]
|
||||||
65
backend/app/services/publish_service.py
Normal file
65
backend/app/services/publish_service.py
Normal 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"],
|
||||||
|
}
|
||||||
95
backend/app/services/remap_service.py
Normal file
95
backend/app/services/remap_service.py
Normal 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
|
||||||
107
backend/app/services/scheme_validation.py
Normal file
107
backend/app/services/scheme_validation.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
130
backend/app/services/structure_diff.py
Normal file
130
backend/app/services/structure_diff.py
Normal 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,
|
||||||
|
}
|
||||||
62
backend/app/services/structure_sync.py
Normal file
62
backend/app/services/structure_sync.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
34
backend/docs/roadmap-status.md
Normal file
34
backend/docs/roadmap-status.md
Normal 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
|
||||||
Reference in New Issue
Block a user