# Sale Import — function reference How Barker's sales become brewman Sale Vouchers: what every function does, what it takes, what it returns, and what it writes. The code docstrings are the primary source; this page is the map. ## Pipeline ``` routers/sales_import.py thin HTTP layer (auth + permission only) └─ services/sales_import/ the subsystem (this package) __init__.py public surface: preview, execute, load/save mapping, cost_centres service.py orchestration: fetch → map categories → plan → apply → shape schema products.py provisioning: which brewman product/SKU does a Barker line mean? vouchers.py planning + persistence: planned vouchers, signatures, write/delete barker_client.py adapter: Barker's /api/export/sales → typed payloads ``` Dependency direction: `service → vouchers → products`, and `service → products`. Nothing imports upward, `__init__.py` re-exports only the public five, and the mapping table (`barker_products`) is written only inside `products.py`. ## The three kinds of logic Every product/SKU function mixes three concerns; knowing which one you are looking at is half the reading: 1. **Identity policy** (ADR-0004) — *what a Barker line means*: the standing mapping, once made, is served verbatim; a Barker product's other SKUs bind next; the slugified name is only the resolver for a product never mapped before. 2. **Normalization + validity mechanics** — *how matching is decided*: names and units are compared in slug form; "active" means the version's date range covers the business date. 3. **Session discipline** — *incidental machinery*: the session runs with `autoflush=False`, so every write flushes before returning or later lookups in the same run won't see it. ## Invariants worth remembering - **`autoflush=False`**: `db.add(...)` is invisible until `db.flush()`. Provisioning writes flush before returning; a failed run rolls back whole (`get_session`), so `execute`'s single `db.commit()` is the only durable write. - **The mapping is standing truth**: once a Barker SKU has a mapping row, it is served verbatim forever. Barker renames and units changes after first mapping are ignored — the drift is visible in the preview (which shows Barker's names) and fixed by editing brewman by hand. - **Slug identity**: names and units match through `_normalize` (trim, casefold, punctuation stripped, whitespace/underscores → hyphens). "Butter Chicken", "Butter-Chicken" and "butter chicken" are one product. The stored `handle` is never read. - **Ambiguity fails hard**: if a slug matches more than one active product (only possible via manual brewman edits), the import raises 409 naming the candidates — no guessing. - **Creation cannot clash**: branch 3 creates only when no active version's slug matches, so the name and handle exclusions can never be hit by the import. - **Per-SKU mapping**: `barker_products` holds one row per Barker SKU, so one Barker product can span several brewman SKUs; branch 2 binds a new Barker SKU to the product its sibling SKUs already use. - **Legacy forever-versions**: old products have `valid_from = NULL` (active since forever); the name search links to them like any other active version. - **Idempotence**: a day whose booked vouchers match the plan is skipped entirely (signature equality), so re-running the same range is a no-op. --- ## `barker_client.py` — the Barker adapter Pure adapter: GETs Barker's export endpoint and validates the payload. Raises `BarkerError` on anything unusable; callers translate that to HTTP 502. | Function / class | What | Inputs | Outputs | | --- | --- | --- | --- | | `fetch_sales(start_date, finish_date)` | Fetch and validate one date range of Barker sales | start and finish business dates | `BarkerSales` (validated pydantic model) | | `BarkerSaleLine` | One POS sale line: product/SKU ids, `name`, `units`, sale category, `quantity`, `price`, `tax_rate`, `discount` | Barker payload (camelCase aliases) | typed line | | `BarkerSaleDay` | One business date with its lines | Barker payload | typed day (`.date_`, `.lines`) | | `BarkerSaleCategory` | A sale category id and name | Barker payload | typed category | | `BarkerSales` | The whole export: `sales` days + `sale_categories` | Barker payload | typed export | | `BarkerError` | Raised when Barker is unreachable or the payload is unusable | — | exception | --- ## `products.py` — provisioning ### Public seam **`resolve_product(db, line, date_) -> ProvisionedProduct`** The single entry point; the planning loop learns only this. - *Inputs*: session, the Barker line, the line's business date. - *Outputs*: `ProvisionedProduct(product_id, sku_id, disposition)`. - *Resolution order* (ADR-0004): Barker SKU mapped → return verbatim; else Barker product mapped (sibling SKU row) → place this SKU under that product; else match the active product by normalized name → use it or create → place the SKU. The mapping row is written whenever a binding is established. - *Writes*: the mapping row always; SKU and product rows only when creating. Flushes. - *Errors*: 409 when the normalized name matches more than one active product. **`ProvisionedProduct`** (frozen dataclass) — `product_id`, `sku_id`, `disposition`. **`Disposition`** — what provisioning did: `mapped` (served from the standing mapping, nothing written), `linked` (new binding to an existing product — sibling-SKU branch or name-resolved), `created` (a new product and its first SKU). This is what the preview's insert-vs-update classification will read. **`existing_binding(db, sku_id) -> tuple[product_id, sku_id] | None`** — read-only lookup of the mapping row; also branch 1's lookup, and used by preview so it never provisions. ### Implementation (private) | Function | What | Inputs | Outputs / Errors | | --- | --- | --- | --- | | `_normalize(value)` | The slug comparison form of a name or units: trim, casefold, punctuation stripped, whitespace/underscores → hyphens | raw string | normalized string | | `_find_product_id_by_name(db, name, date_)` | The one active product whose normalized name matches | session, Barker name, date | product id or None; **409** naming candidates when several match | | `_find_or_create_sku(db, product_id, line, date_)` | Reuse the product's active SKU whose normalized units match; else add a SKU under it | session, product id, line, date | `sku_id` | | `_create_product(db, line, date_)` | New product ("Menu Items", All Purchases) + first SKU; cannot clash — only called when no active slug matches | session, line, date | `product_id` | | `_create_sku(db, product_id, line, date_)` | New SKU row + first version | session, product id, line, date | `sku_id` | | `_write_mapping(db, line, product_id, sku_id)` | Insert the mapping row if absent or correct it, then flush (autoflush off) | session, line, ids | None | Constants: `MENU_ITEM_GROUP_ID`, `ALL_PURCHASES_ID` (the imported product's account). --- ## `vouchers.py` — planning and persistence ### Plan side **`PlannedLine`** (dataclass) — one aggregated sale line: `product_id`/`sku_id` (None when not provisioned), `name`, `units`, `quantity`, `rate`, `tax_rate`, `discount`. `amount` = `quantity × rate × (1 + tax) × (1 − discount)` at paise precision (ADR-0001). `signature()` is the fingerprint compared against booked vouchers. **`PlannedVoucher`** (dataclass) — one planned Sale Voucher: `business_date`, `sale_category_id`/`name`, `cost_centre_id`, sorted `lines`. `amount` sums lines; `narration` renders the standard import narration; `signature()` = `(cost_centre_id, sorted line signatures)`. **`build_planned_vouchers(db, day, mapping, provision) -> dict[cost_centre_id, PlannedVoucher]`** - *Inputs*: session, a Barker day, the category→cost-centre mapping, `provision` flag. - *What*: groups lines by sale category, aggregates lines with identical (SKU, price, tax, discount) by summing quantity, resolves ids — through `resolve_product` when `provision=True` (preview=False never writes) — and drops categories absent from the mapping (preview surfaces them in the editor instead). - *Outputs*: plans keyed by cost centre — the same key booked vouchers are keyed by, which is what makes the comparison possible. ### Comparison side | Function | What | Inputs | Outputs | | --- | --- | --- | --- | | `existing_vouchers(db, business_date)` | Booked Sale Vouchers for a day, keyed by the cost centre of their credit journal | session, date | `dict[cost_centre_id, Voucher]` | | `voucher_signature(voucher)` | Fingerprint of a booked voucher in the plan's shape: credit cost centre + sorted (SKU, qty, rate, tax, discount) at the same quantisation | voucher | signature tuple | ### Persistence side | Function | What | Inputs | Outputs / Errors | | --- | --- | --- | --- | | `create_voucher(db, plan, user_id)` | Write one plan as a read-only Sale Voucher: synthetic batches (`quantity_remaining=0`), two journals on All Purchases (credit sale-category cost centre, debit Production). No commit | session, plan, user | None; **422** if a line lacks a SKU (defence in depth) | | `delete_voucher(db, voucher)` | Delete a booked Sale Voucher and its import-created batches (only when no other inventory references them; purchased batches survive) | session, voucher | None | | `check_locks(db, business_dates)` | Refuse the run when a target date is locked for SALE vouchers | session, dates | None; **423** with the lock message | | `all_purchases_account_types(db)` | Account types of All Purchases, for the lock check | session | `list[int]` | --- ## `service.py` — orchestration and settings ### Mapping settings (public API) | Function | What | Inputs | Outputs / Errors | | --- | --- | --- | --- | | `load_mapping(db)` | Standing category→cost-centre mapping from `DbSetting` (today-valid row) | session | `dict[category_id, cost_centre_id]`, `{}` when never saved | | `save_mapping(db, mapping)` | Validate and persist the mapping, committing immediately; drops null-cost-centre entries | session, `MappingUpdate` | None; **422** for Purchase/Production targets or unknown cost centres | | `cost_centres(db)` | Cost centres a category may map to (all, minus Purchase and Production) | session | `list[CostCentreLink]` | ### Planning helpers (private) | Function | What | Inputs | Outputs | | --- | --- | --- | --- | | `_category_names(sales_days)` | Every category in the fetched data with its name | Barker days | `dict[category_id, name]` | | `_require_mapping(db, categories)` | Load the mapping and refuse while any fetched category is unmapped | session, categories | complete mapping; **422** naming all missing | | `_to_schema_voucher(plan, action, existing_amount)` | Shape a plan into the API's `VoucherPlan` | plan, action, booked amount or None | `schema.VoucherPlan` | | `_day_plan(db, day, mapping, provision)` | Plan one day against what is booked for it | session, day, mapping, provision | `schema.DayPlan` | | `_plan_from(business_date, expected, existing)` | Decide actions: `skip` when signatures match, `replace` when a voucher exists but differs, `create` when none; day action = the common action or `mixed`; a day whose booked vouchers have no plan also reports `replace` (they will be removed) | date, plans, booked | `schema.DayPlan` | ### The two entry points **`preview(db, request) -> PreviewResponse`** — read-only. Fetches Barker (502 on failure), plans mapped categories with `provision=False` (no writes at all), lists never-provisioned product labels (`new_products`), and returns every category ever seen — mapped or not — for the mapping editor. Lenient by design: it never fails on unmapped categories, or the mapping editor could never be filled in. **`execute(db, request, user_id) -> ExecuteResponse`** — writes. Strict: 422 while any fetched category is unmapped. Checks locks (423) across the whole range first. Per day, in order: load booked → plan with provisioning → delete every booked voucher that has no matching plan (signature compare; a category no longer mapped/sold is removed this way) → create the rest. `db.flush()` per day, one `db.commit()` at the end; any failure rolls back the whole run. --- ## `routers/sales_import.py` — HTTP layer Thin: permission gate + session + delegation. `POST /api/sales-import/preview`, `POST /api/sales-import/execute`, `PUT /api/sales-import/mapping`, `GET /api/sales-import/voucher/{id_}` (404 unless the voucher is a SALE voucher). Sale vouchers are read-only everywhere else too — edit/delete are refused for imported ones. ## Decision records - ADR-0001 — sale vouchers are valued at POS sale price. - ADR-0002 — products are provisioned automatically by ID; versions carry master-data change. - ADR-0003 — the handle is ignored; the name was made identity (partly superseded). - ADR-0004 — the product mapping is standing truth; slug-normalized names and units are the first-sight resolvers; renames and units changes after mapping are ignored.