RO Import Inbox — Detail
RO Import Inbox — Detail — Technical Spec
Route: GET /ro/inbox/{inbox_id} in app/ro_import.py.
Data
Reads one ro_inbox row plus all its ro_inbox_line rows, ordered by line_no. No writes happen on page load. A missing inbox_id renders the same template with not_found=True and HTTP 404.
Line columns rendered: line_no, line_type, sku, description, qty, uom, change_type/qty_prior (as New line / Dropped from source (was N) / Qty A → B), would_hold (row highlighted) and hold_reason. The Type dropdown and the three action buttons render only when can_write and the row is non-terminal. [Ver 2, 2026-09-06]
Actions on this screen
Same three POST routes as the list screen (/ro/inbox/{id}/hold, /ro/inbox/{id}/reject, /ro/inbox/{id}/accept), plus POST /ro/inbox/{id}/type (ro_type form field) for setting the operator-chosen type inline. See docs/tech/ro_inbox.md for the full contract of each.
Accept
POST /ro/inbox/{id}/accept:
- 400 if
ro_typeis not set, or the row is alreadyAccepted/Rejected. - Calls
promote_inbox_to_ro(inbox_id, user)(implemented inro_import.pysince 2026-09-05), outside any transaction this route holds, in its own transaction: on success it returns anorder_nohaving writtenro_header+ro_detailin full; on failure it raises having written nothing. If the source job already produced an RO it refreshes that one viarefresh_ro()and returns its existingorder_no— so a "successful Accept" does not always mean a new order number. Seedocs/tech/ro_inbox.md. - On success:
ro_inbox.status = 'Accepted',order_noset,ro_auditrow written, JSON{"ok": true, "order_no": N}returned. The template redirects the browser to/ro/{order_no}?imported=1. - On the stub raising:
ro_inbox.status = 'Error',parse_errorset to the exception text,ro_auditrow written, HTTP 502 with the error message. The row is untouched otherwise — Accept can be retried once whatever's wrong is fixed.
Review-screen header edit + pre-RO line fix (2026-09-07)
DDL: skills/requirements_orders/scripts/ro_inbox_review_2026-09-07.sql (applied live). New ro_inbox columns: qb_customer_id, qb_customer_name, class_ref, date_required, priority_code, link_system, link_order_no, link_label. ro_inbox.ro_type CHECK widened to match ro_header (Roofing/Administration were offered by the class list but refused on save). ro_detail.line_type CHECK now allows SURPLUS.
Routes (app/ro_import.py):
POST /ro/inbox/{id}/header— one save for the whole header; validates class against the RO classes setting, priority against R/1-5, and the source order number throughcheck_source_link(). Writes onero_auditrow per changed field (action='inbox_edit_header',note='inbox_id=<id>').GET /ro/inbox/link-check?system=&order_no=— FP is a livero_fp.find_joblookup (returns the FP record id, which is stored onro_inbox.fp_job_id); BT is checked against thebt_documentcache, because the app itself has no browser.GET /ro/inbox/suggest-date?ro_type=—po.add_working_days(today, lt_* setting).POST /ro/inbox/{id}/line/{line_no}— sets sku/qty on a staged line, clearswould_holdwhen the line is complete,drop=1deletes it. SKU is validated against IM2 before it is accepted.
promote_inbox_to_ro() now carries date_required (header value, else the class lead time), priority_code, class_ref, qb_customer_id/name, entry_source, and the link_system/link_order_no pair as the RO's source system/id. Header date_required seeds every ro_detail.date_required.
Refresh-vs-new fix: the prior-RO lookup matched on source_id alone, so a second BOM on the same FP job refreshed the first BOM's RO instead of creating a second RO. It now matches source_id AND bom_name, per the v3.4 design rule.
Review screen: surplus, BT free text, Fix-now autosave (2026-09-07)
ro_inbox_line.line_typeCHECK widened toINV | NONINV | SURPLUS(scripts/ro_inbox_surplus_2026-09-07.sql).POST /ro/inbox/{id}/line/{line_no}takessurplus=1: NONINV only, qty required (falls back to the line's own qty), setsline_type='SURPLUS', clearswould_hold/hold_reason, auditsinbox_line_surplus.promote_inbox_to_roalready passesline_typethrough and now keeps the SKU on a SURPLUS line.check_source_linkfor BT no longer fails on a cache miss: BT jobs have no order number (Dave, 2026-09-07), so any text is accepted,unmatched:trueflags the no-cache-hit case and the label isBT <text>. FP is unchanged (live lookup).- Lost-qty defect:
fixLine()only ran on the row's Save button, so a qty typed and left in the box was discarded and Accept promoted the line with qty 0 (inbox 22 → RO 799025: noinbox_line_fixaudit row exists, which is how this was diagnosed). Fix-now inputs now mark their row dirty on input, save onchange, andinboxAction('accept')callsflushAll()beforesaveHeader().fixLinegained aquietflag and returns a boolean so the flush can abort Accept on failure. - Accept's missing-class error message says "Class" (the field was renamed on 09-07).
NONINV Save no-op, and "Use xxxxx" hold_reason (2026-09-15, Dave)
- Save did nothing on a NONINV line with a qty already/newly entered.
inbox_fix_line()(POST /ro/inbox/{id}/line/{line_no}) computedstill_held = not new_sku or new_qty <= 0— a SKU was effectively required even for a deliberately non-inventory line, so a NONINV row with a qty typed in stayedwould_hold=true/hold_reasonunchanged and looked like Save had no effect. Fixed:still_heldis nownew_qty <= 0only — a SKU is never required to clear a hold. Also added: leaving the SKU box blank over a line that currently has one now explicitly clears it (sku = NULL, line_type = 'NONINV') instead of being silently ignored, matching the Issues screen's "blank SKU = stay/become non-inventory" rule from the same day. - "SKU not in the QB item master" now checks QB's inactive catalogue for a rename.
ro_parse.classify()takes an optionalinactive_idx({sku: item_name}for every QB-inactive item, fromqb_inactive_index()) and, when the unmatched SKU is found there, parses QB's own retirement convention"<old item> - Use <new SKU>"(use_replacement()). If the replacement SKU is itself active,hold_reasonbecomes"SKU not in the QB item master — inactive in QB, use <replacement>"; otherwise it names the inactive item so the reviewer can judge for themselves. Requiresqb_refresh.py's 2026-09-15 change (seebg_qb_item_refresh.md) to have backfilled QB's inactive items into IM2 — before that backfill this lookup finds almost nothing. Wired through both callers inro_parse.py(run()/recheck()) and all threero_fp.pycall sites (P.classify(lines, P.qb_skus(), P.qb_inactive_index())) so the manual "stage from FP/BT/upload" path and the hourly watcher both get it.
Screen title rename (2026-09-15)
Renamed from "RO Inbox — Detail" to "RO Import Inbox — Detail" everywhere user-facing (Dave) — matches the menu tile label. See docs/tech/ro_inbox.md for the full rename note (applies across the whole RO Inbox screen family).
Find SKU + Surplus mark (2026-10-03, Dave #sa-it-discussion 1791000954.909369)
GET /ro/inbox/{id}/line/{n}/find-sku[?q=]->{candidates:[{sku,item_name, description_po,manufacturer,uom,qty_on_hand,primary_bin,score,why}]}(max 8). Read-only.find_sku_candidates(part_no, description)inro_import.py: IM2items(archived_at null) cached 5 min; score 100 = part # equals sku / qb_sku_alias / mfr_part_no (alnum-compacted); 90 = part # (>=4 chars) inside name/PO desc/mfr/mfr PN; else description-token overlap ÷ description tokens, +0.15 (cap 0.85) if a part-# fragment matches, ×0.35 when a description number is missing from the item. Accents stripped, fractions → decimals,10AWG/#10→10. Floor 20.q= operator search (also tried as an exact part #). No AI.- UI:
.find-skubutton before.fix-sku; panel row#fs-{line}; Use fills.fix-skuand marks the row dirty (Save / Accept flush writes it). Nothing posts. - Surplus:
surplus=1now setspending_action='SURPLUS'(+ qty) only; auditinbox_line_mark_surplus. Promotion converts SURPLUS marks toline_type='SURPLUS', would_hold false. CHECKro_inbox_line_pending_action_checkwidened toNONINV|DROP|SURPLUS. Review CTE treats SURPLUS like NONINV. Legacy lines alreadyline_type='SURPLUS'with no mark render "Surplus on Accept"; Undo returns them to NONINV, held, reason "Surplus undone — needs review". - Column header Marked → On Accept. Removed a duplicated Detected/Status/ Reviewed/Note block (and a stray
</div>) that rendered above Accept. - Backups:
*.bak.pre_findsku_20261003_045639.
Ver 5 — 2026-10-03 header source details
- New
ro_inboxcolumns:bom_created_at timestamptz,bom_source_label text,fp_status_at timestamptz(additive migration). ro_inbox_watcher.py→refresh_header_info()runs after staging on every FP poll, for every non-terminal row withfp_job_id. It setsaddressfrom the job Location (/job/{id}?rel[location]; the job has onlylocation_id, which is why Address was always blank),link_status= FP job status name +fp_status_at, and the BOM date: material listcreated_at(labelFP ML · <title>) or the matched file'supdated_at(label = file name), from job + project files. FP timestamps are UTC.- Template: Imported from moved to col 1 beside Source order #; BOM created placed in its old slot; Status renamed RO Import Status.
- Backfilled 59 of 60 open FP rows 2026-10-03. BT/manual rows show the file name with no date.
Ver 6 — 2026-10-03 one import per FP material list
material_lists_for_jobs()now returns every live list per job (not deleted/archived, not Canceled status 1087683), oldest first; the watcher stages one row per list. Dedupe stays (FP #, bom_name); a repeated title on the same job gets " #<ML cuid>" appended.- Header label is now
FP ML #<cuid> · <title>(cuid = FP list #, e.g. #1260). Backfilled on open rows.
Ver 7 — 2026-10-28 Reopen a Rejected row
- New
POST /ro/inbox/{id}/reopen(write access): Rejected -> Awaiting Review, clears note, auditsinbox_reopen. 400 if not Rejected; 409 if a non-Rejected row with the same source_system/source_id/bom_name exists. Detail page shows a Reopen button on Rejected rows. Backups in /opt/im2/backups/*.pre_reopen.