new-site/scripts/document_gen/templates/mcs150_pdf_filler.py
justin 35f204c2b8 fix(mcs150): point intake email to per-slug wizard (not sales page) + add Trailers field
The MCS-150 intake-completion email linked customers to /order/dot-compliance,
which is the sales/checkout page -- it ignores ?order= and asks the customer to
re-pick services and pay again, so they 'cannot enter any data' (Paul Wilson's
report). Link to the per-service intake wizard /order/<slug>?order=... instead,
which loads the paid order, pre-fills from the FMCSA census, and drops payment.

Also add a Trailers field to the DOT intake fleet section and wire it through to
the MCS-150 PDF Q26 trailer row, so carriers can update trucks AND trailers.
2026-06-16 16:21:57 -05:00

605 lines
27 KiB
Python

"""MCS-150 Official PDF Form Filler.
Fills the official FMCSA MCS-150/150B/150C fillable PDF forms using
intake data from the order. Produces a ready-to-fax or electronically
submit PDF.
Forms stored at (FORM pages only -- the FMCSA instruction/example pages are
trimmed off the source PDFs so the filled output is fax/submit-ready and never
includes the instruction pages):
docs/MCS-150 Form.pdf — standard (3 pages, 289 fields)
docs/MCS-150B Form.pdf — hazmat safety permit (4 pages, 349 fields)
docs/MCS-150C Form.pdf — intermodal equipment (2 pages, 33 fields)
Usage:
from scripts.document_gen.templates.mcs150_pdf_filler import fill_mcs150
pdf_path = fill_mcs150(intake_data, order_number="CO-12345")
"""
from __future__ import annotations
import logging
import os
import tempfile
from datetime import datetime
from pathlib import Path
from copy import copy
LOG = logging.getLogger("document_gen.mcs150_pdf_filler")
try:
from pypdf import PdfReader, PdfWriter
from pypdf.generic import NameObject, BooleanObject, TextStringObject
except ImportError:
LOG.warning("pypdf not installed — MCS-150 PDF filling unavailable")
PdfReader = None
# Path to the official forms
DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
FORMS = {
"mcs150": DOCS_DIR / "MCS-150 Form.pdf",
"mcs150b": DOCS_DIR / "MCS-150B Form.pdf",
"mcs150c": DOCS_DIR / "MCS-150C Form.pdf",
}
# ── Field mappings ────────────────────────────────────────────────────
# Question 22: COMPANY OPERATIONS (interstate / intrastate classification).
# A=Interstate Carrier, B=Intrastate Hazmat Carrier, C=Intrastate Non-Hazmat
# Carrier, D=Interstate Hazmat Shipper, E=Intrastate Hazmat Shipper.
COMPANY_OPERATION_MAP = {
"interstate": "22aBox",
"intrastate_hazmat": "22bBox",
"intrastate_non_hazmat": "22cBox",
}
# Question 23: OPERATION CLASSIFICATIONS (how the carrier operates).
# A=Authorized For-Hire, B=Exempt For-Hire, C=Private Property,
# D=Private Passengers (Business), E=Private Passengers (Non-Business),
# F=Migrant, G=U.S. Mail, H=Federal Govt, I=State Govt, J=Local Govt,
# K=Indian Tribe.
CARRIER_OP_MAP = {
"authorized_for_hire": "23aBox",
"exempt_for_hire": "23bBox",
"private_property": "23cBox",
"private_passengers_business": "23dBox",
"private_passengers_non_business": "23eBox",
"migrant": "23fBox",
"us_mail": "23gBox",
"federal_government": "23hBox",
"state_government": "23iBox",
"local_government": "23jBox",
"indian_tribe": "23kBox",
}
# (legacy synonyms kept so older intake values still map sensibly)
CARRIER_OP_MAP.setdefault("private_passengers", "23dBox")
# Question 24: Cargo Types (checkboxes — a through z, aa through dd)
CARGO_TYPE_MAP = {
"general": "24aBox",
"household": "24bBox",
"metal": "24cBox",
"motor_vehicles": "24dBox",
"drivetow": "24eBox",
"logs": "24fBox",
"building_materials": "24gBox",
"mobile_homes": "24hBox",
"machinery": "24iBox",
"fresh_produce": "24jBox",
"liquids": "24kBox",
"intermodal": "24lBox",
"passengers": "24mBox",
"oilfield": "24nBox",
"livestock": "24oBox",
"grain": "24pBox",
"coal": "24qBox",
"meat": "24rBox",
"garbage": "24sBox",
"chemicals": "24tBox",
"commodities_dry": "24uBox",
"refrigerated": "24vBox",
"beverages": "24wBox",
"paper": "24xBox",
"utilities": "24yBox",
"farm_supplies": "24zBox",
"construction": "24aaBox",
"water_well": "24bbBox",
"other": "24ccBox",
}
# Question 25: Hazardous Materials matrix. Each commodity row has four columns:
# C=Carrier, S=Shipper, B=Bulk (cargo tanks), NB=Non-Bulk (in packages). The
# AcroForm fields are 25{row}{COL}Box where COL in {C,S,B,NB}. The row letters
# follow the printed list A..Z then AA..MM (with a few gaps for headers).
HAZMAT_ROW_MAP = {
"div_1_1": "a", "div_1_2": "b", "div_1_3": "c", "div_1_4": "d",
"div_1_5": "e", "div_1_6": "f", "div_2_1_flam_gas": "g", "div_2_1_lpg": "h",
"div_2_1_methane": "i", "div_2_2": "j", "div_2_3a": "k", "div_2_3b": "l",
"div_2_3c": "m", "div_2_3d": "n", "class_3": "o", "comb_liq": "p",
"div_4_1": "q", "div_4_2": "r", "div_4_3": "s", "div_5_1": "t",
"div_5_2": "u", "div_6_1a": "v", "div_6_1b": "w", "div_6_1_liquid": "x",
"div_6_1_solid": "y", "div_6_2_infectious": "z", "div_6_2_select": "aa",
"class_7": "bb", "hrcq": "cc", "class_8": "dd", "class_8a": "ee",
"class_8b": "ff", "class_9": "gg", "elevated_temp": "hh",
"infectious_waste": "ii", "marine_pollutants": "jj", "hazardous_sub_rq": "kk",
"hazardous_waste": "ll", "ltd_qty": "mm",
}
HAZMAT_COL_MAP = {"carrier": "C", "shipper": "S", "bulk": "B", "non_bulk": "NB"}
# Vehicle-count rows (Q26): map an intake vehicle-type key to the field prefix
# used for Owned/Term-leased/Trip-leased columns ({prefix}Own/{prefix}Term/
# {prefix}Trip).
VEHICLE_TYPE_PREFIX = {
"straight": "straight",
"tractor": "tractor",
"trailer": "trailer",
"hazmat_truck": "haztruck",
"hazmat_trailer": "haztrail",
"motorcoach": "coach",
"school_bus_1_8": "school1-8",
"school_bus_9_15": "school9-15",
"school_bus_16": "school16+",
"bus_16": "bus16+",
"van_1_8": "van1-8",
"van_9_15": "van9-15",
"limo_1_8": "limo1-8",
"limo_9_15": "limo9-15",
"limo_16": "limo16+",
}
# US states / territories -> the MCS-150B "states of operation" checkbox field
# name (the field name is the full state name).
US_STATES = [
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado",
"Connecticut", "Delaware", "District of Columbia", "Florida", "Georgia",
"Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky",
"Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
"Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire",
"New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota",
"Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Puerto Rico", "Rhode Island",
"South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming",
]
# Accept two-letter codes too.
US_STATE_BY_CODE = {
"AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas",
"CA": "California", "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware",
"DC": "District of Columbia", "FL": "Florida", "GA": "Georgia", "HI": "Hawaii",
"ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas",
"KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
"MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi",
"MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada",
"NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York",
"NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma",
"OR": "Oregon", "PA": "Pennsylvania", "PR": "Puerto Rico", "RI": "Rhode Island",
"SC": "South Carolina", "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas",
"UT": "Utah", "VT": "Vermont", "VA": "Virginia", "WA": "Washington",
"WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming",
}
def determine_form_type(intake: dict) -> str:
"""Determine which MCS-150 form to use.
Returns 'mcs150', 'mcs150b', or 'mcs150c'.
"""
if intake.get("is_intermodal_equipment_provider"):
return "mcs150c"
if intake.get("hazmat") == "yes" and intake.get("needs_hmsp"):
return "mcs150b"
return "mcs150"
def _stamp_check_marks(writer, marks: list) -> None:
"""Draw an explicit "X" mark inside each (page, rect) in ``marks``.
AcroForm checkbox/radio appearances render inconsistently across viewers
(poppler, Preview), so we burn the mark into the page content to guarantee
it shows on screen and in the faxed/printed output. Grouped per page so we
merge a single overlay onto each affected page.
"""
if not marks:
return
try:
import io
from reportlab.pdfgen import canvas as _canvas
from pypdf import PdfReader as _PdfReader
except Exception as exc: # reportlab missing — fall back to AcroForm only
LOG.warning("Checkmark overlay unavailable (%s); relying on AcroForm", exc)
return
# Group rects by their page object's index in the writer.
page_index = {id(p): i for i, p in enumerate(writer.pages)}
by_page: dict[int, list] = {}
for page, rect in marks:
idx = page_index.get(id(page))
if idx is not None:
by_page.setdefault(idx, []).append(rect)
for idx, rects in by_page.items():
page = writer.pages[idx]
pw = float(page.mediabox.width)
ph = float(page.mediabox.height)
buf = io.BytesIO()
c = _canvas.Canvas(buf, pagesize=(pw, ph))
c.setLineWidth(1.3)
for (x0, y0, x1, y1) in rects:
# Draw an "X" that fills most of the box with a small inset.
inset = max(1.2, (x1 - x0) * 0.18)
c.line(x0 + inset, y0 + inset, x1 - inset, y1 - inset)
c.line(x0 + inset, y1 - inset, x1 - inset, y0 + inset)
c.save()
buf.seek(0)
overlay = _PdfReader(buf).pages[0]
page.merge_page(overlay)
def fill_mcs150(intake: dict, order_number: str = "") -> str:
"""Fill the official MCS-150 PDF form.
Args:
intake: Dict with all MCS-150 fields from intake form.
order_number: Order number for filename.
Returns:
Path to the filled PDF.
"""
if PdfReader is None:
raise ImportError("pypdf not installed")
form_type = determine_form_type(intake)
form_path = FORMS[form_type]
if not form_path.exists():
raise FileNotFoundError(f"MCS-150 form not found: {form_path}")
reader = PdfReader(str(form_path))
writer = PdfWriter()
writer.clone_document_from_reader(reader)
# Build field values
field_updates = {}
# ── Text fields ──────────────────────────────────────────────────
# The 150C form renumbers a few identification fields (D&B/EIN/email shift
# up by one because it has no MC/MX line). Pick the right field names per
# variant so the values land in the correct boxes.
if form_type == "mcs150c":
ein_field, email_field, dunbrad_field = "18irsNumber", "19eMail", "17dunbradNumber"
else:
ein_field, email_field, dunbrad_field = "19irsNumber", "20eMail", "18dunbradNumber"
field_updates["1bizName"] = intake.get("legal_name", "")
field_updates["2dbaName"] = intake.get("dba_name", "")
field_updates["3principalStreet"] = intake.get("address_street", "")
field_updates["4principalCity"] = intake.get("address_city", "")
field_updates["5principalState"] = intake.get("address_state", "")
field_updates["6principalZip"] = intake.get("address_zip", "")
field_updates["13bizPhone"] = intake.get("phone", "")
field_updates["14cellPhone"] = intake.get("cell_phone", "")
field_updates["15faxNumber"] = intake.get("fax", "")
field_updates["16usdotNumber"] = intake.get("dot_number", "")
field_updates["usdotNumber"] = intake.get("dot_number", "") # duplicate field
field_updates["17mcmxNumber"] = intake.get("mc_number", "")
field_updates[dunbrad_field] = intake.get("dun_bradstreet", "")
field_updates[ein_field] = intake.get("ein", "")
field_updates[email_field] = intake.get("email", "")
field_updates["21carrierMileage"] = str(intake.get("annual_miles", "") or "")
# Mailing address (if different from principal place of business)
if intake.get("mailing_street"):
field_updates["8mailStreet"] = intake.get("mailing_street", "")
field_updates["9mailCity"] = intake.get("mailing_city", "")
field_updates["10mailState"] = intake.get("mailing_state", "")
field_updates["11mailZip"] = intake.get("mailing_zip", "")
# Fleet / drivers (base + 150B)
if intake.get("drivers") not in (None, ""):
field_updates["totalDrivers"] = str(intake.get("drivers"))
field_updates["totalCDL"] = str(intake.get("cdl_drivers", intake.get("drivers")))
# Vehicle counts (Q26). Accept a structured `vehicles` dict of
# {vehicle_type: {owned, term_leased, trip_leased}} for full fleets; fall
# back to a single power-unit count on the primary vehicle type.
vehicles = intake.get("vehicles") or {}
if isinstance(vehicles, dict) and vehicles:
for vtype, counts in vehicles.items():
prefix = VEHICLE_TYPE_PREFIX.get(vtype)
if not prefix or not isinstance(counts, dict):
continue
for col, suffix in (("owned", "Own"), ("term_leased", "Term"),
("trip_leased", "Trip")):
val = counts.get(col)
if val not in (None, "", 0, "0"):
field_updates[f"{prefix}{suffix}"] = str(val)
else:
power_units = intake.get("power_units", "")
prefix = VEHICLE_TYPE_PREFIX.get(
intake.get("primary_vehicle_type", "straight"), "straight")
if power_units not in (None, ""):
field_updates[f"{prefix}Own"] = str(power_units)
# Simple intake also collects a flat trailer count (no owned/leased
# breakdown). Default trailers to the Owned column of the trailer row.
trailers = intake.get("trailers", "")
if trailers not in (None, "", 0, "0"):
field_updates["trailerOwn"] = str(trailers)
# Non-CMV count, if provided.
if intake.get("non_cmv_vehicles") not in (None, ""):
field_updates["non-CMV"] = str(intake.get("non_cmv_vehicles"))
# Intermodal equipment counts (150C only).
if form_type == "mcs150c":
for key, fld in (("iep_owned", "20owned"), ("iep_leased", "20leased"),
("iep_serviced", "20serviced")):
if intake.get(key) not in (None, ""):
field_updates[fld] = str(intake.get(key))
# HMSP accident count (150B only).
if form_type == "mcs150b" and intake.get("hmsp_accident_count") not in (None, ""):
field_updates["32accidentNumber"] = str(intake.get("hmsp_accident_count"))
# Officers (up to two named on the form)
field_updates["officerName1"] = intake.get("signer_name", "")
field_updates["officerTitle1"] = intake.get("signer_title", "")
field_updates["officerName2"] = intake.get("officer2_name", "")
field_updates["officerTitle2"] = intake.get("officer2_title", "")
# Certification
field_updates["certifyName"] = intake.get("signer_name", "")
field_updates["certifyTitle"] = intake.get("signer_title", "")
field_updates["certifyDate"] = datetime.now().strftime("%m/%d/%Y")
# Interstate/intrastate mileage
interstate = intake.get("interstate_intrastate", "")
if interstate == "interstate":
field_updates["interWithin"] = str(intake.get("annual_miles", ""))
elif interstate in ("intrastate_hazmat", "intrastate_non_hazmat"):
field_updates["intraWithin"] = str(intake.get("annual_miles", ""))
# ── Checkbox fields (on/off, value /Yes) ─────────────────────────
checkbox_on = {}
# Q22 Company operations (interstate / intrastate classification)
company_op = intake.get("interstate_intrastate", "")
if company_op in COMPANY_OPERATION_MAP:
checkbox_on[COMPANY_OPERATION_MAP[company_op]] = True
# Q23 Operation classifications (for-hire / private / government / etc.)
carrier_op = intake.get("carrier_operation", "")
if carrier_op in CARRIER_OP_MAP:
checkbox_on[CARRIER_OP_MAP[carrier_op]] = True
# Q24 Cargo types
for cargo in intake.get("cargo_types", []):
if cargo in CARGO_TYPE_MAP:
checkbox_on[CARGO_TYPE_MAP[cargo]] = True
# Q25 Hazardous materials matrix. `hazmat_materials` is a list of
# {commodity, roles:[carrier|shipper|bulk|non_bulk]} (or a dict
# {commodity: [roles]}). Each commodity row x role column = one box.
hazmat_materials = intake.get("hazmat_materials") or {}
if isinstance(hazmat_materials, list):
hazmat_materials = {m.get("commodity"): m.get("roles", [])
for m in hazmat_materials if isinstance(m, dict)}
for commodity, roles in (hazmat_materials or {}).items():
row = HAZMAT_ROW_MAP.get(commodity)
if not row:
continue
for role in roles or []:
col = HAZMAT_COL_MAP.get(role)
if col:
checkbox_on[f"25{row}{col}Box"] = True
# MCS-150B: states of operation (full names or 2-letter codes).
if form_type == "mcs150b":
for st in intake.get("operating_states", []) or []:
name = US_STATE_BY_CODE.get(str(st).upper(), st)
if name in US_STATES:
checkbox_on[name] = True
# Q29 Passenger Carrier Compliance Certification "YES" box. Only motor
# passenger carriers certify here -- leave it unchecked for freight/property
# carriers. (The Q31 perjury declaration is made via the signature, not a
# checkbox.)
is_passenger = (
intake.get("is_passenger_carrier") == "yes"
or carrier_op in ("private_passengers_business", "private_passengers_non_business",
"private_passengers")
)
if is_passenger:
checkbox_on["certifyBox"] = True
# ── Radio-button groups ──────────────────────────────────────────
# These are single-select radios. _set_button resolves the semantic value
# against each field's actual export states, so it works whether the form
# uses numeric (/0../4) exports (base/150B) or named exports (150C).
radio_values = {}
# Reason for filing. Semantic -> {numeric index, named export} so it maps on
# both export styles.
reason = intake.get("reason_for_filing", "biennial_update")
reason_choices = {
"new_application": ("0", "New Application"),
"biennial_update": ("1", "Biennial Update or Changes"),
"out_of_business": ("2", "Out of Business Notification"),
"reapplication": ("3", "Reapplication"),
"reactivate": ("4", "Reactivate"),
}
radio_values["Reason Button"] = reason_choices.get(reason, reason_choices["biennial_update"])
# Mailing address: same-as-principal vs separate mailing address below.
if intake.get("mailing_street"):
radio_values["Mailing Button"] = ("1", "Please enter mailing address below.")
else:
radio_values["Mailing Button"] = ("0", "Same as Principal Address")
# Q28 Is USDOT registration currently revoked? Options render Yes then No.
revoked = intake.get("usdot_revoked") == "yes"
radio_values["Revoke Button"] = ("0", "Yes") if revoked else ("1", "No")
# 150C asks the same as a USDOT Button (named Yes/No).
radio_values["USDOT Button"] = ("Yes",) if revoked else ("No",)
# MCS-150B HMSP questions (Hazard/Permit/Security): default No (index 1).
if form_type == "mcs150b":
radio_values["Hazard Button"] = ("0",) if intake.get("hmsp_hazard") == "yes" else ("1",)
radio_values["Permit Button"] = ("0",) if intake.get("hmsp_has_permit") == "yes" else ("1",)
radio_values["Security Button"] = ("0",) if intake.get("hmsp_security_plan") == "yes" else ("1",)
# ── Apply fields to PDF ──────────────────────────────────────────
# Apply text-field values to every page. The template ships with only the
# fillable FORM pages (the FMCSA instruction pages are trimmed off the
# source PDF so we never fax/submit them), so we update across all pages
# (pypdf silently ignores field names not present on a given page). auto_regenerate=True makes pypdf
# build appearance streams from the values, so viewers that ignore
# /NeedAppearances (Preview, Chrome) still render the text.
text_values = {k: v for k, v in field_updates.items() if v}
for page in writer.pages:
try:
writer.update_page_form_field_values(
page, text_values, auto_regenerate=True,
)
except Exception as exc:
LOG.debug("Form field apply on page failed: %s", exc)
# Apply checkbox fields (value /Yes) and radio groups (value /0../n) by
# walking the widget annotations. For radios, the selected option is the
# kid whose appearance state (/AP /N) contains the chosen index; we set the
# parent /V and each kid's /AS so the correct circle renders filled.
# ``marks`` collects (page, rect) of every "on" widget so we can stamp an
# explicit checkmark overlay (AcroForm appearances render inconsistently).
marks: list = []
def _set_button(field_name: str, candidates) -> None:
"""Set a /Btn field (checkbox or radio) to the first of ``candidates``
that matches one of the field's actual export states. ``candidates`` may
be a single string or a tuple of strings (e.g. ('1', 'No') to cover both
numeric and named export styles). Handles flat checkboxes and
parent/kid radio groups."""
if isinstance(candidates, str):
candidates = (candidates,)
wanted = ["/" + str(c).lstrip("/") for c in candidates]
# Collect every widget annotation belonging to this field, plus the
# union of all of its export states, so we can resolve which candidate
# to use.
widgets = []
all_states: set = set()
for page in writer.pages:
for annot in page.get("/Annots", []) or []:
obj = annot.get_object()
name = obj.get("/T")
parent = obj.get("/Parent")
pobj = parent.get_object() if parent else None
if name is None and pobj is not None:
name = pobj.get("/T")
if name is None or str(name) != field_name:
continue
states = []
ap = obj.get("/AP")
if ap:
n = ap.get_object().get("/N")
if n is not None:
states = [str(k) for k in n.get_object().keys() if str(k) != "/Off"]
all_states.update(states)
widgets.append((page, obj, pobj, states))
if not widgets:
return
# Choose the candidate that this field actually supports.
target = next((w for w in wanted if w in all_states), None)
if target is None:
target = "/Yes" if "/Yes" in all_states else (wanted[0] if wanted else "/Yes")
for page, obj, pobj, states in widgets:
field_obj = pobj if pobj is not None else obj
field_obj[NameObject("/V")] = NameObject(target)
if target in states or (not states and target == "/Yes"):
obj[NameObject("/AS")] = NameObject(target)
# Record the on-widget rectangle + page so we can draw the mark
# directly onto the page content. AcroForm /AP appearances are
# unreliable across viewers (poppler/Preview regenerate them and
# lose the ZapfDingbats checkmark), so we stamp our own mark.
rect = obj.get("/Rect")
if rect is not None:
marks.append((page, [float(x) for x in rect]))
else:
obj[NameObject("/AS")] = NameObject("/Off")
for field_name, checked in checkbox_on.items():
if checked:
try:
_set_button(field_name, "Yes")
except Exception as e:
LOG.debug("Checkbox %s set failed: %s", field_name, e)
for field_name, value in radio_values.items():
try:
_set_button(field_name, value)
except Exception as e:
LOG.debug("Radio %s set failed: %s", field_name, e)
# Stamp an explicit checkmark onto every "on" checkbox/radio. AcroForm /AP
# appearances for these boxes use a ZapfDingbats glyph that poppler/Preview
# fail to render reliably (the value is set but the box looks empty). Drawing
# the mark directly into the page content guarantees it shows in every
# viewer and in the faxed/printed output.
_stamp_check_marks(writer, marks)
# Force viewers to (re)generate field appearance streams from the values we
# set. Without /NeedAppearances, pypdf leaves the template's blank /AP streams
# in place, so the typed values are present in /V but the viewer renders the
# empty widget on top -- the data looks missing / "covered up by the form
# field". Setting NeedAppearances=true on the AcroForm fixes the rendering.
try:
catalog = writer._root_object
if "/AcroForm" in catalog:
acro = catalog["/AcroForm"]
acro_obj = acro.get_object() if hasattr(acro, "get_object") else acro
acro_obj[NameObject("/NeedAppearances")] = BooleanObject(True)
except Exception as exc:
LOG.warning("Could not set NeedAppearances: %s", exc)
# Save
work_dir = tempfile.mkdtemp(prefix="pw_mcs150_")
dot = intake.get("dot_number", "unknown")
date_str = datetime.now().strftime("%Y%m%d")
filename = f"MCS150_DOT{dot}_{date_str}_filled.pdf"
filepath = os.path.join(work_dir, filename)
with open(filepath, "wb") as f:
writer.write(f)
LOG.info("Filled MCS-150 (%s) → %s", form_type, filepath)
return filepath
if __name__ == "__main__":
test_intake = {
"legal_name": "ADAMS LUMBER INC",
"dba_name": "Adams Trucking",
"dot_number": "1157913",
"mc_number": "MC-456789",
"address_street": "123 Timber Lane",
"address_city": "Portland",
"address_state": "OR",
"address_zip": "97201",
"phone": "(503) 555-1234",
"email": "mark@adamslumber.com",
"entity_type": "corporation",
"carrier_operation": "authorized_for_hire",
"interstate_intrastate": "interstate",
"hazmat": "no",
"power_units": "5",
"drivers": "6",
"annual_miles": "250000",
"cargo_types": ["general", "building_materials", "logs"],
"signer_name": "Mark Adams",
"signer_title": "President",
}
path = fill_mcs150(test_intake, order_number="CO-TEST123")
print(f"Generated: {path}")
print(f"Size: {os.path.getsize(path)} bytes")