Files
brewman/docs/sale-import.md
T
tanshu b425b299bf In progress Sales import and Recipe import.
First I will finish Sales Import. Then move on to recipe import
2026-09-11 10:43:23 +00:00

210 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.