feat(products): import_validate → draft, preview records, discard + TEL-1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,9 +16,11 @@ from .errors import (
|
||||
RunNotFound,
|
||||
)
|
||||
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||
from .service import discard_draft, get_draft, get_draft_records, import_validate
|
||||
|
||||
__all__ = [
|
||||
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||
"PreviewStale", "NothingToApply", "RunNotFound",
|
||||
"MAX_DATA_ROWS", "MAX_FILE_BYTES",
|
||||
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
|
||||
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
|
||||
catalog content, or secret bytes (§6.3-handbook)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger("ecomm.telemetry")
|
||||
|
||||
|
||||
def emit(event: str, **fields: object) -> None:
|
||||
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
|
||||
Reference in New Issue
Block a user