"""products service — the import/export use-case orchestration (SD-0002 §6.5). Coordinates codec → validate → diff → repo; owns transaction boundaries (repo never commits). Preview is read-only against catalog tables (INV-11): validation writes exactly one row — the import_draft. TEL events per §9.1. """ from __future__ import annotations import time from datetime import datetime, timezone import psycopg from app.platform import telemetry from . import codec, diff, repo, validate from .errors import DraftExpired, DraftNotFound def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int, file_name: str, data: bytes) -> dict: """Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected.""" started = time.monotonic() # Commit the sweep before parsing: a FileRejected mid-parse must not roll # back expired-draft cleanup along with it. repo.sweep_expired_drafts(conn) conn.commit() parsed = codec.parse_csv(data) products = validate.build_products(parsed) catalog = repo.load_catalog(conn, storefront_id) diff_result = diff.compute_diff(catalog, products) draft = repo.insert_draft( conn, storefront_id, account_id, file_name, parsed.dialect, data, diff_result.summary, diff_result.records, diff_result.fingerprint, parsed.unknown_columns, ) conn.commit() telemetry.emit( "import_draft_created", storefront_id=storefront_id, dialect=parsed.dialect, row_count=len(parsed.rows), adds=diff_result.summary["adds"], updates=diff_result.summary["updates"], unchanged=diff_result.summary["unchanged"], errors=diff_result.summary["errors"], unknown_columns_count=len(parsed.unknown_columns), duration_ms=int((time.monotonic() - started) * 1000), ) return draft def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict: """The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3).""" row = repo.get_draft_row(conn, storefront_id, draft_id) if row is None: raise DraftNotFound() if row["expires_at"] < datetime.now(timezone.utc): repo.delete_draft(conn, storefront_id, draft_id) conn.commit() raise DraftExpired() return row def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict: """The §6.4 draft payload — never file_bytes or the full records list.""" row = _live_draft_row(conn, storefront_id, draft_id) return { "id": row["id"], "file_name": row["file_name"], "dialect": row["dialect"], "summary": row["summary"], "unknown_columns": row["unknown_columns"], "expires_at": row["expires_at"].isoformat(), } def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int, kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]: """The draft's preview records, paged, optionally filtered by kind (PUC-3).""" _live_draft_row(conn, storefront_id, draft_id) return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset) def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None: """Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a).""" repo.delete_draft(conn, storefront_id, draft_id) conn.commit()