From b95ee04752c455b38cfb256234c82028e937544e Mon Sep 17 00:00:00 2001 From: justin Date: Wed, 10 Jun 2026 13:41:48 -0500 Subject: [PATCH] mcs150: fill all checkboxes/radios correctly + stamp explicit checkmarks Fixes a batch of missing fields the FMCSA census does not provide and the filler was mis-mapping: - Corrected the question->field mapping to match the actual form: Q22 = COMPANY OPERATIONS (interstate/intrastate, 22xBox), Q23 = OPERATION CLASSIFICATIONS (for-hire/private/govt, 23xBox). These were swapped, and the bogus entity-type->23xBox map (no entity-type question exists on this form revision) was removed. - Added proper radio-group handling for Reason for Filing (Biennial Update), Mailing-address (Same as principal vs below), and Q28 USDOT-revoked, with correct option indices (these are /0../n radios, not /Yes checkboxes; the old code set them to /Yes and never selected the right option). - Map interstate/intrastate from the FMCSA census carrierOperationCode, and populate email/phone/mileage/cargo from intake. - AcroForm checkbox/radio appearances use a ZapfDingbats glyph that poppler/Preview fail to render (value set but box looks empty). Now stamp an explicit X overlay into the page content for every 'on' box so it shows in every viewer and in the faxed output. --- .../templates/mcs150_pdf_filler.py | 206 ++++++++++++++---- scripts/workers/services/mcs150_update.py | 17 ++ 2 files changed, 185 insertions(+), 38 deletions(-) diff --git a/scripts/document_gen/templates/mcs150_pdf_filler.py b/scripts/document_gen/templates/mcs150_pdf_filler.py index 49d39fb..c81c3f8 100644 --- a/scripts/document_gen/templates/mcs150_pdf_filler.py +++ b/scripts/document_gen/templates/mcs150_pdf_filler.py @@ -44,24 +44,37 @@ FORMS = { # ── Field mappings ──────────────────────────────────────────────────── -# Question 22: Carrier Operation (checkboxes) -CARRIER_OP_MAP = { - "authorized_for_hire": "22aBox", - "exempt_for_hire": "22bBox", - "private_property": "22cBox", - "private_passengers": "22dBox", - "us_mail": "22eBox", +# 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: Entity Type (checkboxes) -ENTITY_TYPE_MAP = { - "sole_proprietorship": "23aBox", - "partnership": "23bBox", - "corporation": "23cBox", - "llc": "23dBox", - "other": "23kBox", +# 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", @@ -108,6 +121,50 @@ def determine_form_type(intake: dict) -> str: 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. @@ -186,30 +243,54 @@ def fill_mcs150(intake: dict, order_number: str = "") -> str: elif interstate in ("intrastate_hazmat", "intrastate_non_hazmat"): field_updates["intraWithin"] = str(intake.get("annual_miles", "")) - # ── Checkbox fields ────────────────────────────────────────────── + # ── Checkbox fields (on/off, value /Yes) ───────────────────────── checkbox_on = {} - # Carrier operation + # 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 - # Entity type - entity_type = intake.get("entity_type", "") - if entity_type in ENTITY_TYPE_MAP: - checkbox_on[ENTITY_TYPE_MAP[entity_type]] = True - - # Cargo types + # Q24 Cargo types for cargo in intake.get("cargo_types", []): if cargo in CARGO_TYPE_MAP: checkbox_on[CARGO_TYPE_MAP[cargo]] = True - # Reason for filing — biennial update - checkbox_on["Reason Button"] = True # Biennial update - - # Certify box + # The bottom certification ("I ... certify ...") box is always checked -- + # the client signs the perjury certification. checkbox_on["certifyBox"] = True + # ── Radio-button groups (value /0../4, not /Yes) ───────────────── + # These are single-select radio fields; the selected option index is the + # field value. + radio_values = {} + + # Reason for filing (form REASON FOR FILING). 0=New Application, + # 1=Biennial Update or Changes, 2=Out of Business, 3=Reapplication, + # 4=Reactivate. Map the service slug / explicit reason to the index. + reason_map = { + "new_application": "0", + "biennial_update": "1", + "out_of_business": "2", + "reapplication": "3", + "reactivate": "4", + } + reason = intake.get("reason_for_filing", "biennial_update") + radio_values["Reason Button"] = reason_map.get(reason, "1") + + # Mailing address: 0 = same as principal place of business, 1 = different + # address provided below. + radio_values["Mailing Button"] = "1" if intake.get("mailing_street") else "0" + + # Q28 Is USDOT registration currently revoked? On this form the options + # render Yes (index 0) then No (index 1), so No = "1". + radio_values["Revoke Button"] = "0" if intake.get("usdot_revoked") == "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 @@ -226,24 +307,73 @@ def fill_mcs150(intake: dict, order_number: str = "") -> str: except Exception as exc: LOG.debug("Form field apply on page failed: %s", exc) - # Apply checkbox fields + # 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, on_value: str) -> None: + """Set a /Btn field (checkbox or radio) to ``on_value`` (e.g. 'Yes' or + '1'). Handles both flat widgets and parent/kid radio groups.""" + target = "/" + on_value.lstrip("/") + for page in writer.pages: + for annot in page.get("/Annots", []) or []: + obj = annot.get_object() + # Resolve the field name from this widget or its parent. + 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 + # Determine the appearance state this widget represents. + ap = obj.get("/AP") + on_states = [] + if ap: + n = ap.get_object().get("/N") + if n is not None: + on_states = [str(k) for k in n.get_object().keys() if str(k) != "/Off"] + # Set the field value on the field object (parent for radios). + field_obj = pobj if pobj is not None else obj + field_obj[NameObject("/V")] = NameObject(target) + # The widget is "on" only if its own appearance state matches. + if target in on_states or (not on_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 (see overlay below). + # AcroForm /AP appearances are unreliable across viewers + # (poppler/Preview regenerate them and lose the ZapfDingbats + # checkmark), so we stamp our own mark to guarantee it shows. + 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: - # Find the field across all pages and set it - for page in writer.pages: - if "/Annots" in page: - for annot in page["/Annots"]: - obj = annot.get_object() - if obj.get("/T") and str(obj["/T"]) == field_name: - obj.update({ - NameObject("/V"): NameObject("/Yes"), - NameObject("/AS"): NameObject("/Yes"), - }) - break + _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 diff --git a/scripts/workers/services/mcs150_update.py b/scripts/workers/services/mcs150_update.py index 1a2e82b..4d2798f 100644 --- a/scripts/workers/services/mcs150_update.py +++ b/scripts/workers/services/mcs150_update.py @@ -460,6 +460,23 @@ class MCS150UpdateHandler: "power_units": s(c.get("totalPowerUnits")), "drivers": s(c.get("totalDrivers")), } + + # Interstate vs intrastate (form Q26/27). The census exposes this as + # carrierOperation.carrierOperationCode: A=Interstate, B=Intrastate + # Hazmat, C=Intrastate Non-Hazmat. + op = c.get("carrierOperation") or {} + code = (op.get("carrierOperationCode") or "").strip().upper() + if code == "A": + out["interstate_intrastate"] = "interstate" + elif code == "B": + out["interstate_intrastate"] = "intrastate_hazmat" + elif code == "C": + out["interstate_intrastate"] = "intrastate_non_hazmat" + + # Passenger carrier flag (form Q29 passenger compliance cert). + if s(c.get("isPassengerCarrier")).upper() == "Y": + out["is_passenger_carrier"] = "yes" + return {k: v for k, v in out.items() if v} def _check_current_status(self, dot_number: str) -> str: