From a3aeedd7164c1c3ddfe9c2113e29cc58a564ceb3 Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 10 Jun 2026 14:03:28 -0500 Subject: [PATCH] mcs150: census-prefilled intake-completion flow + completeness gate Closes the data gap for orders that bypass the full intake (e.g. the DOT compliance-remediation pipeline) and for all MCS-150 variants: - Worker intake-completeness gate (mcs150_update): before filling, check the customer-required operational fields the FMCSA census cannot supply (operation classification, cargo, CURRENT annual mileage, email; plus signer/address for new-registration/reactivation, and states-of-operation for 150B hazmat). If missing, email the customer a census-pre-filled intake link and hold the order at fulfillment_status='awaiting_intake' with an admin todo, instead of fabricating a blank filing. The existing intake PUT endpoint already re-dispatches the worker on submit, so filing auto-resumes. - Intake wizard (Wizard.astro): when resuming ?order=CO-xxx for a DOT/MCS order, seed still-empty fields from the FMCSA census (name/address/fleet/interstate) so the customer only confirms the operational details. - /api/v1/dot/census now also returns total_drivers + a normalized carrier_operation_code for the prefill. - MCS150Step.astro extended to collect every field the filler needs across all variants: mailing address, cdl_drivers, primary_vehicle_type, reason_for_filing, usdot_revoked, cell/fax, hazmat-safety-permit block (needs_hmsp, operating states, security plan), and intermodal-equipment provider counts; all prefill from intake_data. verify_mcs150_variants.py covers 150/150B/150C end-to-end (ALL PASS). --- api/src/routes/dot-lookup.ts | 6 + scripts/workers/services/mcs150_update.py | 128 ++++++++ site/src/components/intake/Wizard.astro | 34 +++ .../components/intake/steps/MCS150Step.astro | 273 +++++++++++++++++- 4 files changed, 439 insertions(+), 2 deletions(-) diff --git a/api/src/routes/dot-lookup.ts b/api/src/routes/dot-lookup.ts index 55a34db..0a8ff3a 100644 --- a/api/src/routes/dot-lookup.ts +++ b/api/src/routes/dot-lookup.ts @@ -973,8 +973,14 @@ router.get("/api/v1/dot/census", async (req, res) => { phy_zip: c.phy_zip || null, power_units: c.nbr_power_unit ?? null, drivers: c.driver_total ?? null, + total_drivers: c.driver_total ?? null, for_hire: c.authorized_for_hire || false, carrier_operation: c.carrier_operation || null, + // Normalized interstate/intrastate code (A/B/C) if the stored value + // encodes it, for the intake prefill. + carrier_operation_code: (typeof c.carrier_operation === "string" + && /^[ABC]$/i.test(c.carrier_operation.trim())) + ? c.carrier_operation.trim().toUpperCase() : null, }); } catch (err) { console.error("[dot-census] Error:", err); diff --git a/scripts/workers/services/mcs150_update.py b/scripts/workers/services/mcs150_update.py index 4d2798f..e3937cd 100644 --- a/scripts/workers/services/mcs150_update.py +++ b/scripts/workers/services/mcs150_update.py @@ -124,6 +124,23 @@ class MCS150UpdateHandler: LOG.info("[%s] Backfilled signer_name from signed certification: %s", order_number, signed_name) + # INTAKE-COMPLETENESS GATE. The FMCSA census gives us the carrier's + # registered name/address/fleet, but it cannot tell us the operational + # details the MCS-150 requires the filer to confirm/update: the + # operation classification (Q23), cargo types (Q24), current annual + # mileage (Q21 -- must be CURRENT), and a contact email. If those are + # missing we must NOT fabricate a filing; instead we ask the customer to + # complete a short, census-pre-filled intake and hold the order until + # they do. (New-registration / reactivation flows that have not yet + # signed also route through here.) + missing = self._missing_intake_fields(slug, intake) + if missing and not client_approved and not admin_approved: + self._request_intake_completion( + order_number, entity_name, customer_email, dot_number, missing) + LOG.info("[%s] Held for customer intake completion; missing=%s", + order_number, missing) + return [] + # Step 1: Fill the official MCS-150 PDF pdf_path = None try: @@ -355,6 +372,117 @@ class MCS150UpdateHandler: return [minio_path] if minio_path else [] + def _missing_intake_fields(self, slug: str, intake: dict) -> list: + """Return the customer-required intake fields still missing for this + service. These are the operational details the FMCSA census cannot + supply and that the MCS-150 requires the filer to confirm/update. + """ + # Per-variant required operational fields. + base_required = ["carrier_operation", "interstate_intrastate", + "annual_miles", "email"] + # Cargo only applies to carriers (not pure intermodal-equipment providers). + if not intake.get("is_intermodal_equipment_provider"): + base_required.append("cargo_types") + # New registrations / reactivations also need the signer + address. + if slug in ("dot-registration", "usdot-reactivation", "dot-full-compliance"): + base_required += ["signer_name", "signer_title", "address_street", + "address_city", "address_state", "address_zip"] + # Hazmat safety-permit (150B) needs states of operation. + if intake.get("hazmat") == "yes" and intake.get("needs_hmsp"): + base_required.append("operating_states") + + missing = [] + for f in base_required: + v = intake.get(f) + if v in (None, "", [], {}): + missing.append(f) + return missing + + def _request_intake_completion(self, order_number, entity_name, + customer_email, dot_number, missing): + """Email the customer a census-pre-filled intake link and create a + low-priority admin todo noting we're waiting on intake completion.""" + domain = os.environ.get("PUBLIC_SITE_URL", "https://performancewest.net").rstrip("/") + intake_url = f"{domain}/order/dot-compliance?order={order_number}" + + # Customer email (no paper/mail mechanics; public form names are fine). + try: + from scripts.workers.worker_email import send_worker_email + label = { + "carrier_operation": "how your company operates (for-hire, private, etc.)", + "interstate_intrastate": "interstate vs intrastate operation", + "annual_miles": "your current annual mileage", + "cargo_types": "the types of cargo you haul", + "email": "a contact email address", + "operating_states": "the states you operate in", + "signer_name": "the name of the company officer signing", + "signer_title": "the officer's title", + } + needed = ", ".join(label.get(m, m.replace("_", " ")) for m in missing) + subject = f"Action needed: confirm your MCS-150 details (DOT {dot_number})" + text = ( + f"Hi,\n\n" + f"We're ready to prepare the MCS-150 update for {entity_name} " + f"(USDOT {dot_number}). We've pre-filled everything we can from " + f"your current FMCSA record. To finish, we just need you to " + f"confirm a few current details: {needed}.\n\n" + f"Please review and confirm here (it takes about 2 minutes):\n" + f"{intake_url}\n\n" + f"Once you submit, we'll prepare your filing for your signature " + f"and handle the rest.\n\n" + f"Thank you,\nPerformance West" + ) + html = ( + f"

Hi,

" + f"

We're ready to prepare the MCS-150 update for " + f"{entity_name} (USDOT {dot_number}). We've " + f"pre-filled everything we can from your current FMCSA record. " + f"To finish, we just need you to confirm a few current details: " + f"{needed}.

" + f"

Review and confirm your details " + f"(about 2 minutes).

" + f"

Once you submit, we'll prepare your filing for your " + f"signature and handle the rest.

" + f"

Thank you,
Performance West

" + ) + if customer_email: + send_worker_email(customer_email, subject, html, text=text, + cc="justin@performancewest.net") + except Exception as exc: + LOG.warning("[%s] Could not send intake-completion email: %s", + order_number, exc) + + # Hold the order + admin todo. + self._set_fulfillment_status(order_number, "awaiting_intake") + try: + import psycopg2 + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + cur.execute( + """INSERT INTO admin_todos ( + title, category, priority, order_number, service_slug, + description, data, status + ) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending')""", + ( + f"Awaiting MCS-150 intake -- {entity_name} (DOT {dot_number})", + "intake", "normal", order_number, self.SERVICE_SLUG, + (f"Order {order_number} is missing customer-required intake " + f"fields: {', '.join(missing)}.\n" + f"A census-pre-filled intake link was emailed to " + f"{customer_email or 'the customer'}.\n" + f"Intake link: {intake_url}\n" + f"Filing auto-resumes once the customer submits."), + json.dumps({"order_number": order_number, "dot_number": dot_number, + "entity_name": entity_name, "awaiting_intake": True, + "missing_fields": missing}), + ), + ) + conn.commit() + conn.close() + except Exception as exc: + LOG.warning("[%s] Could not create intake-pending todo: %s", + order_number, exc) + def _restamp_signed_form(self, order_number: str, document_key: str) -> None: """Point the signed esign record at ``document_key`` (the freshly filled form) and re-stamp the signature onto it, so the signed PDF an admin diff --git a/site/src/components/intake/Wizard.astro b/site/src/components/intake/Wizard.astro index 557ddc8..ca59275 100644 --- a/site/src/components/intake/Wizard.astro +++ b/site/src/components/intake/Wizard.astro @@ -420,6 +420,40 @@ const STEP_LABELS: Record = { state.entity = { ...state.entity, frn: intake.frn }; } } + + // For DOT/MCS-150 orders, seed any still-empty fields from the + // carrier's current FMCSA census so the customer only has to fill + // the operational details FMCSA cannot give us (operation class, + // cargo, current mileage, email). Customer-entered values always win. + const dotForCensus = (state.intake_data.dot_number as string) || ""; + const slug = (order.service_slug as string) || ""; + const isDotOrder = /^(mcs150|usdot|dot-|mc-)/.test(slug) || /mcs150|usdot-reactivation|dot-registration|dot-full/.test(slug); + if (dotForCensus && isDotOrder) { + try { + const cr = await fetch(`${API}/api/v1/dot/census?dot=${dotForCensus.replace(/\D/g, "")}`); + if (cr.ok) { + const c = await cr.json(); + const d = state.intake_data; + const seed = (k: string, v: any) => { if (v && !d[k]) d[k] = v; }; + seed("legal_name", c.legal_name); + seed("dba_name", c.dba_name); + seed("address_street", c.phy_street); + seed("address_city", c.phy_city); + seed("address_state", c.phy_state); + seed("address_zip", c.phy_zip); + seed("phone", c.telephone); + seed("power_units", c.power_units != null ? String(c.power_units) : ""); + seed("drivers", c.total_drivers != null ? String(c.total_drivers) : ""); + // Interstate/intrastate from the census operation code. + if (!d.interstate_intrastate && c.carrier_operation_code) { + const code = String(c.carrier_operation_code).toUpperCase(); + d.interstate_intrastate = code === "A" ? "interstate" + : code === "B" ? "intrastate_hazmat" + : code === "C" ? "intrastate_non_hazmat" : d.interstate_intrastate; + } + } + } catch { /* census seed is best-effort */ } + } saveState(state); } } catch {} diff --git a/site/src/components/intake/steps/MCS150Step.astro b/site/src/components/intake/steps/MCS150Step.astro index cf32b45..ec09ab0 100644 --- a/site/src/components/intake/steps/MCS150Step.astro +++ b/site/src/components/intake/steps/MCS150Step.astro @@ -83,11 +83,91 @@ -
+
+ + +
+ +

Mailing Address

+ +
+ +
+ + + +

Filing Details

+ +
+ +

Entity & Operations

@@ -158,6 +238,25 @@
+
+ + +
+

Cargo Types (check all that apply)

@@ -192,6 +291,73 @@
+ + +

Intermodal Equipment Provider

+ +
+ +
+ + +

Authorized Signer

@@ -244,6 +410,11 @@ .pw-field input:focus, .pw-field select:focus { outline: none; border-color: #f97316; box-shadow: 0 0 0 2px rgba(249,115,22,0.2); } .pw-cargo-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.3rem 1rem; font-size: 0.85rem; color: #374151; } .pw-cargo-grid label { display: flex; align-items: center; gap: 0.4rem; cursor: pointer; } + .pw-checkbox-row { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.85rem; color: #374151; } + .pw-checkbox-row input { width: 16px; height: 16px; } + .pw-field-label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 0.4rem; } + .pw-state-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.3rem 0.6rem; font-size: 0.8rem; color: #374151; max-height: 180px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 0.5rem; background: #fff; } + .pw-state-grid label { display: flex; align-items: center; gap: 0.3rem; cursor: pointer; } .pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; background: #fee2e2; padding: 0.5rem 0.75rem; border-radius: 6px; } .pw-security-notice { display: flex; gap: 8px; align-items: flex-start; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 14px; margin-bottom: 1rem; font-size: 12px; color: #1e40af; line-height: 1.5; } .pw-field-help { font-size: 0.8rem; color: #64748b; margin: 0 0 0.5rem; } @@ -256,6 +427,7 @@ @media (max-width: 640px) { .pw-row-2, .pw-row-3 { grid-template-columns: 1fr; } .pw-cargo-grid { grid-template-columns: 1fr 1fr; } + .pw-state-grid { grid-template-columns: repeat(5, 1fr); } } @@ -285,11 +457,51 @@ "mcs-signer-name": d.signer_name || "", "mcs-signer-title": d.signer_title || "", "mcs-ein": d.ein || "", + "mcs-reason": d.reason_for_filing || "biennial_update", + "mcs-cdl-drivers": d.cdl_drivers || "", + "mcs-vehicle-type": d.primary_vehicle_type || "straight", + "mcs-revoked": d.usdot_revoked || "no", + "mcs-cell-phone": d.cell_phone || "", + "mcs-fax": d.fax || "", + "mcs-mailing-street": d.mailing_street || "", + "mcs-mailing-city": d.mailing_city || "", + "mcs-mailing-state": d.mailing_state || "", + "mcs-mailing-zip": d.mailing_zip || "", + "mcs-hmsp-security": d.hmsp_security_plan || "no", + "mcs-iep-owned": d.iep_owned || "", + "mcs-iep-leased": d.iep_leased || "", + "mcs-iep-serviced": d.iep_serviced || "", }; for (const [id, val] of Object.entries(fields)) { const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement; if (el && val) el.value = val; } + + // Prefill mailing-address toggle: show fields when a different mailing address is on file + const hasMailing = !!(d.mailing_street || d.mailing_city || d.mailing_state || d.mailing_zip); + const mailingSame = document.getElementById("mcs-mailing-same") as HTMLSelectElement | null; + const mailingFields = document.getElementById("mcs-mailing-fields") as HTMLDivElement | null; + if (mailingSame) mailingSame.value = hasMailing ? "different" : "same"; + if (mailingFields) mailingFields.hidden = !hasMailing; + + // Prefill HMSP section + const needsHmsp = document.getElementById("mcs-needs-hmsp") as HTMLInputElement | null; + if (needsHmsp) needsHmsp.checked = !!d.needs_hmsp; + if (Array.isArray(d.operating_states)) { + d.operating_states.forEach((code: string) => { + const cb = document.getElementById("mcs-opstate-" + code) as HTMLInputElement | null; + if (cb) cb.checked = true; + }); + } + // Reveal HMSP fields if hazmat == yes + const hmspFields = document.getElementById("mcs-hmsp-fields") as HTMLDivElement | null; + if (hmspFields) hmspFields.hidden = (d.hazmat || "") !== "yes"; + + // Prefill IEP section + const isIep = document.getElementById("mcs-is-iep") as HTMLInputElement | null; + const iepFields = document.getElementById("mcs-iep-fields") as HTMLDivElement | null; + if (isIep) isIep.checked = !!d.is_intermodal_equipment_provider; + if (iepFields) iepFields.hidden = !d.is_intermodal_equipment_provider; }); // Save to PWIntake on step-next @@ -323,8 +535,16 @@ }); const val = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value?.trim() || ""; + const checked = (id: string) => !!(document.getElementById(id) as HTMLInputElement)?.checked; + + // Collect operating states (2-letter codes) from checked checkboxes + const operatingStates: string[] = []; + document.querySelectorAll("[id^='mcs-opstate-']").forEach(cb => { + if (cb.checked) operatingStates.push(cb.value); + }); + const state = PW.get(); - const intake = { + const intake: Record = { ...state.intake_data, legal_name: val("mcs-legal-name"), dba_name: val("mcs-dba"), @@ -335,19 +555,47 @@ address_state: val("mcs-state"), address_zip: val("mcs-zip"), phone: val("mcs-phone"), + cell_phone: val("mcs-cell-phone"), + fax: val("mcs-fax"), entity_type: val("mcs-entity-type"), carrier_operation: val("mcs-carrier-op"), interstate_intrastate: val("mcs-interstate"), hazmat: val("mcs-hazmat"), + reason_for_filing: val("mcs-reason"), + usdot_revoked: val("mcs-revoked"), power_units: val("mcs-power-units"), drivers: val("mcs-drivers"), + cdl_drivers: val("mcs-cdl-drivers"), + primary_vehicle_type: val("mcs-vehicle-type"), annual_miles: val("mcs-miles"), cargo_types: cargoTypes, signer_name: val("mcs-signer-name"), signer_title: val("mcs-signer-title"), ein: val("mcs-ein"), + needs_hmsp: checked("mcs-needs-hmsp"), + operating_states: operatingStates, + hmsp_security_plan: val("mcs-hmsp-security"), + is_intermodal_equipment_provider: checked("mcs-is-iep"), + iep_owned: val("mcs-iep-owned"), + iep_leased: val("mcs-iep-leased"), + iep_serviced: val("mcs-iep-serviced"), photo_id_uploaded: !!(window as any).__mcs150PhotoId, }; + + // Mailing address: only include when "different" is selected, otherwise omit + // (so the filler treats it as same-as-principal). + if (val("mcs-mailing-same") === "different") { + intake.mailing_street = val("mcs-mailing-street"); + intake.mailing_city = val("mcs-mailing-city"); + intake.mailing_state = val("mcs-mailing-state"); + intake.mailing_zip = val("mcs-mailing-zip"); + } else { + delete intake.mailing_street; + delete intake.mailing_city; + delete intake.mailing_state; + delete intake.mailing_zip; + } + PW.set({ ...state, intake_data: intake }); }); @@ -382,4 +630,25 @@ if (idPreview) idPreview.hidden = true; if (idBtn) idBtn.style.display = "flex"; }); + + // Toggle: mailing address fields shown only when "different" is selected + const mailingSameSel = document.getElementById("mcs-mailing-same") as HTMLSelectElement | null; + const mailingFieldsDiv = document.getElementById("mcs-mailing-fields") as HTMLDivElement | null; + mailingSameSel?.addEventListener("change", () => { + if (mailingFieldsDiv) mailingFieldsDiv.hidden = mailingSameSel.value !== "different"; + }); + + // Toggle: hazmat safety permit fields shown only when hazmat == "yes" + const hazmatSel = document.getElementById("mcs-hazmat") as HTMLSelectElement | null; + const hmspFieldsDiv = document.getElementById("mcs-hmsp-fields") as HTMLDivElement | null; + hazmatSel?.addEventListener("change", () => { + if (hmspFieldsDiv) hmspFieldsDiv.hidden = hazmatSel.value !== "yes"; + }); + + // Toggle: intermodal equipment provider fields shown only when checkbox is checked + const iepCheckbox = document.getElementById("mcs-is-iep") as HTMLInputElement | null; + const iepFieldsDiv = document.getElementById("mcs-iep-fields") as HTMLDivElement | null; + iepCheckbox?.addEventListener("change", () => { + if (iepFieldsDiv) iepFieldsDiv.hidden = !iepCheckbox.checked; + });