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
BIN
docs/CMS-10114 Form.pdf
Normal file
BIN
docs/CMS-10114 Form.pdf
Normal file
Binary file not shown.
|
|
@ -49,22 +49,42 @@ Internal capability map. Confirms how each healthcare service is fulfilled with
|
|||
- Paper-to-MAC, original signatures, routed by state. "Sign and date section 8
|
||||
using ink."
|
||||
|
||||
### NPPES data update / NPI reactivation (CMS-10114 paper path) [PARTIAL]
|
||||
- 855I confirms NPI is obtained/changed via NPPES and "you may apply **online** at
|
||||
NPPES.cms.hhs.gov" — implies the online path; the paper CMS-10114 to the NPI
|
||||
Enumerator (PO Box 6059, Fargo ND 58108-6059) is the documented paper
|
||||
alternative per the existing `healthcare-no-login-value-add.md` (NPI Enumerator
|
||||
800-465-3203).
|
||||
- **UNCERTAIN (verify before relying on it as the sole standard path):** whether
|
||||
CMS still accepts paper CMS-10114 for *updates/reactivation* (vs initial
|
||||
enumeration only) today. Could not reach the live CMS-10114 instruction from
|
||||
this host (search engines blocked, direct URLs 404).
|
||||
- **Decision:** for NPPES-only services, offer Expedited (surrogate → file in
|
||||
NPPES online) as the primary fast path, and the CMS-10114 paper path as the
|
||||
Standard fallback. Confirm the CMS-10114-for-changes acceptance + obtain the
|
||||
current form PDF before building `cms10114_pdf_filler.py`. Until confirmed, the
|
||||
honest Standard fallback for a declined-surrogate NPPES update may be "we
|
||||
prepare it and guide the one-step online submission" — TBD on verification.
|
||||
### NPPES data update / NPI reactivation (CMS-10114 paper path) [CONFIRMED]
|
||||
- **VERIFIED firsthand** against the live CMS-10114 PDF (Rev. 02/25, OMB
|
||||
0938-0931, exp. 03/2028) downloaded from
|
||||
`cms.gov/Medicare/CMS-Forms/CMS-Forms/downloads/CMS10114.pdf`, and the CMS
|
||||
"How to Apply" page which titles it the **"NPI Application/Update Form"**.
|
||||
- The form's Section 1A "Reason for Submittal" has four checkboxes:
|
||||
**#1 Initial Application, #2 Change of Information, #3 Deactivation,
|
||||
#4 Reactivation** — all completed on the SAME paper form, each requiring a
|
||||
signed + dated certification (Section 4A individual / 4B organization).
|
||||
Verbatim: *"If changing information, check box #2... then sign and date the
|
||||
certification... All changes must be reported to the NPI Enumerator within 30
|
||||
days."* and *"If you are reactivating the NPI, check box #4... Sign and date
|
||||
the certification."*
|
||||
- So paper CMS-10114 is a valid no-login path for **changes AND reactivation**,
|
||||
not just initial enumeration. UNCERTAIN flag resolved.
|
||||
- **Mailing address (current, printed on the form page 5):**
|
||||
```
|
||||
CMS NPI Enumerator Services
|
||||
Mail Stop DO-01-51
|
||||
7500 Security Blvd.
|
||||
Baltimore, MD 21244
|
||||
```
|
||||
The previously-documented **Fargo, ND PO Box 6059 address is RETIRED** — code
|
||||
(`mac_routing.NPI_ENUMERATOR`) and docs updated to the Baltimore address.
|
||||
Enumerator phone for questions: 1-800-465-3203.
|
||||
- **No electronic no-login equivalent exists for CMS.** Unlike FMCSA (whose
|
||||
`ask.fmcsa.dot.gov/app/ticket` accepts an MCS-150 update + attachments with no
|
||||
account), CMS has no public web form to submit an NPPES change without a login.
|
||||
Probed live: the NPI Registry API (`npiregistry.cms.hhs.gov/api`) is
|
||||
**read-only lookup**; PECOS and NPPES I&A both require login. Therefore for
|
||||
NPPES changes the two genuine tiers are: **Standard = paper CMS-10114 to
|
||||
Baltimore (no login)**, **Expedited = surrogate files in NPPES online**.
|
||||
- **Decision (updated):** offer Expedited (surrogate → NPPES online) as the fast
|
||||
path and the CMS-10114 paper-to-Baltimore path as the Standard default. Build
|
||||
`cms10114_pdf_filler.py` to produce the signable form (Reason checkbox driven
|
||||
by service: change vs reactivation vs deactivation).
|
||||
|
||||
## CMS I&A Surrogate (Expedited path)
|
||||
- Provider adds Performance West as a **Surrogate** in CMS I&A; we then file in
|
||||
|
|
@ -78,14 +98,14 @@ Internal capability map. Confirms how each healthcare service is fulfilled with
|
|||
| Medicare revalidation | 855I/B → MAC, client signs | file in PECOS | 855B yes |
|
||||
| Medicare enrollment | 855I/B/O → MAC, client signs | file in PECOS | 855B yes |
|
||||
| NPI reactivation | 855I → MAC (reactivation reason) | PECOS/NPPES | no |
|
||||
| NPPES data update | CMS-10114 → Fargo *(verify)* | NPPES online | no |
|
||||
| NPPES data update | CMS-10114 → NPI Enumerator (Baltimore), client signs | NPPES online | no |
|
||||
| OIG/SAM screening | public DBs, **zero client action** | n/a (no portal) | no |
|
||||
| Provider compliance bundle | spawns reval + screening + NPPES | mixed | per-piece |
|
||||
|
||||
## Daily batch (Standard-path mailing)
|
||||
Each **postal working day morning** (skip weekends + USPS/federal holidays — use
|
||||
`scripts/workers/business_days.py` calendar): gather all signed+pending paper
|
||||
filings, **group by destination agency** (each MAC; NPI Enumerator Fargo; each
|
||||
filings, **group by destination agency** (each MAC; NPI Enumerator Baltimore; each
|
||||
state agency), merge each group into ONE print job + a cover sheet (PW sender,
|
||||
destination, date, enclosed count, per-item order#/provider/NPI/form), one
|
||||
**Priority Mail** envelope per agency. Mark each order mailed with batch date +
|
||||
|
|
@ -100,9 +120,17 @@ Certification. Need a **state → MAC → mailing-address** table to address env
|
|||
|
||||
## Implementation status (built + validated)
|
||||
- **mac_routing.py** — state→MAC (56 jurisdictions, 12 destinations) +
|
||||
NPI_ENUMERATOR (Fargo). Addresses marked VERIFY before first live mail.
|
||||
NPI_ENUMERATOR (NPI Enumerator Services, Baltimore MD — VERIFIED from CMS-10114
|
||||
Rev. 02/25). MAC addresses still marked VERIFY before first live mail.
|
||||
- **npi_provider.py** — two-tier `access` strings; NPPES update/reactivation no
|
||||
longer "online-only"; surrogate answer surfaced in the admin todo.
|
||||
longer "online-only"; surrogate answer surfaced in the admin todo. nppes-update
|
||||
now generates the CMS-10114 for e-signature (Standard path) via
|
||||
`_generate_10114_for_signing`.
|
||||
- **cms10114_pdf_filler.py** — fills the official CMS-10114 (flat PDF, text
|
||||
overlay): reason checkbox (change/reactivation/deactivation) + NPI + Section 2A
|
||||
identity + Section 4A certification name, with a signature anchor on the cert
|
||||
line. The signed form joins the daily batch to the NPI Enumerator (Baltimore).
|
||||
`test_cms10114.py` 27/27 pass.
|
||||
- **checkout.ts + service pages + intake** — client-facing copy stripped of
|
||||
mechanics; surrogate is the only optional, positively-framed ask (faster,
|
||||
never required, never share password, never mentions paper). Astro build green.
|
||||
|
|
@ -118,9 +146,12 @@ Certification. Need a **state → MAC → mailing-address** table to address env
|
|||
## TODO before first live mail (manual / verify)
|
||||
1. Fill the real MAC provider-enrollment PO Box addresses in `mac_routing.py`
|
||||
(marked VERIFY) from each MAC's current enrollment page.
|
||||
2. Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF, then
|
||||
build `cms10114_pdf_filler.py` for the NPPES Standard path (until then
|
||||
nppes-update falls to surrogate/manual).
|
||||
2. ~~Confirm CMS-10114 paper-for-changes acceptance + obtain the form PDF~~
|
||||
~~build `cms10114_pdf_filler.py`~~ **DONE** — verified against CMS-10114
|
||||
Rev. 02/25 (address = NPI Enumerator Services, Baltimore MD); filler built +
|
||||
wired into `npi_provider._generate_10114_for_signing`; `test_cms10114.py`
|
||||
27/27 pass. Remaining nicety: org (Entity Type 2 / Section 4B) overlay path
|
||||
(currently flagged for manual completion when NPI-2 detected).
|
||||
3. Run migration 089 on the DB; confirm the worker picks up a signed test filing
|
||||
and produces the per-agency cover + merged PDF in MinIO.
|
||||
4. Phase 2: wire a print-mail API (Lob/Click2Mail) to auto-mail the merged PDF
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ single paper form (or e-sign), and we file it by mail or as the preparer.
|
|||
| Service | Paper/no-login path | Provider's only job | Verified source |
|
||||
|---|---|---|---|
|
||||
| **Medicare PECOS Revalidation** | CMS-855I/855B/855A **paper** application mailed to the provider's **MAC** (revalidation is a checkbox reason in Section 1A). | **Sign** the form (wet/original signature). We prepare 100% of it. | CMS-855I: "submit your application with **original signatures**… to your designated MAC… by mail." |
|
||||
| **NPPES / NPI data update** (address, taxonomy, contact) | **CMS-10114** paper NPI Application/Update mailed to the **NPI Enumerator, PO Box 6059, Fargo, ND 58108-6059**; Enumerator staff key it into NPPES. | Sign the paper form. **No NPPES/I&A login at all.** | NPI Enumerator paper process (PO Box 6059, Fargo ND; 800-465-3203). |
|
||||
| **NPPES / NPI data update** (address, taxonomy, contact) | **CMS-10114** paper NPI Application/Update mailed to the **NPI Enumerator (CMS NPI Enumerator Services, 7500 Security Blvd, Baltimore, MD 21244)**; Enumerator staff key it into NPPES. | Sign the paper form. **No NPPES/I&A login at all.** | NPI Enumerator paper process (Baltimore, MD; 800-465-3203; CMS-10114 Rev. 02/25). |
|
||||
| **NPI Reactivation / enrollment reactivation** | Same paper-855 path to the MAC (reactivation is a Section 1A reason). | Sign. | CMS-855 instructions. |
|
||||
| **Medicare Enrollment (new)** | Paper CMS-855I/855B to the MAC with original signature. | Sign + provide info. | CMS-855I. |
|
||||
| **OIG / SAM Exclusion Screening** | We screen against the **public** OIG LEIE + SAM.gov databases by name/NPI. | **Nothing — zero provider involvement.** No login, no signature. | OIG LEIE + SAM.gov are public federal databases. |
|
||||
|
|
@ -39,7 +39,7 @@ time of navigating CMS portals. Our differentiator:
|
|||
> website. (For exclusion screening you don't even sign — we just do it.)
|
||||
|
||||
## Mailing destinations (for credibility in copy)
|
||||
- NPI/NPPES paper: **NPI Enumerator, PO Box 6059, Fargo, ND 58108-6059**
|
||||
- NPI/NPPES paper: **CMS NPI Enumerator Services, Mail Stop DO-01-51, 7500 Security Blvd, Baltimore, MD 21244**
|
||||
- 855 enrollment/revalidation: the provider's **MAC** (varies by state/jurisdiction;
|
||||
we determine and route it).
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ the "paper" alternative is NEVER surfaced to the client):
|
|||
|
||||
- **Standard service (default):** we handle the filing end-to-end. Client signs
|
||||
ONE certification and we submit + track to confirmation. (Internally this is the
|
||||
paper path — 855 to the MAC, CMS-10114 to the NPI Enumerator in Fargo — but we
|
||||
paper path — 855 to the MAC, CMS-10114 to the NPI Enumerator (Baltimore MD) — but we
|
||||
never say "paper" to the client; it's just "we file it for you.")
|
||||
- **Expedited (faster, framed positively at intake):** we ask if they can
|
||||
**electronically grant us CMS I&A Surrogate access**. We position this as
|
||||
|
|
@ -28,11 +28,11 @@ surrogate as *required* when a paper path exists; paper is always the default.
|
|||
- `api/src/routes/checkout.ts` (~L2155-2207) — already has the two-tier copy, BUT
|
||||
treats NPPES update/reactivation as **"online-only, surrogate required"** with
|
||||
NO standard fallback (`NPPES_ONLY_SLUGS`, `hasNppesOnly`). The directive says:
|
||||
give those a Standard paper path too (CMS-10114 to Fargo) and make surrogate the
|
||||
give those a Standard paper path too (CMS-10114 to NPI Enumerator (Baltimore MD)) and make surrogate the
|
||||
*expedited* option, not the only option.
|
||||
- `scripts/workers/services/npi_provider.py` — `access` strings call NPPES
|
||||
update/reactivation "NPPES via CMS I&A surrogate access (online-only)". Must
|
||||
become "Standard: CMS-10114 paper to NPI Enumerator (Fargo), client signs;
|
||||
become "Standard: CMS-10114 paper to NPI Enumerator (Baltimore MD), client signs;
|
||||
Expedited: NPPES via I&A surrogate." Also `_STANDARD_FILING_SLUGS` currently
|
||||
excludes `nppes-update` (it has no 855) — needs a CMS-10114 paper path instead.
|
||||
- `scripts/document_gen/templates/cms855_pdf_filler.py` — working 855 paper path
|
||||
|
|
@ -58,7 +58,7 @@ pricing changes.
|
|||
## Approach (concrete ordered steps)
|
||||
1. **Confirm the CMS-10114 paper path** for NPPES data updates AND NPI
|
||||
reactivation (not just initial enumeration): paper CMS-10114 mailed to NPI
|
||||
Enumerator (PO Box 6059, Fargo ND), client signature only, no I&A login.
|
||||
Enumerator (Baltimore MD), client signature only, no I&A login.
|
||||
Cite the official source. This is what makes NPPES services "Standard, no
|
||||
login" instead of surrogate-required. (We currently have no CMS-10114 PDF.)
|
||||
2. **Lock the two-tier matrix for the 6 federal services.** For each slug record:
|
||||
|
|
@ -81,13 +81,13 @@ pricing changes.
|
|||
paper + e-sign, and the admin todo reflects Standard-default / Expedited-if-
|
||||
surrogate-granted. Mirror the existing 855 generate→upload→esign flow.
|
||||
5. **MAC + Fargo routing rule.** Document which Standard filings go where
|
||||
(855 → provider's MAC by state/jurisdiction; CMS-10114 → Fargo). Confirm
|
||||
(855 → provider's MAC by state/jurisdiction; CMS-10114 → NPI Enumerator (Baltimore MD)). Confirm
|
||||
`practice_state` intake field drives MAC envelope addressing.
|
||||
7. **Daily batched-mail fulfillment (Standard path).** Design the operational
|
||||
flow for paper filings that are signed + pending submission:
|
||||
- On each **postal working day morning** (skip weekends + federal/USPS
|
||||
holidays), gather ALL signed-and-pending paper filings, **group by
|
||||
destination agency/address** (each MAC, the NPI Enumerator in Fargo, each
|
||||
destination agency/address** (each MAC, the NPI Enumerator (Baltimore MD), each
|
||||
state Medicaid/CLIA agency).
|
||||
- For each destination, **merge all that day's documents into one print job**
|
||||
plus a **cover sheet** (Performance West sender, destination agency, date,
|
||||
|
|
@ -129,7 +129,7 @@ pricing changes.
|
|||
and Standard is the default on every surface.
|
||||
- **Standard path mechanism check:** `cms855_pdf_filler.fill_cms855("855i",...)`
|
||||
still yields filled PDF + cert anchor (read-only smoke render). If CMS-10114
|
||||
filler is built, same smoke render proves a signable Fargo-bound PDF.
|
||||
filler is built, same smoke render proves a signable Baltimore-bound PDF.
|
||||
- **Expedited path check:** confirm the surrogate-grant CTA + `ia_surrogacy`
|
||||
success action + admin todo together let a human file in PECOS/NPPES same-day;
|
||||
surrogate scope wording matches what CMS I&A actually grants (PECOS vs NPPES).
|
||||
|
|
@ -138,11 +138,11 @@ pricing changes.
|
|||
no "unknown" and no service that's portal-only-but-marketed-as-no-login.
|
||||
|
||||
## Open questions / decisions
|
||||
1. **NPPES update/reactivation Standard path:** is CMS-10114 paper-to-Fargo
|
||||
accepted for *changes/reactivation* today (RESOLVE in step 1)? If yes, both
|
||||
become Standard-default + surrogate-expedited. If CMS has gone online-only,
|
||||
they stay surrogate-required and we say so honestly. (Directive assumes paper
|
||||
works as Standard; step 1 verifies.)
|
||||
1. **NPPES update/reactivation Standard path:** ✅ RESOLVED — CMS-10114 paper IS
|
||||
accepted for *changes AND reactivation* (verified against CMS-10114 Rev. 02/25,
|
||||
Section 1A boxes #2 Change / #4 Reactivation). Mailing address is the NPI
|
||||
Enumerator in **Baltimore MD** (the old Fargo PO Box 6059 is retired). Both
|
||||
nppes-update and npi-reactivation are Standard-default + surrogate-expedited.
|
||||
2. **Build `cms10114_pdf_filler.py` this pass?** Needed for a real NPPES Standard
|
||||
path. Recommend: yes if step 1 confirms paper, mirroring the 855 filler;
|
||||
otherwise defer.
|
||||
|
|
|
|||
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)}")
|
||||
128
scripts/tests/test_cms10114.py
Normal file
128
scripts/tests/test_cms10114.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Tests for the CMS-10114 NPI Application/Update filler (Standard no-login path).
|
||||
|
||||
Verifies the overlay-based filler:
|
||||
- selects the correct Reason-for-Submittal checkbox per reason/slug
|
||||
- places the NPI on the correct line for each reason
|
||||
- writes the provider name into Section 2A and the Section 4A certification
|
||||
- produces a signature anchor on the certification page
|
||||
- surfaces the right "manual completion" notes
|
||||
- mails to the verified Baltimore NPI Enumerator address (via mac_routing)
|
||||
|
||||
Run: python scripts/tests/test_cms10114.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import importlib.util # noqa: E402
|
||||
|
||||
import pdfplumber # noqa: E402
|
||||
from workers import mac_routing as mr # noqa: E402
|
||||
|
||||
|
||||
def _load_module(name, rel_path):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / rel_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# Load the filler directly to avoid document_gen/__init__ side-effects (python-docx).
|
||||
f = _load_module("cms10114_pdf_filler",
|
||||
"scripts/document_gen/templates/cms10114_pdf_filler.py")
|
||||
|
||||
H = 792.0
|
||||
_fails = 0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
global _fails
|
||||
status = "PASS" if cond else "FAIL"
|
||||
if not cond:
|
||||
_fails += 1
|
||||
print(f" {status} {name}")
|
||||
|
||||
|
||||
def _tokens(pdf_bytes, page_index):
|
||||
import io
|
||||
pdf = pdfplumber.open(io.BytesIO(pdf_bytes))
|
||||
page = pdf.pages[page_index]
|
||||
return {w["text"]: (w["x0"], H - w["bottom"]) for w in page.extract_words()}
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"provider_name": "Jane Q Smith",
|
||||
"npi": "1234567893",
|
||||
"practice_state": "CA",
|
||||
"enumeration_type": "NPI-1",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
print("reason normalization")
|
||||
check("slug nppes-update -> change", f.normalize_reason("nppes-update") == "change")
|
||||
check("slug npi-reactivation -> reactivation", f.normalize_reason("npi-reactivation") == "reactivation")
|
||||
check("explicit 'deactivation' kept", f.normalize_reason("deactivation") == "deactivation")
|
||||
check("unknown -> change (safe default)", f.normalize_reason("zzz") == "change")
|
||||
|
||||
print("checkbox + NPI placement per reason (page 2)")
|
||||
# Expected checkbox X positions (lower-left) per reason, from the form layout.
|
||||
expect = {
|
||||
"change": {"X": (48, 582), "NPI": (132, 570)},
|
||||
"reactivation": {"X": (325, 506), "NPI": (408, 529)},
|
||||
"deactivation": {"X": (325, 608), "NPI": (408, 599)},
|
||||
}
|
||||
for reason, exp in expect.items():
|
||||
pdf, anchors, missing = f.fill_cms10114(SAMPLE, reason=reason)
|
||||
toks = _tokens(pdf, f.PAGE_BASIC)
|
||||
x = toks.get("X")
|
||||
npi = toks.get("1234567893")
|
||||
check(f"{reason}: X near {exp['X']}",
|
||||
x and abs(x[0] - exp["X"][0]) < 6 and abs(x[1] - exp["X"][1]) < 6)
|
||||
check(f"{reason}: NPI near {exp['NPI']}",
|
||||
npi and abs(npi[0] - exp["NPI"][0]) < 6 and abs(npi[1] - exp["NPI"][1]) < 6)
|
||||
|
||||
print("identity + certification name (Section 2A / 4A)")
|
||||
pdf, anchors, missing = f.fill_cms10114(SAMPLE, reason="change")
|
||||
p2 = _tokens(pdf, f.PAGE_BASIC)
|
||||
p4 = _tokens(pdf, f.PAGE_CERT)
|
||||
check("Section 2A first name placed", "Jane" in p2)
|
||||
check("Section 2A last name placed", "Smith" in p2)
|
||||
check("Section 4A cert first name placed", "Jane" in p4)
|
||||
check("Section 4A cert last name placed", "Smith" in p4)
|
||||
|
||||
print("signature anchor")
|
||||
check("exactly one signature anchor", len(anchors) == 1)
|
||||
check("anchor on certification page", anchors and anchors[0]["page"] == f.PAGE_CERT)
|
||||
check("anchor field is 'signer'", anchors and anchors[0]["field"] == "signer")
|
||||
|
||||
print("manual-completion notes")
|
||||
_, _, m_change = f.fill_cms10114(SAMPLE, reason="change")
|
||||
_, _, m_react = f.fill_cms10114(SAMPLE, reason="reactivation")
|
||||
_, _, m_deact = f.fill_cms10114(SAMPLE, reason="deactivation")
|
||||
check("change asks to mark changing fields", any("changing" in x.lower() for x in m_change))
|
||||
check("reactivation asks for reason text", any("reactivation reason" in x.lower() for x in m_react))
|
||||
check("deactivation asks for reason box", any("deactivation reason" in x.lower() for x in m_deact))
|
||||
_, _, m_noni = f.fill_cms10114({"provider_name": "No NPI"}, reason="change")
|
||||
check("missing NPI flagged", any("npi is required" in x.lower() for x in m_noni))
|
||||
_, _, m_org = f.fill_cms10114({**SAMPLE, "enumeration_type": "NPI-2"}, reason="change")
|
||||
check("org (NPI-2) flagged for 4B path", any("organization" in x.lower() for x in m_org))
|
||||
|
||||
print("routing destination = verified Baltimore Enumerator")
|
||||
addr = " ".join(mr.NPI_ENUMERATOR.address_lines).lower()
|
||||
check("destination is Baltimore (not Fargo)", "baltimore" in addr and "fargo" not in addr)
|
||||
check("destination has the Mail Stop", "do-01-51" in addr)
|
||||
|
||||
print()
|
||||
if _fails:
|
||||
print(f"FAILED: {_fails} checks")
|
||||
sys.exit(1)
|
||||
print("all CMS-10114 checks passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -63,7 +63,7 @@ def test_routing():
|
|||
tx = dpb._destination_for("TX", "cms855b")
|
||||
check("TX 855B -> Novitas JH", tx and tx[0] == "novitas_jh")
|
||||
enum = dpb._destination_for("TX", "cms10114")
|
||||
check("any-state CMS-10114 -> NPI Enumerator (Fargo)", enum and enum[0] == "npi_enumerator")
|
||||
check("any-state CMS-10114 -> NPI Enumerator (Baltimore)", enum and enum[0] == "npi_enumerator")
|
||||
check("CMS-10114 ignores state for routing", dpb._destination_for("CA", "cms10114")[0] == "npi_enumerator")
|
||||
check("unknown state -> None (human review)", dpb._destination_for("ZZ", "cms855i") is None)
|
||||
check("blank state -> None", dpb._destination_for("", "cms855i") is None)
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ def _is_postal_working_day(d: date) -> bool:
|
|||
def _destination_for(practice_state: str, document_type: str):
|
||||
"""Return the (key, name, address_lines) destination for a filing.
|
||||
|
||||
NPPES/CMS-10114 updates go to the NPI Enumerator (Fargo); CMS-855s go to the
|
||||
provider's MAC by state.
|
||||
NPPES/CMS-10114 updates go to the NPI Enumerator (Baltimore, MD); CMS-855s go
|
||||
to the provider's MAC by state.
|
||||
"""
|
||||
try:
|
||||
from scripts.workers import mac_routing as mr
|
||||
|
|
|
|||
|
|
@ -107,9 +107,13 @@ STATE_TO_MAC: dict[str, MAC] = {
|
|||
|
||||
# NPI Enumerator paper address (NPPES / CMS-10114 paper path) — not a MAC, but a
|
||||
# destination the daily batch groups by, same as a MAC.
|
||||
# VERIFIED 2025 against CMS-10114 (Rev. 02/25), OMB 0938-0931, page 5 "Or send the
|
||||
# completed signed application to:". The earlier Fargo, ND PO Box 6059 address is
|
||||
# RETIRED; the current address printed on the form is the Baltimore one below.
|
||||
NPI_ENUMERATOR = MAC(
|
||||
"npi_enumerator", "NPI Enumerator (NPPES / CMS-10114 paper)",
|
||||
("NPI Enumerator", "P.O. Box 6059", "Fargo, ND 58108-6059"),
|
||||
("CMS NPI Enumerator Services", "Mail Stop DO-01-51",
|
||||
"7500 Security Blvd.", "Baltimore, MD 21244"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,16 +126,22 @@ _SLUG_META = {
|
|||
# batched mailing). The bundle's revalidation piece is handled by the dedicated
|
||||
# revalidation order it spawns, so it is not listed here.
|
||||
#
|
||||
# nppes-update is intentionally NOT here: its Standard path is the CMS-10114 (NPPES
|
||||
# update mailed to the NPI Enumerator in Fargo), handled by a separate filler when
|
||||
# built. Until then, nppes-update falls to the admin todo (Expedited if surrogate
|
||||
# granted, otherwise manual CMS-10114 prep).
|
||||
# nppes-update is NOT a CMS-855 filing: its Standard (no-login) path is the
|
||||
# CMS-10114 NPI Application/Update form, signed by the provider and mailed to the
|
||||
# NPI Enumerator (CMS NPI Enumerator Services, Baltimore MD). It is handled by
|
||||
# the dedicated CMS-10114 filler below (_STANDARD_10114_SLUGS).
|
||||
_STANDARD_FILING_SLUGS = {
|
||||
"npi-revalidation",
|
||||
"npi-reactivation",
|
||||
"medicare-enrollment",
|
||||
}
|
||||
|
||||
# Slugs whose Standard path is the CMS-10114 (NPPES update/reactivation/deactivation
|
||||
# mailed to the NPI Enumerator). The reason checkbox is derived from the slug.
|
||||
_STANDARD_10114_SLUGS = {
|
||||
"nppes-update",
|
||||
}
|
||||
|
||||
|
||||
class _BaseNPIHandler:
|
||||
"""Shared review-staged behaviour for all NPI services."""
|
||||
|
|
@ -178,6 +184,14 @@ class _BaseNPIHandler:
|
|||
except Exception as exc: # never block the admin todo on PDF issues
|
||||
LOG.error("[%s] CMS-855 generation failed: %s", order_number, exc)
|
||||
filing_note = f"CMS-855 auto-generation FAILED ({exc}); prepare the form manually."
|
||||
elif self.SERVICE_SLUG in _STANDARD_10114_SLUGS:
|
||||
try:
|
||||
filing_note = self._generate_10114_for_signing(
|
||||
order_number, intake, provider, customer_email
|
||||
)
|
||||
except Exception as exc: # never block the admin todo on PDF issues
|
||||
LOG.error("[%s] CMS-10114 generation failed: %s", order_number, exc)
|
||||
filing_note = f"CMS-10114 auto-generation FAILED ({exc}); prepare the form manually."
|
||||
|
||||
surrogate_label = {
|
||||
"yes": "YES — client can grant I&A Surrogate access -> file the EXPEDITED way (online via surrogate).",
|
||||
|
|
@ -267,16 +281,78 @@ class _BaseNPIHandler:
|
|||
note_lines.extend(f" - {m}" for m in missing)
|
||||
return "\n".join(note_lines)
|
||||
|
||||
def _generate_10114_for_signing(self, order_number, intake, provider, customer_email) -> str:
|
||||
"""Generate the official CMS-10114 (NPPES update path), upload it, and
|
||||
request an e-signature.
|
||||
|
||||
Standard (no-login) path for NPPES data updates / NPI reactivation: the
|
||||
provider signs the certification and the signed form is mailed to the NPI
|
||||
Enumerator (CMS NPI Enumerator Services, Baltimore MD) via the daily batch.
|
||||
Returns a human-readable note for the admin todo.
|
||||
"""
|
||||
try:
|
||||
from scripts.document_gen.templates.cms10114_pdf_filler import (
|
||||
fill_cms10114, normalize_reason,
|
||||
)
|
||||
except ImportError:
|
||||
from document_gen.templates.cms10114_pdf_filler import ( # type: ignore
|
||||
fill_cms10114, normalize_reason,
|
||||
)
|
||||
|
||||
# Reason comes from the explicit intake reason if present, else the slug.
|
||||
reason = normalize_reason(intake.get("filing_reason") or self.SERVICE_SLUG)
|
||||
pdf_bytes, anchors, missing = fill_cms10114(intake, reason=reason, order_number=order_number)
|
||||
|
||||
document_key = f"compliance/{order_number}/cms10114_unsigned.pdf"
|
||||
try:
|
||||
import tempfile
|
||||
try:
|
||||
from scripts.document_gen.minio_client import MinioStorage
|
||||
except ImportError:
|
||||
from document_gen.minio_client import MinioStorage # type: ignore
|
||||
storage = MinioStorage()
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tf:
|
||||
tf.write(pdf_bytes)
|
||||
tf.flush()
|
||||
storage.upload(tf.name, document_key, content_type="application/pdf")
|
||||
except Exception as exc:
|
||||
LOG.error("[%s] CMS-10114 upload failed: %s", order_number, exc)
|
||||
return f"CMS-10114 generated but upload FAILED ({exc})."
|
||||
|
||||
signed = self._create_855_esign_record(
|
||||
order_number, intake, provider, customer_email,
|
||||
form_type="10114", document_key=document_key, anchors=anchors,
|
||||
document_type="cms10114",
|
||||
document_title="NPI Application/Update Form (CMS-10114)",
|
||||
)
|
||||
|
||||
note_lines = [
|
||||
f"CMS-10114 generated (reason: {reason}; official form, auto-filled where possible).",
|
||||
f"Unsigned PDF: {document_key}",
|
||||
]
|
||||
if signed and customer_email:
|
||||
note_lines.append(f"E-sign link emailed to {customer_email}. After signing, the form joins the daily mail batch to the NPI Enumerator (standard) or file via NPPES surrogate access (expedited).")
|
||||
else:
|
||||
note_lines.append("No customer email or esign infra — send the form for signature manually.")
|
||||
if missing:
|
||||
note_lines.append("MANUAL COMPLETION NEEDED:")
|
||||
note_lines.extend(f" - {m}" for m in missing)
|
||||
return "\n".join(note_lines)
|
||||
|
||||
def _create_855_esign_record(self, order_number, intake, provider, customer_email,
|
||||
form_type, document_key, anchors) -> bool:
|
||||
form_type, document_key, anchors,
|
||||
document_type=None, document_title=None) -> bool:
|
||||
"""Create the esign record + email the signing link via the shared helper,
|
||||
then attach the official signature anchors. Returns True on success.
|
||||
|
||||
Used for both CMS-855 and CMS-10114 (pass document_type/document_title to
|
||||
override the defaults).
|
||||
"""
|
||||
if not customer_email:
|
||||
return False
|
||||
|
||||
document_type = f"cms{form_type}"
|
||||
document_title = f"Medicare Enrollment Form (CMS-{form_type.upper()})"
|
||||
document_type = document_type or f"cms{form_type}"
|
||||
document_title = document_title or f"Medicare Enrollment Form (CMS-{form_type.upper()})"
|
||||
|
||||
try:
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue