mcs150: enrich intake from FMCSA carrier census before PDF fill

The MCS-150 biennial update re-confirms the carrier's existing FMCSA
record. Previously the PDF filler only had whatever the intake form
collected; rescued/sparse orders (or orders where the carrier's data
lives in FMCSA, not the intake) produced near-empty forms. Now we pull
the carrier census (legal name, address, EIN, fleet counts) from the
FMCSA carrier API and merge it under any customer-provided intake values
(customer edits win), so the form is pre-filled with the carrier's
current registered data. Refactored the FMCSA fetch into a shared
_fetch_fmcsa_carrier helper used by both enrichment and status check.
This commit is contained in:
justin 2026-06-10 12:35:43 -05:00
parent 7e5946d65a
commit d5e66786a2
8 changed files with 294 additions and 15 deletions

View file

@ -0,0 +1,55 @@
"""Diagnostic: inspect the actual MCS-150 fill output.
Reports, per page: the AcroForm fields present (name + value + rect), and the
rendered text, so we can see WHERE data lands vs WHERE the blank/example fields
are. Run in the workers container against the live template + a fresh fill.
"""
import sys, json
sys.path.insert(0, "/app")
from pypdf import PdfReader
TEMPLATE = "/app/docs/MCS-150 Form.pdf"
def field_rows(reader, label):
print(f"\n===== {label} =====")
for i, pg in enumerate(reader.pages):
annots = pg.get("/Annots")
if not annots:
continue
annots = annots.get_object() if hasattr(annots, "get_object") else annots
rows = []
for a in annots:
o = a.get_object()
t = o.get("/T")
if t is None and o.get("/Parent"):
t = o["/Parent"].get_object().get("/T")
v = o.get("/V")
rect = o.get("/Rect")
ft = o.get("/FT")
if t:
rows.append((str(t), str(ft), str(v) if v is not None else "", [round(float(x)) for x in rect] if rect else None))
if rows:
print(f"-- PDF page {i+1} ({len(rows)} fields) --")
for name, ft, val, rect in rows:
vstr = f" = {val!r}" if val else ""
print(f" {name} [{ft}]{vstr} rect={rect}")
# 1) the template as-is — what fields + any example values?
r = PdfReader(TEMPLATE)
field_rows(r, "TEMPLATE (original)")
# 2) a fresh fill
from scripts.document_gen.templates.mcs150_pdf_filler import fill_mcs150
intake = {
"dot_number": "1609564", "legal_name": "MITCHELL W ALLEN",
"dba_name": "", "entity_type": "sole_proprietorship",
"business_street": "123 Main St", "business_city": "Town",
"business_state": "TX", "business_zip": "75001",
"phone": "5125551234", "ein": "123456789",
"signer_name": "Mitchell W Allen", "signer_title": "Owner",
"power_units": "2", "drivers": "1",
}
p = fill_mcs150(intake, order_number="DIAG")
print("\nfilled file:", p)
field_rows(PdfReader(p), "FILLED")

View file

@ -107,6 +107,9 @@ SERVICE_HANDLERS: dict[str, type] = {
# ── Foreign qualification (Certificate of Authority) ─────────────────
"foreign-qualification-single": ForeignQualificationHandler,
"foreign-qualification-multi": ForeignQualificationHandler, # same handler, fans out per-state
# ── Business name reservation (admin-assisted; files SOS name hold) ──
"name-reservation-tx": MCS150UpdateHandler, # admin-assisted: file Form 501 via SOSDirect
"name-reservation-nv": MCS150UpdateHandler, # admin-assisted: file NV reservation via SilverFlume
# ── State PUC/PSC registration ────────────────────────────────────
"state-puc": StatePucFilingHandler,
# ── CDR storage tier add-ons (quota bumps, not filings) ────────────

View file

@ -91,6 +91,85 @@ class PlotterConfig:
servo_up_angle: int = 90
# Line-us mapping (only used when dialect == "lineus").
lineus: "LineUsConfig | None" = None
# Pen-specific tuning (ink flow, dwell, line look). See PenProfile / PENS.
pen: "PenProfile | None" = None
@dataclass(frozen=True)
class PenProfile:
"""Per-pen tuning for a writing instrument mounted in the plotter.
Different pens lay ink differently when the plotter drops the tip STRAIGHT
DOWN (no human wrist angle/pressure modulation). Wet gel/rollerball pens read
as a genuine signature but need (a) a short dwell at pen-down so the ink wets
the paper before the first move (avoids a starting gap), and (b) a slower draw
feed so the line lays down evenly without skipping on fast reversals. These
values are applied by the emitters on top of the machine PlotterConfig.
Recommended default for legal filings: uni-ball Signo (UM-151 0.38mm)
archival, waterproof, fraud-resistant gel ink that reads as original wet ink.
pen_down_dwell_ms : pause after the pen contacts paper, before the first
drawing move, to let wet ink start cleanly (G4 dwell).
pen_up_dwell_ms : brief pause after lifting (lets gel/rollerball tips
"snap" off cleanly without a tail). Usually small/0.
draw_feed : per-pen drawing feed (mm/min). Overrides the machine
default when set; wet pens like ~600-900.
pen_down_bias_mm : small extra downward offset (mm) added to pen_down so a
spring holder keeps positive contact for this tip.
tip_mm : nominal tip width (mm), informational / preview only.
"""
name: str = "uniball-signo"
label: str = "uni-ball Signo UM-151 0.38 (gel)"
pen_down_dwell_ms: int = 120
pen_up_dwell_ms: int = 0
draw_feed: float = 750.0
pen_down_bias_mm: float = 0.0
tip_mm: float = 0.38
# Tuned presets for the pens we keep on hand. Default = uni-ball Signo.
PENS: dict[str, PenProfile] = {
"uniball-signo": PenProfile(
name="uniball-signo",
label="uni-ball Signo UM-151 0.38 (gel, archival/waterproof)",
pen_down_dwell_ms=120, pen_up_dwell_ms=0, draw_feed=750.0,
pen_down_bias_mm=0.0, tip_mm=0.38,
),
"pilot-g2": PenProfile(
name="pilot-g2",
label="Pilot G-2 0.7 (gel, hand-written look)",
# G-2 can blob on a long pen-down dwell; keep dwell short.
pen_down_dwell_ms=80, pen_up_dwell_ms=0, draw_feed=800.0,
pen_down_bias_mm=0.0, tip_mm=0.7,
),
"energel": PenProfile(
name="energel",
label="Pentel EnerGel 0.5 (liquid-gel, fast-dry batch)",
# Liquid-gel starts instantly and dries fast — minimal dwell, faster feed.
pen_down_dwell_ms=60, pen_up_dwell_ms=0, draw_feed=900.0,
pen_down_bias_mm=0.0, tip_mm=0.5,
),
"fountain": PenProfile(
name="fountain",
label="Fountain pen + document ink (max ink character)",
# Fountain nibs need a touch more settle time and a gentle, slower line;
# add a small downward bias so the nib keeps consistent flow.
pen_down_dwell_ms=180, pen_up_dwell_ms=40, draw_feed=600.0,
pen_down_bias_mm=-0.1, tip_mm=0.5,
),
}
DEFAULT_PEN = "uniball-signo"
def load_pen(name: str | None) -> PenProfile:
"""Return a PenProfile for a named pen (default uni-ball Signo)."""
key = (name or DEFAULT_PEN).lower()
if key not in PENS:
raise ValueError(f"unknown pen '{name}'; choices: {sorted(PENS)}")
return PENS[key]
@dataclass(frozen=True)

View file

@ -97,6 +97,22 @@ class MCS150UpdateHandler:
# Check current MCS-150 status via FMCSA API
mcs150_status = self._check_current_status(dot_number)
# Enrich the intake with the carrier's CURRENT registered data from the
# FMCSA carrier API. The MCS-150 biennial update re-confirms the carrier's
# existing FMCSA record, so the authoritative source for the form is the
# FMCSA census (legal name, address, EIN, fleet counts) -- the intake form
# only collects the DOT number + any changes. Customer-provided intake
# values take precedence over the census (so edits/changes win).
census = self._fetch_carrier_record(dot_number)
if census:
merged = {**census, **{k: v for k, v in intake.items() if v not in (None, "")}}
intake = merged
LOG.info("[%s] Enriched intake from FMCSA census (legal_name=%s)",
order_number, intake.get("legal_name"))
else:
LOG.warning("[%s] No FMCSA census data for DOT %s -- form may be sparse",
order_number, dot_number)
# Step 1: Fill the official MCS-150 PDF
pdf_path = None
try:
@ -321,30 +337,59 @@ class MCS150UpdateHandler:
return [minio_path] if minio_path else []
def _check_current_status(self, dot_number: str) -> str:
"""Check current MCS-150 status via FMCSA API."""
def _fetch_fmcsa_carrier(self, dot_number: str) -> dict:
"""Fetch the raw FMCSA carrier census record for a DOT number."""
try:
import urllib.request
api_key = os.environ.get("FMCSA_API_KEY", "")
if not api_key:
return "API key not configured"
return {}
url = f"https://mobile.fmcsa.dot.gov/qc/services/carriers/{dot_number}?webKey={api_key}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
carrier = data.get("content", {}).get("carrier", {})
outdated = carrier.get("mcs150Outdated", "?")
status = carrier.get("statusCode", "?")
allowed = carrier.get("allowedToOperate", "?")
return (
f"Status: {status}, Allowed: {allowed}, "
f"MCS-150 Outdated: {outdated}"
)
return data.get("content", {}).get("carrier", {}) or {}
except Exception as exc:
return f"Could not check: {exc}"
LOG.warning("FMCSA carrier fetch failed for %s: %s", dot_number, exc)
return {}
def _fetch_carrier_record(self, dot_number: str) -> dict:
"""Return the carrier's current FMCSA data mapped to the intake keys the
PDF filler expects (so the biennial-update form is pre-filled with the
carrier's existing registered data). Empty dict if unavailable.
"""
c = self._fetch_fmcsa_carrier(dot_number)
if not c:
return {}
def s(v):
return "" if v is None else str(v)
out: dict = {
"legal_name": s(c.get("legalName")),
"dba_name": s(c.get("dbaName")),
"dot_number": s(c.get("dotNumber") or dot_number),
"ein": s(c.get("ein")),
"address_street": s(c.get("phyStreet")),
"address_city": s(c.get("phyCity")),
"address_state": s(c.get("phyState")),
"address_zip": s(c.get("phyZipcode")),
"phone": s(c.get("telephone") or c.get("phone")),
"power_units": s(c.get("totalPowerUnits")),
"drivers": s(c.get("totalDrivers")),
}
return {k: v for k, v in out.items() if v}
def _check_current_status(self, dot_number: str) -> str:
"""Check current MCS-150 status via FMCSA API."""
c = self._fetch_fmcsa_carrier(dot_number)
if not c:
return "Could not check FMCSA status"
return (
f"Status: {c.get('statusCode', '?')}, "
f"Allowed: {c.get('allowedToOperate', '?')}, "
f"MCS-150 Outdated: {c.get('mcs150Outdated', '?')}"
)
def _create_pending_signature_todo(self, order_number, entity_name, dot_number,
slug, minio_path, customer_email):