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

13 KiB
Raw Permalink Blame History

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 mechanicshow matching is decided: names and units are compared in slug form; "active" means the version's date range covers the business date.
  3. Session disciplineincidental 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.