"""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)}")