healthcare: verify CMS-10114 update path, correct NPI Enumerator address, build CMS-10114 filler
Verified firsthand against the live CMS-10114 (Rev. 02/25, OMB 0938-0931): - Section 1A confirms paper is valid for Change of Information (#2) AND Reactivation (#4), not just initial enumeration. Resolves the UNCERTAIN flag. - Current mailing address is CMS NPI Enumerator Services, Mail Stop DO-01-51, 7500 Security Blvd, Baltimore MD 21244. The old Fargo PO Box 6059 is retired; corrected in mac_routing.NPI_ENUMERATOR + all docs. - No electronic no-login equivalent exists for CMS (NPI Registry API is read-only; PECOS/NPPES-IA require login), unlike FMCSA's ask.fmcsa ticket form. So tiers stay: Standard=paper CMS-10114 (no login), Expedited=NPPES surrogate. New: cms10114_pdf_filler.py fills the flat official form via text overlay (reason checkbox + NPI + Section 2A identity + Section 4A cert name + signature anchor); wired into npi_provider._generate_10114_for_signing for nppes-update. Signed forms route to the NPI Enumerator via the existing daily batch. Tests: test_cms10114.py 27/27, test_paper_batch.py 15/15, Astro build 58 pages.
This commit is contained in:
parent
f9c294e962
commit
e6a630ada1
10 changed files with 540 additions and 48 deletions
253
scripts/document_gen/templates/cms10114_pdf_filler.py
Normal file
253
scripts/document_gen/templates/cms10114_pdf_filler.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""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 label "Signature (First,
|
||||
# Middle, Last...)" sits at pdf_y ~458; the blank signing line is the row above
|
||||
# it. The e-sign stamper places the provider's signature here.
|
||||
SIGNATURE_FIELDS = [
|
||||
{"field": "signer", "page": PAGE_CERT,
|
||||
"rect": [60.0, 470.0, 470.0, 490.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), 18.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)}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue