Pick List — Technical Spec
1. Routes
All in app/ro_pick.py, router prefix /ro. Must be mounted before ro.py's router — ro.py has an unconstrained GET /ro/{order_no} that would otherwise swallow /ro/pick and 422.
| Method | Path | Purpose | Permission | |
|---|---|---|---|---|
| GET | /ro/pick?order_no=&pick_id=¬hing=&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/find | Resolve typed job text -> redirect with order_no | signed in | |
| POST | /ro/pick/find-job | Open/reuse a no-RO session against a QB customer | require_write | |
| POST | /ro/pick/{pick_id}/class | Set/change ro_pick.class_ref | require_write | |
| POST | /ro/pick/leave | "Change RO / customer" and the handheld Menu button (redirect_to=/handheld); deletes an empty session | require_write | |
| POST | /ro/pick/{pick_id}/save | Bulk save (action=save_later\ | post_now) | require_write; post_now also POST_ROLES |
| GET | /ro/orders/suggest?q= | RO typeahead | signed 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 row | signed 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).
roreads:ro_header_v,ro_header,ro_detail_v,ro_detail,ro_audit.rowrites:ro_pick,ro_pick_line,ro_audit(andro_txnat post time, inro_post.py).- IM2 reads only:
items(description_po,uom,primary_bin,gen_loc,qty_on_hand,purchase_cost,qb_item_id). IM2 unreachable →_item_attrs_for_skusswallows the exception and returns{}; the screen falls back to whatever RO already snapshotted.
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
find_order()resolves the typed string in order: numericorder_no→ exactsource_ref→ exactsource_id(only if unique) → numericfp_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.get_or_open_pick()reuses the OPEN session or inserts one and calls_seed_pick_lines()._seed_pick_lines()inserts one row perro_detail_vline withline_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.purge_empty_picks()hard-deletes OPEN sessions with noqty_pickedon any line andupdated_atolder thanEMPTY_PICK_GRACE_MIN = 30, skipping the session being viewed.delete_empty_pick()does the same for one session on save/leave.load_pick_lines()splitsmanualrows out as additions, overlaysgen_locfresh from IM2 for every line with noprimary_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 insortCards().
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=):
| Input | Result |
|---|---|
| blank/None | no_entry — nothing written, qty_picked stays NULL |
0 without confirm | needs_confirm → caller returns it as an error string |
0 with confirm | saved, zeroed = true (requirement dropped at post time) |
| negative | saved (OPTION_A_MIXED_POST = True; Option B path is dead code) |
| non-numeric | ValueError → 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
- QuickBooks:
search_qb_jobs()(Customer query, read-only, errors returned as{"error": "QuickBooks lookup failed: …"}),ro_import.class_for_job, and posting viaro_post.post. - No other external systems. Nothing here writes IM2.
7. Known limits / gaps
- A saved addition cannot be edited or deleted from the pick screens — its qty box renders
disabled. It can be corrected or removed from Pick Review viaPOST /ro/picks/{pick_id}/line-qty(qty 0 deletes a manual line), or the whole session discarded. save_order_line_qty()returnsNonewhenrowcount == 0: a staleline_key(BOM changed since the session opened) is silently ignored, no message to the operator.- Additions are written
line_type='INV', never'SURPLUS', soro_post.py's SURPLUS branch (consume the RO line, no QB document) is unreachable from these screens. _CLASS_TRIEDis per-process, so under multiple workers/restarts derivation may repeat; it is also never cleared, so a session that failed derivation once will not retry until restart.- Client-side confirms (zero-drop, over-on-hand) are the only real gate — the server treats an unconfirmed
0as an error rather than prompting.
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.