← All manuals Operator Guide for this screen IM2

Pick List — Technical Spec

Pick List (handheld) + Pick List Full Screen (PC)
Technical Spec Ver 2 Updated 2026-09-06 Matches the live screen

1. Routes

All in app/ro_pick.py, router prefix /ro. Must be mounted before ro.py's routerro.py has an unconstrained GET /ro/{order_no} that would otherwise swallow /ro/pick and 422.

MethodPathPurposePermission
GET/ro/pick?order_no=&pick_id=&nothing=&kept=Handheld Pick List (ro_pick.html)signed in (save gated by can_write)
GET/ro/pick-full?…Desktop sibling (ro_pick_full.html)same
POST/ro/pick/findResolve typed job text -> redirect with order_nosigned in
POST/ro/pick/find-jobOpen/reuse a no-RO session against a QB customerrequire_write
POST/ro/pick/{pick_id}/classSet/change ro_pick.class_refrequire_write
POST/ro/pick/leave"Change RO / customer" and the handheld Menu button (redirect_to=/handheld); deletes an empty sessionrequire_write
POST/ro/pick/{pick_id}/saveBulk save (action=save_later\post_now)require_write; post_now also POST_ROLES
GET/ro/orders/suggest?q=RO typeaheadsigned in
GET/ro/jobs/suggest?q=QB customer typeahead (cached via ro_import._cached)signed in
GET/ro/pick-item/lookup?sku=IM2 attrs for the add rowsigned in

redirect_to on the find/save/leave/class posts is whitelisted against _PICK_LIST_TEMPLATES (/ro/pick, /ro/pick-full) and _PICK_ITEM_TEMPLATES; anything else falls back to /ro/pick. The handheld templates ro_pick.html and ro_pick_item.html each carry a second /ro/pick/leave form with redirect_to=/handheld — the Menu button: it leaves the pick, clears the remembered-job cookie and returns to the handheld menu.

Roles: POST_ROLES = ("admin", "level1") for posting (_require_post); picking is can_write / require_write (level2+). POSTING_ENABLED = False and POST_BLOCKED_MSG = "Posting is not allowed during test." are module constants — the only test lockout.

2. Databases and tables

Two databases: ro (via ro.ro_db(), RO_DSN) and IM2 (im2.db(), IM2_DSN).

Session columns used: ro_pick.status (OPEN/POSTED/VOID), screen (PICK_LIST/PICK_ITEM, treated as "most recently used"), operator, class_ref, qb_customer_id, qb_customer_name, post_error, posted_by, posted_at, qb_invoice_id/_number, qb_credit_id/_number. ro_pick_line snapshots description, uom, primary_bin, qty_required (= remaining), qty_ordered, qty_issued, qty_oh, plus qty_picked (NULL = untouched), zeroed, manual, line_key, line_type. Unique (pick_id, sku); partial unique index gives one OPEN session per order and one OPEN no-RO session per operator+QB job.

3. Session lifecycle

  1. find_order() resolves the typed string in order: numeric order_no → exact source_ref → exact source_id (only if unique) → numeric fp_job_id/fp_material_list_id → ILIKE fuzzy across source_ref/job_name/bom_name/source_id/fp ids, accepted only if exactly one row matches.
  2. get_or_open_pick() reuses the OPEN session or inserts one and calls _seed_pick_lines().
  3. _seed_pick_lines() inserts one row per ro_detail_v line with line_type='INV', line_status is null, qty_open > 0; qty_required = qty_open, qty_issued = qty_issued - qty_returned. on conflict (pick_id, sku) do nothing.
  4. purge_empty_picks() hard-deletes OPEN sessions with no qty_picked on any line and updated_at older than EMPTY_PICK_GRACE_MIN = 30, skipping the session being viewed. delete_empty_pick() does the same for one session on save/leave.
  5. load_pick_lines() splits manual rows out as additions, overlays gen_loc fresh from IM2 for every line with no primary_bin (never stored), and sorts order lines by _bin_sort_key (binned lines by bin first, then unbinned grouped by gen_loc) — mirrored client-side in sortCards().

4. Class resolution

resolve_class() = ro_pick.class_ref else ro_header.class_ref. ensure_pick_class() fills a blank one: _derive_class() (→ ro_import.class_for_job("QB", …), validated against class_ids()), then, for direct issues only, _last_operator_class() (most recent ro_pick.class_ref by that operator). Derived values are written onto the session. Live QB reads make this expensive, so it is attempted once per pick_id per process (_CLASS_TRIED, an in-memory set). Class list comes from IM2 Settings "RO classes" via ro_import.qb_classes(), with _CLASS_FALLBACK (Residential 1202519, Commercial 1202520, Service 1202540, Administration 1202535) if that fails. Only ids in class_ids() are accepted by POST /ro/pick/{pick_id}/class (else 400).

5. Quantity semantics

apply_qty_entry(raw, zero_confirmed=):

InputResult
blank/Noneno_entry — nothing written, qty_picked stays NULL
0 without confirmneeds_confirm → caller returns it as an error string
0 with confirmsaved, zeroed = true (requirement dropped at post time)
negativesaved (OPTION_A_MIXED_POST = True; Option B path is dead code)
non-numericValueError → error string

POST /ro/pick/{pick_id}/save form shape: qty__<line_key> / confirm__<line_key> per order line, plus either one add_sku/add_qty/add_confirm row (RO pick) or add_sku__<i>/add_qty__<i> (direct issue). Add rows: blank SKU skipped, duplicate SKU (case-insensitive) rejected, SKU with no qty rejected, unknown SKU rejected (SKU … not found in IM2), SKU already a non-manual line rejected. Errors are collected and returned as 400 {"ok": false, "errors": [...]}. Save all is all-or-nothing (Dave, 2026-09-06): every line and add row is applied inside one ro_db() transaction and a non-empty error list raises _PickSaveRollback, so psycopg rolls the entire submit back — nothing is saved and the picker re-submits the corrected screen. manual additions are inserted with line_type = 'INV' and line_key = NULL.

Rejected/aborted states: session not found → 404; status != OPEN → 400; action=post_now from a non-POST_ROLES user → 403; posting disabled → 403 POST_BLOCKED_MSG.

6. External calls

7. Known limits / gaps

Surplus lines on the pick screens (2026-09-07)

DDL: skills/requirements_orders/scripts/ro_pick_surplus_2026-09-07.sql. The unconditional unique index ro_pick_line_uniq (pick_id, sku) is now partial (where line_type <> 'SURPLUS') — every surplus row carries the literal sku 'SURPLUS' (ro_pick.SURPLUS_SKU), so an RO with two surplus lines would have violated it. Manual addition rows are always line_type = 'INV' and keep the old protection.

_seed_pick_lines() now selects line_type in ('INV','SURPLUS') and is also re-run on every reuse of an open session (get_or_open_pick), filtering out line_keys already on the session. A session opened before a NONINV line was resolved to surplus therefore picks the new line up instead of staying frozen at open time. ON CONFLICT (pick_id, sku) was relaxed to ON CONFLICT DO NOTHING because surplus rows are no longer covered by that index.

Posting needed no change: ro_post.post() already routes SURPLUS lines to a ro_txn row with no QB document, and po._demand_lines() already excludes them.

Bug found and fixed while testing: @router.post("/pick/{pick_id}/save") sat directly above class _PickSaveRollback, so the decorator bound the exception class as the endpoint. Every Save/Post from the Pick List screen 422'd with body.errors: Field required — the pick screens could not save at all. The decorator now sits on pick_list_save. Verified live: qty saved, redirect 200.