The Standard no-login CMS path needs an ORIGINAL ink signature on paper (CMS-10114: 'Stamped, faxed or copied signatures will not be accepted'). This adds a pipeline to redraw the provider's own captured strokes in real ink with a pen on a CR-10 V2 (or any Marlin/GRBL machine) — original, in ink, never copied. - migration 090: esign_records.signature_vector (JSONB stroke paths, 0..1). - signing page now captures normalized stroke paths alongside the PNG; API stores a size-bounded vector for drawn signatures. - ink_signature_plotter.py (hardware-independent): fit strokes to the signature anchor box, PDF-pt -> bed-mm via jig offset, emit Marlin/GRBL G-code (Z pen or M280 servo/BLTouch), SVG toolpath preview, and render_signature_on_pdf (a digital twin that proves the toolpath lands on the cert line). Gated serial sender (dry_run default). - ink_signature_cli.py: end-to-end load-record -> gcode+preview, --test-box jig calibration, --plot to stream over USB. - Corrected CMS-10114 signature anchor to sit inside the Section 4A signing cell (above the bottom rule, below the label). - docs/ink-signature-plotter.md documents the CR-10 retrofit + interpretive risk. Tests: test_ink_signature.py 30/30, test_cms10114.py 27/27, test_paper_batch.py 15/15, API tsc clean, Astro build 58 pages.
254 lines
9.8 KiB
Python
254 lines
9.8 KiB
Python
"""Fill the official CMS-10114 NPI Application/Update form (flat PDF, overlay).
|
|
|
|
The CMS-10114 is the "NPI Application/Update Form" (Rev. 02/25, OMB 0938-0931).
|
|
Unlike the CMS-855 forms it is a **flat PDF with no AcroForm fields**, so we
|
|
fill it by drawing a text overlay at fixed coordinates (reportlab) and merging
|
|
that over the original page (pypdf), mirroring the stamper approach used by the
|
|
e-sign pipeline.
|
|
|
|
This is the Standard (no-login) path for NPPES data updates, NPI reactivation,
|
|
and deactivation: the provider signs the certification (Section 4A individual /
|
|
4B organization) and the signed form is mailed to:
|
|
|
|
CMS NPI Enumerator Services
|
|
Mail Stop DO-01-51
|
|
7500 Security Blvd.
|
|
Baltimore, MD 21244
|
|
|
|
(verified against the form, page 5; the older Fargo PO Box 6059 is retired).
|
|
Enumerator staff key the data into NPPES; no NPPES/I&A login is required.
|
|
|
|
Section 1A "Reason for Submittal" has four mutually-exclusive checkboxes:
|
|
1 Initial Application, 2 Change of Information, 3 Deactivation, 4 Reactivation.
|
|
We drive the checkbox from the service slug / intake reason so the same filler
|
|
serves nppes-update (change), npi-reactivation, and deactivation.
|
|
|
|
Usage:
|
|
from scripts.document_gen.templates.cms10114_pdf_filler import fill_cms10114
|
|
pdf_bytes, anchors, missing = fill_cms10114(intake, reason="change",
|
|
order_number="CO-1234")
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
LOG = logging.getLogger("cms10114_pdf_filler")
|
|
|
|
# docs/ lives two levels up from scripts/document_gen/templates/
|
|
DOCS_DIR = Path(__file__).resolve().parents[3] / "docs"
|
|
FORM_PATH = DOCS_DIR / "CMS-10114 Form.pdf"
|
|
|
|
# Page index (0-based) of the actual fill-in application form pages within the
|
|
# downloaded PDF (the first pages are instructions).
|
|
PAGE_BASIC = 2 # Section 1 (reason + entity) and Section 2A identity
|
|
PAGE_CERT = 4 # Section 4 certification / signature
|
|
|
|
PAGE_W = 612.0
|
|
PAGE_H = 792.0
|
|
|
|
# Reason-for-submittal checkbox glyph positions (Section 1A). The checkbox sits
|
|
# just left of each label; coordinates are the lower-left of where we draw an
|
|
# "X". Verified from pdfplumber word boxes on the form page.
|
|
REASON_CHECKBOX = {
|
|
"initial": {"x": 48, "y": 610},
|
|
"change": {"x": 48, "y": 584},
|
|
"deactivation": {"x": 325, "y": 610},
|
|
"reactivation": {"x": 325, "y": 508},
|
|
}
|
|
|
|
# Where to write the NPI for each reason (the "NPI: (Required)" line beside the
|
|
# chosen checkbox). Change uses the left column NPI line; deactivation/
|
|
# reactivation use the right column NPI lines.
|
|
REASON_NPI_POS = {
|
|
"change": {"x": 132, "y": 570},
|
|
"deactivation": {"x": 408, "y": 599},
|
|
"reactivation": {"x": 408, "y": 529},
|
|
}
|
|
|
|
# Section 2A individual identity fields (the blank row under the Prefix/First/
|
|
# Middle/Last header at pdf_y ~320; we write on the entry row just below it).
|
|
IDENTITY_POS = {
|
|
"first": {"x": 168, "y": 305},
|
|
"middle": {"x": 281, "y": 305},
|
|
"last": {"x": 370, "y": 305},
|
|
}
|
|
|
|
# Section 4A (individual) printed-name fields under "First*/Middle/Last*" header.
|
|
CERT_NAME_POS = {
|
|
"first": {"x": 142, "y": 372},
|
|
"middle": {"x": 280, "y": 372},
|
|
"last": {"x": 429, "y": 372},
|
|
}
|
|
|
|
# Signature line for Section 4A (individual). The cell "1. Practitioner's
|
|
# Signature" has its label at pdf_y ~458 and its bottom rule at pdf_y ~441; the
|
|
# signer writes in the band between them (x 36..483). We anchor the signature to
|
|
# rest just above the bottom rule. The e-sign stamper / pen plotter use this box.
|
|
SIGNATURE_FIELDS = [
|
|
{"field": "signer", "page": PAGE_CERT,
|
|
"rect": [44.0, 442.0, 474.0, 456.0], "page_w": PAGE_W, "page_h": PAGE_H},
|
|
]
|
|
|
|
VALID_REASONS = ("initial", "change", "deactivation", "reactivation")
|
|
|
|
# Map service slug -> default reason, so callers can pass a slug instead.
|
|
SLUG_TO_REASON = {
|
|
"nppes-update": "change",
|
|
"npi-data-update": "change",
|
|
"npi-reactivation": "reactivation",
|
|
"npi-deactivation": "deactivation",
|
|
"npi-enumeration": "initial",
|
|
}
|
|
|
|
|
|
def _split_name(full: str) -> tuple[str, str, str]:
|
|
parts = (full or "").split()
|
|
if not parts:
|
|
return "", "", ""
|
|
if len(parts) == 1:
|
|
return parts[0], "", ""
|
|
if len(parts) == 2:
|
|
return parts[0], "", parts[1]
|
|
return parts[0], parts[1][0], " ".join(parts[2:])
|
|
|
|
|
|
def normalize_reason(reason_or_slug: str) -> str:
|
|
r = (reason_or_slug or "").strip().lower()
|
|
if r in VALID_REASONS:
|
|
return r
|
|
return SLUG_TO_REASON.get(r, "change")
|
|
|
|
|
|
def _build_overlay(reason: str, values: dict) -> bytes:
|
|
"""Render a multi-page overlay matching the form's page count."""
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.pdfgen import canvas
|
|
|
|
buf = io.BytesIO()
|
|
c = canvas.Canvas(buf, pagesize=letter)
|
|
|
|
def draw(x, y, text, size=9):
|
|
if not text:
|
|
return
|
|
c.setFont("Helvetica", size)
|
|
c.drawString(x, y, str(text))
|
|
|
|
# We only draw on PAGE_BASIC and PAGE_CERT; emit blank pages for the rest so
|
|
# page indices line up when merging.
|
|
n_pages = max(PAGE_BASIC, PAGE_CERT) + 1
|
|
for pi in range(n_pages):
|
|
if pi == PAGE_BASIC:
|
|
cb = REASON_CHECKBOX.get(reason)
|
|
if cb:
|
|
draw(cb["x"], cb["y"], "X", size=11)
|
|
npi_pos = REASON_NPI_POS.get(reason)
|
|
if npi_pos and values.get("npi"):
|
|
draw(npi_pos["x"], npi_pos["y"], values["npi"])
|
|
draw(IDENTITY_POS["first"]["x"], IDENTITY_POS["first"]["y"], values.get("first"))
|
|
draw(IDENTITY_POS["middle"]["x"], IDENTITY_POS["middle"]["y"], values.get("middle"))
|
|
draw(IDENTITY_POS["last"]["x"], IDENTITY_POS["last"]["y"], values.get("last"))
|
|
elif pi == PAGE_CERT:
|
|
draw(CERT_NAME_POS["first"]["x"], CERT_NAME_POS["first"]["y"], values.get("first"))
|
|
draw(CERT_NAME_POS["middle"]["x"], CERT_NAME_POS["middle"]["y"], values.get("middle"))
|
|
draw(CERT_NAME_POS["last"]["x"], CERT_NAME_POS["last"]["y"], values.get("last"))
|
|
c.showPage()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
|
|
def fill_cms10114(intake: dict, reason: str = "change",
|
|
order_number: str = "") -> tuple[bytes, list[dict], list[str]]:
|
|
"""Fill the official CMS-10114 form via text overlay.
|
|
|
|
Args:
|
|
intake: dict with provider_name/first_name/last_name, npi, practice_state.
|
|
reason: one of initial/change/deactivation/reactivation, OR a service
|
|
slug (nppes-update, npi-reactivation, ...) which is normalized.
|
|
order_number: for logging/traceability only.
|
|
|
|
Returns (pdf_bytes, signature_anchors, missing_notes).
|
|
"""
|
|
try:
|
|
from pypdf import PdfReader, PdfWriter
|
|
except ImportError as exc: # pragma: no cover
|
|
raise RuntimeError("pypdf is required to fill CMS-10114") from exc
|
|
|
|
if not FORM_PATH.exists():
|
|
raise FileNotFoundError(f"Official CMS-10114 not found: {FORM_PATH}")
|
|
|
|
reason = normalize_reason(reason)
|
|
|
|
provider = intake.get("provider_name", "")
|
|
f, m, l = _split_name(provider)
|
|
values = {
|
|
"first": intake.get("first_name", f),
|
|
"middle": intake.get("middle_initial", m),
|
|
"last": intake.get("last_name", l),
|
|
"npi": (intake.get("npi") or "").strip(),
|
|
}
|
|
|
|
overlay_bytes = _build_overlay(reason, values)
|
|
|
|
base = PdfReader(str(FORM_PATH))
|
|
overlay = PdfReader(io.BytesIO(overlay_bytes))
|
|
writer = PdfWriter()
|
|
|
|
for i, page in enumerate(base.pages):
|
|
if i < len(overlay.pages):
|
|
page.merge_page(overlay.pages[i])
|
|
writer.add_page(page)
|
|
|
|
buf = io.BytesIO()
|
|
writer.write(buf)
|
|
pdf_bytes = buf.getvalue()
|
|
|
|
anchors = []
|
|
for sig in SIGNATURE_FIELDS:
|
|
llx, lly, urx, ury = sig["rect"]
|
|
anchors.append({
|
|
"field": sig["field"],
|
|
"page": sig["page"],
|
|
"x": float(llx) + 4,
|
|
"y": float(lly) + 1,
|
|
"w": float(urx - llx) - 8,
|
|
"h": max(float(ury - lly), 12.0),
|
|
"page_w": sig["page_w"],
|
|
"page_h": sig["page_h"],
|
|
})
|
|
|
|
missing = []
|
|
if reason in ("change", "deactivation", "reactivation") and not values["npi"]:
|
|
missing.append(f"NPI is required for a CMS-10114 {reason} but was not provided.")
|
|
if not (values["first"] and values["last"]):
|
|
missing.append("Provider first/last name not collected — required on the certification.")
|
|
if reason == "deactivation":
|
|
missing.append("Deactivation reason (Death / Business Dissolved / Other) must be selected by reviewer.")
|
|
if reason == "reactivation":
|
|
missing.append("Reactivation reason free-text must be completed by reviewer.")
|
|
if reason == "change":
|
|
missing.append("Mark which specific fields are changing in Section 2/3 before mailing.")
|
|
# The overlay targets the individual (Section 4A / Section 2A) path. Org
|
|
# filings (Entity Type 2 / Section 4B) need the org variant.
|
|
if (intake.get("enumeration_type") or "").upper() in ("NPI-2", "2", "ORGANIZATION"):
|
|
missing.append("Organization (NPI-2) detected — complete Section 2B/4B; this overlay targets the individual path.")
|
|
|
|
LOG.info("[cms10114] order=%s reason=%s npi=%s missing=%d",
|
|
order_number, reason, values["npi"], len(missing))
|
|
return pdf_bytes, anchors, missing
|
|
|
|
|
|
if __name__ == "__main__": # quick local render for visual verification
|
|
sample = {
|
|
"provider_name": "Jane Q Smith",
|
|
"npi": "1234567893",
|
|
"practice_state": "CA",
|
|
"enumeration_type": "NPI-1",
|
|
}
|
|
for r in ("change", "reactivation", "deactivation"):
|
|
pdf, anchors, missing = fill_cms10114(sample, reason=r, order_number="CO-TEST")
|
|
out = f"/tmp/cms10114_{r}.pdf"
|
|
with open(out, "wb") as fh:
|
|
fh.write(pdf)
|
|
print(f"wrote {out} anchors={len(anchors)} missing={len(missing)}")
|