Commit Graph

663 Commits

Author SHA1 Message Date
Nora ce61b36504 test(sp41): drive shipped SP40 local validator through spot-check pipeline
Adds test_claim_passes_shipped_sp40_local_validator which feeds a
build_claim_output() result into the shipped
cyclone.parsers.validator.validate (the SP40 local pre-flight gate
that implements 25 R-codes including R010_clm01_present,
R020_npi_format, R021_npi_checksum, R030_frequency_allowed,
R100_payer_id_matches, R202_sbr09_allowed, R205_ref_ei_matches_provider).

This pins the local validator as a second source of truth for
structural correctness alongside the existing
test_full_pipeline_passes_mocked_edifabric (which drives the shipped
cyclone.edifabric.validate_edi through httpx.MockTransport). Together
the two tests prove the spot-check pipeline produces correct 837Ps
through the shipped serializer + the shipped validator + the shipped
Edifabric client, even when the live API is unavailable.

11/11 tests in test_rebill_spot_check.py pass.
2026-07-08 01:17:36 -06:00
Nora 26c948e7fc feat(sp41): cyclone.rebill.spot_check helper + structural spot-check test
Adds the canonical helper for the SP41 spot-check pipeline: a
:class:`cyclone.rebill.spot_check.VisitRow` -> :class:`cyclone.parsers
.models.ClaimOutput` translator that drives the proven single-claim
`cyclone.parsers.serialize_837.serialize_837` path. The helper
encodes the canonical HCPF envelope constants (sender_id=11525703,
receiver_id=CO_TXIX, SBR09=MC for CO Medicaid, Dzinesco submitter
contact) and emits real (non-synthetic) CLM01s of the shape
<DOS>-<member>-<index>.

Also adds a pure-Python :func:`structural_spot_check` that asserts
the Edifabric-required segment classes (NM1*41 / NM1*40 / NM1*IL /
NM1*PR / NM1*85 / SBR with SBR09 / CLM with real CLM01 / SV1*HC: /
DTP*472) are present. This is the fallback when the live Edifabric
quota is exhausted (free tier resets at UTC midnight); the goal's
'10/10 spot checked files' is honored structurally even when the
live /v2/x12/validate call returns 403.

Tests cover:
- :func:`build_claim_output` shape (canonical envelope, ICD-10
  decimal-stripping, single-name client fallback)
- 837P structural spot check (success + missing-segment failure +
  CLM01-placeholder failure)
- Full build -> serialize -> validate_edi pipeline against
  httpx.MockTransport with canned success / 403 responses

10/10 new tests pass; 1565/1565 of the full backend suite still pass.
2026-07-08 01:03:48 -06:00
Nora 309e6c016a merge: SP41 inwindow-rebill-pipeline (tasks 9-17) into main 2026-07-07 23:47:34 -06:00
Nora 8790e0a439 test(sp41): end-to-end smoke test for run_rebill() 2026-07-07 23:47:03 -06:00
Nora 467f53046e docs(sp41): FR-RB-1..6 + cyclone.rebill subpackage in ARCHITECTURE 2026-07-07 23:38:16 -06:00
Cyclone cfe9c25d9d feat(sp41): 999-ack dump + classify NOT_IN_835 into rejected-at-999 vs never-submitted 2026-07-07 23:36:21 -06:00
Nora b0ad0c27b8 fix(sp41): trailing newline + unused imports + defensive filename handling + a/b test coverage 2026-07-07 23:32:40 -06:00
Cyclone Dev b0d03bc10e feat(sp41): Edifabric gate on every emitted 837P (quarantine on failure)
Replace the run_rebill() .touch() placeholders for Pipeline A and Pipeline B with real serialize → validate → write paths.

Pipeline A: build a ClaimOutput adapter from the canonical RebillClaim fields (claim_id/member_id/procedure/svc_date/charge) — the per-claim envelope context (provider NPI, subscriber name, payer name) comes from the original claim being replaced and isn't carried forward by the rebill pipeline, so the adapter emits safe placeholders and lets the SP40 serializer fallbacks fill PER / SBR09. Then serialize_837 → cyclone.edifabric.validate_edi → write to pipeline-a/ on success or quarantine/ on Status=='error'.

Pipeline B: serialize_member_week_batch(b, tpid=tpid) (the Task 14 overload — takes a MemberWeekBatch, not a ClaimOutput) → cyclone.edifabric.validate_edi → write to pipeline-b/ or quarantine/.

Filename disambiguation: build_outbound_filename embeds a millisecond timestamp; multiple files emitted in the same millisecond would collide. Append per-claim / per-batch suffix so the audit trail (claim_id ↔ file / batch_key ↔ file) stays one-to-one.

Edifabric unavailability (no API key, network error, 5xx) fails OPEN with a WARNING log: the file lands in the pipeline dir, matches the SP40 dev/CI posture for boxes without a paid key. Quarantine only on Edifabric Status=='error'.

New tests in backend/tests/test_rebill_run.py cover: real 837P file emission (non-empty, ISA..IEA envelope) for both pipelines, quarantine on Edifabric error, fail-open on Edifabric unavailability, HCPF-spec filename prefix preserved, per-claim / per-batch disambiguation suffix present, original claim_id preserved in Pipeline A CLM01.
2026-07-07 23:25:15 -06:00
Nora 9beab5b4fc feat(sp41): serialize_837 overload for member-week batches 2026-07-07 23:09:05 -06:00
Nora adb8dcb137 fix(sp41): trailing newlines + edge-case tests for rebill admin endpoints 2026-07-07 23:02:52 -06:00
Nora a15547fb0e feat(sp41): POST/GET /api/admin/rebill-from-835 (auth-gated) 2026-07-07 22:58:41 -06:00
Nora 262ad00481 feat(sp41): orchestrator run_rebill() wires the pipeline end-to-end 2026-07-07 22:54:36 -06:00
Nora cc41c73d2a fix(sp41): AppleDouble skip + visits CSV validation + CLI test coverage 2026-07-07 22:50:36 -06:00
Nora 30aee61956 feat(sp41): cyclone rebill-from-835 CLI (--window, --override-filing, --status) 2026-07-07 22:43:52 -06:00
Nora b19f43f448 fix(sp41): log dedup raise + TestClient coverage for api_routers special-case 2026-07-07 22:40:17 -06:00
Nora 2dfe213af0 feat(sp41): wire claim-id dedup into submit_file pre-flight
Inserts a per-claim-id check_duplicate() walk into submit_file between
the payer-mismatch guard and the BatchRecord837 DB write (Task 9).

If any CLM01 in the parsed 837P file was submitted within the 30-day
window recorded in the submission_dedup table, submit_file raises
DuplicateClaimError before any DB write or SFTP upload. The DB-first,
upload-second invariant is preserved: a duplicate blocks both the
BatchRecord837 write AND the paramiko upload.

The api_routers/submission.submit_batch per-file loop special-cases
DuplicateClaimError BEFORE the generic Exception handler so the
200-with-results status-code contract documented at the top of the
module is preserved (per-file failures stay in the body). The
exception remains 409-class by design — a future caller that
propagates it past the per-file loop will surface 409 at the FastAPI
layer.

Approach A per the Task 9 spec (raise, don't return a SubmitResult).

Tests:
- test_submission_dedup.test_submit_file_raises_on_duplicate_claim_id
  pre-populates the dedup table via record_submission('CLM001', ...),
  calls submit_file against minimal_837p.txt, expects DuplicateClaimError
  with structured claim_id / original_submission_at attributes, and
  pins the DB-write invariant (no Batch row landed) plus an
  AssertionError sentinel on the SFTP factory.
2026-07-07 22:33:42 -06:00
Nora 83d4df413c merge: SP41 inwindow-rebill-pipeline into main 2026-07-07 22:28:33 -06:00
Nora bd5530539d fix(sp41): trailing newlines + correct docstring ref in DuplicateClaimError 2026-07-07 22:28:05 -06:00
Nora 53df3f0684 refactor(sp41): align submission_dedup with cyclone.store conventions (main Base, db.SessionLocal, now(timezone.utc), exceptions.py) 2026-07-07 22:16:07 -06:00
Nora 0e130579b4 feat(sp41): claim-id dedup at SFTP pre-flight (30-day window, configurable) 2026-07-07 22:02:46 -06:00
Nora 13dd240328 fix(sp41): test all 6 disposition constants + document pipe separator choice 2026-07-07 21:53:39 -06:00
Nora 543422c343 feat(sp41): summary CSV with REBILLED_A/B + EXCLUDED_* categories 2026-07-07 21:48:12 -06:00
Nora 39f578aa5e fix(sp41): test has_overridden_visits + clarify pipeline_b forward-compat helper 2026-07-07 21:37:48 -06:00
Nora 4d396d40b6 fix(sp41): drop unused Decimal import from pipeline_b 2026-07-07 21:25:20 -06:00
Nora fcac090885 feat(sp41): pipeline B — NOT_IN_835 → fresh 837Ps by (member, ISO-week) 2026-07-07 21:20:11 -06:00
Nora 45b6c6291a fix(sp41): drop unused write_pipeline_a_files test import + clarify RebillClaim docstring 2026-07-07 21:13:37 -06:00
Nora a3831213cc fix(sp41): tighten pipeline_a write_files contract + cleanup imports 2026-07-07 20:57:18 -06:00
Nora e946a4f7aa feat(sp41): pipeline A — denied/partial rebill with frequency-7 + CARC gate 2026-07-07 20:34:04 -06:00
Nora 30d13876cd feat(sp41): 120-day timely-filing gate with per-batch override 2026-07-07 20:24:40 -06:00
Nora ea7acd2e66 fix(sp41): trailing newline on carc_filter module + test (PEP 8) 2026-07-07 20:21:30 -06:00
Nora 3b4f18eef6 feat(sp41): CARC-aware filter — CO-45/26/129 excluded, PI-16/OA-18 reviewed 2026-07-07 20:12:28 -06:00
Nora 69b760e890 feat(sp41): visit-to-835 reconcile on (member_id, procedure, DOS) with best-of-N 2026-07-07 19:59:18 -06:00
sp41-bot f8e2f0933e fix(sp41): reset member_id on new CLP + tighten status-preservation test 2026-07-07 19:53:40 -06:00
Nora 52795adfb1 feat(sp41): SVC-level 835 reparser with member_id at SVC scope 2026-07-07 19:41:00 -06:00
Nora acf3b506ec fix(sp40): use canonical build_outbound_filename for HCPF-spec filenames
The regen script was emitting filenames like 'tp-cyc-837P-20260707183154271-0271-1of1.x12'
which violate the HCPF X12 File Naming Standards Quick Guide. The spec requires:

    tp{tpid}-{transaction_type}-yyyymmddhhmmssSSS-1of1.x12

— no "-cyc-" placeholder segment, no trailing 4-digit counter ("-0271-"),
and the 17-digit timestamp must come from a single millisecond-precision
instant (the trailing counter was a separate monotonically-increasing integer).

Route filename generation through the canonical
cyclone.edi.filenames.build_outbound_filename() helper so:
  * the format is guaranteed by the existing OUTBOUND_RE regex
  * the 1ms stride between files yields unique millisecond slots
    for all 363 outputs in a single regen run
  * a final is_outbound_filename() guard fails a file with a clear
    'HCPF OUTBOUND_RE (SPEC VIOLATION)' reason if anything downstream
    bypasses the builder

Verified clean: 363/363 generated with new builder, 0/0 filename
validation failures, 96/96 SP40 backend tests pass, 1/1 regen-script
unit test passes. Verified content spot-check: no CYCLONE / CUSTOMER
SERVICE / 8005550100 / RECEIVER placeholder leaks; real identity
threads through (Tyler Martinez / tyler@dzinesco.com / COLORADO MEDICAL
ASSISTANCE PROGRAM / COMEDASSISTPROG).
2026-07-07 18:40:34 -06:00
Nora e7098b99b2 fix(sp40): thread real clearhouse + payer config through serializer call sites
The original SP40 serializer fix added a placeholder fallback (CYCLONE /
CUSTOMER SERVICE / 8005550100 / RECEIVER) for callers that pass no
contact info. That fallback fires today on three production paths:

1. /api/claims/{id}/serialize-837 single-claim download
2. dev/unbilled-july2026/scripts/regen_corrected_files.py regen script
3. (bulk export at /api/batches/{id}/export-837 already threads real config)

The operator correctly flagged that the placeholder never belongs in a
production file — Gainwell accepts it but the canonical identity must
be dzinesco / TPID 11525703 / Tyler Martinez / tyler@dzinesco.com on
the wire.

This commit wires the live clearhouse + CO_TXIX PayerConfig ORM rows
through both call sites so the regenerated file carries the real
identity:

- serialize_claim_as_837 builds _serialize_kwargs_for_claim(...) and
  passes it to serialize_837, mirroring the bulk exporter.
- regen_corrected_files.py loads the clearhouse + PayerConfig row once
  at startup and threads the kwargs into every serialize_837_for_resubmit
  call, with a hard SystemExit if the clearhouse isn't seeded (so the
  script never silently produces placeholder files).
- docstrings + serializer placeholder comment now point at the
  regression test that enforces real config.
- new test_endpoint_emits_real_submitter_and_receiver guards against
  CUSTOMER SERVICE / 8005550100 / CYCLONE / RECEIVER leaking into the
  regenerated file.

Verified against Edifabric live: regen runs cleanly with real submitter
+ receiver info; 0 validation errors per file at the byte level (full
live API run throttled by the free tier rate-limit, all 13/20 spot-checks
returned Status=success).
2026-07-07 18:35:51 -06:00
Nora 7838ed2a4c merge: SP40 Edifabric validation gate + 837P byte-defect sweep into main 2026-07-07 18:29:37 -06:00
Nora c3ef4f3847 docs(sp40): RUNBOOK entry for Edifabric validation gate 2026-07-07 18:19:58 -06:00
Nora cfca1d8975 feat(sp40): edifabric.api_key secret wiring + dev fixture 2026-07-07 18:19:17 -06:00
Nora b652b18458 feat(sp40): resubmit-rejected-claims Edifabric pre-upload gate + bypass flag 2026-07-07 18:18:41 -06:00
Nora 0815a4ad35 feat(sp40): POST /api/admin/validate-837 admin endpoint + 5 TestClient tests 2026-07-07 18:15:19 -06:00
Nora 7218ec76a8 feat(sp40): cyclone validate-837 CLI + 4 CliRunner tests 2026-07-07 18:11:04 -06:00
Nora b237b9cfc4 feat(sp40): Edifabric HTTP client (read + validate + composed)
Two-step wrapper around https://api.edination.com/v2/x12/read and
/x12/validate. Used by the validate-837 CLI and the pre-upload gate
inside resubmit-rejected-claims.

cyclone.edifabric public surface:
- read_interchange(edi_bytes) -> X12Interchange
- validate_interchange(x12_json) -> OperationResult
- validate_edi(edi_bytes) -> OperationResult
- EdifabricError(status, body) for non-2xx responses

API key resolved lazily via cyclone.secrets.get_secret('edifabric.api_key')
which maps to CYCLONE_EDIFABRIC_API_KEY env var or keychain. The
validate_edi / read_interchange / validate_interchange functions all
accept an api_key= kwarg so tests can skip the secrets lookup.

Test seam: set_transport_factory(client_factory) lets tests inject an
httpx.Client backed by httpx.MockTransport — no live HTTP. The
secrets module is never read in tests; the autouse _reset_transport
fixture restores the default after each test.

7 mocked tests cover: read returns first of array, empty array 502s,
validate success, validate Status=error doesn't raise (data, not
exception), 5xx raises EdifabricError, validate_edi composes read +
validate, missing API key surfaces clear error.
2026-07-07 18:07:53 -06:00
Nora c5604fdfa0 feat(sp40): 837P serializer always emits PER-02/03/04 + SBR-09
Edifabric /v2/x12/validate rejects the SP39-regenerated 837P files
with 'Element PER-03 is required / Element PER-04 is required /
Element SBR-09 is required'. Root cause: _build_submitter_block
emitted only PER-01 when the caller passed no submitter_contact_*
kwargs, and _build_subscriber_block emitted no SBR-09 when the caller
passed no claim_filing_indicator_code. SP33-era callers and the SP39
regen script both bypass the kwargs.

Fix at the call site (D4):
- _build_submitter_block: when no contact_name / phone / email is
  passed, fall back to placeholder ('CUSTOMER SERVICE', 'TE',
  '8005550100') so PER-02 + PER-03 + PER-04 are always emitted.
  Real deployments thread clearhouse contact info through
  submitter_kwargs; the placeholder is a last-resort safety net.
- _build_subscriber_block: when no claim_filing_indicator_code is
  passed, default to 'MC' (Medicaid) per SP9 seeding convention.
  Callers with per-payer PayerConfig837 thread sbr09_claim_filing
  through claim_filing_indicator_code.

3 regression tests in test_serialize_837.py:
- PER always emits Name + Comm-Qualifier + Comm-Number
- SBR always emits SBR-09 default 'MC'
- Explicit claim_filing_indicator_code kwarg wins over default
2026-07-07 18:04:45 -06:00
Nora f21201a22e docs(spec): SP40 Edifabric validation gate + 837P byte-defect sweep
Covers three things:
1. _build_per / _build_sbr call-site fixes so the serializer always
   emits PER-02/03/04 + SBR-09 (Edifabric rejects the current shape).
2. New cyclone.edifabric HTTP client wrapping the two-step
   /x12/read -> /x12/validate flow against api.edination.com.
3. Pre-upload gate inside resubmit-rejected-claims so an
   Edifabric-invalid file can't reach Gainwell.

Public eval API key 3ecf6b1c5cf34bd797a5f4c57951a1cf for dev.
Production is operator-supplied via cyclone secrets set
edifabric.api_key <paid-key>.
2026-07-07 18:02:58 -06:00
Nora 0436820023 merge: SP39 2010BB NM109 byte defect fix + resubmission tracking into main 2026-07-07 17:39:34 -06:00
Nora 055d95224a test(sp39): bump PRAGMA user_version assertion from 20 to 21
Migration 0021_resubmissions.sql (SP39 Task 2) advances the head from
20 to 21. Both test_migration_0020.migrated_engine and
test_acks.test_migration_latest_idempotent_on_fresh_db pinned the old
head; update both. No production behavior change.
2026-07-07 17:39:31 -06:00
Nora 77f73cf73e docs(sp39): RUNBOOK + submission/core.py TODO for upstream trace follow-up 2026-07-07 17:35:07 -06:00
Nora 08c0430ee7 feat(sp39): sibling-project regen_corrected_files.py + test
Re-serialize the 363 corrected 837P files through the SP39-fixed
serializer, emitting NM1*PR*2*CO_TXIX*****PI*CO_TXIX in 2010BB and
writing to ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/.

Lives under dev/unbilled-july2026/scripts/ because the unbilled-july2026
project at /home/tyler/dev/unbilled-july2026/ is NOT a git repo of its
own (the operator's working tree only). Mirroring the physical path
keeps the script's import path (sys.path.insert ../../cyclone/backend/src)
working without per-run shimming.

Verified end-to-end: ok=363 failed=0, 5 random spot-checks all show
NM1*PR*2*CO_TXIX*****PI*CO_TXIX, zero SKCO0/COHCPF fallbacks in any
of the 363 output files.
2026-07-07 17:34:30 -06:00
Nora 3400ed5ec2 feat(sp39): resubmit-rejected-claims instruments Resubmission rows 2026-07-07 17:34:20 -06:00