Skip to main content

Purchases module

The purchasing console. PO ledger + Purchase Cart (vendor-grouped, per-line steppers) + Vendors management + PO lifecycle (Choose Vendor → New PO → Order Card → issue → receive). Split out of the former two-face Products module in v0.7.69; the Inventory side became its own Inventory module. Rebuilt to prior-implementation POs-tab parity in v0.7.66.

Tab labelPurchases
Tab accent#0d9488 (teal)
Strip position4th — between Customers and Inventory (position set by setTabOrder() in src/modules/index.ts, v0.7.409+)
Foldersrc/modules/purchases/
Migrations020_purchasing.sql, 021_po_line_variant_nullable.sql, 023_purchasing_view_perm.sql, 025_po_status_lifecycle.sql, 026_bucket_line_cost.sql, 033_special_order_po_link.sql, 062_supplier_name_key.sql
Steward#pur-search — focus stewardship adopted v0.7.90; multi-add search stays open, no auto-select on cost fields, matching modal auto-focus
Ledger headingTitle-case "Purchase Orders" (v0.7.99)

What it owns​

src/modules/purchases/
index.ts — registers the module with the Kernel
view.ts — markup + styles for the PO ledger + Purchase Cart + Vendors
client.ts — browser JS (Cart, PO lifecycle, Vendor popup)
api.ts — server-side routes (/api/m/purchases/*)

What it does​

Supplier dedup — match-before-create HARD BLOCK (migration 062, 120ab9c)​

Supplier create / rename / merge / dedup lives in one shared Core engine (core/suppliers.ts) that Purchases and Inventory both call — the two modules' POST/PUT /supplier handlers were byte-for-byte duplicates before this consolidation. Every write keeps suppliers.name_key in sync via normalizeKey (case / space / punctuation / accent-insensitive), and match-before-create HARD-BLOCKS a second active supplier with the same normalized name — Tom's call. The partial-unique index idx_suppliers_name_key_active (mig 062) is the DB backstop, scoped to is_active=1 so a merged-away loser's key never blocks a legitimate reuse. Merge repoints the 4 child tables (POs, Bucket lines, supplier-product links, ledger) and soft-deletes the loser. Merge UI lives on the Vendors pill.

Bucket dedup — a customer's special order is never merged in (v0.6.955)​

Bucket dedup previously coalesced any two rows for the same variant into one line. That was wrong for special orders — merging a customer-earmarked line with a stock-reorder line silently dropped the customer attribution. Now a bucket row with a customer_id (special order) stays its own line, distinct from any stock-reorder rows for the same variant. General dedup still applies to stock-reorder-vs-stock-reorder merges.

Retired 17 dead API routes (v0.6.956, re-land)​

Seventeen /api/* routes left over from the Products→Inventory+Purchases split were removed after grep confirmed zero callers in the current client. Re-landed from the pre-rollback branch as part of the housekeeping pass.

The Purchase Cart (Bucket)​

Staging area for reorders. Variants drop into the Cart from Inventory's Reorder Dashboard, from a Stock-Card + Add to PO, from a Special-Order deposit (v0.7.86 auto-post), from a Sales zero-stock ring-through (auto-bucket on Charge, v0.7.190; see Backorder → Bucket flow), from a Service Order-parts-to-ticket prompt (v0.7.141, BIKE.L2-0074; earmarked to the ticket's customer, vendor auto-derived from primary supplier, ticket flips to Parts hold), or from the Cart's own SKU search. Grouped by vendor (the variant's is_primary_supplier decides the group); each vendor group accumulates until built into a PO. The old manual /reorder endpoint was retired in v0.7.190 — everything in the Bucket now originates from a real signal (a customer ring, an explicit dashboard add, or a special-order deposit).

Owner attribution — "for Stock" or "for <Customer>" (v0.7.191). Every Bucket row shows who the order is for: a neutral chip "Awaiting order · for Stock" when the ring didn't have a customer attached, or a blue chip "for <Name>" when a customer's earmarked. /bucket GET returns customer_id, special_order_transaction_id, and customers.display_name so the chip renders without a second round-trip. Vocabulary — Bucket = "Awaiting order" (hasn't been ordered from a vendor yet); PO cut = "Ordered". The Sales ledger's "Awaiting order" status mirrors the Bucket row (v0.7.132).

Special-order lifecycle stages (v0.7.248 / .268 / .270 / .273 / .274). The Sales ledger's Status dropdown breaks the special-order umbrella into distinct stages — Awaiting order → Ordered → Order Arrived → Ready for pickup. Server case for Order Arrived added v0.7.268; the pill gets its own gold colour #fde68a (v0.7.274) to distinguish it visually from Ordered. After arrival the row goes payment-aware (v0.7.248): still owing → Layaway; paid in full → Ready for pickup. The umbrella "Special order" filter was restored (v0.7.270) and then removed again per the shop's ask (v0.7.273) — operators filter on the specific lifecycle stage, not the umbrella.

Inline line controls. Each Cart line has qty stepper, unit cost, vendor reassignment, and a cost-override that survives the PO build (migration 026 stores cost_override_cents on order_queue).

Create a SKU from the Cart (BIKE.L2-0049, v0.7.70). The Cart's Add-a-product search gains a + New SKU button that opens the create-product modal; the new SKU is then searchable + addable to the Cart.

Clear bucket (BIKE.L2-0050). Empty all staged Cart lines across every vendor at once, kbConfirm-gated.

PO lifecycle​

Build & issue. Click a vendor's Cart group → opens a draft PO with that supplier's lines pre-loaded; adjust quantities, costs, notes; Issue stamps status='ordered' (aligned to the prior implementation's vocabulary in v0.7.39), writes the timestamp, drops the rows from order_queue.

PO status vocabulary (migration 025):

StatusMeaning
pending_orderEditable draft; Order Card writes accepted
orderedIssued; receive controls live
receivedEvery line fully received
closedManually closed
cancelledVoided

The PO ledger. Lists POs by status with colour-coded chips + a column-sort + a vendor filter chip (v0.7.42). Clicking a row loads the Order Card.

Editable Order Card (BIKE.L2-0042). Pending POs are editable from the Order Card itself — change a line's quantity or unit cost, add a new product to the PO, remove a line. Totals recompute server-side on every save so the displayed total can never drift from the line sum. Once a PO transitions to ordered, the Order Card flips to read-only with receive controls on each line.

New PO from scratch (BIKE.L2-0041 / 0048). Build a PO without going through the Cart: pick a vendor → empty PO opens → line editor walks through adding variants by SKU search or barcode.

Receiving depth (BIKE.L2-0044 / 0083)​

When stock arrives against an issued PO, Receive supports the realistic edge cases:

  • Cost override on receive. Vendor's invoice came in different from the PO line cost? The receive line carries an overridden actual cost; the variant's cost-of-goods picks it up.
  • Cost ladder. Successive receipts on the same variant build a ladder of supplier costs visible on the Vendor popup + the Inventory variant detail.
  • supplier_products cost stamp. Each receipt stamps the receiving cost onto supplier_products so the next "default cost" for that variant from that vendor reflects the most-recent reality.
  • Default-0 + over-receipt. A line can default-receive 0 (mark received-but-skipped) or over-receive (vendor sent more than ordered); both write movement rows of the appropriate type.
  • Discrepancy roll-up. The Order Card summarises ordered-vs-received counts across all lines.

Receipts fire movement_type='receive' rows through core/movements.ts — quantity_on_hand updates, the Bucket → PO → on-hand loop closes.

Vendors management​

The Vendors section on this tab lists every supplier. Each row opens the Vendor popup — rich vendor record (BIKE.L2-0051): account #, contact address, website, B2B portal URL, drop-ship-capable flag, logo, display_color_hex for Cart group-header colour. Edits go through PUT /api/m/purchases/suppliers/:id; the Order Card chrome reads the logo + display colour for visual grouping.

A Sales special-order deposit auto-posts a line to the Purchases Cart the moment the deposit is taken. Once the Cart line becomes a PO line, migration 033's link table binds the Sales transaction → Bucket line → PO line. The Sales ledger row for that special order then tracks live PO status: Special order → Ordered → Partially received → Ready for pickup. One PO line links to exactly one special-order transaction; one PO can carry lines for several different customers' special orders at once (per-line link, not per-PO).

PO line totals at COST, not retail (mig 105 + 106)​

What was wrong. The AIM import wrote the product's selling price into purchase_order_lines.line_total_cents while unit_cost_cents held the real cost. Measured over the 6,477 lines that had a variant and a selling price: 4,871 (75%) had line_total = qty × RETAIL and only 22 had line_total = qty × cost. A purchase order is what the shop pays a supplier, so every downstream figure that read line_total_cents (spend reports, margin, YoY cost roll-ups) was reading the retail figure — an overstate of every historical PO.

  • Mig 105 restates every affected line to qty × unit_cost_cents.
  • Mig 106 recomputes the header tax that had been derived from the retail subtotal. Deliberately separated from 105 — a supplier invoice carries what it carries, so overwriting an invoiced figure to fix a derived one had to be its own step with its own audit narration.

Building a PO must not destroy the reorder requests (30b09e6)​

The PO builder used to clear the reorder-request rows the moment it consumed them into a draft PO — meaning that a builder abandoned mid-flow lost the entire request list, and the operator had no way back to "what did I ask to reorder yesterday?". Now the builder consumes requests idempotently and only marks them fulfilled once the PO issues.

Omega read-only /price (740f610)​

Read-only endpoint so the Omega auditor can re-do the PO arithmetic with its own numbers. Never writes; respects the same permission gates as the equivalent client read.

Permissions​

PermissionWhat it gates
screen.purchasesThe Purchases tab is visible at all
purchasing.viewRead the ledger + Order Card + Cart
purchasing.editCart actions + draft / issue / receive POs · Vendor edits

See also​