210 lines
13 KiB
Markdown
210 lines
13 KiB
Markdown
# 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.
|