Implement display artifacts, pricing integrity, draft base and publish preview bundle
This commit is contained in:
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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user