Skip to content

The warehouse floor on a handheld: scan to receive, count, put away, pick and load; part and failed deliveries and return pickups on a run; SonarQube removed - #34

Merged
goetchstone merged 9 commits into
mainfrom
feat/handheld-floor
Oct 17, 2026

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

Summary

PR 4 of the stock-ledger plan (docs/domains/inventory.md → "Roadmap", phases 15–20): the warehouse floor on a handheld. Receiving, counting, put-away, picking and loading now happen where the stock is, through the same ledger functions as the desk, and each scan is done once however often a bad connection resends it. Delivery runs can now record what actually happened at a stop: a part delivery, a stop that could not be done, or a return collected on the way.

It also removes SonarQube at the owner's direction: the local server was not being run, and CodeQL in CI covers the analysis.

What is in it

  • Scanner pages under /app/scan (lookup, receive, count, move, pick). Each sits behind the permission of the desk screen that does the same job. One resolver reads Holt's place labels (L-<id>), box labels (B-<product>-<box>-<of>), UPCs, consignment barcodes, product numbers and place codes. A retry key (withAction, StockAction) makes each handheld write happen once.
  • Labels: place and box labels, plus one renderer for every label with ZPL escaping. A label routes only to its own store's printer.
  • Boxes are evidence, never a quantity (DECISIONS TypeScript is strict across the whole tree, not five files: strict: true plus noImplicitReturns, noImplicitOverride, noFallthroughCasesInSwitch #22). A unit short of boxes is received into quarantine with a note naming what is missing; it moves to stock when the last box arrives.
  • Count by place (DECISIONS An order's balance counts only the lines still owed: cancelled and replaced lines no longer charge the customer #21). Counting needs inventory.count; posting needs inventory.adjust and moves the difference as ADJUST movements, free stock first. The Oversold report shows the last count's difference. The store-grain count stays as the POS cutover tool.
  • Put away and move (quick moves only; committed units keep their order) and pick to the loading bay, then load the run.
  • Delivery outcomes (DECISIONS Merge train: #15 #18 #14 #16 #17 #19 #20 #21 #22 in one CI run (merge commit, not squash) #23):
    • A part delivery hands over only what went and raises fulfilledQty. The rest stays committed, and a DELIVERY appointment for it goes back in the dispatch pool. The last units are the handover.
    • A stop that could not be done comes off the run, with the reason on the order.
    • A return's pickup is a run stop (RETURN_PICKUP), added from the pickup schedule.
  • Migration 20261020120000_handheld_floor: additive only.

Found while checking it in a browser, fixed here

  • The driver screen could not complete any stop. "Mark Arrived" sent PENDING → ARRIVED, which the transition table refused, and nothing sets EN_ROUTE. PENDING → ARRIVED is now allowed.
  • A failed stop whose appointment was IN_PROGRESS dropped out of the dispatch pool.
  • A place or box label for a store with no printer printed at another store's printer. It now refuses and names what to set up (rule 63).
  • "Transfer it first" compared a delivery's units with the store the order was sold at, not where it leaves from.
  • A count of a place at another store than the scanner's store picker was refused.
  • Demo seed: committed stock and order-linked transfers used products that were not on the order, and the open delivery runs carried orders that were already delivered. Picking and delivery could not be shown at all.

Trace / Verification

  • Trace (rule 45). The only shared-infrastructure file touched is lib/auth/pagePermissions.ts: 11 added lines (the scanner pages and two admin pages). PAGE_ACCESS is read by pagePermissions.test.ts, which checks every page under (dashboard)/app has an entry. Nothing under lib/adapters, paymentService, secureUpload or automations changed.
  • Migration. migrate deploy into an empty database, then npm run setup -- --scale=ci --no-cms, then prisma migrate diff --from-config-datasource --to-schema --exit-code: "No difference detected". seed:coverage passes on it. Rebased onto main after Returns money: refund to the original payment or to store credit, restocking fees, and a return that books as its own order #33; both migrations apply in order.
  • Browser, on a demo-seeded scratch database (keyboard input standing in for a scanner, at phone width), each with the database checked afterwards:
    • Lookup: by product and by place label.
    • Receive: two lines received; status RECEIVED_PARTIAL; the movement carries its action key.
    • Count: a quick count posted; free stock removed first, the order's unit kept.
    • Move: a cross-store move refused with "start a transfer"; a same-store move done.
    • Pick: picked to a loading bay; "Load the truck" refused with what is left.
    • Driver: a part delivery (one line of five), "Couldn't deliver" with a reason, and a return pickup completed.
    • Pickup schedule: "Add to run".
    • Place label: the refusal message.
  • Mutation-checked (13). Each was reintroduced, its test failed, and the file was restored:
    • open quantity ignoring fulfilledQty;
    • the stop not claimed before completing;
    • pickup stops on the pull list;
    • the any-store printer fallback;
    • PENDING → ARRIVED refused;
    • the pool dropping IN_PROGRESS;
    • delivery cross-site checked against the sale store;
    • a delivery of nothing accepted;
    • a part delivery consuming nothing;
    • a pickup completed as a handover;
    • an incomplete set picked;
    • a count taking committed stock before free;
    • the retry key doing the work twice.
  • Unverified (rule 58).
    • A real Zebra (DataWedge, keystroke output) on HTTPS staging. The pages have only seen keyboard input in a desktop browser.
    • Printing on a real label printer. Only the refusal and printer-timeout paths ran here; there is no printer.
    • Offline behaviour beyond "the request fails, and resending is safe".

Scan / gate state

npm run check:local, run on the final branch tip after rebasing onto main (it is five steps now that SonarQube is gone):

Check Result
validate (lint, typecheck, format) clean; 0 errors (lint warnings are the existing ones, plus nested-ternary and complexity warnings in the new scanner pages)
test:coverage 3,985 unit tests pass (14 skipped); 111 integration files, none failing; merged coverage thresholds met
markdownlint clean
Semgrep clean (no ERROR findings)
OSV skipped: the lockfile did not change
CodeQL runs in CI on this PR

Deferred (rule 50)

Listed in docs/domains/inventory.md → "The handheld floor" and tracked in the owner's ROADMAP.md → "Handheld floor (left by ledger-plan PR 4)":

  • an offline outbox;
  • the SCANNED move mode;
  • a truck place per run;
  • the store-grain count's whole-number columns;
  • the stop and run-status routes still on role lists;
  • a run that can be completed with stops open;
  • return pickups suggested by the run planner.

Test plan

  • On a phone-width browser, /app/scan/lookup: scan a product number, then a place label L-<id>.
  • Create a STAGING place, then on /app/scan/pick pick a run's unit to it. The pull list shrinks; "Load the truck" refuses while rows remain.
  • On /app/dispatch/driver, mark a stop arrived and complete it with one line set to 0. The order stays open, and a new DELIVERY appointment appears in the unassigned pool.
  • "Couldn't deliver" on another stop: it leaves the run, and the reason shows on the order's change log.
  • On /app/warehouse/pickups, add a pickup to a run, then complete it on the driver screen. The return reads PICKUP_COMPLETED.
  • Before a store relies on any of this: run receive, count, move and pick on a Zebra against HTTPS staging.

🤖 Generated with Claude Code

goetchstone and others added 9 commits October 16, 2026 13:39
…abels, the stock ledger check and the PWA manifest (migration follows with the last step)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…assembleUnits, box notes), one receive function for the desk and the handheld, and the handheld receive

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th Adjust inventory, counted-absent and committed shortfalls resynced) and the Oversold count flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p their order), stray-box notes, and move modes inherited down the place tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ray boxes reunited), ticking the run's pick list, and loading a run once nothing is left to pull

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s as run stops

A delivery stop now says what went, line by line. Fewer than all is a part
delivery: the units that went leave stock from the loading bay first and
OrderLineItem.fulfilledQty goes up; the rest stays committed to the order, and
a DELIVERY appointment for it goes back in the dispatch pool. The last units
are the handover, which now consumes only what was still to fulfil.
getActiveOrderLines returns the open quantity (ordered less fulfilled), so a
resync no longer commits stock again for units already delivered.

Completing a stop claims it first (status set where it was not COMPLETED), so
a retry or two racing taps cannot hand the same goods over twice.

A stop that could not be done comes off the run with the reason on the order
(DELIVERY_FAILED); its appointment goes back in the pool and the stock stays
the order's.

A return's pickup can go on a run (RETURN_PICKUP appointment, linked by
ServiceAppointment.returnId): completing it marks the return picked up and is
never a handover, and its order's lines are kept off the pull list and picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One additive migration for all of PR 4's schema (StockAction and
StockMovement.actionKey, Product.boxesPerUnit, BoxNote, StockCount and its
lines, RETURN_PICKUP and ServiceAppointment.returnId). Checked by migrate
deploy into an empty scratch database, then migrate diff against the schema:
no difference; the demo seed and seed coverage pass on it.

Runbooks: inventory (phases 15-20 and "The handheld floor"), service
dispatch (run stop outcomes and return pickups), purchasing (the one receive
function), printing (label contexts, ZPL escaping, routing), reporting (the
Oversold count flag), returns (two endpoints said to be ungated are gated on
sales.return), revenue recognition (the stop route and fulfilledQty have
changed under its plan). DECISIONS 21 (count by place; the store-grain count
stays for the cutover), 22 (boxes are evidence), 23 (a part delivery is not
the handover).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ound

- Label routing fell back to the first printer anywhere, so a warehouse
  place's label printed at a showroom. It now refuses, naming what to set up,
  when the store has no printer loaded with the template's size (rule 63).
- The driver's "Mark Arrived" was refused on every stop: the transition table
  allowed only PENDING -> EN_ROUTE, and nothing sets EN_ROUTE. PENDING ->
  ARRIVED is allowed, and an EN_ROUTE stop gets the button too.
- A failed stop whose appointment was IN_PROGRESS fell out of the dispatch
  pool. The pool's filter is one shared constant and includes it.
- A delivery of nothing is refused: that stop could not be done.
- The driver's message now says what the server did, not what was asked.
- "Transfer it first" compared a delivery's units with the store the order
  was sold at. A delivery leaves from its store or the store that fulfils for
  it; the pick page compares with the loading bay's store once scanned.
- A count of a place at another store than the scanner's store picker was
  refused; a place label says its own store.
- The receive confirmation was replaced by the PO header at once.
- The place lookup shows product numbers, which tell same-named products
  apart.
- Demo seed: committed stock and order-linked transfers now use products on
  the order, and the open runs carry undelivered orders, so picking and
  delivery can be shown at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At the owner's direction: the local SonarQube server was not being run, and
CodeQL (default setup, a required check that refuses a high or critical
alert) covers the analysis. The SonarSource rules worth keeping already run
in lint through eslint-plugin-sonarjs, which stays.

Gone: docker-compose.sonar.yml, sonar-project.properties,
scripts/sonar-set-gate.sh, the sonar:scan script, check:local's Sonar step
(now five steps), and the pre-PR hook's Sonar gate block with its
sonar-gate-justified marker. The session-start hook no longer queries the
server. Skills, the PR template and the runbooks (CI-OPERATIONS,
OPERATIONS, WORKFLOW, FRAMEWORK, RULE-PROVENANCE, buyer-drafts) say what
runs now. Source comments that cite a Sonar rule number are left: they
explain why code is shaped as it is.

docs-not-needed: tooling and docs only; the runbooks are updated in this
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant