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.
This commit is contained in:
parent
386467bedf
commit
b95ee04752
2 changed files with 185 additions and 38 deletions
|
|
@ -44,24 +44,37 @@ FORMS = {
|
||||||
|
|
||||||
# ── Field mappings ────────────────────────────────────────────────────
|
# ── Field mappings ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Question 22: Carrier Operation (checkboxes)
|
# Question 22: COMPANY OPERATIONS (interstate / intrastate classification).
|
||||||
CARRIER_OP_MAP = {
|
# A=Interstate Carrier, B=Intrastate Hazmat Carrier, C=Intrastate Non-Hazmat
|
||||||
"authorized_for_hire": "22aBox",
|
# Carrier, D=Interstate Hazmat Shipper, E=Intrastate Hazmat Shipper.
|
||||||
"exempt_for_hire": "22bBox",
|
COMPANY_OPERATION_MAP = {
|
||||||
"private_property": "22cBox",
|
"interstate": "22aBox",
|
||||||
"private_passengers": "22dBox",
|
"intrastate_hazmat": "22bBox",
|
||||||
"us_mail": "22eBox",
|
"intrastate_non_hazmat": "22cBox",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Question 23: Entity Type (checkboxes)
|
# Question 23: OPERATION CLASSIFICATIONS (how the carrier operates).
|
||||||
ENTITY_TYPE_MAP = {
|
# A=Authorized For-Hire, B=Exempt For-Hire, C=Private Property,
|
||||||
"sole_proprietorship": "23aBox",
|
# D=Private Passengers (Business), E=Private Passengers (Non-Business),
|
||||||
"partnership": "23bBox",
|
# F=Migrant, G=U.S. Mail, H=Federal Govt, I=State Govt, J=Local Govt,
|
||||||
"corporation": "23cBox",
|
# K=Indian Tribe.
|
||||||
"llc": "23dBox",
|
CARRIER_OP_MAP = {
|
||||||
"other": "23kBox",
|
"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)
|
# Question 24: Cargo Types (checkboxes — a through z, aa through dd)
|
||||||
CARGO_TYPE_MAP = {
|
CARGO_TYPE_MAP = {
|
||||||
"general": "24aBox",
|
"general": "24aBox",
|
||||||
|
|
@ -108,6 +121,50 @@ def determine_form_type(intake: dict) -> str:
|
||||||
return "mcs150"
|
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:
|
def fill_mcs150(intake: dict, order_number: str = "") -> str:
|
||||||
"""Fill the official MCS-150 PDF form.
|
"""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"):
|
elif interstate in ("intrastate_hazmat", "intrastate_non_hazmat"):
|
||||||
field_updates["intraWithin"] = str(intake.get("annual_miles", ""))
|
field_updates["intraWithin"] = str(intake.get("annual_miles", ""))
|
||||||
|
|
||||||
# ── Checkbox fields ──────────────────────────────────────────────
|
# ── Checkbox fields (on/off, value /Yes) ─────────────────────────
|
||||||
checkbox_on = {}
|
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", "")
|
carrier_op = intake.get("carrier_operation", "")
|
||||||
if carrier_op in CARRIER_OP_MAP:
|
if carrier_op in CARRIER_OP_MAP:
|
||||||
checkbox_on[CARRIER_OP_MAP[carrier_op]] = True
|
checkbox_on[CARRIER_OP_MAP[carrier_op]] = True
|
||||||
|
|
||||||
# Entity type
|
# Q24 Cargo types
|
||||||
entity_type = intake.get("entity_type", "")
|
|
||||||
if entity_type in ENTITY_TYPE_MAP:
|
|
||||||
checkbox_on[ENTITY_TYPE_MAP[entity_type]] = True
|
|
||||||
|
|
||||||
# Cargo types
|
|
||||||
for cargo in intake.get("cargo_types", []):
|
for cargo in intake.get("cargo_types", []):
|
||||||
if cargo in CARGO_TYPE_MAP:
|
if cargo in CARGO_TYPE_MAP:
|
||||||
checkbox_on[CARGO_TYPE_MAP[cargo]] = True
|
checkbox_on[CARGO_TYPE_MAP[cargo]] = True
|
||||||
|
|
||||||
# Reason for filing — biennial update
|
# The bottom certification ("I ... certify ...") box is always checked --
|
||||||
checkbox_on["Reason Button"] = True # Biennial update
|
# the client signs the perjury certification.
|
||||||
|
|
||||||
# Certify box
|
|
||||||
checkbox_on["certifyBox"] = True
|
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 fields to PDF ──────────────────────────────────────────
|
||||||
# Apply text-field values to every page. The template ships with only the
|
# Apply text-field values to every page. The template ships with only the
|
||||||
# fillable FORM pages (the FMCSA instruction pages are trimmed off 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:
|
except Exception as exc:
|
||||||
LOG.debug("Form field apply on page failed: %s", 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():
|
for field_name, checked in checkbox_on.items():
|
||||||
if checked:
|
if checked:
|
||||||
try:
|
try:
|
||||||
# Find the field across all pages and set it
|
_set_button(field_name, "Yes")
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.debug("Checkbox %s set failed: %s", field_name, 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
|
# Force viewers to (re)generate field appearance streams from the values we
|
||||||
# set. Without /NeedAppearances, pypdf leaves the template's blank /AP streams
|
# 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
|
# in place, so the typed values are present in /V but the viewer renders the
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,23 @@ class MCS150UpdateHandler:
|
||||||
"power_units": s(c.get("totalPowerUnits")),
|
"power_units": s(c.get("totalPowerUnits")),
|
||||||
"drivers": s(c.get("totalDrivers")),
|
"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}
|
return {k: v for k, v in out.items() if v}
|
||||||
|
|
||||||
def _check_current_status(self, dot_number: str) -> str:
|
def _check_current_status(self, dot_number: str) -> str:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue