13 KiB
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:
- 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.
- 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.
- 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 untildb.flush(). Provisioning writes flush before returning; a failed run rolls back whole (get_session), soexecute's singledb.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 storedhandleis 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_productsholds 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,
provisionflag. - What: groups lines by sale category, aggregates lines with identical
(SKU, price, tax, discount) by summing quantity, resolves ids — through
resolve_productwhenprovision=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.