Issues to Resolve — One RO, All, Unattached
Issues to Resolve — flat list routes — Technical Spec
app/ro.py. One template, ro_issues.html, screen key ro_issues, serves three GET routes that differ only in the filter they add and the title_suffix / chip_url they pass. The summary screen at /ro/issues is documented separately (help/tech ro_issues_summary).
Routes
| Route | Function | Extra filter | title_suffix |
|---|---|---|---|
GET /ro/issues/all | issues_screen() | none | "" |
GET /ro/issues/unattached | issues_unattached_screen() | order_no is null | " — Unattached" |
GET /ro/issues/{order_no:int} | issues_by_order_screen() | order_no = %s | " — RO {order_no}" |
POST /ro/issues/{issue_id}/action | issue_action() | — | form: action, note, qty, sku (value kept for old callers) |
POST /ro/issues/bulk | issues_bulk() | — | form: action, note, issue_ids (CSV), qty |
All three GETs take status (default Open), issue_type, search. _query_url() builds chip links that merge the current filters with one override, so chips compose. All three pass back_url="/ro/issues", back_label="Issues Summary".
{order_no:int} is declared after /all and /unattached, so the literal paths win.
Query
query_issues(conn, status, issue_type, search, order_no, unattached, limit=LIST_LIMIT) against ro_issue:
statusunlessAll;issue_typeexact;searchILIKE acrosstitle, sku, source_ref, detail.order_noandunattachedare mutually exclusive by construction (no caller passes both).order by created_at asc limit 1000, plus a separatecount(*)for the total.- Adds computed
age_days = now()::date - created_at::date; the template styles>= 7red.
issue_status_counts(conn) is global (no filters at all) — the N open badge counts every Open issue in the system, even on a per-order list. Intentional but easy to misread.
ISSUE_TYPES: SKU not in QB, no qty, unmatched job, duplicate line, header only, unmatched NONINV refresh, unlinked QB PO. ISSUE_STATUSES: Open / Resolved / Ignored / All.
Write path — apply_issue_action()
Single chokepoint for both the row action and bulk. Order of operations:
require_write(user)(level3 rejected) — the routes themselves first call_ro_write(user)→require_action(user, "ro_write"), default admin + level1.actionmust beresolveorignore;ignoreis rejected for types inNO_IGNORE = {"no qty"}; an issue not in statusOpenis rejected 400.- Typed resolves, from
RESOLVE_INPUT = {"SKU not in QB": "sku", "no qty": "qty"}:
- the target line is found by
_issue_line()—line_keyfirst, elseorder_no + coalesce(src_ref, sku)and only when that matches exactly one row; no line → 400 "resolve it on the RO Maintenance screen instead". - sku: value required, must exist in IM2
items(_sku_exists(), cross-database viaim2_db()); setsro_detail.line_type='INV',sku,adj_by,adj_at; writes aro_auditrowissue_resolve_sku; also stampsro_issue.sku. - qty: must parse as float and be
> 0; setsro_detail.qty_source(+adj_by,adj_at);ro_auditrowissue_resolve_qty; stampsro_issue.qty.
ro_issue.status/resolved_at/resolved_byupdated.- One
ro_auditrowissue_resolve/issue_ignore.ro_audithas no issue column, so the id is carried innoteas a parseable prefix:issue_id=<id>; resolved as …/issue_id=<id>; ignored: …. sync_line_hold(cur, line_key, operator)— if the line has no Open issue left,ro_detail.line_statusHOLD → NULL plus aro_audithold_clearrow.Cancelled/Closed Shortare left alone.
Bulk calls the same function per id with no value, collecting HTTPException.detail per failure into errors and returning {ok, changed, errors}. All ids share one ro_db() transaction, committed once when the request ends, so a rejected id (validation raises before any write) does not undo the ones that succeeded.
Client behaviour (ro_issues.html)
NEEDS_SKU_TYPES/NO_IGNORE are duplicated in JS to drive the Resolve/Ignore modal and to hide the Ignore button for no qty rows; the server enforces the same rules, so the JS copy is cosmetic. The modal (#resolveModal) replaced the old prompt() chain 2026-09-15 — see the section above. After a successful action the row is removed from the DOM and the counts decremented only when the active status filter is Open; otherwise location.reload().
The Help button on this template links to /docs/help/ro_issues_summary. See Known limits.
Known limits / gaps
- Help button points at the summary guide, not this one.
ro_issues.htmlhard-codes/docs/help/ro_issues_summary; it should be/docs/help/ro_issues. Patch noted ingroup5_helplinks.md. [found 2026-09-06] - The open badge is global, not scoped to the current list (see above).
- The JS duplicates the server's typed-resolve rules — change both together.
- No
ro_viewgate on the GETs (Depends(current_user)only); see techro_inquiry.
Permission gate (added 2026-09-06)
Every route in ro.py now depends on ro_user() instead of current_user directly. ro_user() calls require_action(user, "ro_view"), so revoking View Requirements Orders screens on the Field Permissions screen actually blocks the RO screens (403) — before this the key existed and nothing read it. All four roles hold ro_view by default, so nothing changed for anyone until someone revokes it. Pick and Rush screens live in ro_pick.py / rush.py and keep their own ro_write / rush_* checks.
Use Surplus + UOM (2026-09-07)
apply_issue_action() takes a third action, surplus, alongside resolve/ignore. It is refused unless the RO line is NONINV. It sets ro_detail.line_type = 'SURPLUS', stamps adj_note, writes two ro_audit rows (line change + issue status) and calls sync_line_hold() like the other paths.
po._demand_lines() excludes line_type = 'SURPLUS', so a surplus line never reaches the workbench or a buy list. Surplus lines are not yet on the pick screens — ro_pick still selects line_type = 'INV' only; the "SURPLUS row on the pick list" work is separate.
query_issues() is now aliased (ro_issue i) and left-joins ro_detail on line_key to bring uom and line_type onto the row. Every filter is i.-qualified — the join made sku and order_no ambiguous.
Quantity-required issues are decided by the line, not the label (2026-09-07)
The old rule was NO_IGNORE = {"no qty"} keyed on issue_type. The importer files the same defect under several labels (no qty, Qty problem for an unparseable source qty, sometimes Review), so a SKU line with qty 0 could be resolved with no quantity and Ignore was still offered. Dave, 2026-09-07: "SKU items must have a qty, no ignore option."
ro._needs_qty(issue, line) is now the single test: an INV line (or any line with a SKU) whose ro_detail.qty_source is null or <= 0 requires a quantity. It gates all three paths:
query_issues()stampsneeds_qtyon every row (the query also returnsline_qty/line_skufor it); the template rendersdata-needsqtyand hides the Ignore button on those rows.apply_issue_action()refusesignore(400) and forcesneeds = "qty"onresolveregardless of issue_type, so the API and the bulk actions are covered too, not just the buttons.- Client-side, Resolve prompts for the qty and rejects <= 0 before the round trip.
RESOLVE_INPUT also gained "Qty problem": "qty" so the label-driven path agrees with the line-driven one.
Qty/SKU split on Resolve, reason-note syscon, bulk qty (2026-09-15, Dave)
Three related changes, all requested in the same message:
apply_issue_action()now takes separateqtyandskuform fields instead of one genericvalue. A NONINV line that is simultaneously unmatched (issue_typeSKU not in QB) and missing a quantity no longer forces a SKU out of the operator — supplying onlyqtyresolves the quantity and leaves the line NONINV; supplyingsku(with or withoutqty) converts it to INV as before.needs(fromRESOLVE_INPUT/_needs_qty) still decides what's required:qtyrequired whenever the line needs one,skunever required. The oldvalueparam is still accepted (mapped onto whichever of qty/sku the issue type needed) so nothing already calling the old shape breaks.POST /ro/issues/bulktakes an optionalqty, applied to every selected id via the sameapply_issue_action()call — ids that don't need a qty simply ignore it. No bulksku— a shared SKU across different lines isn't a safe default, so a row needing a SKU match still has to be resolved one at a time.- New syscon
ro_issues_ask_reason(im2.pySETTING_DEFS/BOOL_SETTINGS, defaultNo). The client reads it asask_reasonin the three issues-screen routes (setting_bool("ro_issues_ask_reason")) and only shows the note textarea in the Resolve/Ignore modal when it's Yes. Off by default per Dave, 2026-09-15.
Client-side, the old prompt() chain (value prompt, then note prompt) is replaced by one modal (#resolveModal in ro_issues.html): Quantity box (shown/required per data-needsqty), SKU box (shown whenever the issue is SKU not in QB or the line is NONINV; always optional), and the reason textarea (shown only when ASK_REASON is true). Bulk resolve opens the same modal with just the Quantity box when any selected row still needs one, defaulting to blank (operator usually types 1).
RO Inbox back-link on the order screen (2026-09-15, Dave)
ro_order.html's top bar only had "← RO Inquiry", but Accept on the RO Inbox (ro_import.py / ro_inbox_detail.html) navigates straight to /ro/{order_no}?imported=1 with no way back to the Inbox queue. Added a second link, "← RO Inbox" → /ro/inbox, always shown (not gated on the imported banner) since any RO can legitimately have started life there.
Resolve wrongly demanding a qty that was already there (2026-09-15, Dave)
needs (what Resolve requires) came purely from RESOLVE_INPUT.get(issue["issue_type"]) -- fixed at import time. For a no qty/Qty problem issue, needs == "qty" never changed even after the line's quantity was fixed some other way (Fix-now on RO Import Inbox, a manual RO edit, an earlier resolve) while the issue itself stayed Open. The frontend already used the line's CURRENT quantity (_qty_missing(), refreshed every page load) to decide whether to show the Qty box as required — so the modal looked optional, but the backend still hard-blocked with "a quantity is required to resolve this issue" on submit. Fixed apply_issue_action() to only require it when the line's current qty is actually still missing (or the line itself can't be found) — if needs == "qty" and not qty_str and (line is None or _qty_missing(line)).
qb_skus() wasn't active-only — retired SKUs stopped being flagged (2026-09-15, Dave)
Found via "This one has an inactive record pointing to a good SKU": ro_parse.py::qb_skus() (the "known" set classify() checks a BOM's SKU against) had no qb_active filter. Before today's 926-row QB-inactive backfill this was harmless — IM2 held almost no inactive rows — but afterward it meant ANY sku with an inactive IM2 row counted as "known", so a BOM line referencing a retired SKU stopped getting flagged/held at all, let alone getting the "use X" suggestion. Fixed: qb_skus() now filters qb_active = true. Backfilled 3 already-open SKU not in QB issues (647, 693, 694) whose title/detail predated the fix and never picked up the "use X" hint their SKU's inactive record already had by the time this ran.
Resolve stopped showing the Quantity box when it isn't needed (2026-09-15, Dave)
Follow-up to the fix above: even after that fix, the Resolve modal still showed the Quantity box (as optional) on every row, including a SKU not in QB row that already had a perfectly good quantity — Dave: "the only issue is asking for a qty when it's not needed." showQty was hardcoded true for every Resolve regardless of type. Changed to showQty = qtyMissing || issueType in {'no qty', 'Qty problem'} — the box only appears when the line genuinely has no usable qty yet or the issue is qty-flavoured. When it does show but isn't required (line already has a qty, e.g. the no qty type after some other fix landed), it now defaults to the line's current quantity instead of blank, per Dave's own suggested fallback. Bulk resolve already had the equivalent anyNeedsQty-gated behavior and needed no change.
Bulk Resolve on "SKU not in QB" rows silently failed (2026-09-15, Dave)
Bulk resolve has no SKU field — each SKU not in QB row would need its OWN, different SKU, which a single bulk box can't supply. Selecting several and clicking "Resolve selected" posted qty="" sku="" for each; the backend correctly rejected every one ("enter a quantity, a SKU, or both") but that surfaced as a confusing "0 updated" plus a wall of per-issue errors. Now caught client-side before the modal even opens: if any selected row is SKU not in QB, an immediate message explains bulk Resolve can't do those and to resolve them individually (each row's own modal already has the SKU box) or bulk-Ignore them instead. Bulk Ignore is unaffected (no SKU needed there).
Bulk Ignore/Resolve had one dialog too many (2026-09-15, Dave)
Bulk Ignore/Resolve used to chain three separate boxes: a native confirm(), then the modal (with its own Cancel/Save), then a final alert("N updated"). Dave: "too many boxes to respond to." The native confirm() was pure redundancy — the modal's own Cancel/Save already is the confirmation step — so it's gone. The final alert now only fires when something needs attention (partial or zero success with per-issue errors); a clean full success reloads straight into the updated list with nothing to dismiss. Net: one dialog (the modal) for the common case instead of three.
"Ignore" wording only means Non-Inv for SKU-not-in-QB rows (2026-09-15, Dave)
"Ignore" is the shared close-this-issue action for every issue type (SKU not in QB, duplicate line, unmatched job, header only, unmatched NONINV refresh, unlinked QB PO) — it just marks the issue Ignored, no line change, except that a "SKU not in QB" line is already sitting as NONINV pending a QB item, so ignoring it in practice means "leave it non-inventory." The single-row button already said "Leave Non-Inv" for line_type == 'NONINV' rows; the bulk button/modal title now does the same (via each row's data-linetype) — "Leave Non-Inv selected" / "Leave N as Non-Inv" when every checked row is NONINV, otherwise the generic "Ignore selected" wording stays, since Ignore doesn't mean Non-Inv for the other issue types.
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.