Skip to main content

Slice 6 — Purchase Orders

Outbound stock orders to vendors. The full lifecycle — bucket → ordered → received — is live as of 2026-05-19, driven from the redesigned Inventory page that mirrors the Sales cart pattern.

Status: Built end-to-end. Migrations 044, 045, 046, 047 closed the loop. The "schema-only, UI-not-built" framing the prior version of this page used is obsolete.

v0.2 — Bucket + PO workflow live (2026-05-19)

The Bucket pattern — Inventory mirrors Sales

The Inventory page was redesigned to mirror the Sales screen 1:1. Where Sales has a Cart, Inventory has a Bucket. Where Sales has page-actions (+ Customer · Park · Estimate · Charge), Inventory has matching bucket-actions. The mental model — operator collects items, then commits — is identical.

What's different:

  • A Sales cart commits to a transactions row (a sale)
  • An Inventory bucket commits to a purchase_orders row (a PO) — or, more precisely, to one PO per supplier in an atomic per-supplier split

The bucket itself is backed by inventory_order_queue rows with status='pending_order' and converted_to_po_id IS NULL. Once Generate PO runs, the rows are stamped with the new PO id and they disappear from the bucket view (the filter is converted_to_po_id IS NULL).

Schema

purchase_orders + purchase_order_lines are the canonical PO tables (since the original schema). The order-queue → PO bridge added across this slice:

MigrationWhat it adds
034inventory_order_queue — first iteration; reorder intent from product-card editor
038inventory_order_queue accepts bulk items too (bulk_slug + bulk_label + supplier_name_free); CHECK enforces variant XOR bulk
044Seed 20 real bike-shop suppliers; rename the AIM placeholder to aim_legacy_unmapped + flip is_active=0
045inventory_order_queue.converted_to_po_id + customer_id (special-order earmark)
046inventory_order_queue.source_po_line_id — closes the receive loop back to the source PO line
047suppliers.aim_ven_pk — round-trip column for the eventual scvendor decode (see migration from AIM → supplier decode)
048suppliers.display_color_hex — buyer-picked supplier color (NULL = fall back to the hash-derived pastel). Auto-contrast text computed client-side via WCAG relative luminance.
049suppliers.contact_address + suppliers.logo_url — display-only mailing address + logo URL. Surfaced on the Supplier Card edit modal and the bucket accordion expanded header.
051version column on purchase_orders + purchase_order_lines + suppliers — Layer 1 optimistic locking (data-consistency layers).

See PO lifecycle for the state machine.

Endpoints

  • GET /api/purchase-orders/recent — list (filterable by status, supplier, attached supplier from Bucket panel)
  • GET /api/purchase-orders/:id — detail (for the PO detail modal)
  • POST /api/order-queue — add a variant or bulk item to the bucket
  • GET /api/order-queue — bucket contents (filter ?status=pending_order)
  • DELETE /api/order-queue/:id — remove from bucket
  • POST /api/order-queue/generate-poatomic per-supplier split: scans pending bucket entries, groups by supplier, creates one PO per supplier in one transaction, stamps converted_to_po_id on every consumed entry
  • POST /api/order-queue/receive — bulk-receive selected entries: bumps inventory.quantity_on_hand, writes inventory_movements rows, updates purchase_order_lines.quantity_received, and (via 046) flips the source PO's status to received when every line is fully received
  • POST /api/purchase-orders/:id/edit — edit a Pending PO before transmission

UI surfaces

Inventory page — Bucket panel

Right-hand-side panel that mirrors the Sales cart:

  • Bucket items list — one row per pending order queue entry. Click a row → opens the unified Product Card (helmOpenVariantEdit) for that variant.
  • Per-line supplier dropdown — pickable from the variant's linked suppliers; defaults to primary.
  • Click-PO-to-populate — clicking a PO row in the ledger copies its lines into the bucket so they can be re-ordered or referenced.
  • Bulk items — the bulk-items dropdown (Bar Tape, Brake Cable, etc.) can add cart-style pills to the bucket; these flow through the same queue with bulk_slug set instead of variant_id.
  • Bucket supplier groups — collapsed-by-default accordions with the supplier name in the header. Header has Edit + View pills that open the Supplier Card or the supplier's filtered bucket view. Coloured by supplier for fast scanning.
  • Sub-bucket card from View pill — opens an inline detail accordion below the group header.

Bucket-actions row (mirrors Sales cart-actions)

  • Generate PO — atomic per-supplier split. Each group becomes one PO; lines come over with cost basis, qty, and the customer earmark if set.
  • Receive mode — toggles the bucket into receive layout: each line gets a checkbox (default UNCHECKED) and a [−] [qty] [+] stepper. Confirm posts an inventory_movements row per checked line, increments on-hand, and (via migration 046) closes the PO line and rolls up the PO status when complete. Clear button works in receive mode the same way it does in shopping mode.

PO ledger (under the Bucket)

  • Filtered by attached supplier when the bucket has lines, "all suppliers" when empty
  • Status labels: Pending Order, Ordered, Received (per the lifecycle)
  • Click a row → PO detail modal (lines, totals, supplier info)

PO detail modal

  • Editable while status is pending_order (lines, qty, expected date, supplier)
  • Read-only once ordered — except the Receive mode lives here too
  • Receive enrichments (2026-05-19):
    • Cost basis captured per line on receipt
    • Supplier metadata carried through (lead time, contact info)
    • Special-order calls — the customer earmark from migration 045 shows on the line so the receiver knows to hold the unit
    • Cost override on receive — if the invoice line differs from the PO unit cost, override at receive time; the override is logged

Supplier Card

Matches the Product Card layout (consistent in-situ modal pattern across the app). Opened from the bucket supplier group header (Edit pill) or from Settings → Suppliers.

What's built

  • All endpoints above ✓
  • Inventory page Bucket panel mirroring Sales cart 1:1 ✓
  • Click-PO-to-populate flow ✓
  • Bulk items in bucket via bulk_slug
  • Bucket supplier groups as colored accordions, click-to-edit-supplier ✓
  • Generate PO with atomic per-supplier split ✓
  • PO statuses (Pending Order → Ordered → Received) with the loop closed via migration 046 ✓
  • Receive mode with checkbox + qty stepper, post movements on confirm ✓
  • Cost basis + supplier metadata + special-order calls + cost override on receive ✓
  • Editable Pending POs ✓
  • PO detail modal ✓
  • Search POs from inventory ✓
  • Customer earmark on order queue rows (special-order flow from Sales Deposit) ✓
  • Today's till tile shows order-queue / PO activity (when applicable)
  • 20 seeded bike-shop suppliers; AIM placeholder renamed to aim_legacy_unmapped + inactive ✓
  • Order Card — per-supplier ordering surface (live 2026-05-20, v0.4.193 + .200/.201/.207/.209/.210) ✓ — opened from the bucket per-supplier sub-header. Owns the Print / Email / Post-PO actions for that supplier's slice of the bucket. Renders the supplier's letterhead inline (logo + contact address + website) above the line list. Receive flow runs in the same modal (unbounded receive — partial receives stay open). Open-PO button moved into the Order Card; the bucket sub-header just opens the card now. Capped at viewport with internal scroll (v0.4.230).
  • Print PO + Email PO with full letterhead (v0.4.209/.210) ✓ — print output and email body both render the shop letterhead at top + supplier letterhead inline + the line list. Inline <img> for suppliers.logo_url; multi-line suppliers.contact_address renders verbatim with \n<br>.
  • Supplier polish (v0.4.194 → .219) ✓ — display-color palette + auto-contrast text (WCAG luminance); supplier directory icon on the bucket; bucket accordion-supplier-name → Supplier Card; "+ New Supplier" button + supplier-dropdown auto-sizing to widest name; vendor-details strip (email mailto: + phone tel: + website links) shown when the bucket has lines from that supplier; logo upload field (text URL today; R2 upload TBD); Supplier Card scrollable in viewport.
  • New Order modal — vendor + items in one popup (live 2026-05-24, v0.6.9) ✓ — clicking the Bucket icon on an empty bucket opens #new-order-modal, a two-panel popup. Top panel: vendor <select> (TBD pinned first, then every active supplier sorted by display name) with sibling + New and Edit buttons that delegate to the Supplier Card modal — the operator can create or tweak a vendor without leaving New Order. The dropdown listens for helm:suppliers-changed and re-paints; a freshly-created vendor auto-selects. Bottom panel: the same /api/inventory/search the Till uses, results show SKU + on-hand + weighted-avg cost, clicking stages with qty=1 (re-click bumps), staged rows have − / + / × controls. Footer: Cancel + Add to Bucket. Confirm POSTs one /api/inventory/order-queue row per staged line; TBD lines omit supplier_id so the server auto-derives from the variant's supplier_products link. Partial failures keep the failed lines staged with an inline error; successful lines drop out so the operator can retry just the failures. When the bucket already has lines, the Bucket icon goes back to opening the supplier directory (unchanged dispatch from before).
  • Unified work-area header on the Bucket (live 2026-05-24, v0.6.12) ✓ — the bucket header carries the same anatomy as the Till and Slot: line count on the left, Clear × on the right (only visible when populated; runs a confirm-and-DELETE-loop over every queued row). See work-area headers for the cross-surface pattern.
  • No customer pill on the Bucket (live 2026-05-24, v0.6.13) ✓ — v0.6.12 added a + Customer pill to the bucket header for symmetry with Till + Slot. User feedback: "The Bucket doesn't need a customer." Per-line earmarking is the correct primitive (each inventory_order_queue row already carries its own customer_id from the Sales-Till Special Order flow); a single bucket-wide customer doesn't model how POs actually get assembled across multiple customers' special orders. The pill was reverted; the bucket subline went back to the static "Each line carries its own supplier · pick below" copy the bucket has shown since slice 1. The Clear × on the right stayed.
  • Clone SKU on the Product Card modal (live 2026-05-24, v0.6.10) ✓ — Inventory adjacency (not strictly a PO surface but lives on the Product Card the bucket flows into). Duplicate-a-record affordance: one click on Clone SKU in the footer captures every operator-editable field from the loaded variant, switches the modal into create mode pre-filled with those values, suffixes the display name with " (copy)", clears the SKU, focuses the SKU input. Not cloned: stock fieldset (starts at zero), barcodes (would conflict on the unique constraint), supplier links (per-variant), order-queue rows (reference the source). Cloning carries the "what is this product" identity (name, type, category, pricing, manufacturer, MPN, description) but not the per-variant state physical reality controls. Button hidden in create mode.

Purchases overhaul: load historic PO into Bucket + inline Receive + +Supplier filter (v0.6.36 → v0.6.40.9)

A 14-commit run that turned the Bucket from "compose a brand-new PO" into a dual-mode work area: Workflow A = compose fresh (existing behaviour), Workflow B = load a historic PO as an editable working copy and receive it line-by-line, all from the same Bucket. The Bucket header was also renamed to Purchases (v0.6.30.1) to match the operator-facing tab name.

  • Products Till + Supplier pill filters the PO ledger (v0.6.36) ✓ — the Products Till header (was: Bucket) now mirrors the Sales + Service shape: line count + Clear × on the right, plus a clickable subline that says + Supplier. Attaching a supplier filters the Purchase Orders ledger on the left to that supplier's POs only and retitles the section header (e.g. "Acme Cycles — Purchase Orders"). Per-line supplier assignment on bucket rows is unchanged. helm:bucket-supplier-attached event carries the change across boot blocks.
  • Till doctrine: header + Supplier is empty-only (v0.6.39) ✓ — The Products Till is a collection of Slots — one per supplier — that aggregate lines from across the shop (Sales-tab special orders, Service-ticket parts, Products-page direct orders). Each line carries its own supplier, so a single bucket-wide supplier pill in the header only makes sense when the till is empty (as a discovery affordance for finding a supplier's historic POs to load). The + Supplier pill hides automatically when the till has lines; clearing the till also detaches the header supplier (clean reset). The per-supplier Open PO button on each slot is the preview-then-send step for that slot's PO.
  • Order Card: inline → reverted to popup (v0.6.37 → v0.6.37.1) — v0.6.37 inlined the Order Card into the Bucket panel as a data-bucket-view="po" display swap (header / items / totals / actions all swapped in place; no DOM destruction). v0.6.37.1 reverted: the inline mode wiped the Bucket's own header (line count + Supplier pill + ×), so the Order Card went back to a body-level popup. Per-line receive inputs default to 0 (was: outstanding qty) — the operator types in what physically arrived in the box; auto-filling the outstanding qty risked a confirm-without-thinking receive that didn't match the shipment.
  • PO ledger header matches "Today's" pattern (v0.6.38.1) ✓ — the Purchase Orders ledger heading on the Products tab uses the same date-picker + count format as Today's Sales and Today's Tickets.
  • Click historic PO to load into Bucket as editable working copy (v0.6.38) ✓ — PO ledger row click no longer opens the Order Card modal. Instead it POSTs to /api/inventory/order-queue/from-po, which inserts the PO's lines as fresh pending bucket entries and attaches the PO's supplier to the Bucket's + Supplier pill. The bucket then behaves like the empty-bucket flow — same line UI, same buttons (+ New SKU / + New Supplier / Suppliers ›) — only the contents are pre-populated. The original issued PO is untouched; edits affect only the working copy in the bucket. source_po_line_id is stamped on each loaded line so the receive flow knows which PO line each bucket row maps back to.
  • PO ledger row click is a toggle (v0.6.40.9) ✓ — first click loads the PO into the bucket; second click on the same row removes it (clears the bucket of those loaded lines, detaches the supplier). Lets the operator try-and-back-out without using Clear × to wipe the work area.
  • Inline Receive on bucket lines copied from historic PO (v0.6.40) ✓ — when the operator loads a not-yet-received historic PO into the till (Workflow B), each line shows a small Rcvd input under its qty stepper: "From PO-12345 · ordered N, received M, outstanding K · Rcvd now: [0]". The supplier-slot footer gains a Save Receive button next to Open PO; it batches every typed Rcvd value in that slot and POSTs them in one round-trip to /api/inventory/order-queue/receive. Server bumps quantity_received on the source PO line, posts inventory movements, and flips PO status to received when fully closed. Lines that don't carry source_po_line_id (fresh search-add Workflow A items) render unchanged. Required server change: apiInventoryOrderQueueList SELECT now joins purchase_order_lines + purchase_orders to surface source_po_quantity_ordered / source_po_quantity_received / source_po_number on each bucket row.
  • Qty stepper IS the receive count (v0.6.40.3) ✓ — refinement of the v0.6.40 model. For historic-PO lines the existing qty stepper next to each line is the Rcvd input; the separate Rcvd field gets dropped. One control, two meanings: on a fresh Workflow A line the stepper is "how many to order"; on a Workflow B line it's "how many physically arrived." The receive-mode UI relabels the column accordingly.
  • Products Till qty stepper bottoms at 0 (v0.6.40.1) ✓ — for receive purposes a line can legitimately be "0 received" (nothing in the box). The minimum stepper value drops from 1 to 0.
  • Migration 059 — relax inventory_order_queue.quantity CHECK to allow 0 (v0.6.40.2) ✓ — paired schema change for v0.6.40.1. The CHECK constraint that required quantity >= 1 now allows quantity >= 0. A zero-qty bucket row represents an explicit "nothing received yet" placeholder for an outstanding historic-PO line.
  • Save Receive with nothing received → offer to email supplier (v0.6.40.4) ✓ — clicking Save Receive when every Rcvd value in the slot is 0 prompts the operator with a "Nothing was received — was this shipment short? Want to email the supplier about it?" pathway, which prefills a short-shipment template addressed to suppliers.email.
  • Receive-mode qty stepper is draft-only (v0.6.40.6) ✓ — typing in the qty/Rcvd stepper no longer writes to the DB on every keystroke. The value is held in local draft state; the round-trip only happens on Save Receive (or Cancel discards the draft). Avoids partial-receive rows littering the DB on a mistyped digit.
  • Till header flips to "Receiving · N items" when a pending PO is loaded (v0.6.40.7) ✓ — visual signal that the Bucket is in Workflow B (receive mode) vs Workflow A (compose mode). Reverts to "Purchases · N items" when the loaded PO is unloaded or fully saved.
  • Delete a Pending or Ordered PO from the ledger (v0.6.40.8) ✓ — small trash icon on each PO ledger row (visibility gated to roles with the appropriate permission key). Confirms via helmConfirm, deletes the PO + its lines, audit-chained.
  • Bug fix: Save Receive crash from updated_at UPDATE (v0.6.40.5) ✓ — the inventory_order_queue UPDATE statement set an updated_at column that doesn't exist on that table; SQLite hard-errored on Save Receive. Dropped the column from the UPDATE; receive flow is back.

Waves 1–5 + bucket polish (v0.6.83 → v0.6.187)

A run of corrections and refinements after the Purchases overhaul shipped. The "Wave" commits are thematic bundles named in the commit subject; the rest are targeted fixes.

  • Fix: loading a PO into the bucket duplicates pre-existing manual lines (v0.6.84) ✓ — race between the loaded-PO insert and the existing manual-line render path caused the bucket to show each manual line twice. Dedup-on-load now keys against (variant_id, source_po_line_id).
  • Fix: PO ledger row double-click duplicates lines (v0.6.83) ✓ — clicking the same PO row twice in quick succession fired from-po twice. Now guarded with a row-level "in flight" flag; second click is a no-op until the first request resolves.
  • Wave 1 corrections + PO-click opens Order Card (v0.6.131) ✓ — refinements across the bucket: a fresh PO-ledger click on a row that's NOT loaded into the bucket opens the Order Card directly (vs. requiring a manual Open PO click after loading). Operator can review a past PO without staging it.
  • Wave 4 (part 1): inline bucket-line cost edits (v0.6.134) ✓ — cost_cents per bucket line is now click-to-edit (Excel-style, same pattern as the inline price edit on Sales / Service lines). Edits update the line's draft cost basis WITHOUT touching the weighted-average inventory cost (which only changes on actual Receive). Useful when the operator has the supplier's quote in front of them and wants to set the line cost before generating the PO.
  • Wave 3: SMS do-not-reply sender + body rework (v0.6.133, migration 066) ✓ — shop_config.sms_do_not_reply_sender (new column, NULLable). When set, outbound service SMS messages (Notify, etc.) include "Do not reply — call us back at <shop_phone>" suffix. SMS body composition was also reworked to be more humane (no all-caps, no auto-shouting). Used by the v0.6.180 / v0.6.181 send guards.
  • Wave 5: service queue ETA from per-ticket labor minutes (v0.6.135) ✓ — the Service tab's "X mechanics on duty" header metric (v0.6.106) gains a paired metric: ~N hours queued, computed as the sum of labor_minutes across open tickets divided by mechanics on duty. Rough estimate (doesn't account for parts-hold, mechanic skill specialization, etc.) but useful as a peripheral-vision signal for the shop owner.
  • Remove the bucket-header Supplier pill (v0.6.183) ✓ — v0.6.36's + Supplier pill in the bucket header (which filtered the PO ledger to one supplier) was removed. The filter wasn't earning its space — operators just clicked PO ledger rows directly, never via the pill. Per-line supplier assignment stays unchanged.
  • Reorder qty input + bucket dedup (v0.6.186, v0.6.187) ✓ — bucket lines created via the Service reorder prompt (v0.6.111) now dedup by variant_id: adding the same part again to a ticket increments the existing pending bucket row's quantity instead of stacking duplicate rows. Reorder qty input also accepts arbitrary typed digits (previously stepper-only).
  • Reorder prompt also fires when stock will hit zero (v0.6.182) ✓ — the reorder prompt's trigger gate expanded: previously fired only when the variant was already below the reorder threshold; now also fires when this allocation will take stock to zero (catches the case where the part is at qty=1 and the operator is staging it). See slice 4 for the Slot side.
  • Reorder prompt: -/+ stepper + auto-flip ticket to Parts-Hold (v0.6.184) ✓ — when the operator confirms the reorder, the source ticket auto-flips to awaiting_parts (display: Parts - Hold) status. Mechanic doesn't have to remember to walk to the kanban and change it manually.
  • Bike-on-record link from Bucket (v0.6.128 prerequisite) — see slice-2 for the customer side.

What's not yet built

  • Email-to-vendor transmission — Generate PO creates the record; sending the PO to the vendor is still manual today (operator copies the PO PDF and emails it). The vendor-API integrations for QBP / Shimano / etc. ride on top of the custom_integrations framework when they wire up.
  • Invoice reconciliation — receive captures cost basis, but matching a vendor invoice against the PO + flagging variances is a follow-up.
  • scvendor decode — migration 047 added suppliers.aim_ven_pk; the actual Python decode script that walks scvendor → supplier_products and repoints all 9,443 product-supplier links is pending (see migration from AIM → supplier decode).

Migration from AIM

1,772 POs from AIM's po table; these are historical (all migrated as received). The interesting part of the AIM migration for this slice is the supplier decode story — the original ETL punted on scvendor and stamped every supplier_products row at one placeholder (id=1, imported_aim). Migration 044 renamed it and seeded 20 hand-picked Vancouver-Island suppliers; migration 047 added aim_ven_pk so the future decode script can UPSERT cleanly. See migration from AIM → supplier decode for the full plan.

See also