RO Import Inbox
RO Import Inbox — Technical Spec
Bulk reject (2026-09-18)
The bulk bar now includes Reject selected. No new backend endpoint: after one confirmation and, when configured, one optional shared reason prompt, rejectSelected() sequentially calls existing POST /ro/inbox/{id}/reject for each rendered/checked id. This preserves the existing write-role, nonterminal-row, status update and per-row inbox_reject audit behavior. Failures are collected without stopping later rows; the summary is shown once, then the browser returns to /ro/inbox. Accepted/Rejected rows do not render checkboxes. No source record, BOM, RO, polling setting, or re-import rule changes.
Clean-first release and per-import counts (2026-09-18)
status=No%20Errors is a derived filter, not a stored status. It includes only Awaiting Review rows with error_count=0. INBOX_REVIEW_CTE is shared by the list, chip counts and optional clean_only=true acceptance guard. Reads only; no schema, polling, source-system or QuickBooks changes.
- A retained material line counts once if qty is null/nonpositive, or if it remains
would_hold, or if INV has no SKU. pending_action=DROPexcludes that line;NONINVclears hold/SKU errors but not missing quantity. These mirror the decisions applied at promotion.- Missing/invalid Class (against the configured class IDs) adds one problem.
- Error status or a nonblank parse_error adds one import problem.
- Zero retained lines adds one problem unless already covered by an import error.
- Held/Accepted/Rejected rows never enter No Errors, even with zero problems.
- Awaiting Review/Error totals are disjoint; never subtract them to infer clean.
- Filter is applied before the existing 500-row limit; counts cover the full set.
- Per-import pink Errors N links to its detail page; title/accessible label names the aggregate reasons. On Hold excludes lines already marked DROP/NONINV.
Select-all covers rendered checkboxes only. Both individual and bulk Accept from No Errors send clean_only=true; the server rechecks the stored row and returns 409 before promotion if it is no longer clean/Awaiting Review. Existing acceptance semantics elsewhere (including intentional acceptance with HOLD lines), permission gates and sequential bulk processing remain unchanged. This is staged-data readiness, not live source validation or a guarantee against runtime failure. There is no automatic acceptance during polling or page load.
Screen title (2026-09-15)
Renamed from "RO Inbox" to "RO Import Inbox" everywhere it's user-facing (Dave) — matches the menu tile label, which already said "RO Import Inbox". Changed: base.html's screen_names['ro_inbox']/['ro_inbox_detail'], the ro_inbox_detail.html title block, and every in-app link/back-button text ("← RO Inbox" -> "← RO Import Inbox" on ro_inbox_detail.html/ro_order.html, the RO Inquiry link label). Routes/URLs (/ro/inbox), table names (ro_inbox, ro_inbox_line) and code identifiers are unchanged — this is a display-text-only rename.
RO family jump-nav (2026-09-15)
Added a small nav bar (RO Inquiry / RO Import Inbox / RO Import Issues to Resolve) at the top of every RO screen, so an operator can jump between the three without returning to Main Menu — one change in base.html: ro_nav_screens (a fixed list of screen values) gates whether the bar renders at all, and the active tab highlights based on screen. Covers: ro_inquiry, ro_new, ro_order, ro_inbox, ro_inbox_detail, ro_issues, ro_issues_summary, ro_imports, ro_pick_full, ro_pick_item_full, ro_picks. Deliberately excludes the handheld ro_pick/ro_pick_item screens (phone-optimized, already skip the Main Menu link for the same reason). Existing single-purpose back-links on individual screens (e.g. ro_order.html's "← RO Inquiry"/"← RO Import Inbox") were left in place, not removed — this nav is additional, not a replacement.
Bulk accept (2026-09-15)
Added checkboxes (.pick-one, header #pickAll) and a bulk bar matching RO Import Issues to Resolve's pattern (.bulk/.pick shared styles from base.html). A checkbox only renders on rows still status not in ('Accepted','Rejected') — those are the only rows with an Accept button at all. No new backend endpoint — the bulk bar's acceptSelected() calls the existing single-row POST /ro/inbox/{id}/accept once per selected id, sequentially (not in parallel, so two staged rows for the same job/BOM can't both read "no RO yet" at the same instant and each try to create one). Each row becomes its own RO, same as accepting it alone. Failures (e.g. "choose a Class before accepting") are collected per-row and shown in one summary alert; a failure on one row does not stop the rest from being processed. Page reloads after the loop finishes.
Router: app/ro_import.py, mounted at /ro before ro_router (see the im2.py mount patch — this ordering is load-bearing: ro_router's /{order_no:int} and /{screen:str} catch-alls would otherwise swallow /ro/inbox).
Data
ro_inbox— one staged job (or manual xlsx upload). Never an RO. Seecreate_inbox_tables.sql.ro_inbox_line— the lines exactly as they'd land on Accept.
Both tables must be applied via create_inbox_tables.sql before this router is deployed — they do not exist in the schema this package shipped against.
Routes
| Method | Path | Notes | ||
|---|---|---|---|---|
| GET | /ro/inbox | List screen. status: Awaiting Review (default) / No Errors / Held / Rejected / Error / All. | ||
| POST | /ro/inbox/import | source (FP\ | BT\ | XLSX), source_id, optional bom_ref, optional file. See "Import flow" below. |
| GET | /ro/inbox/{inbox_id} | Detail screen (Screen B). | ||
| POST | /ro/inbox/{inbox_id}/type | ro_type — sets the operator-chosen RO type before Accept. | ||
| POST | /ro/inbox/{inbox_id}/hold | note optional; prompt controlled by ro_issues_ask_reason. | ||
| POST | /ro/inbox/{inbox_id}/reject | note optional; returns to Inbox after success. | ||
| POST | /ro/inbox/{inbox_id}/accept | Calls promote_inbox_to_ro(). Requires Class; optional clean_only=true rechecks readiness. |
All POST routes require level2+ (ro_import.can_write / require_write — a fixed role check, admin/level1/level2, per Ver 3 addendum ruling C.3, not a role_action_perms lookup).
Import flow (stage_source_job)
No longer a stub [Ver 2, 2026-09-06]: stage_source_job() now dispatches into app/ro_fp.py (stage_fp_job(), stage_bt_job(), stage_upload()); lookup_item() / search_items() query the IM2 items table directly; promote_inbox_to_ro() is implemented in this file. The contract below still holds — every caller treats a raise as "nothing was written".
One entry point covers all three sources:
- FP: one call,
stage_source_job("FP", source_id, user)→{"inbox_id": N}. - BT: two calls.
stage_source_job("BT", source_id, user)(nobom_ref) →{"choices": [...]}, nothing staged yet.stage_source_job("BT", source_id, user, bom_ref=<picked>)→{"inbox_id": N}.
- XLSX: one call with
file_bytes/file_nameset instead ofsource_id→{"inbox_id": N}. The form'ssource=XLSXis dispatched through the same branch asMANUAL. Ruled by Dave 2026-09-04: an uploaded sheet and a hand-typed order are both staged assource_system='MANUAL'(added to the CHECK onro_inbox/ro_header/ro_import_log) withentry_source='Manual'andsource_ref=file_name— not NULL, and not'XLSX'. The upload is rejected client-side unless the filename ends.xlsx/.xlsm(a PDF BOM cannot be parsed), and server-side with the same check. [Ver 2, 2026-09-06]
A raise from stage_source_job at any point means nothing was written — the route returns {"ok": false, "error": ...} with HTTP 502, and the operator can just retry.
Accept: promote or refresh (promote_inbox_to_ro) [Ver 2, 2026-09-06]
Dave, 2026-09-05: a re-import must refresh the RO the source job already produced, not create a second one. promote_inbox_to_ro() first matches source_id plus BOM name (newest first); if found it hands off to refresh_ro() and returns that existing order_no. refresh_ro():
- matches source lines to
ro_detailby SKU (case-insensitive), and NONINV lines by description text (without this every no-SKU line re-added itself as "new" on each refresh); - qty differences →
qty_sourceupdated in place plusqty_source_at_adj/adj_by/adj_at/adj_note("Re-import of X: qty A → B"), onero_bom_auditand onero_auditrow each; - new source lines → inserted with
origin='Source'and the same trail; - lines no longer on the source →
line_status='HOLD'with an explanatoryadj_noteand an openro_issue(issue_type='no qty'). Never deleted — material may already be picked or on a PO; _open_issue_exists()(added 2026-09-05) stops a second identical Open issue stacking on the same line, which used to keep the line on HOLD forever;- writes
ro_audit(action='ro_refresh') with the touched-line count.
On the fresh path it inserts ro_header (job_name falling back to customer_name / FP <id>, bom_name falling back to source_ref / Inbox <id>, carrying fp_material_list_id, fp_job_id, fp_job_cuid, source_system, source_id, note) then one ro_detail per ro_inbox_line, mapping would_hold to line_status='HOLD' and raising a ro_issue ('SKU not in QB' when the reason mentions QB, else 'no qty'). ro_header_job_bom_uniq (job_name, bom_name) makes a duplicate Accept fail cleanly.
A held line is non-blocking; the rest of the order picks, posts and orders normally. Once the last Open issue on a line is closed, ro.sync_line_hold() clears the HOLD by itself (Dave, 2026-09-05).
Status machine
Awaiting Review / Held / Error → (Accept) → Accepted, or → (Reject) → Rejected. Accepted and Rejected are terminal — every action route 400s on a terminal row. One exception, added 2026-09-05: POST /ro/{order_no}/delete (admin only, in ro.py) sets any ro_inbox row pointing at that RO back to status='Awaiting Review' with order_no, reviewed_by and reviewed_at cleared and a note — a deleted RO's source job returns to this queue.
Transaction rules (unchanged)
See the docstrings on stage_source_job() and promote_inbox_to_ro() in app/ro_import.py: a raise from either must never leave a half-written row. POST /ro/inbox/{id}/accept still updates ro_inbox in its own follow-up transaction, and on a raise sets status='Error' + parse_error and returns 502.
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.
Accept looked hung with no feedback (2026-09-15, Dave)
Accept on Import Inbox Detail can take ~10s (flush any dirty Fix-now edits, save the header, then create the RO) but the "Working…" message next to the buttons only got set right before the final fetch — the whole flush/save phase before it gave zero feedback, and the buttons stayed clickable the entire time. inboxAction() now sets Working… and disables all .actions-bar buttons before any of that starts, and clears/re-enables in a finally so a failed step still leaves the screen usable.
Fix-now Save gave "line 0 not found on inbox row N" (2026-09-15, Dave)
flushRow() computed the line number from row.children[0].textContent — the LINE # cell only when there's no pick-checkbox column. Once any line on the row is held (held_count > 0), a checkbox <td> is inserted as children[0] instead, which has no text, so Number('') came out 0 and the save failed. Every <tr> in the lines table now carries data-line="{{ ln.line_no }}"; flushRow() reads that instead of counting cells.
Qty box spinner arrows removed (2026-09-15, Dave)
"Remove the v^ in the qty box" — the native up/down spinner on <input type=number> is now hidden site-wide via a rule in base.html (::-webkit-outer/inner-spin-button + Firefox -moz-appearance:textfield), not just on this screen.