RO Import — Rules & Decisions
RO Import — Rules & Decisions
Every rule the import path applies, why it is that rule, who decided it, and what breaks if it is changed. Companion to ro_import_guide (tech) and the operator guide of the same name.
D-1 · Duplicate detection key = (FP #, BOM name)
Rule. A staged row, an Accept, and the watcher all identify an RO by the source order number plus the BOM file name. Same job + same BOM = refresh the existing RO. Same job + different BOM = a new, separate RO.
Why. Resi jobs legitimately carry a roof BOM and an electrical BOM on one FP #. Keying on the FP # alone made the second BOM look like a revision of the first, and diffed every line as a change. (Dave, 2026-09-07 / re-confirmed 2026-09-08.)
Consequence to live with. Renaming a revised BOM ("-1") produces a second RO, not an update. The file name is the identity — this is stated plainly in the operator guide because no code can distinguish a rename from a genuinely new BOM.
Enforced by. ro_parse.existing_ro(conn, source_id, bom_name), ro_fp._open_inbox_row, ro_import.promote_inbox_to_ro, ro_watch.already_known, and the ro_header_job_bom_uniq (job_name, bom_name) index as the backstop.
D-2 · Class comes from the FP project workflow. Only.
Rule. Class = 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 a workflow that doesn't map → no class, and the operator sets it on the RO screen.
Why by name and not by id. A renumbered or rebuilt workflow in FP would silently mislabel every RO if the mapping were by id. By name it fails visibly instead.
Why no job-type fallback. An earlier build used the job type ("Warranty - Commercial") to break the tie when a project sat on the default Service workflow. Dave, 2026-09-08: "FP Class is messy but the workflow should be the rule. I don't like rules to offset the errors. They can change the class in the RO screen." The guess is removed. It hid an FP data problem instead of surfacing it.
Known live consequence. Only three FP projects are on the Commercial workflow; commercial work including Hagar's House sits on the Service workflow, so it imports as Service and the operator corrects it. That is intended behaviour until the FP workflows are cleaned up — it is an FP data fix, not a code fix.
Enforced by. ro_fp._link_context / link_context, surfaced through GET /ro/inbox/link-check.
D-3 · The Check button owns some fields and not others
| Field | Rule | Why |
|---|---|---|
| Customer | Overwrite | FP is the system of record for who the job is for |
| Class | Overwrite (when the workflow yields one) | See D-2 |
| FP job status | FP only, read-only in IM2 | IM2 never authors a FieldPulse status |
| Priority | Fill only if blank | The operator's judgement outranks FP, where priority is null on most jobs anyway |
| Required date | Fill only if blank | Same — an operator who typed a date meant it |
Order-independence. The same rules run whether the FP # is keyed first or last, because they run inside Check rather than at page load. Dave's scenario — operator fills everything, then keys the FP # — was the reason.
Disclosure. Check names every field it changed in the hint line, so an overwrite is never silent, and says "no class from FP — set it here" when D-2 yields nothing.
D-4 · The link stamps source_id immediately
Rule. A validated FP link writes source_id and link_status onto the inbox row at Check/save time, not overnight.
Why. source_id used to arrive only when fp_status_sync.py backfilled it, so a same-BOM re-drop inside that window hit the unique index instead of refreshing the RO. A timing bug, and timing bugs are the ones operators can't explain.
D-5 · Attachment wins; the material list is the fallback
Rule. Job files → project files (if the job has a project) → material list. The material-list path — and only that path — sets the RO to Service.
Why. Class is the result of which path ran, never the test for which path to take. Several spreadsheets = ask, never auto-pick. A non-spreadsheet BOM (PDF) is a hard error, not a silent skip.
D-6 · The watcher is narrow on purpose
Only FP jobs in Pending Material are polled; uploaded spreadsheets never are. Anything outside that window is a deliberate manual import. Polling applies the same keys and the same link behaviour as a manual FP # entry.
D-7 · One save
Header fields autosave on change through POST /ro/inbox/{id}/header (same ro_audit rows the old Save-header button produced); Accept is the only button. Dave, 2026-09-08: "Why do we need the Save Header chip? Seems one save option is enough."
D-8 · Type and Class are different questions — both stay
Rule. ro_header.ro_type = kind of demand: Job | Stock | Truck. ro_header.class_ref = the QuickBooks class it posts to. Independent fields.
Why. Dave, 2026-09-08: stock replenishment and, later, truck replenishment ROs are coming, and they have no FP job and no customer at all — but they still have to post somewhere. Type answers what kind of demand; Class answers where the money goes.
What was wrong before. ro_type was being filled with class names (Residential / Commercial / Service / Administration), i.e. a second copy of the class, which is why the two columns looked redundant on the RO screen.
Migration (applied 2026-09-08). class_ref backfilled from the old ro_type name wherever it was null; every existing ro_header and ro_inbox row set to ro_type = 'Job'; CHECK constraints on both tables replaced with ('Job','Stock','Truck'). Roofing and the old class-name values are gone from the constraint. Five inbox rows had no class at all and still don't — Accept refuses them until the operator sets one.
Jobless ROs. Stock and truck ROs book to QB customer SA:Admin (15326) and class Administration — Dave, 2026-09-08: "truck and stock jobs will be admin (per the syscon) and class admin." A blank class on a Stock/Truck row is defaulted to Administration on save. po_rel._qb_customer_id now routes on the class name (Service → SA:Admin) plus Type in (Stock, Truck) → SA:Admin; it used to test ro_type == 'Service', which this change would have broken silently.
Everything that read ro_type-as-class now reads class_name_of(row) — the required-date lead time (CLASS_LT_SETTING), the review screen, manual RO entry, and PO release. The RO Inquiry Type filter now filters demand kind, which is what makes "show me all truck replenishment" possible.