Rush Material Request — Technical Spec
Rush Material Request — Technical Spec
app/rush.py, templates ro_rush.html. Mounted before ro_router, same reason as ro_import.py (literal /ro/rush must not fall into ro.py's catch-alls).
Routes
GET /ro/rush— the screen. No permission gate beyond being logged in: any role, including level3, may open and submit this screen. This is the only place in the app a level3 user writes anything.GET /ro/rush/job-search?q=—ro_import.search_jobs()(FP + QB in parallel, 120 s cache, no BT — same as/ro/new), then re-ranked server-side by_rank_jobs()and cut to 8. Norequire_write()gate, unlike the/ro/newequivalent, since level3 must be able to use it. Minimum 3 characters (2 let FP's loose match flood the list); below that it returns{"results": [], "q": q}._rank_jobs()(Dave, 2026-09-05 — "too many returns on the job prompt") scores each hit against the typed text overname + detail + id: whole-phrase match first (earlier position wins), then all-tokens-present, then a single-worddifflibratio ≥ 0.78 for a dictation slip — never a loose substring.GET /ro/rush/item-search?q=— thin wrapper overro_import.search_items()(fuzzy ILIKE acrosssku/item_name/description_po). See "Open questions" below on why this function, notlookup_item(), despite the brief's wording.POST /ro/rush— creates the RO. JSON body, not form-encoded (lines are a nested array). See "Payload shape" below.
What gets written
One ro_header row:
| Column | Value |
|---|---|
job_name | The tapped match's name, or the raw typed/dictated text if nothing matched |
bom_name | Generated: Rush <UTC timestamp> #<6 hex> — see "Open questions" |
ro_type | 'Service' — hardcoded, see "Open questions" |
source_system | 'FP' if a FieldPulse job was matched, else 'MANUAL' |
source_id / fp_job_id | Set only when source_system = 'FP' |
entry_source | 'Rush' — new column, see ddl/rush_ro.sql |
priority_code | 'R' — always, this is the only path in the app that writes it besides the new Workbench toggle |
notes | The optional Note field, or NULL |
ro_type value | RUSH_RO_TYPE = 'Service' |
created_by | The submitting user's username |
One ro_detail row per line, origin = 'Manual', line_no in submission order. A line whose SKU was accepted (matched and re-validated server-side against the item master) is INV; every other line — non-stock, or a SKU that failed server-side validation — is NONINV, with a ro_issue ('SKU not in QB') raised on the latter case only, mirroring /ro/new's existing fallback. suggested_vendor carries the per-line vendor hint verbatim. Duplicate SKUs within one submission are merged (quantities summed) before insert — ro_detail_order_sku_uniq allows a SKU only once per order.
One ro_audit row on the header (action = 'rush_create', field priority_code, new value R) noting line count and whether the job match was confirmed by a tap or left as the automatic guess. Plus, per line, one extra ro_audit row (field = 'description') whenever the tech's dictated words (said) differ from the description actually stored — tapping a SKU replaces the box with the catalog description, and the tech's own words have to survive in the trail (Dave, 2026-09-05). [Ver 2, 2026-09-06]
Never written by this screen: any cost field, any QuickBooks call, any PO object. Rule 0 of the brief.
Payload shape (POST /ro/rush)
{ "job_name": "Ernest Gabriel — 4162 Durham", "job_source": "FP", "job_source_id": "4548", "fp_job_id": 918822, "job_guessed": false, "note": "", "lines": [ {"description": "20 ft of 10-3 MC cable", "qty": 20, "sku": "51310", "non_stock": false, "sku_guessed": true, "uom": "FT", "vendor_hint": "Elliott", "said": "twenty feet of ten three MC"} ] }
Validation: job_name required (400), at least one line (400), per line a non-blank description and a numeric qty > 0 (400 naming the line). uom defaults to the matched item's UOM, else EA.
job_guessed / sku_guessed are read-back state only — the server never trusts sku blindly regardless of these flags (see lookup_item() re-validation above).
Notifications
app/notify.py: notify_slack(), notify_sms(), notify_rush() (assembler), notify_release() (written, not called — see its own docstring). Fired after the RO transaction commits, wrapped in try/except in rush.py's route handler — a notify failure can never roll back or fail the request. Config via env (RUSH_SLACK_WEBHOOK_URL, CLICKSEND_USERNAME, CLICKSEND_API_KEY, CLICKSEND_SENDER_NUMBER) — unset means a logged warning, not an error.
Open questions for Viktor/Dave (see the handoff README for the numbered list)
ro_typehardcoded to'Service'.bom_nameis generated, since Rush has no BOM concept.entry_sourceis a new column (no existing use onro_header).search_items()used for SKU suggestions instead oflookup_item()(which takes an exact SKU, not free text).
Alert delivery is now recorded (added 2026-09-06)
notify.notify_rush() returns {"slack": …, "sms": …} where each value is True (delivered), False (tried and failed, including missing configuration) or None (switched off in System Controls). rush.py writes that outcome to ro_audit as action rush_notify and, if either channel failed, appends a warning to the operator's confirmation: the request is saved but the office was not alerted. Before this, a missing webhook or missing credentials meant the request was filed and nobody was told, with nothing on the RO to show it.
CLICKSEND_USERNAME / CLICKSEND_API_KEY / CLICKSEND_SENDER_NUMBER are now set in /opt/im2/env, so SMS works as soon as Text rush alerts is switched on and at least one rush contact is active. RUSH_SLACK_WEBHOOK_URL is still unset — Slack alerts are switched on but every one of them is dropped and now audited as "not delivered" until someone creates an Incoming Webhook for the channel.