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 app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.audit import router as audit_router
|
||||
from app.api.routes.editor import router as editor_router
|
||||
from app.api.routes.pricing import router as pricing_router
|
||||
from app.api.routes.publish import router as publish_router
|
||||
from app.api.routes.schemes import router as schemes_router
|
||||
from app.api.routes.structure import router as structure_router
|
||||
from app.api.routes.system import router as system_router
|
||||
@@ -16,3 +19,6 @@ router.include_router(structure_router)
|
||||
router.include_router(pricing_router)
|
||||
router.include_router(test_mode_router)
|
||||
router.include_router(audit_router)
|
||||
router.include_router(admin_router)
|
||||
router.include_router(editor_router)
|
||||
router.include_router(publish_router)
|
||||
|
||||
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.repositories.audit import create_audit_event
|
||||
@@ -12,33 +14,44 @@ from app.repositories.pricing import (
|
||||
update_price_rule,
|
||||
update_pricing_category,
|
||||
)
|
||||
from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot
|
||||
from app.repositories.schemes import get_scheme_record_by_scheme_id
|
||||
from app.repositories.scheme_versions import get_current_scheme_version
|
||||
from app.schemas.pricing import (
|
||||
DeleteResponse,
|
||||
PriceRuleCreateRequest,
|
||||
PriceRuleCreateResponse,
|
||||
PriceRuleItem,
|
||||
PriceRuleUpdateRequest,
|
||||
PriceRuleUpdateResponse,
|
||||
PricingBundleResponse,
|
||||
PricingCategoryCreateRequest,
|
||||
PricingCategoryCreateResponse,
|
||||
PricingCategoryItem,
|
||||
PricingCategoryUpdateRequest,
|
||||
PricingCategoryUpdateResponse,
|
||||
SchemePricingResponse,
|
||||
)
|
||||
from app.security.auth import require_api_key
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=SchemePricingResponse)
|
||||
async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key)):
|
||||
await get_scheme_record_by_scheme_id(scheme_id)
|
||||
async def _refresh_current_draft_snapshot_if_possible(scheme_id: str) -> dict | None:
|
||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||
version = await get_current_scheme_version(
|
||||
scheme_id=scheme.scheme_id,
|
||||
current_version_number=scheme.current_version_number,
|
||||
)
|
||||
if scheme.status != "draft" or version.status != "draft":
|
||||
return None
|
||||
|
||||
return await replace_scheme_version_pricing_snapshot(
|
||||
scheme_id=scheme.scheme_id,
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing", response_model=PricingBundleResponse)
|
||||
async def get_pricing_bundle(scheme_id: str, role: str = Depends(require_api_key)):
|
||||
categories = await list_pricing_categories(scheme_id)
|
||||
rules = await list_price_rules(scheme_id)
|
||||
|
||||
return SchemePricingResponse(
|
||||
return PricingBundleResponse(
|
||||
categories=[
|
||||
PricingCategoryItem(
|
||||
pricing_category_id=row.pricing_category_id,
|
||||
@@ -56,7 +69,7 @@ async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key
|
||||
pricing_category_id=row.pricing_category_id,
|
||||
target_type=row.target_type,
|
||||
target_ref=row.target_ref,
|
||||
amount=row.amount,
|
||||
amount=str(row.amount),
|
||||
currency=row.currency,
|
||||
created_at=row.created_at.isoformat(),
|
||||
)
|
||||
@@ -65,34 +78,36 @@ async def get_scheme_pricing(scheme_id: str, role: str = Depends(require_api_key
|
||||
)
|
||||
|
||||
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories", response_model=PricingCategoryCreateResponse)
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories")
|
||||
async def create_pricing_category_endpoint(
|
||||
scheme_id: str,
|
||||
payload: PricingCategoryCreateRequest,
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
await get_scheme_record_by_scheme_id(scheme_id)
|
||||
pricing_category_id = await create_pricing_category(
|
||||
scheme_id=scheme_id,
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.category.created",
|
||||
object_type="pricing_category",
|
||||
object_ref=pricing_category_id,
|
||||
details={"name": payload.name, "code": payload.code},
|
||||
)
|
||||
return PricingCategoryCreateResponse(
|
||||
pricing_category_id=pricing_category_id,
|
||||
scheme_id=scheme_id,
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
|
||||
)
|
||||
|
||||
return {
|
||||
"pricing_category_id": pricing_category_id,
|
||||
"scheme_id": scheme_id,
|
||||
"name": payload.name,
|
||||
"code": payload.code,
|
||||
}
|
||||
|
||||
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=PricingCategoryUpdateResponse)
|
||||
|
||||
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
|
||||
async def update_pricing_category_endpoint(
|
||||
scheme_id: str,
|
||||
pricing_category_id: str,
|
||||
@@ -105,53 +120,71 @@ async def update_pricing_category_endpoint(
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.category.updated",
|
||||
object_type="pricing_category",
|
||||
object_ref=pricing_category_id,
|
||||
details={"name": payload.name, "code": payload.code},
|
||||
)
|
||||
return PricingCategoryUpdateResponse(
|
||||
pricing_category_id=row.pricing_category_id,
|
||||
scheme_id=row.scheme_id,
|
||||
name=row.name,
|
||||
code=row.code,
|
||||
details={"name": payload.name, "code": payload.code, "snapshot": snapshot},
|
||||
)
|
||||
|
||||
return {
|
||||
"pricing_category_id": row.pricing_category_id,
|
||||
"scheme_id": row.scheme_id,
|
||||
"name": row.name,
|
||||
"code": row.code,
|
||||
}
|
||||
|
||||
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}", response_model=DeleteResponse)
|
||||
|
||||
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/categories/{{pricing_category_id}}")
|
||||
async def delete_pricing_category_endpoint(
|
||||
scheme_id: str,
|
||||
pricing_category_id: str,
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
await delete_pricing_category(scheme_id=scheme_id, pricing_category_id=pricing_category_id)
|
||||
await delete_pricing_category(
|
||||
scheme_id=scheme_id,
|
||||
pricing_category_id=pricing_category_id,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.category.deleted",
|
||||
object_type="pricing_category",
|
||||
object_ref=pricing_category_id,
|
||||
details=None,
|
||||
details={"snapshot": snapshot},
|
||||
)
|
||||
return DeleteResponse(status="deleted")
|
||||
|
||||
return {"deleted": True, "pricing_category_id": pricing_category_id}
|
||||
|
||||
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules", response_model=PriceRuleCreateResponse)
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules")
|
||||
async def create_price_rule_endpoint(
|
||||
scheme_id: str,
|
||||
payload: PriceRuleCreateRequest,
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
await get_scheme_record_by_scheme_id(scheme_id)
|
||||
try:
|
||||
amount = Decimal(payload.amount)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Некорректная сумма",
|
||||
)
|
||||
|
||||
price_rule_id = await create_price_rule(
|
||||
scheme_id=scheme_id,
|
||||
pricing_category_id=payload.pricing_category_id,
|
||||
target_type=payload.target_type,
|
||||
target_ref=payload.target_ref,
|
||||
amount=payload.amount,
|
||||
amount=amount,
|
||||
currency=payload.currency,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.rule.created",
|
||||
@@ -161,37 +194,49 @@ async def create_price_rule_endpoint(
|
||||
"pricing_category_id": payload.pricing_category_id,
|
||||
"target_type": payload.target_type,
|
||||
"target_ref": payload.target_ref,
|
||||
"amount": str(payload.amount),
|
||||
"amount": payload.amount,
|
||||
"currency": payload.currency,
|
||||
"snapshot": snapshot,
|
||||
},
|
||||
)
|
||||
return PriceRuleCreateResponse(
|
||||
price_rule_id=price_rule_id,
|
||||
scheme_id=scheme_id,
|
||||
pricing_category_id=payload.pricing_category_id,
|
||||
target_type=payload.target_type,
|
||||
target_ref=payload.target_ref,
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
)
|
||||
|
||||
return {
|
||||
"price_rule_id": price_rule_id,
|
||||
"scheme_id": scheme_id,
|
||||
"pricing_category_id": payload.pricing_category_id,
|
||||
"target_type": payload.target_type,
|
||||
"target_ref": payload.target_ref,
|
||||
"amount": payload.amount,
|
||||
"currency": payload.currency,
|
||||
}
|
||||
|
||||
|
||||
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=PriceRuleUpdateResponse)
|
||||
@router.put(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
|
||||
async def update_price_rule_endpoint(
|
||||
scheme_id: str,
|
||||
price_rule_id: str,
|
||||
payload: PriceRuleUpdateRequest,
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
try:
|
||||
amount = Decimal(payload.amount)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Некорректная сумма",
|
||||
)
|
||||
|
||||
row = await update_price_rule(
|
||||
scheme_id=scheme_id,
|
||||
price_rule_id=price_rule_id,
|
||||
pricing_category_id=payload.pricing_category_id,
|
||||
target_type=payload.target_type,
|
||||
target_ref=payload.target_ref,
|
||||
amount=payload.amount,
|
||||
amount=amount,
|
||||
currency=payload.currency,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.rule.updated",
|
||||
@@ -201,33 +246,41 @@ async def update_price_rule_endpoint(
|
||||
"pricing_category_id": payload.pricing_category_id,
|
||||
"target_type": payload.target_type,
|
||||
"target_ref": payload.target_ref,
|
||||
"amount": str(payload.amount),
|
||||
"amount": payload.amount,
|
||||
"currency": payload.currency,
|
||||
"snapshot": snapshot,
|
||||
},
|
||||
)
|
||||
return PriceRuleUpdateResponse(
|
||||
price_rule_id=row.price_rule_id,
|
||||
scheme_id=row.scheme_id,
|
||||
pricing_category_id=row.pricing_category_id,
|
||||
target_type=row.target_type,
|
||||
target_ref=row.target_ref,
|
||||
amount=row.amount,
|
||||
currency=row.currency,
|
||||
)
|
||||
|
||||
return {
|
||||
"price_rule_id": row.price_rule_id,
|
||||
"scheme_id": row.scheme_id,
|
||||
"pricing_category_id": row.pricing_category_id,
|
||||
"target_type": row.target_type,
|
||||
"target_ref": row.target_ref,
|
||||
"amount": str(row.amount),
|
||||
"currency": row.currency,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}", response_model=DeleteResponse)
|
||||
@router.delete(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/rules/{{price_rule_id}}")
|
||||
async def delete_price_rule_endpoint(
|
||||
scheme_id: str,
|
||||
price_rule_id: str,
|
||||
role: str = Depends(require_api_key),
|
||||
):
|
||||
await delete_price_rule(scheme_id=scheme_id, price_rule_id=price_rule_id)
|
||||
await delete_price_rule(
|
||||
scheme_id=scheme_id,
|
||||
price_rule_id=price_rule_id,
|
||||
)
|
||||
snapshot = await _refresh_current_draft_snapshot_if_possible(scheme_id)
|
||||
|
||||
await create_audit_event(
|
||||
scheme_id=scheme_id,
|
||||
event_type="pricing.rule.deleted",
|
||||
object_type="price_rule",
|
||||
object_ref=price_rule_id,
|
||||
details=None,
|
||||
details={"snapshot": snapshot},
|
||||
)
|
||||
return DeleteResponse(status="deleted")
|
||||
|
||||
return {"deleted": True, "price_rule_id": price_rule_id}
|
||||
|
||||
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,
|
||||
get_scheme_record_by_scheme_id,
|
||||
list_scheme_records,
|
||||
publish_scheme,
|
||||
rollback_scheme_to_version,
|
||||
unpublish_scheme,
|
||||
)
|
||||
@@ -34,6 +33,8 @@ from app.schemas.scheme_versions import (
|
||||
SchemeVersionListResponse,
|
||||
)
|
||||
from app.security.auth import require_api_key
|
||||
from app.services.publish_service import publish_current_draft_scheme
|
||||
from app.services.scheme_validation import build_scheme_validation_report
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -180,22 +181,27 @@ async def create_next_scheme_version_endpoint(
|
||||
)
|
||||
|
||||
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", response_model=SchemePublishResponse)
|
||||
@router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish/validation")
|
||||
async def get_publish_validation(scheme_id: str, role: str = Depends(require_api_key)):
|
||||
scheme = await get_scheme_record_by_scheme_id(scheme_id)
|
||||
version = await get_current_scheme_version(
|
||||
scheme_id=scheme.scheme_id,
|
||||
current_version_number=scheme.current_version_number,
|
||||
)
|
||||
report = await build_scheme_validation_report(
|
||||
scheme_id=scheme.scheme_id,
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
)
|
||||
return {
|
||||
"scheme_id": scheme.scheme_id,
|
||||
"scheme_version_id": version.scheme_version_id,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish")
|
||||
async def publish_scheme_endpoint(scheme_id: str, role: str = Depends(require_api_key)):
|
||||
row = await publish_scheme(scheme_id)
|
||||
await create_audit_event(
|
||||
scheme_id=row.scheme_id,
|
||||
event_type="scheme.published",
|
||||
object_type="scheme",
|
||||
object_ref=row.scheme_id,
|
||||
details={"current_version_number": row.current_version_number, "status": row.status},
|
||||
)
|
||||
return SchemePublishResponse(
|
||||
scheme_id=row.scheme_id,
|
||||
status=row.status,
|
||||
current_version_number=row.current_version_number,
|
||||
published_at=row.published_at.isoformat() if row.published_at else None,
|
||||
)
|
||||
return await publish_current_draft_scheme(scheme_id=scheme_id)
|
||||
|
||||
|
||||
@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/unpublish", response_model=SchemePublishResponse)
|
||||
|
||||
@@ -8,6 +8,7 @@ from lxml import etree
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.pricing import find_effective_price_rule
|
||||
from app.repositories.scheme_artifacts import get_latest_scheme_artifact
|
||||
from app.repositories.scheme_groups import list_scheme_version_groups
|
||||
from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, list_scheme_version_seats
|
||||
from app.repositories.scheme_sectors import list_scheme_version_sectors
|
||||
@@ -66,13 +67,29 @@ async def _load_current_context(scheme_id: str):
|
||||
return scheme, version, upload
|
||||
|
||||
|
||||
async def _generate_default_display_artifact_if_needed(scheme, version, upload) -> tuple[bytes, Path]:
|
||||
async def _load_default_display_artifact(scheme, version, upload) -> tuple[bytes, Path]:
|
||||
artifact = await get_latest_scheme_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
artifact_type="display_svg",
|
||||
artifact_variant=settings.svg_display_mode,
|
||||
)
|
||||
if artifact is not None:
|
||||
path = Path(artifact.storage_path)
|
||||
if path.exists() and path.is_file():
|
||||
return path.read_bytes(), path
|
||||
|
||||
if version.display_svg_status == "ready" and version.display_svg_storage_path:
|
||||
path = Path(version.display_svg_storage_path)
|
||||
if path.exists() and path.is_file():
|
||||
return path.read_bytes(), path
|
||||
|
||||
sanitized_path = Path(upload.sanitized_storage_path)
|
||||
sanitized_artifact = await get_latest_scheme_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
artifact_type="sanitized_svg",
|
||||
artifact_variant="source",
|
||||
)
|
||||
sanitized_path = Path(sanitized_artifact.storage_path if sanitized_artifact else upload.sanitized_storage_path)
|
||||
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
if version.display_svg_status == "pending":
|
||||
raise HTTPException(
|
||||
@@ -106,6 +123,16 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload)
|
||||
)
|
||||
display_path = Path(display_path_str)
|
||||
|
||||
from app.repositories.scheme_artifacts import create_scheme_artifact
|
||||
await create_scheme_artifact(
|
||||
scheme_id=scheme.scheme_id,
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
artifact_type="display_svg",
|
||||
artifact_variant=settings.svg_display_mode,
|
||||
storage_path=display_path_str,
|
||||
status="ready",
|
||||
meta_json=meta,
|
||||
)
|
||||
await update_scheme_version_display_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
display_svg_storage_path=display_path_str,
|
||||
@@ -113,14 +140,6 @@ async def _generate_default_display_artifact_if_needed(scheme, version, upload)
|
||||
display_svg_generated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"display_svg.lazy_generate scheme_id=%s scheme_version_id=%s mode=%s view_box=%s",
|
||||
scheme.scheme_id,
|
||||
version.scheme_version_id,
|
||||
settings.svg_display_mode,
|
||||
meta.get("view_box"),
|
||||
)
|
||||
|
||||
return display_bytes, display_path
|
||||
|
||||
|
||||
@@ -253,11 +272,16 @@ async def get_scheme_current_display_svg(
|
||||
scheme, version, upload = await _load_current_context(scheme_id)
|
||||
|
||||
if resolved_mode == settings.svg_display_mode:
|
||||
display_bytes, display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload)
|
||||
_display_bytes, display_path = await _load_default_display_artifact(scheme, version, upload)
|
||||
filename = f"{scheme.name or scheme.scheme_id}.{resolved_mode}.svg"
|
||||
return FileResponse(path=display_path, media_type="image/svg+xml", filename=filename)
|
||||
|
||||
sanitized_path = Path(upload.sanitized_storage_path)
|
||||
artifact = await get_latest_scheme_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
artifact_type="sanitized_svg",
|
||||
artifact_variant="source",
|
||||
)
|
||||
sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path)
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -292,11 +316,16 @@ async def get_scheme_current_display_svg_meta(
|
||||
scheme, version, upload = await _load_current_context(scheme_id)
|
||||
|
||||
if resolved_mode == settings.svg_display_mode:
|
||||
display_bytes, _display_path = await _generate_default_display_artifact_if_needed(scheme, version, upload)
|
||||
display_bytes, _display_path = await _load_default_display_artifact(scheme, version, upload)
|
||||
meta = _parse_svg_meta_from_bytes(display_bytes)
|
||||
generated_at = version.display_svg_generated_at
|
||||
else:
|
||||
sanitized_path = Path(upload.sanitized_storage_path)
|
||||
artifact = await get_latest_scheme_artifact(
|
||||
scheme_version_id=version.scheme_version_id,
|
||||
artifact_type="sanitized_svg",
|
||||
artifact_variant="source",
|
||||
)
|
||||
sanitized_path = Path(artifact.storage_path if artifact else upload.sanitized_storage_path)
|
||||
if not sanitized_path.exists() or not sanitized_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -305,7 +334,7 @@ async def get_scheme_current_display_svg_meta(
|
||||
|
||||
sanitized_bytes = sanitized_path.read_bytes()
|
||||
try:
|
||||
display_bytes, meta = generate_display_svg(sanitized_bytes, resolved_mode)
|
||||
display_bytes, _meta = generate_display_svg(sanitized_bytes, resolved_mode)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"display_svg.meta_on_demand failed scheme_id=%s scheme_version_id=%s mode=%s",
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.scheme_artifacts import create_scheme_artifact
|
||||
from app.repositories.scheme_groups import replace_scheme_version_groups
|
||||
from app.repositories.scheme_seats import replace_scheme_version_seats
|
||||
from app.repositories.scheme_sectors import replace_scheme_version_sectors
|
||||
@@ -222,6 +223,33 @@ async def upload_scheme_svg(
|
||||
display_svg_generated_at=display_svg_generated_at,
|
||||
)
|
||||
|
||||
await create_scheme_artifact(
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
artifact_type="sanitized_svg",
|
||||
artifact_variant="source",
|
||||
storage_path=sanitized_storage_path,
|
||||
status="ready",
|
||||
)
|
||||
await create_scheme_artifact(
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
artifact_type="normalized_json",
|
||||
artifact_variant="default",
|
||||
storage_path=normalized_storage_path,
|
||||
status="ready",
|
||||
)
|
||||
if display_svg_storage_path:
|
||||
await create_scheme_artifact(
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
artifact_type="display_svg",
|
||||
artifact_variant=settings.svg_display_mode,
|
||||
storage_path=display_svg_storage_path,
|
||||
status="ready",
|
||||
meta_json=display_meta,
|
||||
)
|
||||
|
||||
normalized_payload_from_file = read_normalized_payload_from_path(normalized_storage_path)
|
||||
|
||||
await replace_scheme_version_sectors(
|
||||
|
||||
@@ -74,5 +74,9 @@ class Settings(BaseSettings):
|
||||
def storage_display_dir(self) -> str:
|
||||
return f"{self.storage_root_dir}/display"
|
||||
|
||||
@property
|
||||
def storage_preview_dir(self) -> str:
|
||||
return f"{self.storage_root_dir}/preview"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _ensure_unique_price_rule_target(
|
||||
*,
|
||||
session,
|
||||
scheme_id: str,
|
||||
target_type: str,
|
||||
target_ref: str,
|
||||
exclude_price_rule_id: str | None = None,
|
||||
) -> None:
|
||||
stmt = select(PriceRuleRecord).where(
|
||||
PriceRuleRecord.scheme_id == scheme_id,
|
||||
PriceRuleRecord.target_type == target_type,
|
||||
PriceRuleRecord.target_ref == target_ref,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return
|
||||
|
||||
if exclude_price_rule_id and row.price_rule_id == exclude_price_rule_id:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Pricing rule already exists for this target",
|
||||
)
|
||||
|
||||
|
||||
async def create_pricing_category(
|
||||
*,
|
||||
scheme_id: str,
|
||||
@@ -96,6 +124,13 @@ async def create_price_rule(
|
||||
price_rule_id = uuid4().hex
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
await _ensure_unique_price_rule_target(
|
||||
session=session,
|
||||
scheme_id=scheme_id,
|
||||
target_type=target_type,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
|
||||
row = PriceRuleRecord(
|
||||
price_rule_id=price_rule_id,
|
||||
scheme_id=scheme_id,
|
||||
@@ -136,6 +171,14 @@ async def update_price_rule(
|
||||
detail="Price rule not found",
|
||||
)
|
||||
|
||||
await _ensure_unique_price_rule_target(
|
||||
session=session,
|
||||
scheme_id=scheme_id,
|
||||
target_type=target_type,
|
||||
target_ref=target_ref,
|
||||
exclude_price_rule_id=price_rule_id,
|
||||
)
|
||||
|
||||
row.pricing_category_id = pricing_category_id
|
||||
row.target_type = target_type
|
||||
row.target_ref = target_ref
|
||||
|
||||
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 sqlalchemy import asc, delete, select
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import asc, select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.scheme_group import SchemeGroupRecord
|
||||
from app.models.scheme_seat import SchemeSeatRecord
|
||||
|
||||
|
||||
async def replace_scheme_version_groups(
|
||||
@@ -14,37 +15,29 @@ async def replace_scheme_version_groups(
|
||||
groups: list[dict],
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
delete(SchemeGroupRecord).where(
|
||||
SchemeGroupRecord.scheme_version_id == scheme_version_id
|
||||
)
|
||||
existing_result = await session.execute(
|
||||
select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == scheme_version_id)
|
||||
)
|
||||
existing_rows = list(existing_result.scalars().all())
|
||||
|
||||
for row in existing_rows:
|
||||
await session.delete(row)
|
||||
|
||||
for item in groups:
|
||||
row = SchemeGroupRecord(
|
||||
group_record_id=uuid4().hex,
|
||||
group_record_id=item["group_record_id"] if "group_record_id" in item and item["group_record_id"] else uuid4().hex,
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
element_id=item.get("id"),
|
||||
group_id=item.get("group_id"),
|
||||
name=item.get("group_id") or item.get("id") or "unnamed-group",
|
||||
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False),
|
||||
name=item.get("group_id"),
|
||||
classes_raw=str(item.get("classes")),
|
||||
)
|
||||
session.add(row)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeGroupRecord)
|
||||
.where(SchemeGroupRecord.scheme_version_id == scheme_version_id)
|
||||
.order_by(asc(SchemeGroupRecord.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def clone_scheme_version_groups(
|
||||
*,
|
||||
source_scheme_version_id: str,
|
||||
@@ -52,23 +45,124 @@ async def clone_scheme_version_groups(
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeGroupRecord).where(
|
||||
SchemeGroupRecord.scheme_version_id == source_scheme_version_id
|
||||
)
|
||||
select(SchemeGroupRecord).where(SchemeGroupRecord.scheme_version_id == source_scheme_version_id)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
session.add(
|
||||
SchemeGroupRecord(
|
||||
group_record_id=uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
group_id=row.group_id,
|
||||
name=row.name,
|
||||
classes_raw=row.classes_raw,
|
||||
)
|
||||
cloned = SchemeGroupRecord(
|
||||
group_record_id=uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
group_id=row.group_id,
|
||||
name=row.name,
|
||||
classes_raw=row.classes_raw,
|
||||
)
|
||||
session.add(cloned)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_scheme_version_groups(scheme_version_id: str) -> list[SchemeGroupRecord]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeGroupRecord)
|
||||
.where(SchemeGroupRecord.scheme_version_id == scheme_version_id)
|
||||
.order_by(asc(SchemeGroupRecord.created_at), asc(SchemeGroupRecord.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_scheme_version_group_by_record_id(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
group_record_id: str,
|
||||
group_id: str | None,
|
||||
name: str | None,
|
||||
) -> tuple[SchemeGroupRecord, str | None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeGroupRecord).where(
|
||||
SchemeGroupRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeGroupRecord.group_record_id == group_record_id,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Group record not found in current draft version",
|
||||
)
|
||||
|
||||
old_group_id = row.group_id
|
||||
row.group_id = group_id
|
||||
row.name = name
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row, old_group_id
|
||||
|
||||
|
||||
async def create_scheme_version_group(
|
||||
*,
|
||||
scheme_id: str,
|
||||
scheme_version_id: str,
|
||||
element_id: str | None,
|
||||
group_id: str,
|
||||
name: str | None,
|
||||
classes_raw: str | None,
|
||||
) -> SchemeGroupRecord:
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = SchemeGroupRecord(
|
||||
group_record_id=uuid4().hex,
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
element_id=element_id,
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
classes_raw=classes_raw,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_scheme_version_group_by_record_id(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
group_record_id: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
group_result = await session.execute(
|
||||
select(SchemeGroupRecord).where(
|
||||
SchemeGroupRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeGroupRecord.group_record_id == group_record_id,
|
||||
)
|
||||
)
|
||||
group = group_result.scalar_one_or_none()
|
||||
|
||||
if group is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Group record not found in current draft version",
|
||||
)
|
||||
|
||||
if group.group_id:
|
||||
seats_result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.group_id == group.group_id,
|
||||
)
|
||||
)
|
||||
seats = list(seats_result.scalars().all())
|
||||
if seats:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete group while seats still reference it",
|
||||
)
|
||||
|
||||
await session.delete(group)
|
||||
await session.commit()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import asc, delete, select
|
||||
from sqlalchemy import asc, select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.scheme_seat import SchemeSeatRecord
|
||||
@@ -15,15 +12,17 @@ async def replace_scheme_version_seats(
|
||||
seats: list[dict],
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
delete(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id
|
||||
)
|
||||
existing_result = await session.execute(
|
||||
select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == scheme_version_id)
|
||||
)
|
||||
existing_rows = list(existing_result.scalars().all())
|
||||
|
||||
for row in existing_rows:
|
||||
await session.delete(row)
|
||||
|
||||
for item in seats:
|
||||
row = SchemeSeatRecord(
|
||||
seat_record_id=uuid4().hex,
|
||||
seat_record_id=item["seat_record_id"] if "seat_record_id" in item and item["seat_record_id"] else __import__("uuid").uuid4().hex,
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
element_id=item.get("id"),
|
||||
@@ -33,7 +32,7 @@ async def replace_scheme_version_seats(
|
||||
row_label=item.get("row"),
|
||||
seat_number=item.get("seat_number"),
|
||||
tag=item.get("tag"),
|
||||
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False),
|
||||
classes_raw=str(item.get("classes")),
|
||||
x=item.get("x"),
|
||||
y=item.get("y"),
|
||||
cx=item.get("cx"),
|
||||
@@ -46,12 +45,48 @@ async def replace_scheme_version_seats(
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def clone_scheme_version_seats(
|
||||
*,
|
||||
source_scheme_version_id: str,
|
||||
target_scheme_version_id: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(SchemeSeatRecord.scheme_version_id == source_scheme_version_id)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
cloned = SchemeSeatRecord(
|
||||
seat_record_id=__import__("uuid").uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
seat_id=row.seat_id,
|
||||
sector_id=row.sector_id,
|
||||
group_id=row.group_id,
|
||||
row_label=row.row_label,
|
||||
seat_number=row.seat_number,
|
||||
tag=row.tag,
|
||||
classes_raw=row.classes_raw,
|
||||
x=row.x,
|
||||
y=row.y,
|
||||
cx=row.cx,
|
||||
cy=row.cy,
|
||||
width=row.width,
|
||||
height=row.height,
|
||||
)
|
||||
session.add(cloned)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_scheme_version_seats(scheme_version_id: str) -> list[SchemeSeatRecord]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord)
|
||||
.where(SchemeSeatRecord.scheme_version_id == scheme_version_id)
|
||||
.order_by(asc(SchemeSeatRecord.id))
|
||||
.order_by(asc(SchemeSeatRecord.created_at), asc(SchemeSeatRecord.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -79,40 +114,199 @@ async def get_scheme_version_seat_by_seat_id(
|
||||
return row
|
||||
|
||||
|
||||
async def clone_scheme_version_seats(
|
||||
async def update_scheme_version_seat_by_record_id(
|
||||
*,
|
||||
source_scheme_version_id: str,
|
||||
target_scheme_version_id: str,
|
||||
) -> None:
|
||||
scheme_version_id: str,
|
||||
seat_record_id: str,
|
||||
seat_id: str | None,
|
||||
sector_id: str | None,
|
||||
group_id: str | None,
|
||||
row_label: str | None,
|
||||
seat_number: str | None,
|
||||
) -> SchemeSeatRecord:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == source_scheme_version_id
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.seat_record_id == seat_record_id,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Seat record not found in current draft version",
|
||||
)
|
||||
|
||||
row.seat_id = seat_id
|
||||
row.sector_id = sector_id
|
||||
row.group_id = group_id
|
||||
row.row_label = row_label
|
||||
row.seat_number = seat_number
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def bulk_update_scheme_version_seats_by_record_id(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
items: list[dict],
|
||||
) -> list[SchemeSeatRecord]:
|
||||
updated_rows: list[SchemeSeatRecord] = []
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
for item in items:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.seat_record_id == item["seat_record_id"],
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Seat record not found in current draft version: {item['seat_record_id']}",
|
||||
)
|
||||
|
||||
row.seat_id = item.get("seat_id")
|
||||
row.sector_id = item.get("sector_id")
|
||||
row.group_id = item.get("group_id")
|
||||
row.row_label = item.get("row_label")
|
||||
row.seat_number = item.get("seat_number")
|
||||
updated_rows.append(row)
|
||||
|
||||
await session.commit()
|
||||
|
||||
for row in updated_rows:
|
||||
await session.refresh(row)
|
||||
|
||||
return updated_rows
|
||||
|
||||
|
||||
async def bulk_remap_scheme_version_seats(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
items: list[dict],
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for item in items:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.seat_record_id == item["seat_record_id"],
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Seat record not found in current draft version: {item['seat_record_id']}",
|
||||
)
|
||||
|
||||
row.sector_id = item["after_sector_id"]
|
||||
row.group_id = item["after_group_id"]
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def cascade_update_seat_sector_reference(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
old_sector_id: str | None,
|
||||
new_sector_id: str | None,
|
||||
) -> int:
|
||||
if not old_sector_id or old_sector_id == new_sector_id:
|
||||
return 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.sector_id == old_sector_id,
|
||||
)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
session.add(
|
||||
SchemeSeatRecord(
|
||||
seat_record_id=uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
seat_id=row.seat_id,
|
||||
sector_id=row.sector_id,
|
||||
group_id=row.group_id,
|
||||
row_label=row.row_label,
|
||||
seat_number=row.seat_number,
|
||||
tag=row.tag,
|
||||
classes_raw=row.classes_raw,
|
||||
x=row.x,
|
||||
y=row.y,
|
||||
cx=row.cx,
|
||||
cy=row.cy,
|
||||
width=row.width,
|
||||
height=row.height,
|
||||
)
|
||||
)
|
||||
row.sector_id = new_sector_id
|
||||
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def cascade_update_seat_group_reference(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
old_group_id: str | None,
|
||||
new_group_id: str | None,
|
||||
) -> int:
|
||||
if not old_group_id or old_group_id == new_group_id:
|
||||
return 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.group_id == old_group_id,
|
||||
)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
row.group_id = new_group_id
|
||||
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def repair_orphan_sector_refs(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
new_sector_id: str,
|
||||
orphan_values: list[str],
|
||||
) -> int:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id
|
||||
)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
changed = 0
|
||||
for row in rows:
|
||||
if row.sector_id in orphan_values:
|
||||
row.sector_id = new_sector_id
|
||||
changed += 1
|
||||
|
||||
await session.commit()
|
||||
return changed
|
||||
|
||||
|
||||
async def repair_orphan_group_refs(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
new_group_id: str,
|
||||
orphan_values: list[str],
|
||||
) -> int:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id
|
||||
)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
changed = 0
|
||||
for row in rows:
|
||||
if row.group_id in orphan_values:
|
||||
row.group_id = new_group_id
|
||||
changed += 1
|
||||
|
||||
await session.commit()
|
||||
return changed
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import asc, delete, select
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import asc, select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.scheme_sector import SchemeSectorRecord
|
||||
from app.models.scheme_seat import SchemeSeatRecord
|
||||
|
||||
|
||||
async def replace_scheme_version_sectors(
|
||||
@@ -14,37 +15,29 @@ async def replace_scheme_version_sectors(
|
||||
sectors: list[dict],
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
delete(SchemeSectorRecord).where(
|
||||
SchemeSectorRecord.scheme_version_id == scheme_version_id
|
||||
)
|
||||
existing_result = await session.execute(
|
||||
select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == scheme_version_id)
|
||||
)
|
||||
existing_rows = list(existing_result.scalars().all())
|
||||
|
||||
for row in existing_rows:
|
||||
await session.delete(row)
|
||||
|
||||
for item in sectors:
|
||||
row = SchemeSectorRecord(
|
||||
sector_record_id=uuid4().hex,
|
||||
sector_record_id=item["sector_record_id"] if "sector_record_id" in item and item["sector_record_id"] else uuid4().hex,
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
element_id=item.get("id"),
|
||||
sector_id=item.get("sector_id"),
|
||||
name=item.get("sector_id") or item.get("id") or "unnamed-sector",
|
||||
classes_raw=json.dumps(item.get("classes", []), ensure_ascii=False),
|
||||
name=item.get("sector_id"),
|
||||
classes_raw=str(item.get("classes")),
|
||||
)
|
||||
session.add(row)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSectorRecord)
|
||||
.where(SchemeSectorRecord.scheme_version_id == scheme_version_id)
|
||||
.order_by(asc(SchemeSectorRecord.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def clone_scheme_version_sectors(
|
||||
*,
|
||||
source_scheme_version_id: str,
|
||||
@@ -52,23 +45,124 @@ async def clone_scheme_version_sectors(
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSectorRecord).where(
|
||||
SchemeSectorRecord.scheme_version_id == source_scheme_version_id
|
||||
)
|
||||
select(SchemeSectorRecord).where(SchemeSectorRecord.scheme_version_id == source_scheme_version_id)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
for row in rows:
|
||||
session.add(
|
||||
SchemeSectorRecord(
|
||||
sector_record_id=uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
sector_id=row.sector_id,
|
||||
name=row.name,
|
||||
classes_raw=row.classes_raw,
|
||||
)
|
||||
cloned = SchemeSectorRecord(
|
||||
sector_record_id=uuid4().hex,
|
||||
scheme_id=row.scheme_id,
|
||||
scheme_version_id=target_scheme_version_id,
|
||||
element_id=row.element_id,
|
||||
sector_id=row.sector_id,
|
||||
name=row.name,
|
||||
classes_raw=row.classes_raw,
|
||||
)
|
||||
session.add(cloned)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def list_scheme_version_sectors(scheme_version_id: str) -> list[SchemeSectorRecord]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSectorRecord)
|
||||
.where(SchemeSectorRecord.scheme_version_id == scheme_version_id)
|
||||
.order_by(asc(SchemeSectorRecord.created_at), asc(SchemeSectorRecord.id))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_scheme_version_sector_by_record_id(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
sector_record_id: str,
|
||||
sector_id: str | None,
|
||||
name: str | None,
|
||||
) -> tuple[SchemeSectorRecord, str | None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(SchemeSectorRecord).where(
|
||||
SchemeSectorRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSectorRecord.sector_record_id == sector_record_id,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Sector record not found in current draft version",
|
||||
)
|
||||
|
||||
old_sector_id = row.sector_id
|
||||
row.sector_id = sector_id
|
||||
row.name = name
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row, old_sector_id
|
||||
|
||||
|
||||
async def create_scheme_version_sector(
|
||||
*,
|
||||
scheme_id: str,
|
||||
scheme_version_id: str,
|
||||
element_id: str | None,
|
||||
sector_id: str,
|
||||
name: str | None,
|
||||
classes_raw: str | None,
|
||||
) -> SchemeSectorRecord:
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = SchemeSectorRecord(
|
||||
sector_record_id=uuid4().hex,
|
||||
scheme_id=scheme_id,
|
||||
scheme_version_id=scheme_version_id,
|
||||
element_id=element_id,
|
||||
sector_id=sector_id,
|
||||
name=name,
|
||||
classes_raw=classes_raw,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_scheme_version_sector_by_record_id(
|
||||
*,
|
||||
scheme_version_id: str,
|
||||
sector_record_id: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
sector_result = await session.execute(
|
||||
select(SchemeSectorRecord).where(
|
||||
SchemeSectorRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSectorRecord.sector_record_id == sector_record_id,
|
||||
)
|
||||
)
|
||||
sector = sector_result.scalar_one_or_none()
|
||||
|
||||
if sector is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Sector record not found in current draft version",
|
||||
)
|
||||
|
||||
if sector.sector_id:
|
||||
seats_result = await session.execute(
|
||||
select(SchemeSeatRecord).where(
|
||||
SchemeSeatRecord.scheme_version_id == scheme_version_id,
|
||||
SchemeSeatRecord.sector_id == sector.sector_id,
|
||||
)
|
||||
)
|
||||
seats = list(seats_result.scalars().all())
|
||||
if seats:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete sector while seats still reference it",
|
||||
)
|
||||
|
||||
await session.delete(sector)
|
||||
await session.commit()
|
||||
|
||||
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 typing import List
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _validate_decimal_amount(value: Decimal) -> Decimal:
|
||||
try:
|
||||
normalized = Decimal(value)
|
||||
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||
raise ValueError("Некорректная сумма") from exc
|
||||
|
||||
if not normalized.is_finite():
|
||||
raise ValueError("Некорректная сумма")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class PricingCategoryCreateRequest(BaseModel):
|
||||
name: str
|
||||
code: str | None = None
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
code: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class PricingCategoryUpdateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
code: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class PricingCategoryItem(BaseModel):
|
||||
pricing_category_id: str
|
||||
scheme_id: str
|
||||
name: str
|
||||
code: str | None = None
|
||||
code: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class PricingCategoryCreateResponse(BaseModel):
|
||||
@@ -28,98 +51,41 @@ class PricingCategoryUpdateResponse(BaseModel):
|
||||
code: str | None
|
||||
|
||||
|
||||
class DeleteResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class PriceRuleCreateRequest(BaseModel):
|
||||
pricing_category_id: str | None = None
|
||||
target_type: str
|
||||
target_ref: str
|
||||
pricing_category_id: str | None = Field(default=None, max_length=32)
|
||||
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
||||
target_ref: str = Field(..., min_length=1, max_length=128)
|
||||
amount: Decimal
|
||||
currency: str = "RUB"
|
||||
|
||||
@field_validator("target_type")
|
||||
@classmethod
|
||||
def validate_target_type(cls, value: str) -> str:
|
||||
allowed = {"sector", "group", "seat"}
|
||||
if value not in allowed:
|
||||
raise ValueError("Поле target_type должно быть одним из: sector, group, seat")
|
||||
return value
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def validate_currency(cls, value: str) -> str:
|
||||
if value != "RUB":
|
||||
raise ValueError("В v1 поддерживается только валюта RUB")
|
||||
return value
|
||||
|
||||
@field_validator("amount", mode="before")
|
||||
@classmethod
|
||||
def parse_amount(cls, value):
|
||||
if value is None:
|
||||
raise ValueError("Поле amount обязательно")
|
||||
text = str(value).strip()
|
||||
if text == "":
|
||||
raise ValueError("Поле amount обязательно")
|
||||
try:
|
||||
return Decimal(text)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValueError("Некорректная сумма. Используйте формат 2500.00")
|
||||
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
||||
|
||||
@field_validator("amount")
|
||||
@classmethod
|
||||
def validate_amount(cls, value: Decimal) -> Decimal:
|
||||
if value < Decimal("0.00"):
|
||||
raise ValueError("Сумма не может быть отрицательной")
|
||||
if value.quantize(Decimal("0.01")) != value:
|
||||
raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой")
|
||||
return value
|
||||
return _validate_decimal_amount(value)
|
||||
|
||||
|
||||
class PriceRuleUpdateRequest(BaseModel):
|
||||
pricing_category_id: str | None = None
|
||||
target_type: str
|
||||
target_ref: str
|
||||
pricing_category_id: str | None = Field(default=None, max_length=32)
|
||||
target_type: str = Field(..., pattern="^(seat|group|sector)$")
|
||||
target_ref: str = Field(..., min_length=1, max_length=128)
|
||||
amount: Decimal
|
||||
currency: str = "RUB"
|
||||
|
||||
@field_validator("target_type")
|
||||
@classmethod
|
||||
def validate_target_type(cls, value: str) -> str:
|
||||
allowed = {"sector", "group", "seat"}
|
||||
if value not in allowed:
|
||||
raise ValueError("Поле target_type должно быть одним из: sector, group, seat")
|
||||
return value
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def validate_currency(cls, value: str) -> str:
|
||||
if value != "RUB":
|
||||
raise ValueError("В v1 поддерживается только валюта RUB")
|
||||
return value
|
||||
|
||||
@field_validator("amount", mode="before")
|
||||
@classmethod
|
||||
def parse_amount(cls, value):
|
||||
if value is None:
|
||||
raise ValueError("Поле amount обязательно")
|
||||
text = str(value).strip()
|
||||
if text == "":
|
||||
raise ValueError("Поле amount обязательно")
|
||||
try:
|
||||
return Decimal(text)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValueError("Некорректная сумма. Используйте формат 2500.00")
|
||||
currency: str = Field(default="RUB", min_length=3, max_length=8)
|
||||
|
||||
@field_validator("amount")
|
||||
@classmethod
|
||||
def validate_amount(cls, value: Decimal) -> Decimal:
|
||||
if value < Decimal("0.00"):
|
||||
raise ValueError("Сумма не может быть отрицательной")
|
||||
if value.quantize(Decimal("0.01")) != value:
|
||||
raise ValueError("Сумма должна быть с точностью до 2 знаков после запятой")
|
||||
return value
|
||||
return _validate_decimal_amount(value)
|
||||
|
||||
|
||||
class PriceRuleItem(BaseModel):
|
||||
price_rule_id: str
|
||||
scheme_id: str
|
||||
pricing_category_id: str | None
|
||||
target_type: str
|
||||
target_ref: str
|
||||
amount: Decimal | str
|
||||
currency: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class PriceRuleCreateResponse(BaseModel):
|
||||
@@ -142,30 +108,6 @@ class PriceRuleUpdateResponse(BaseModel):
|
||||
currency: str
|
||||
|
||||
|
||||
class PricingCategoryItem(BaseModel):
|
||||
pricing_category_id: str
|
||||
scheme_id: str
|
||||
name: str
|
||||
code: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class PriceRuleItem(BaseModel):
|
||||
price_rule_id: str
|
||||
scheme_id: str
|
||||
pricing_category_id: str | None
|
||||
target_type: str
|
||||
target_ref: str
|
||||
amount: Decimal
|
||||
currency: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class SchemePricingResponse(BaseModel):
|
||||
categories: List[PricingCategoryItem]
|
||||
rules: List[PriceRuleItem]
|
||||
|
||||
|
||||
class EffectiveSeatPriceResponse(BaseModel):
|
||||
scheme_id: str
|
||||
scheme_version_id: str
|
||||
@@ -175,5 +117,15 @@ class EffectiveSeatPriceResponse(BaseModel):
|
||||
matched_rule_level: str
|
||||
matched_target_ref: str
|
||||
pricing_category_id: str | None
|
||||
amount: Decimal
|
||||
amount: Decimal | str
|
||||
currency: str
|
||||
|
||||
|
||||
class SchemePricingResponse(BaseModel):
|
||||
categories: list[PricingCategoryItem]
|
||||
rules: list[PriceRuleItem]
|
||||
|
||||
|
||||
class PricingBundleResponse(BaseModel):
|
||||
categories: list[PricingCategoryItem]
|
||||
rules: list[PriceRuleItem]
|
||||
|
||||
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