In progress Sales import and Recipe import.

First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
2026-09-11 10:43:23 +00:00
parent 75ccbdc6f9
commit b425b299bf
50 changed files with 5306 additions and 123 deletions
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
+30
View File
@@ -0,0 +1,30 @@
# Issue tracker: Local Markdown
Issues and specs for this repo live as markdown files in `.scratch/`.
## Conventions
- One feature per directory: `.scratch/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`, never a single combined tickets file
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
## When a skill says "publish to the issue tracker"
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
## When a skill says "fetch the relevant ticket"
Read the file at the referenced path. The user will normally pass the path or the issue number directly.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
- **Map**: `.scratch/<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
- **Claim**: set `Status: claimed` and save before any work.
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
+209
View File
@@ -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.