fcbf1393f5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89 lines
3.4 KiB
Python
89 lines
3.4 KiB
Python
"""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()
|