Pick Review & Posting — Technical Spec
1. Routes
app/ro_pick.py (screen + orchestration) and app/ro_post.py (QuickBooks posting). Template ro_picks.html. All routes require _require_post → POST_ROLES = ("admin", "level1"), else 403.
| Method | Path | Purpose | |
|---|---|---|---|
| GET | `/ro/picks?status=pending\ | posted` | Review screen |
| GET | /ro/picks/pending | Pending list as JSON (stale-data poll) | |
| GET | /ro/picks/{pick_id}/lines | Line detail for the expand toggle | |
| POST | /ro/picks/{pick_id}/post | Post one session | |
| POST | /ro/picks/post | Bulk post, body {"pick_ids": [...]} | |
| POST | /ro/picks/{pick_id}/discard | Status → VOID + ro_audit row | |
| POST | /ro/picks/{pick_id}/line-qty | Edit a saved line qty; qty=0 removes the line |
POST /ro/pick/{pick_id}/save with action=post_now funnels into the same _do_post.
2. Pending / posted queries
_pending_pick_rows: ro_pick LEFT JOIN ro_header LEFT JOIN ro_pick_line, status = 'OPEN', having count(pl.qty_picked) > 0 — a session with no entered qty is never listed. job_name = coalesce(p.qb_customer_name, h.job_name), bom_name = coalesce(h.bom_name, 'Direct issue — no RO'). Ordered updated_at desc. purge_empty_picks() runs on each pending render.
_extended_cost: one batched ro_pick_line read plus one IM2 read of coalesce(purchase_cost, 0); per session sum(abs(qty_picked) * cost), rounded to 2dp. IM2 unreachable → costs {} → all zeros. Absolute value, so returns increase extended cost.
Posted tab: status='POSTED', newest 100 by posted_at, showing posted_by, posted_at, qb_invoice_number, qb_credit_number.
3. _do_post(pick_id, user, raise_on_error=True)
_require_post;POSTING_ENABLEDfalse → 403 /{"ok": false, error: POST_BLOCKED_MSG}.- 404 if the session is missing; 400 if already
POSTEDorVOID. - Calls
post_pick_to_qb(pick_id, posted_by=user["username"])=ro_post.post(pick_id, posted_by=…). - Any exception:
str(exc)[:400]written toro_pick.post_errorin a separate transaction, then 502 (single post) or an entry in the bulkerrorslist. The session staysOPEN— an OPEN session withpost_errorset is the failed state (there is nopost_statuscolumn).
Bulk post loops _do_post(..., raise_on_error=False) and returns {"ok": true, "posted": n, "failed": m, "results": [...], "errors": [...]} — ok is true even when every session failed.
3a. POST /ro/picks/{pick_id}/line-qty
Form pick_line_id + qty (Dave, 2026-09-06). _require_post; 404 if the session or the line is missing, 400 if the session is not OPEN or qty is not a number.
qty != 0→ro_pick_line.qty_picked = qty,zeroed = false,updated_at = now().qty = 0andmanual→ the row is deleted (the addition disappears).qty = 0and an order line →qty_picked = null,zeroed = false(back to untouched; the requirement stays owed).- Always an
ro_auditrow:action='pick_line_edit',operator = user, notepick_id=… sku=… <old> -> <qty n|addition removed|back to not picked> (Pick Review). - Returns
{ok, pick_id, removed: qty==0, has_qty: pick_has_qty(...)}so the screen can tell whether the session still has any quantity on it.
4. ro_post.post(pick_id, dry_run=False, posted_by=None)
One ro transaction (with conn:), SELECT … FOR UPDATE OF p.
- Session must be
OPEN, elsePostError. - Class =
coalesce(p.class_ref, h.class_ref). Service RO with noqb_customer_idis pinned toSERVICE_CUSTOMER_ID = 15326("SA:Admin") withSERVICE_CLASS_ID = 1202540. Otherwise a blank class is derived fromro_typeviaRO_TYPE_CLASS(service/residential/commercial) and written back toro_header. No RO and no class →PostError(Dave's "prompt and force it or disallow"). qb_item_map(IM2items.qb_item_id) for every SKU; any missing →PostErrorNo QB item id for SKUs: …before any document is created.- Line bucketing:
zeroedorqty_picked == 0→ zero bucket;qty_picked is null→ skipped;line_type/ro_line_type == 'SURPLUS'→ surplus bucket (no QB doc); positive → Invoice line; negative → CreditMemo line. Each line isAmount 0,SalesItemLineDetailwithItemRef,Qty abs(qty),UnitPrice 0,ClassRef= resolved class. - Customer:
p.qb_customer_idelseh.qb_customer_idelseresolve_customer(job_name)— exactFullyQualifiedNamethenDisplayName; anything but exactly one match is aPostError. Resolved ids are written back toro_header. - Creates the
CreditMemofirst, then theInvoice(Dave, 2026-09-06), withTxnDate = today,PrivateNote/CustomerMemo=RO {n} pick #{id} — material issue (no charge)(or the direct-issue wording). Onero_txnrow per posted line (directionIssue/Return,qb_doc_type,qb_doc_id,qb_doc_number,source='Workbench',operator = ro_pick.operator). - Surplus lines:
ro_txnrow withqb_doc_type NULL, no QB document. - Zeroed lines:
UPDATE ro_detail SET qty_adj = -qty_source, qty_source_at_adj = qty_source, adj_by, adj_at = now(), adj_note = 'zeroed on pick #{id} (substituted/not needed)'— never touchesqty_sourceor the generatedqty_required. ro_pick→status='POSTED',posted_by = posted_by or head['operator'](whoever pressed Post;ro_pick.operatorstays the picker),posted_at = now(), plus both QB id / number pairs (either may be NULL).
Failure handling around those two calls:
- credit memo raises →
PostError"… Nothing was written to QuickBooks." and the DB transaction rolls back; nothing exists in QB. - invoice raises with a credit memo already posted →
qb.delete_doc("CreditMemo", id, SyncToken)(QBoperation=delete; invoices would bevoid). Returns True →PostErrorsaying the credit memo was removed again and nothing is left in QuickBooks. Returns False →PostErrornaming the credit memo and telling the operator to void it in QuickBooks by hand before posting again. delete_docnever raises: it returns False on any HTTP or transport failure.
Returns {pick_id, class_ref, invoice_lines, credit_lines, zeroed, surplus, qb: {invoice|credit: {id, number}}}.
5. Discard
status → 'VOID' plus an ro_audit row (action='pick_discard', operator, note='pick_id=…; discarded, not posted'). Never a hard delete. 400 if the session is not OPEN. (Empty sessions are hard-deleted, by purge_empty_picks/delete_empty_pick, because there is nothing to audit.)
6. Known limits / gaps
POSTING_ENABLED = Falseinro_pick.py— no real QB document can be created until it is flipped; the whole post path is therefore untested in production from the UI.- The
SURPLUSbucket is dead code as of this build (nothing writesline_type='SURPLUS'). _extended_costusesabs(), so the totals row is a gross material value, not a net.- Bulk post returns HTTP 200 with
ok: trueeven when every session failed. - Line editing is quantity-only: there is no route to change a SKU, a class or a line's
line_keyfrom this screen, andline-qtydoes not re-checkqty_oh. - If the invoice fails and the credit-memo delete also fails, the QB credit memo survives while the DB transaction rolls back — the operator message is the only record; nothing re-checks it later.
ro_txn.operatoris stillro_pick.operator(the picker), even thoughro_pick.posted_bynow records who pressed Post.
Batch discard (2026-09-06)
POST /ro/picks/discard with {"pick_ids": [...]} voids every selected session in one transaction and returns {"ok": true, "discarded": n}; any id that is missing or not OPEN produces a 400 naming it and nothing is voided. The screen no longer loops one request per id (Dave: partial completion under a "3 discarded, 1 failed" message). POST /ro/picks/{pick_id}/discard is unchanged for the per-row button.
Shared table sort (2026-09-07)
Column sorting is one implementation in templates/base.html (Dave, 2026-09-07: "all screens that have tables like this need sorts on the appropriate columns"). A table opts in with class="sorttable"; every thead th becomes sortable except those with class="nosort"/class="toggle", an empty heading, or a checkbox in the heading. Client-side only, over the rows already rendered. Details: tech/table_sorting.md.