RO Import Guide
RO Import — Technical Spec (Fix Batch, 2026-09-08)
Companion to ro_inbox / ro_inbox_detail. Covers the five changes Dave approved on 2026-09-08 and the dedup rules they depend on.
Dedup keys — there are two, and they must agree
| Moment | Code | Key |
|---|---|---|
| Stage (FP # or upload) | ro_fp.stage_fp_job → ro_parse.existing_ro | (source_id, bom_name) |
| Stage, inbox guard | ro_fp._open_inbox_row | (source_id, bom_name), non-terminal rows |
| Accept | ro_import.promote_inbox_to_ro | (source_id, bom_name) |
| Watcher | ro_watch.already_known | (source_id, bom_name) |
Before this batch the stage-time diff and the watcher both keyed on source_id alone. Consequences that are now fixed: the second BOM on a job diffed against the first BOM's RO and annotated every line as a change; and the watcher skipped a job permanently once it had any RO, so a second or revised BOM was never polled.
ro_header_job_bom_uniq is (job_name, bom_name) and remains the backstop — a second Accept of the same job+BOM fails cleanly rather than half-writing.
Check-button carryover
GET /ro/inbox/link-check → ro_import.check_source_link → ro_fp.link_context.
link_context(source_id) does one FP round trip set: find_job (by cuid) → job_detail → project/{id} when the job has a project → status-workflow and status (both cached in module dicts for the process lifetime).
Class resolution (see ro_import_rules D-2): the project's status_workflow_id mapped by workflow name — Commercial Project Workflow → Commercial, Resi Install Workflow → Residential, Service Project Workflow → Service. No project, or an unmapped workflow → no class; the operator sets it on the RO screen. The job-type tiebreak that earlier shipped here was removed on Dave's ruling (2026-09-08): rules that offset bad FP data hide the data problem. Consequence today: Hagar's House (project on the Service workflow) imports as Service and is corrected by hand.
The old rule — ro_type = "Residential" if project_id else None in ro_fp._collect, with the material-list path forcing Service — could never produce Commercial. It is still the rule on the stage path; Check is what corrects the class before Accept.
Field rules applied in checkLink() (ro_inbox_detail.html): Customer and Class overwritten; Priority and Required date filled only when blank; FP job status is display-only. The date default reads the class Check just set, so class is assigned before the date is defaulted. Every field Check touched is named in the hint line under the box. Order-independent by construction — keying the FP # last behaves exactly like keying it first.
QB customer matching is best-effort (qb_class_for_customer(name=…)); a name FP and QB spell differently (apostrophes, "&") returns no id and the operator picks it. Class does not fall back to the QB transaction class here — the project is the authority Dave asked for.
source_id at link time
inbox_save_header now writes source_id (and link_status) onto ro_inbox whenever an FP link validates. Previously source_id was NULL on an uploaded row until scripts/fp_status_sync.py backfilled it overnight, which left a window where a revised same-BOM re-drop hit the unique index instead of refreshing. Closed.
FP job status
New column ro_inbox.link_status (alter table ro_inbox add column if not exists link_status text — applied live 2026-09-08). Set by Check, displayed read-only on the review screen, and written to ro_header.source_status / source_status_at on Accept. scripts/fp_status_sync.py keeps it current afterwards; that script selects where fp_job_id is not null, so MANUAL/unlinked ROs are never touched and correctly show a blank status.
One save
The "Save header" chip is gone. Every header field autosaves on change through the same POST /ro/inbox/{id}/header route, producing the same ro_audit rows. Accept is the only button. Line-level Fix-now boxes keep their existing on-blur save and Accept still flushes anything dirty.
Files touched
app/ro_fp.py (link_context + workflow/status helpers, bom_name on the stage diff), app/ro_parse.py (existing_ro(conn, source_id, bom_name=None)), app/ro_watch.py (already_known keyed on job+BOM), app/ro_import.py (check_source_link carryover, source_id/link_status on save, source_status on Accept), app/templates/ro_inbox_detail.html (Check fills fields, status row, autosave).
Backup on the droplet: app/ro_import.py.bak.pre_checkcarry.