In progress Sales import and Recipe import.
First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
1 parent
75ccbdc6f9
commit
b425b299bf
50 files changed
+5306
-123
No files matched your search
@@ -0,0 +1,209 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user