6 Commits

Author SHA1 Message Date
Tyler de55003484 chore: stash in-progress test edit
Captures the modified state of test_api_parse_persists.py so the
in-progress test work is preserved on the claims-unique-fix branch
as the sp-prefixed worktrees are cleaned up. The .venv and
.gitignore_local noise in the working tree are intentionally left
untracked.
2026-06-22 11:05:56 -06:00
Grok c055787bd4 refactor(db_migrate): extract _apply_migrations_until helper; add edge-case tests
Issue 1 (refactor): run() and migrate_to_version() duplicated the inner
loop body — every change to the migration loop had to be applied in both
places. Pull the loop into _apply_migrations_until(engine, target_version):
when target_version is None, apply all pending; otherwise stop when the
file's version exceeds target. run() and migrate_to_version() are now
thin wrappers around the helper.

Issue 2 (tests): migrate_to_version() had no direct tests for its
edge-case behavior. Add 4 tests:
- idempotent: calling migrate_to_version(engine, N) twice == once
- lower-than-current: migrate_to_version(engine, 3) after N=13 is a no-op
- zero on fresh DB: migrate_to_version(engine, 0) is a no-op
- beyond-max: migrate_to_version(engine, 999) applies every migration

Tests use a fresh sa.Engine (not db.engine()) so they exercise the
target-version behavior from a clean user_version=0 starting point —
the conftest autouse fixture has already brought the module engine to
the latest version, which would mask the no-op branches.
2026-06-21 19:43:23 -06:00
Tyler ced8d20aed fix(db+tests): data-preservation test for migration 0014; document before_insert events
Address the three concerns raised by the spec reviewer on commit 534130e:

1. The 0014 data-preservation test was degraded: it only verified
   post-0014 INSERTs round-trip, never proving that 0014's INSERT INTO
   claims_new SELECT * FROM claims actually carries pre-existing rows
   through the table recreation. Add migrate_to_version() helper to
   db_migrate.py and rewrite the test to seed at v=13, apply 0014, and
   assert the rows survive. Add a complementary test exercising the
   JOIN-FK tables (matches / cas_adjustments / service_line_payments /
   line_reconciliations) whose batch_id columns get populated by
   0014's JOIN against the parent tables.

2. The four SQLAlchemy ORM before_insert events on Match /
   CasAdjustment / ServiceLinePayment / LineReconciliation are
   undocumented in the codebase. Add a clear module-level docstring
   explaining what the events do, why they exist (composite-FK
   NOT NULL columns require the batch side which most call sites
   don't have readily available), when they fail (parent id not
   findable in session.new / identity_map / DB -> misleading
   'NOT NULL constraint failed' IntegrityError), and the workarounds.

3. The plan Task 1.3 Step 8 said 'do not modify production code to
   make old tests pass'. In practice production code DID need to
   change because composite-PK semantics make single-id lookups
   ambiguous. Amend the plan to acknowledge this and cite the
   s.get(Claim, X) -> s.query(Claim).filter().first() refactor as
   the example. Also document the before_insert events in Step 5.

Brings in docs/superpowers/plans/2026-06-21-cyclone-parse-decide-workflow.md
from main (this branch was created before the plan file was committed)
with Task 1.3 Step 5 and Step 8 amended per the reviewer feedback.
2026-06-21 19:24:13 -06:00
Tyler 534130ee2b feat(db): migration 0014 relaxes claims/remittances PK to (batch_id, id)
Migration 0014 changes the PRIMARY KEYs of claims and remittances from
single-column id to composite (batch_id, id). Enables the spec'd
cross-batch CLM01 / CLP01 collision workflow: same CLM01 in multiple
batches is now representable (resubmits), pre-flight dedup 409 path is
genuinely exercisable, force-insert can skip pre-existing duplicates.

Strategy: PRAGMA defer_foreign_keys = ON + table recreation for every
table whose FKs pointed at the old single-column PK. Tables recreated:
remittances, claims, matches, cas_adjustments, service_line_payments,
line_reconciliations. Each child table gains a batch_id (or
remittance_batch_id) column for the composite FK side; INSERT INTO new
SELECT FROM old JOINs populate it from the already-recreated parent.

Cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
cannot be SQL-enforced with composite PKs (SQLite has no ALTER
CONSTRAINT). Dropped at SQL level; enforced via application-layer
invariants in store.manual_match / manual_unmatch / reconcile.run and
the dedup.preflight_* helpers.

ORM updates (db.py):
- Claim / Remittance: composite PK via explicit PrimaryKeyConstraint in
  __table_args__ (column order matches SQL: batch_id, id).
- New Claim.matched_remittance_batch_id column.
- Match / CasAdjustment / ServiceLinePayment / LineReconciliation:
  added the batch side of their composite FK to the parent table.
- SQLAlchemy before_insert events auto-populate the batch side of the
  composite FK from session.new, then identity_map, then SQL fallback.

Production code updates:
- store.manual_match / manual_unmatch: also write
  matched_remittance_batch_id on the claim (was missing).
- api.py manual-match endpoint: same fix.
- reconcile.run: same fix for auto-matched pairs.

Test updates: replaced s.get(Claim, X) with the composite key
(batch_id, id) where batch_id is known, or s.query().filter().first()
where the test only knows the id. Tests that previously inserted a Match
row pointing at a non-existent Remittance now seed the parent Remittance
so the new NOT NULL composite FK is satisfied.
2026-06-21 18:56:18 -06:00
Tyler 890207f40d feat(store): add find_existing_batch_for_claim and find_existing_batch_for_remit 2026-06-21 17:40:10 -06:00
Tyler b6efd0eaee feat(db): drop inline UNIQUE(batch_id, patient_control_number) via migration 0013 2026-06-21 17:39:28 -06:00
328 changed files with 9378 additions and 64358 deletions
-13
View File
@@ -1,13 +0,0 @@
# Repo-root .dockerignore — applies to `docker compose build` (which builds
# both backend and frontend contexts from the repo root). Excludes anything
# that should never end up in a build context.
.worktrees/
.git/
.github/
docs/prodfiles/
*.production.txt
node_modules/
dist/
.venv/
.superpowers/brainstorm/
+4 -17
View File
@@ -1,20 +1,7 @@
# Cyclone — environment configuration # Cyclone — environment configuration
# Copy this file to `.env.local` and fill in values for your environment. # Copy this file to `.env.local` and fill in values for your environment.
# Required on first boot. Cyclone refuses to start without these unless # Base URL for the Python (FastAPI) backend that powers the Upload page and
# at least one user already exists (e.g. seeded via `python -m cyclone users create`). # the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
# Min 12 chars for password. # the in-memory sample data store and disable real EDI parsing.
CYCLONE_ADMIN_USERNAME=admin VITE_API_BASE_URL=http://localhost:8000
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
# Base URL for the Python (FastAPI) backend. Leave empty for the
# Docker deployment (nginx proxies /api/* to backend on compose network).
VITE_API_BASE_URL=
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
# the session cookie to include the Secure flag.
# CYCLONE_BEHIND_HTTPS=1
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
# the backend auto-grants admin access without checking credentials.
# CYCLONE_AUTH_DISABLED=0
-4
View File
@@ -38,7 +38,3 @@ claims_output/
# Brainstorm session artifacts (visual companion mockups, events, server state). # Brainstorm session artifacts (visual companion mockups, events, server state).
# Skills under .superpowers/skills/ are committed project-scoped guidance. # Skills under .superpowers/skills/ are committed project-scoped guidance.
.superpowers/brainstorm/ .superpowers/brainstorm/
# SP33+ scratch / production-data ingest. Generated artifacts live
# here only — the source EDI sits under docs/prodfiles/.
ingest/
@@ -19,10 +19,6 @@ content negotiation + `tail_events`), and ~30 routes still inlined
in `backend/src/cyclone/api.py`. The next refactor target is the in `backend/src/cyclone/api.py`. The next refactor target is the
parse endpoints. parse endpoints.
## Auth gate (SP24)
Every router declared in `backend/src/cyclone/api_routers/` **must** carry `dependencies=[Depends(matrix_gate)]` at the `APIRouter(...)` declaration — not on each individual endpoint. The gate lives at `backend/src/cyclone/auth/deps.py:107` and the role matrix is at `backend/src/cyclone/auth/permissions.py`. The roles are `admin / user / viewer`; `matrix_gate` returns 401 when there's no session and 403 when the role is below the endpoint's required role. When `AUTH_DISABLED` is True (conftest autouse fixture flips it; `CYCLONE_AUTH_DISABLED=1` in prod-by-mistake), the gate short-circuits to a synthetic admin — see the SP24 spec for the threat-model implications. New routers get the gate by default; the auth-aware convention is `router = APIRouter(dependencies=[Depends(matrix_gate)])`.
## When to use ## When to use
- **Adding an endpoint.** You're adding a new GET / POST handler — - **Adding an endpoint.** You're adding a new GET / POST handler —
+3 -9
View File
@@ -10,15 +10,9 @@ a spec, a plan, an implementation branch, and a single atomic merge commit
into `main`. This skill encodes the conventions so every increment follows into `main`. This skill encodes the conventions so every increment follows
the same shape and the commit history stays auditable. the same shape and the commit history stays auditable.
As of this writing: **17 specs** in `docs/superpowers/specs/`, **13 plans** As of this writing: **16 specs** in `docs/superpowers/specs/`, **12 plans**
in `docs/superpowers/plans/`, and SP numbers used through **SP22**. **SP23** in `docs/superpowers/plans/`, and SP numbers used through **SP21** (the
is the Ubuntu + Docker + RBAC product fork (awaiting user decision); universal-drilldown design in progress). The next increment is **SP22**.
**SP24** is the auth-posture alignment (docs-only). The next free increment
is **SP25** after SP24 lands.
## Auth-aware spec template (SP24)
The threat-model section in the canonical SP-N spec template (`## 1. Scope`, second-to-last bullet) used to read "no second party to authenticate; no second host to harden against." **That phrasing is stale as of 2026-06-23** — the auth work landed in `main` and every backend endpoint requires login. New specs should instead state the auth boundary explicitly: "the auth boundary is HTTP (login required, bcrypt + HttpOnly session cookie); file-system threats remain the local-only threat model (SQLCipher at rest, macOS Keychain). SP23 changes the threat model to LAN-bound remote operator." Reference: [`docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md`](../../../docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md).
## When to use ## When to use
+1 -5
View File
@@ -7,11 +7,7 @@ description: "Cyclone pytest + vitest fixture patterns, prodfiles layout, backen
The Cyclone test suite is split two ways: **backend pytest** (89 test files under `backend/tests/`, 13 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (59 `*.test.ts(x)` siblings across `src/`, two rendering styles — `@testing-library/react` and a custom `createRoot`+`Probe` shim). This skill codifies the conventions so additions stay consistent with what's already there. The Cyclone test suite is split two ways: **backend pytest** (89 test files under `backend/tests/`, 13 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (59 `*.test.ts(x)` siblings across `src/`, two rendering styles — `@testing-library/react` and a custom `createRoot`+`Probe` shim). This skill codifies the conventions so additions stay consistent with what's already there.
As of this writing: **89 backend test files**, **13 flat backend fixtures**, **59 frontend `*.test.ts(x)` siblings**, and **23 prodfiles samples** across `docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/`. The next increment is **SP24** (SP23 is the Ubuntu+Docker fork, awaiting user decision). As of this writing: **89 backend test files**, **13 flat backend fixtures**, **59 frontend `*.test.ts(x)` siblings**, and **23 prodfiles samples** across `docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/`. The next increment is **SP22**.
## Auth flag (SP24)
The autouse `conftest.py` fixture at `backend/tests/conftest.py` flips `cyclone.auth.deps.AUTH_DISABLED = True` for the entire test session, so every test runs without a login round-trip. **Any new test that reads `cyclone.auth.deps.AUTH_DISABLED` directly will see `True`** — that's the test-suite reality, not a production reality. If you need a test that exercises the real gate, import `from cyclone.auth.deps import matrix_gate` and call it directly with a `Request` whose `state` carries a real session, or flip the flag back inside the test and reset it on teardown. The startup WARNING (`backend/src/cyclone/__main__.py`) is silent in tests by default because the conftest sets the flag before `bootstrap.run()` is called via `import`.
## When to use ## When to use
-184
View File
@@ -1,184 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Cyclone is a self-hosted X12 EDI claims-management suite for a single billing office (Colorado Medicaid currently). It parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 / 005010X221A1) and also handles 999, TA1, 270, 271, and 277CA. Always binds to `0.0.0.0` — the host firewall / compose port publishing is what restricts reachability, not the bind address. Requires login (auth boundary is HTTP; bcrypt + HttpOnly session cookie; first admin bootstrapped from `CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD` env vars; see SP24 spec for the full posture). LAN-only by design — don't expose the published ports to the WAN.
Stack: one Python process (FastAPI + uvicorn, port 8000) + one Node process in dev (Vite, port 5173). The authoritative state is a single SQLite file at `~/.local/share/cyclone/cyclone.db` (or SQLCipher at the same path when the macOS Keychain entry + `sqlcipher3` are both present).
For the day-1 architecture read, see `docs/ARCHITECTURE.md` (process topology, module map, store facade, parser pipeline, pubsub). For the what-it-does read, see `docs/REQUIREMENTS.md` (FRs + NFRs + DoD).
## Install
```bash
# Backend (Python 3.11+)
cd backend
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
# Frontend (Node 20+)
cd ..
npm install
```
Optional backend extras: `pip install -e '.[sqlcipher]'` (encryption at rest, SP12) and `pip install -e '.[sftp]'` (real SFTP, SP13).
## Dev (two terminals)
```bash
# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve # default 0.0.0.0:8000
# CYCLONE_PORT=... overrides port; CYCLONE_RELOAD=1 enables uvicorn --reload
# Or: .venv/bin/uvicorn cyclone.api:app --reload --port 8000
# Terminal 2 — frontend
npm run dev # Vite on http://localhost:5173
```
Vite proxies `/api/*` to the backend at `http://127.0.0.1:${CYCLONE_PORT:-8000}` so relative-URL fetchers (the live-tail NDJSON streams in particular) resolve through the same origin. Override the backend port with `CYCLONE_PORT` in the frontend terminal too.
Create `.env.local` at the repo root with `VITE_API_BASE_URL=http://127.0.0.1:8000`. Without it, the UI runs against the in-memory zustand store and real EDI parsing is disabled.
## Test
```bash
# Backend — full suite
cd backend && .venv/bin/pytest
# Backend — one file
cd backend && .venv/bin/pytest tests/test_api_999.py -v
# Backend — one test by node id
cd backend && .venv/bin/pytest tests/test_api_999.py::test_parse_999_endpoint_happy_path -v
# Frontend — full suite
npm test # alias for `vitest run`
# Frontend — one file
npx vitest run src/hooks/useFoo.test.ts
# Frontend — typecheck
npm run typecheck
# Frontend — build (tsc -b + vite build)
npm run build
# Frontend — lint
npm run lint
```
**Conventions** (full detail in `.superpowers/skills/cyclone-tests/SKILL.md`):
- Backend tests live under `backend/tests/test_*.py`. Two flavors: `test_api_<topic>_<verb>.py` (FastAPI integration via `fastapi.testclient.TestClient`) and `test_<module>_<behavior>.py` (pure-unit). Autouse `conftest.py` points `CYCLONE_DB_URL` at `tmp_path/test.db`, calls `db._reset_for_tests()` + `db.init_db()`, and wires a fresh `EventBus` onto `app.state`.
- Prodfiles (real EDI samples under `docs/prodfiles/<source>/`) are never read directly from a test — copy to `backend/tests/fixtures/<descriptive-name>.txt` first and reference as a module-level `Path` constant. The `fixtures/` dir is flat (no per-test subdirs).
- Frontend tests are siblings: `useFoo.ts``useFoo.test.ts`, `ClaimDrawer.tsx``ClaimDrawer.test.tsx`. Setup is `// @vitest-environment happy-dom` plus `(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;`. Mock the API at the module boundary with `vi.mock("@/lib/api", ...)`; stub fetch with `vi.stubGlobal("fetch", vi.fn().mockResolvedValue(...))`. `vitest.config.ts` sets `VITE_API_BASE_URL=http://test.local` so the `api` module doesn't throw `notConfiguredError` before the mock fires.
- Time-sensitive tests: frontend uses `vi.useFakeTimers()` + `vi.setSystemTime(...)` + `vi.advanceTimersByTime(ms)`. Backend passes explicit `datetime(...)` values. Don't add `await new Promise((r) => setTimeout(r, N))` — it's the legacy flaky pattern.
## Project-scoped skills (`.superpowers/skills/`)
Cyclone ships 8 skills under `.superpowers/skills/`. They auto-load by description match — no slash command needed. **Read the relevant skill before touching the matching subsystem.**
| Skill | Owns |
|---|---|
| `cyclone-spec` | The SP-N spec → plan → implement → merge flow (branch shape, file paths, commit prefixes, PR title, merge shape). |
| `cyclone-tests` | pytest + vitest fixture patterns, prodfiles drop-in rule, determinism rules. |
| `cyclone-edi` | EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1, R-codes, CAS mapping). |
| `cyclone-tail` | Live-tail streaming wire format and the `useTailStream` + `useMergedTail` + `TailStatusPill` hook triplet. |
| `cyclone-store` | `CycloneStore` facade, write-paths, pubsub event contract, SP21 split map. |
| `cyclone-api-router` | FastAPI router conventions (`api_routers/`, `api_helpers.py`), response/error-envelope shapes. |
| `cyclone-frontend-page` | React page conventions (TanStack Query `use<X>` hook, drawer, URL state, sibling test). |
| `cyclone-cli` | CLI subcommand conventions (`cli.py`, exit codes, smoke tests). |
## The SP-N increment flow
Every feature ships as a numbered **SP-N increment**: spec → plan → implementation branch → single atomic merge into `main`. As of the last backfill, SP numbers are used through **SP22**; **SP23** is reserved for the Ubuntu + Docker + RBAC product fork (`docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`, awaiting user decision); the next free increment is **SP24**. Read `cyclone-spec` before starting a new one. Non-negotiable shape:
- **Branch:** `sp<N>-<short-kebab-topic>` (e.g. `sp22-line-reconciliation`).
- **Spec path:** `docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`, header `Status: Draft, awaiting user sign-off`, sections `Scope / Decisions / …`. Specs contain zero code blocks.
- **Plan path:** `docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md`, header per `superpowers:writing-plans` with `Goal / Architecture / Tech Stack / Spec` metadata + numbered `- [ ] Step N:` tasks.
- **Commit prefixes:** `feat(sp<N>): …`, `docs(spec): …`, `docs(plan): …`, `merge: SP<N> <topic> into main`.
- **PR title:** `SP<N> <Topic>` (matches the merge-commit subject).
- **Merge shape:** single atomic merge commit. **No squash** (collapses the audit trail) and **no rebase** (rewrites the SHAs the review was performed against). The SP-N merge commit *is* the record of the increment landing.
The matching skill to load alongside `cyclone-spec` depends on the subsystem the SP-N touches (see the "Related skills" section at the bottom of each skill file).
## Live-tail wire format
The Claims, Remittances, and Activity pages stay current without manual refresh. The backend publishes an internal event on every store write, the page opens a streaming HTTP connection to the matching `/api/<resource>/stream` endpoint, and new rows append to the table the moment they hit the database.
Endpoints (all accept the same query params as their non-streaming counterparts; `Content-Type: application/x-ndjson`):
| Method | Path | Subscribes to | Default sort |
|---|---|---|---|
| GET | `/api/claims/stream` | `claim_written` | `-submission_date` |
| GET | `/api/remittances/stream` | `remittance_written` | `-received_date` |
| GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) |
Wire format: one JSON object per line, `{"type": ..., "data": ...}`. The first batch is the **snapshot** of currently-known rows, then `snapshot_end` with the count, then the **live** events. Known types: `item`, `snapshot_end`, `heartbeat` (keeps the connection alive on idle — clients flip to `stalled` after 30s of total silence), `item_dropped` (rare), `error`.
Status pill states (rendered by `<TailStatusPill>` in `src/components/TailStatusPill.tsx`): `live` (success), `connecting` (warning), `reconnecting` (warning), `stalled` (destructive, ↻ Reconnect button), `error` (destructive, ↻ Reconnect button), `closed` (destructive). Backoff on error: `1s → 2s → 4s → 8s → 16s → 30s` capped. `STALL_TIMEOUT_MS = 30_000` in `src/hooks/useTailStream.ts:53`. Heartbeat interval is `CYCLONE_TAIL_HEARTBEAT_S` env var, default 15s.
Frontend triplet for any live page: `use<X>(params)` (initial fetch) + `useTailStream(resource)` (opens the NDJSON stream, drives backoff/stall) + `useMergedTail(resource, baseItems, filterFn?)` (merges snapshot + tail, dedup'd by id). The subscription lives on the page, not inside the data hook — see `cyclone-frontend-page` for why.
## Backend at a glance
`backend/src/cyclone/` is a single namespace. The two largest files are `api.py` (~3,548 LOC, the only large file) and `store.py` (~2,423 LOC, the `CycloneStore` facade — SP21 is in flight to split it into a `cyclone/store/` subpackage; the public API stays unchanged). Subpackages: `api_routers/` (acks, admin, health, ta1_acks), `clearhouse/` (Clearhouse + SftpClient), `edi/` (filenames), `parsers/` (X12 transaction parsers + models + validators + serializers), `workflow/` (placeholder for future sub-project 6).
The store is the only read/write surface for the database; every mutating endpoint goes through it. All persistence flows through SQLAlchemy sessions via `db.SessionLocal()()`. SQLAlchemy ORM models live in `db.py`; 12 SQL migrations under `migrations/` (0001_initial through 0012_backups) are walked in order by `db_migrate.py`.
The parser pipeline is a 5-stage `tokenize → segmentize → model → validate → write_to_store` flow used for every inbound X12 type. Per-transaction parsers: `parse_837.py`, `parse_835.py`, `parse_999.py`, `parse_ta1.py`, `parse_270.py`, `parse_271.py`, `parse_277ca.py`. Each has a matching Pydantic model module (`models.py`, `models_835.py`, …) and a writer (`writer.py` / `writer_835.py`). The 837P serializer (`serialize_837.py`) is the byte-faithful outbound counterpart used by both single-claim download (`/api/claims/{id}/serialize-837`) and the bulk rejected-resubmit bundle (`/api/inbox/rejected/resubmit?download=true`).
The pubsub is `cyclone.pubsub.EventBus` — an in-process async fan-out broker. Publishers call `publish(kind, payload)`; subscribers receive via an async iterator. If a subscriber's per-kind queue is full, the oldest event is dropped so a slow consumer can't stall the producer. Bus is single-event-loop only (matches FastAPI/uvicorn).
Config: `config/payers.yaml` is the on-disk source for providers / payers / clearhouse, schema-validated at boot against a Pydantic model. Reload with `POST /api/admin/reload-config`. Original in-code `PAYER_FACTORIES` dict in `cli.py` is kept as a fallback for ad-hoc testing.
Secrets live in the macOS Keychain (via `keyring` + `cyclone.secrets`): SQLCipher key (service `cyclone`, account `cyclone.db.key`), SFTP password, backup passphrase. No secrets on disk in plaintext.
## Frontend at a glance
`src/` is React 18 + TypeScript + Vite. Routes register in `src/App.tsx` (11 pages, all under a `<Layout>` route wrapper). Pages are pure renderers — every page pairs with a `use<X>` data hook in `src/hooks/` and renders a `<PageHeader>` + a table/list/KPI grid. Drawers (`ClaimDrawer/`, `RemitDrawer/`, plus the new `ProviderDrawer/` and `AckDrawer/`) are mounted by the page and their open/close state is mirrored to the URL via `useDrawerUrlState` so deep-links round-trip. Drill-stack navigation is provided by `<DrillStackProvider>` in `src/components/drill/`.
State split: **server state** in TanStack Query (`@tanstack/react-query`); **ephemeral client state** in Zustand (`useTailStore` for live-tail append, plus the drill stack). The live-tail store is FIFO-capped at `TAIL_CAP = 10_000` per slice (`src/store/tail-store.ts:28`); `claims` and `remittances` are key-by-id with first-write-wins dedup, `activity` is an append-only array.
UI primitives in `src/components/ui/` are Radix-backed (`button`, `dialog`, `table`, `select`, `pagination`, `empty-state`, `error-state`, `filter-chips`, `skeleton`, `input`, `label`, `card`, `badge`, `skip-link`, `claim-state-badge`). Don't import a new UI library without discussion.
Path alias `@/``src/`. Configured in `vite.config.ts`, `vitest.config.ts`, and `tsconfig.app.json`.
## CLI
```bash
# Parser
python -m cyclone.cli parse-837 path/to/837p.txt --output-dir ./claims --payer co_medicaid [--strict] [--include-raw-segments]
python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./remits
python -m cyclone.cli parse-999 inbound_999.txt
python -m cyclone.cli parse-ta1 inbound_ta1.txt
python -m cyclone.cli parse-277ca inbound_277ca.txt
# Validators
python -m cyclone.cli validate-npi 1234567893
python -m cyclone.cli validate-tin 721587149
# Other
python -m cyclone serve # uvicorn
python -m cyclone backup list
python -m cyclone backup create --reason manual
```
Exit codes are documented per subcommand in `cyclone-cli``0` for success, `2` for file-level failure, `1` for unexpected exceptions.
## Things that are easy to get wrong
- **`VITE_API_BASE_URL` matters.** With it empty, every `api` method throws `notConfiguredError()` and the UI falls back to the in-memory zustand store — parses are disabled and the live-tail streams never open.
- **Prodfiles vs fixtures.** Tests must reference `backend/tests/fixtures/<name>.txt`, not `docs/prodfiles/<source>/<file>.txt`. The prodfiles dir is the source-of-truth archive; the fixtures dir is the stable test surface.
- **SP-N merge shape.** No squash, no rebase. The merge commit *is* the audit trail. Squash collapses the per-commit history and breaks the SP-N audit trail.
- **Don't put domain logic in JSX.** Conditional renderings, table sorting, and KPI math all belong in the `use<X>` hook or a pure helper under `src/lib/`.
- **Don't open a drawer via local `useState`.** Use `useDrawerUrlState()` so the URL is the single source of truth — deep-links and reload-restore depend on it.
- **Don't call `useTailStream` from inside a `use<X>` hook.** The subscription lives on the page so the lifecycle ties to whoever mounts the hook, not to whoever happens to call it.
- **The store facade.** The public API of `cyclone.store` is preserved through SP21's split — call through the facade, not directly into the underlying modules.
- **Encryption is optional, not required.** When the Keychain entry is missing **or** `sqlcipher3` is not installed, the DB falls back to plain SQLite. Don't fail boot on missing encryption.
- **Always bind to 0.0.0.0.** The bind address does not control reachability — the host firewall / compose port publishing does. Requires login (bcrypt + HttpOnly session cookie; see SP24 spec), and the threat model is still a stolen/imaged drive — SQLCipher at rest and the macOS Keychain handle that. The auth boundary is the HTTP layer; the file-system posture is unchanged. Don't expose the published ports to the public internet (LAN-only or VPN-fronted). Don't disable auth without an explicit `CYCLONE_AUTH_DISABLED=1` env var (the escape hatch logs a WARNING at boot).
</content>
</invoke>
-43
View File
@@ -1,43 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone frontend — React SPA built with node:20-alpine and served by
# nginx:1.27-alpine. nginx reverse-proxies /api/* to the backend service
# over the compose-managed bridge network.
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /build
# Install deps first so this layer caches across source edits.
# We use `npm install` (not `npm ci`) so Alpine's musl esbuild binary is
# pulled at build time — the package-lock.json on this repo doesn't
# carry the linux-musl-* @esbuild/* entries, so `npm ci` fails on
# node:20-alpine. `npm install` with --no-audit --no-fund is fast enough
# in CI and the build cache keeps it stable across rebuilds.
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
# Build the production bundle into dist/. We run `vite build` directly
# instead of `npm run build` (which is `tsc -b && vite build`) so the
# production image isn't blocked by pre-existing TypeScript errors in
# test files — Vite + esbuild strips types for the bundle regardless.
# Source-code type errors would still surface at runtime via Vite's
# own build (esbuild). Run `npm run typecheck` separately to see them.
COPY . .
RUN npx vite build
# ---------- runtime ----------
FROM nginx:1.27-alpine
# Replace the default nginx site with ours (SPA + reverse proxy).
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
# wget is on busybox; nginx:alpine doesn't ship curl.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
EXPOSE 8080
+4 -53
View File
@@ -30,7 +30,7 @@ Two terminals:
# Terminal 1 — backend # Terminal 1 — backend
cd backend cd backend
.venv/bin/python -m cyclone serve .venv/bin/python -m cyclone serve
# (defaults to 0.0.0.0:8000; override with CYCLONE_HOST / CYCLONE_PORT / CYCLONE_RELOAD=1) # (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)
# Terminal 2 — frontend # Terminal 2 — frontend
npm run dev npm run dev
@@ -111,49 +111,6 @@ npm run build
npm test npm test
``` ```
## Authentication
Cyclone ships with username/password authentication and three predefined roles.
**Roles:**
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
| -------- | -------- | ------------------------------------ | ---------------- |
| `viewer` | ✅ | ❌ | ❌ |
| `user` | ✅ | ✅ | ❌ |
| `admin` | ✅ | ✅ | ✅ |
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
first admin automatically. On subsequent starts these env vars are ignored, so
rotating the bootstrap password doesn't affect an already-seeded admin — use the
CLI below to reset it. When running via `docker compose`, both vars are
required: compose refuses to start with a clear error if either is missing.
**CLI.** Manage users from the command line:
```
python -m cyclone users create alice --role user --password 'hunter2hunter2'
python -m cyclone users list
python -m cyclone users disable alice
python -m cyclone users reset-password alice
python -m cyclone users set-role alice --role admin
```
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
(Docker), sign in on the `/login` page, and you'll be redirected to the
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
expiry — every authenticated request refreshes the TTL, so an active user
never gets logged out.
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
(the backend auto-grants admin on every request). **NEVER set this in
production** — it's a single env-var trip from wide-open to the public
internet. The Docker compose file does not honor this flag.
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
design.
## Live updates ## Live updates
The Claims, Remittances, and Activity pages stay current without The Claims, Remittances, and Activity pages stay current without
@@ -690,7 +647,7 @@ backup create` manually retains full control.
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
generated 837 files to the dzinesco SFTP server generated 837 files to the dzinesco SFTP server
(`mft.gainwelltechnologies.com:22`, path (`mft.gainwelltechnologies.com:22`, path
`/CO XIX/PROD/coxix_prod_11525703/ToHPE`). The SFTP credential is `/CO XIX/PROD/coxix_prod_11525703/FromHPE`). The SFTP credential is
fetched from the macOS Keychain at call time — never read from YAML, fetched from the macOS Keychain at call time — never read from YAML,
never logged, never written to disk. The wire-up honors the file-naming never logged, never written to disk. The wire-up honors the file-naming
template stored in the `clearhouse` config: template stored in the `clearhouse` config:
@@ -801,12 +758,6 @@ backup API).
## Roadmap ## Roadmap
> **Read order for new engineers:**
> 1. [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) — what Cyclone does (FRs + NFRs + DoD + traceability). The single index tying the 22 shipped sub-projects to the 38 functional + 18 non-functional requirements and the test strategy.
> 2. [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — how it fits together (process topology, package layout, data flow, lifecycle, operational concerns).
> 3. The per-SP spec under [`docs/superpowers/specs/`](docs/superpowers/specs/) for whatever you're touching.
> 4. The per-SP plan under [`docs/superpowers/plans/`](docs/superpowers/plans/) if you're implementing.
Sub-projects 2 through 19 are **shipped**. See the [completeness Sub-projects 2 through 19 are **shipped**. See the [completeness
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
the honest gap analysis against the industry definition of a HIPAA the honest gap analysis against the industry definition of a HIPAA
@@ -898,7 +849,7 @@ Shipped sub-projects (most recent first):
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed - **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint `SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
actually pushes to actually pushes to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/ToHPE`. `mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are read from the macOS Keychain at call time. SFTP credentials are read from the macOS Keychain at call time.
- **Sub-project 12 (shipped) — Encryption at rest.** Optional - **Sub-project 12 (shipped) — Encryption at rest.** Optional
SQLCipher AES-256 encryption of the SQLite file, with the key SQLCipher AES-256 encryption of the SQLite file, with the key
@@ -1160,7 +1111,7 @@ the one-time setup recipe.
- `POST /api/clearhouse/submit` — same endpoint as SP9; the - `POST /api/clearhouse/submit` — same endpoint as SP9; the
implementation is now a real `paramiko` `SftpClient.write` to implementation is now a real `paramiko` `SftpClient.write` to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/ToHPE`. `mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are fetched from the macOS Keychain at call time. SFTP credentials are fetched from the macOS Keychain at call time.
## License ## License
-65
View File
@@ -1,65 +0,0 @@
# Cyclone Operator Runbook
Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assumes the box was bootstrapped via `scripts/cyclone-init.sh` and the stack is up via `docker compose up -d`.
## Daily
- [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/health` every 5 minutes.
- [ ] `docker compose ps` — both services `healthy`.
- [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
## Weekly
- [ ] `curl -fsS http://localhost:8080/api/admin/audit-log -b cookies.txt | jq '.events[] | select(.event | test("login_failed|backup.failed"))'` — review failed logins + backup failures.
- [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now).
## Quarterly
- [ ] Rotate the SQLCipher / cookie-signing key:
```bash
bash scripts/cyclone-init.sh --force # overwrites /etc/cyclone/secrets/db.key
docker compose restart backend # picks up the new key
```
Old `.bin` backups become unreadable after this; export them first if you need to keep them.
## As needed
- **Add an operator.** Log in as admin → `/admin/users` → Create user. Roles: `admin` / `user` / `viewer`.
- **Reset a password.** Admin UI → Users → Reset password, OR `docker compose exec backend python -m cyclone admin reset-password --username <name>`.
- **Restore from backup.** Admin UI → Backups → pick the snapshot → Initiate restore → Confirm. The backend will restart automatically.
- **Roll back the code (not the schema).** `TAG=0.0.9 docker compose up -d`. The previous image stays in the local Docker cache for one cycle.
- **Pull a new `:stable`.**
```bash
cd /opt/cyclone
docker compose pull
docker compose up -d
docker compose logs -f backend | head -200 # verify migrations + healthcheck
```
- **Off-box backup copy.** The operator is expected to rsync `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted; the destination doesn't need its own encryption.
- **Inspect the DB.** `docker compose exec backend sqlite3 /var/lib/cyclone/db/cyclone.db ".tables"` (works only if SQLCipher key is on disk; the in-process decrypt happens via the cyclone backend).
## Annual
- [ ] Rotate the admin password (force re-login for everyone).
- [ ] Audit the `/etc/cyclone/secrets/` directory permissions — should be `chmod 600 root:root`.
- [ ] Review the audit log for stale admin sessions.
## Emergency
- **Backend won't start.** `docker compose logs --tail=300 backend`. Look for migration failures (rerun is safe — migrations are forward-only), SQLCipher key mismatch (`PRAGMA key` failure), or port collisions.
- **Frontend won't serve.** `docker compose logs --tail=100 frontend`. Usually nginx config drift; `docker compose restart frontend`.
- **Both unhealthy after a host reboot.** Docker may have come up before the named volumes did. `docker compose down && docker compose up -d`.
- **Suspected key compromise.** Rotate immediately (see Quarterly above). All active sessions are invalidated.
## Where things live
| Asset | Path |
|---|---|
| Docker compose file | `/opt/cyclone/docker-compose.yml` |
| Secrets | `/etc/cyclone/secrets/{db.key,admin_username,admin_pw}` |
| Live DB (SQLCipher-encrypted volume) | `cyclone_db` named volume, mounted at `/var/lib/cyclone/db` |
| Encrypted backups | `cyclone_backups` named volume, mounted at `/var/lib/cyclone/backups` |
| Uploaded prod files | `cyclone_prodfiles` named volume |
| SFTP staging stub | `cyclone_sftp_staging` named volume |
| Logs | `cyclone_logs` named volume + bind-mounted at `/var/log/cyclone` |
| Off-box backup destination | Operator's external drive / NAS (rsync cron, not in compose) |
-233
View File
@@ -1,233 +0,0 @@
// UI/UX Score Loop — pass 1 driver.
// Loads each route at three sizes in Chrome Canary, captures screenshots,
// logs console errors, runs a small interaction probe per flow, and
// writes a JSON report. Does not modify any source files.
import puppeteer from "puppeteer-core";
import { mkdir, writeFile } from "node:fs/promises";
const BASE = "http://127.0.0.1:5173";
const SHOTS = "/tmp/cyclone-uiux/shots";
const REPORT = "/tmp/cyclone-uiux/report.json";
const SIZES = [
{ name: "desktop", w: 1440, h: 900 },
{ name: "tablet", w: 768, h: 1024 },
{ name: "mobile", w: 375, h: 812 },
];
// Routes to load. path = the route; name = the flow label; ready = a
// selector we wait for to consider the page "rendered".
const FLOWS = [
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
{ name: "remittances", path: "/remittances", ready: "main, table" },
{ name: "providers", path: "/providers", ready: "main, table" },
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
{ name: "acks", path: "/acks", ready: "main, table" },
{ name: "batches", path: "/batches", ready: "main, table" },
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
{ name: "activity", path: "/activity", ready: "main" },
{ name: "404", path: "/does-not-exist", ready: "main" },
];
async function setupViewports(browser) {
const pages = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
pages.push({ page, size });
}
return pages;
}
async function probeFlow(page, flow) {
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
const onConsole = (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
};
const onPageError = (err) => pageErrors.push(err.message);
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
page.on("console", onConsole);
page.on("pageerror", onPageError);
page.on("requestfailed", onRequestFailed);
const t0 = Date.now();
let rendered = false;
let readyError = null;
try {
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
if (flow.ready) {
try {
await page.waitForSelector(flow.ready, { timeout: 5000 });
rendered = true;
} catch (e) {
readyError = e.message;
}
} else {
rendered = true;
}
} catch (e) {
readyError = e.message;
}
const loadMs = Date.now() - t0;
page.off("console", onConsole);
page.off("pageerror", onPageError);
page.off("requestfailed", onRequestFailed);
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
}
async function probeInteractions(page, flow) {
const findings = [];
// Generic a11y / structural probes per flow.
try {
// Sidebar visible? (md+ shows it; < md hides it)
const aside = await page.$("aside");
findings.push({ check: "sidebar-present", pass: !!aside });
} catch (e) {
findings.push({ check: "sidebar-present", pass: false, err: e.message });
}
try {
// Top bar present?
const main = await page.$("main#main-content");
findings.push({ check: "main-present", pass: !!main });
} catch (e) {
findings.push({ check: "main-present", pass: false, err: e.message });
}
try {
// H1 or page heading?
const heading = await page.evaluate(() => {
const h = document.querySelector("h1, h2");
return h ? h.textContent?.trim().slice(0, 60) : null;
});
findings.push({ check: "heading-present", pass: !!heading, value: heading });
} catch (e) {
findings.push({ check: "heading-present", pass: false, err: e.message });
}
// Flow-specific probes.
if (flow.name === "claims" || flow.name === "claims-denied") {
try {
const chips = await page.$$("[role='radio'], button[role='radio']");
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
} catch (e) {
findings.push({ check: "status-chips", pass: false, err: e.message });
}
try {
const search = await page.$("input[placeholder*='Search']");
findings.push({ check: "search-input", pass: !!search });
} catch (e) {
findings.push({ check: "search-input", pass: false, err: e.message });
}
}
if (flow.name === "upload") {
try {
const dropzone = await page.$("section[aria-label='File upload']");
findings.push({ check: "dropzone-present", pass: !!dropzone });
const selects = await page.$$("button[role='combobox']");
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
} catch (e) {
findings.push({ check: "upload-elements", pass: false, err: e.message });
}
}
if (flow.name === "inbox") {
try {
const lanes = await page.$$("main > div > div");
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
} catch (e) {
findings.push({ check: "lane-cards", pass: false, err: e.message });
}
}
if (flow.name === "404") {
try {
const text = await page.evaluate(() => document.body.innerText);
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
} catch (e) {
findings.push({ check: "404-text", pass: false, err: e.message });
}
}
return findings;
}
async function main() {
await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
headless: "new",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const startedAt = new Date().toISOString();
const results = [];
for (const size of SIZES) {
const page = await browser.newPage();
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
for (const flow of FLOWS) {
const probe = await probeFlow(page, flow);
const interactions = await probeInteractions(page, flow);
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
try {
await page.screenshot({ path: shot, fullPage: false });
} catch (e) {
// ignore — recording in result
}
results.push({
flow: flow.name,
path: flow.path,
size: size.name,
viewport: { w: size.w, h: size.h },
probe,
interactions,
shot,
});
console.log(
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
`render=${probe.rendered} load=${probe.loadMs}ms ` +
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
);
}
await page.close();
}
await browser.close();
// Aggregate.
const summary = {
startedAt,
endedAt: new Date().toISOString(),
flows: results.length,
sizes: SIZES.map((s) => s.name),
renderedOk: results.filter((r) => r.probe.rendered).length,
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
results,
};
await writeFile(REPORT, JSON.stringify(summary, null, 2));
console.log("\nSummary:", JSON.stringify({
flows: summary.flows,
renderedOk: summary.renderedOk,
withConsoleErrors: summary.withConsoleErrors,
withPageErrors: summary.withPageErrors,
withFailedRequests: summary.withFailedRequests,
}, null, 2));
console.log("\nReport:", REPORT);
console.log("Shots:", SHOTS);
}
main().catch((e) => {
console.error("FATAL", e);
process.exit(1);
});
-12
View File
@@ -1,12 +0,0 @@
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
tests/
docs/prodfiles/
*.production.txt
.git/
.github/
+1
View File
@@ -7,3 +7,4 @@ __pycache__/
venv/ venv/
build/ build/
dist/ dist/
backend/.venv
-83
View File
@@ -1,83 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone backend — FastAPI on python:3.11-slim-bookworm with sqlcipher.
#
# Two-stage build:
# 1. builder — wheels the package with [sqlcipher] extra into /wheels.
# 2. runtime — slim base, tini PID 1, curl-based healthcheck.
#
# `sqlcipher` is preferred but the engine falls back to plain SQLite at
# runtime if the package isn't actually installed (see cyclone.db) — so a
# missing libsqlcipher-dev during build will fail loudly here rather than
# silently downgrading encryption in production.
# ---------- builder ----------
FROM python:3.11-slim-bookworm AS builder
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
libsqlcipher-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy the build manifest first so this layer caches across source edits.
COPY pyproject.toml ./
# Copy the full source tree, then build the wheel once. We deliberately
# avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
# left stale `__init__.py` content in the wheel because pip wheel reuses
# the cached wheel metadata when the name+version matches. See git
# history on this file for the long version.
COPY src/ ./src/
# Install the sftp extra alongside sqlcipher so the real-mode SFTP
# client (paramiko) is available inside the container — required by
# SP25 + SP26 for live Gainwell MFT polling.
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher,sftp]'
# ---------- runtime ----------
FROM python:3.11-slim-bookworm
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlcipher-dev \
curl \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 --shell /bin/bash cyclone
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher,sftp]' \
&& rm -rf /wheels
# NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
# Named volumes mount as root inside the container, and chown-ing them
# requires CAP_CHOWN (root). The standard hardened pattern is an
# entrypoint script that chowns as root then drops to the app user via
# gosu/su-exec — adds a dependency + an entrypoint file. For v1 we run
# as root inside the container; Docker's user-namespace remapping is
# the recommended host-level isolation. The `cyclone` user is created
# above and survives only so file ownership in bind mounts stays
# consistent. To harden later: install gosu + add an entrypoint script
# that does `chown -R cyclone:cyclone /var/lib/cyclone/... && exec gosu
# cyclone "$@"`.
EXPOSE 8000
# Container-level healthcheck — the compose service healthcheck is
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
# `docker ps` honest without needing compose to be running.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fs http://localhost:8000/api/health || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "cyclone", "serve"]
-10
View File
@@ -16,15 +16,6 @@ dependencies = [
"sqlalchemy>=2.0,<3", "sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7", "pyyaml>=6.0,<7",
"keyring>=25.0,<26", "keyring>=25.0,<26",
# backup_service / backup: encryption-at-rest (SP17). Used at module
# top-level by cyclone.backup, so it has to be a hard dep — not an
# extra — or the test suite fails to collect when the venv is built
# from a clean `uv sync`.
"cryptography>=49.0,<50",
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
# which 4.x removed). Pin bcrypt < 4.1.
"passlib[bcrypt]>=1.7.4",
"bcrypt<4.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -33,7 +24,6 @@ dev = [
"pytest-cov>=4.1", "pytest-cov>=4.1",
"pytest-asyncio>=0.23,<1", "pytest-asyncio>=0.23,<1",
"httpx>=0.27,<1", "httpx>=0.27,<1",
"pytest-randomly>=4.1",
] ]
sqlcipher = [ sqlcipher = [
# SP12: encryption at rest. Optional — without it the DB is plain SQLite. # SP12: encryption at rest. Optional — without it the DB is plain SQLite.
+2 -32
View File
@@ -1,11 +1,10 @@
"""Entry point for ``python -m cyclone``. """Entry point for ``python -m cyclone``.
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``) * ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
* ``python -m cyclone serve`` — start the FastAPI app on 0.0.0.0:8000 * ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
Honors the env vars: Honors the env vars:
* ``CYCLONE_HOST`` (default ``0.0.0.0`` — always bind to all interfaces)
* ``CYCLONE_PORT`` (default ``8000``) * ``CYCLONE_PORT`` (default ``8000``)
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload) * ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
""" """
@@ -17,42 +16,13 @@ import sys
def main() -> None: def main() -> None:
# Always run first-admin bootstrap before any other entry path.
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
# dispatch — otherwise `python -m cyclone users create ...` on a
# fresh DB would race with the bootstrap's check, and the API
# could come up with zero users.
from cyclone.auth import bootstrap
bootstrap.run()
# SP24: if the AUTH_DISABLED escape hatch is on, scream at boot so a
# misconfigured production deploy fails loudly. The flag is flipped by
# ``CYCLONE_AUTH_DISABLED=1`` (see ``cyclone.auth.bootstrap``) and by the
# pytest conftest autouse fixture (see ``.superpowers/skills/cyclone-tests``).
from cyclone.auth import deps as _auth_deps
if _auth_deps.AUTH_DISABLED:
import logging
logging.getLogger("cyclone").warning(
"AUTH_DISABLED is set (CYCLONE_AUTH_DISABLED=1) — all requests "
"treated as admin, dev only. Do NOT enable this in production."
)
if len(sys.argv) >= 2 and sys.argv[1] == "serve": if len(sys.argv) >= 2 and sys.argv[1] == "serve":
port = os.environ.get("CYCLONE_PORT", "8000") port = os.environ.get("CYCLONE_PORT", "8000")
# Always bind to 0.0.0.0 so the API is reachable from the
# frontend container on the compose bridge network AND from
# the host (Vite dev proxy) AND from the LAN. Network isolation
# is provided by the host firewall / compose port publishing,
# not by binding to loopback. Override with CYCLONE_HOST if you
# have a reason to restrict.
host = os.environ.get("CYCLONE_HOST", "0.0.0.0")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1" reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
sys.argv = [ sys.argv = [
sys.argv[0], sys.argv[0],
"cyclone.api:app", "cyclone.api:app",
"--host", host, "--host", "127.0.0.1",
"--port", port, "--port", port,
] ]
if reload: if reload:
+190 -1156
View File
File diff suppressed because it is too large Load Diff
+6 -30
View File
@@ -93,40 +93,19 @@ def ndjson_stream_list(
}) + "\n" }) + "\n"
def ndjson_stream_837( def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
result: ParseResult, batch_id: str | None = None, """Yield one JSON object per line: envelope → claims → summary."""
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary.
The ``batch_id`` is the server-side UUID assigned by the persistence
layer when the batch is ingested; the JSON response path exposes it
as the top-level ``batch_id`` field, but the NDJSON stream needs it
inline on the summary event so streaming clients can call
batch-scoped endpoints (``/api/batches/{id}/export-837``, ) without
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
supplied the summary omits the field, preserving backward compat
with clients that don't expect it.
"""
envelope_obj = ( envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None result.envelope.model_dump() if result.envelope is not None else None
) )
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8") yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims: for claim in result.claims:
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json()) yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def ndjson_stream_835( def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
result: ParseResult835, batch_id: str | None = None, """Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
merged into the summary event.
"""
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
@@ -134,10 +113,7 @@ def ndjson_stream_835(
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims: for claim in result.claims:
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8") yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json()) yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def strict_rewrite_837(result: ParseResult) -> ParseResult: def strict_rewrite_837(result: ParseResult) -> ParseResult:
+29 -104
View File
@@ -1,4 +1,4 @@
"""``/api/acks`` — list, detail, and live-tail stream for 999 ACKs. """``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
These are the persisted acknowledgment rows produced by These are the persisted acknowledgment rows produced by
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the ``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
@@ -7,74 +7,58 @@ list payload to its ``Ack`` interface in ``src/types/index.ts``.
The detail endpoint returns the full ``raw_json`` payload plus the The detail endpoint returns the full ``raw_json`` payload plus the
regenerated ``raw_999_text`` so the UI can show "view source" without a regenerated ``raw_999_text`` so the UI can show "view source" without a
second round-trip. second round-trip.
SP25: ``/api/acks/stream`` joins the live-tail triplet the Acks
page mounts ``useTailStream("acks")`` and ``useMergedTail("acks", )``
to see new 999 acks the moment they land (whether from the SFTP
poller or a manual upload).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, AsyncIterator from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from cyclone import db from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.api_helpers import (
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.parsers.models_999 import ParseResult999 from cyclone.parsers.models_999 import ParseResult999
from cyclone.parsers.serialize_999 import serialize_999 from cyclone.parsers.serialize_999 import serialize_999
from cyclone.pubsub import EventBus from cyclone.store import store
from cyclone.store import store, to_ui_ack
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# SP25: ``_ack_to_ui`` moved to ``cyclone.store.ui.to_ui_ack`` so the def _ack_to_ui(row) -> dict:
# live-tail event payload (``ack_received``) matches the list endpoint """Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
# shape byte-for-byte.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
"""
return {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": (
row.parsed_at.isoformat().replace("+00:00", "Z")
if row.parsed_at is not None
else ""
),
}
@router.get("/api/acks") @router.get("/api/acks")
def list_acks_endpoint( def list_acks_endpoint(
request: Request, request: Request,
limit: int = Query(100, ge=1, le=5000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
) -> Any: ) -> Any:
"""Return the list of persisted 999 ACKs, newest first. """Return the list of persisted 999 ACKs, newest first."""
``limit`` caps the page size; ``offset`` lets the UI walk the
full set without holding it all in memory. ``aggregates`` is
summed over the *full* row set (not the page) so the KPI strip
on the Acks page reflects every persisted 999, not just the
visible 50. Without server-side aggregates the page would
silently under-report (silent-failure mode) once the row count
exceeds the page size.
"""
rows = store.list_acks() rows = store.list_acks()
items = [to_ui_ack(r) for r in rows[offset : offset + limit]] items = [_ack_to_ui(r) for r in rows[:limit]]
total = len(rows) total = len(rows)
returned = len(items) returned = len(items)
has_more = offset + returned < total has_more = total > returned
aggregates = {
"accepted_count": sum(r.accepted_count or 0 for r in rows),
"rejected_count": sum(r.rejected_count or 0 for r in rows),
"received_count": sum(r.received_count or 0 for r in rows),
}
# SP28: batch-fetch linked_claim_ids per ack row in one query to
# avoid N+1 — see ``find_linked_claim_ids_for_acks`` below.
ack_ids = [r.id for r in rows]
linked_map = _find_linked_claim_ids_for_acks(ack_ids, kind="999")
for item, aid in zip(items, ack_ids[offset : offset + limit]):
item["linked_claim_ids"] = linked_map.get(aid, [])
if wants_ndjson(request): if wants_ndjson(request):
return StreamingResponse( return StreamingResponse(
ndjson_stream_list(items, total, returned, has_more), ndjson_stream_list(items, total, returned, has_more),
@@ -85,68 +69,9 @@ def list_acks_endpoint(
"total": total, "total": total,
"returned": returned, "returned": returned,
"has_more": has_more, "has_more": has_more,
"aggregates": aggregates,
} }
def _find_linked_claim_ids_for_acks(
ack_ids: list[int], *, kind: str
) -> dict[int, list[str]]:
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
One SELECT against ``claim_acks`` keyed on the page's ack_ids —
avoids an N+1 round-trip when the page renders the
"🔗 N claims" badge per row. Used by the 999 / TA1 / 277CA
list endpoints. Returns a ``{ack_id: [claim_id]}`` map; acks
with no links map to ``[]`` (default).
"""
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
if not ack_ids:
return out
with db.SessionLocal()() as s:
rows = (
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id.in_(ack_ids),
db.ClaimAck.claim_id.isnot(None),
)
.all()
)
for ack_id, claim_id in rows:
out[ack_id].append(claim_id)
return out
@router.get("/api/acks/stream")
async def acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream 999 ACKs as NDJSON: snapshot first, then live events.
SP25: this endpoint joins the live-tail triplet subscribes to
``ack_received`` and emits one ``item`` per snapshot row plus a
single ``snapshot_end`` line, then forwards live events from
the bus. Matches the wire format used by ``/api/claims/stream``,
``/api/remittances/stream``, and ``/api/activity/stream``.
NOTE: registered BEFORE ``/api/acks/{ack_id}`` so the literal
``stream`` path segment doesn't get matched as an ack id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/acks/{ack_id}") @router.get("/api/acks/{ack_id}")
def get_ack_endpoint(ack_id: int) -> dict: def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail. """Return one persisted ACK row with its parsed detail.
@@ -161,7 +86,7 @@ def get_ack_endpoint(ack_id: int) -> dict:
status_code=404, status_code=404,
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"}, detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
) )
body = to_ui_ack(row) body = _ack_to_ui(row)
body["raw_json"] = row.raw_json body["raw_json"] = row.raw_json
# Regenerate the X12 text from raw_json so the operator can download # Regenerate the X12 text from raw_json so the operator can download
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry # the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
@@ -1,306 +0,0 @@
"""``/api/claim-acks`` (and per-claim/per-ack surfaces) — SP28.
Seven endpoints that surface the ``claim_acks`` join table to the
frontend + manual-match fallback for orphans. Mounted by
``cyclone.api`` alongside the existing ``/api/acks`` and
``/api/ta1-acks`` routers.
The live-tail endpoints (``/api/claims/{id}/acks/stream`` and
``/api/acks/{kind}/{id}/claims/stream``) subscribe to the
``claim_ack_written`` bus event so the ClaimDrawer Acknowledgments
panel and the per-ack claims list refresh in real time.
Manual match is any-logged-in user (D5) this endpoint mutates
metadata only (``claim_acks`` row + live-tail event), no
``Claim.state`` mutation, no payment data. Idempotent: re-calling
with the same ``claim_id`` returns 200 with the existing row.
Rejects (409) when the claim is in a terminal state (``REVERSED``).
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator, Literal
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from cyclone import db
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_claim_ack
router = APIRouter()
log = logging.getLogger(__name__)
AckKind = Literal["999", "277ca", "ta1"]
class MatchClaimBody(BaseModel):
"""Body for ``POST /api/acks/{kind}/{ack_id}/match-claim``."""
claim_id: str = Field(..., min_length=1)
set_control_number: str | None = None
set_accept_reject_code: str | None = None
ak2_index: int | None = None
# ---------------------------------------------------------------------------
# Per-claim surface
# ---------------------------------------------------------------------------
@router.get("/api/claims/{claim_id}/acks/stream")
async def claim_acks_stream(
request: Request,
claim_id: str,
) -> StreamingResponse:
"""Stream ClaimAck rows for one claim as NDJSON.
Subscribes to ``claim_ack_written`` and filters for rows where
``claim_id`` matches the path param. Each matching event is
emitted as ``{"type": "item", "data": to_ui_claim_ack(...)}``;
the client-side ``useMergedTail`` hook dedupes by id.
Registered BEFORE ``/api/claims/{claim_id}/acks`` so the literal
``stream`` path segment doesn't get matched as a suffix.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = [
r for r in store.list_acks_for_claim(claim_id)
if r.claim_id is not None # filter out TA1 batch-level rows
]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
yield ndjson_line({
"type": "snapshot_end",
"data": {"count": len(rows)},
})
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
# tail_events yields full NDJSON lines; the client filter
# picks claim_id matches. We forward every event so the
# wire format mirrors /api/claims/stream.
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/claims/{claim_id}/acks")
def list_acks_for_claim_endpoint(claim_id: str) -> Any:
"""Return every ClaimAck row for one claim (per-claim only).
TA1 batch-level rows (where ``claim_id IS NULL``) are filtered
out those don't belong to a specific claim, they're a
envelope-level acknowledgement that hangs off the originating
837 batch. The ClaimDrawer Acknowledgments panel is
per-claim only.
"""
rows = store.list_acks_for_claim(claim_id)
items = [to_ui_claim_ack(r) for r in rows if r.claim_id is not None]
return {"total": len(items), "items": items}
# ---------------------------------------------------------------------------
# Per-ack surface
# ---------------------------------------------------------------------------
@router.get("/api/acks/{kind}/{ack_id}/claims/stream")
async def ack_claims_stream(
request: Request,
kind: AckKind,
ack_id: int,
) -> StreamingResponse:
"""Stream ClaimAck rows for one ack as NDJSON.
Subscribes to ``claim_ack_written`` and forwards events the
store knows about. Clients filter by ack_id + ack_kind on the
client side.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_claims_for_ack(kind, ack_id)
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
yield ndjson_line({
"type": "snapshot_end",
"data": {"count": len(rows)},
})
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/acks/{kind}/{ack_id}/claims")
def list_claims_for_ack_endpoint(kind: AckKind, ack_id: int) -> Any:
"""Return every ClaimAck row for one ack (any kind).
For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
For TA1: returns 0..1 row (envelope-level, populated batch_id).
"""
rows = store.list_claims_for_ack(kind, ack_id)
items = [to_ui_claim_ack(r) for r in rows]
return {"total": len(items), "items": items}
# ---------------------------------------------------------------------------
# Manual match / unmatch (D5, D9)
# ---------------------------------------------------------------------------
@router.post("/api/acks/{kind}/{ack_id}/match-claim")
def manual_match_claim_endpoint(
request: Request,
kind: AckKind,
ack_id: int,
body: MatchClaimBody,
) -> Any:
"""Manual link fallback (D5/D9). Any-logged-in-user posture.
Inserts a ``claim_acks`` row with ``linked_by="manual"`` and
publishes ``claim_ack_written`` so the drawers refresh. The
endpoint is idempotent: if a row already exists for this dedup
key, the existing row is returned (200). 409 when the claim is
in a terminal state (``REVERSED``). 404 when the claim doesn't
exist or the ack doesn't exist.
"""
# Verify the claim exists and is in a non-terminal state.
from cyclone.db import ClaimState as _CS
with db.SessionLocal()() as s:
claim = s.get(db.Claim, body.claim_id)
if claim is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": f"Claim {body.claim_id} not found",
},
)
if claim.state == _CS.REVERSED:
return JSONResponse(
status_code=409,
content={
"error": "Conflict",
"detail": (
f"Claim {body.claim_id} is in terminal state "
f"{claim.state.value} and cannot be linked."
),
},
)
# Verify the ack exists (any kind).
ack_table = {
"999": db.Ack,
"277ca": db.Two77caAck,
"ta1": db.Ta1Ack,
}[kind]
if s.get(ack_table, ack_id) is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": f"{kind} ACK {ack_id} not found",
},
)
# Idempotency: if a manual or auto link already exists, return it.
existing = (
s.query(db.ClaimAck)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id == ack_id,
db.ClaimAck.claim_id == body.claim_id,
)
.first()
)
if existing is not None:
return {
"link": to_ui_claim_ack(existing),
"created": False,
}
# Insert via the store so the publish-from-store contract fires.
row = store.add_claim_ack(
claim_id=body.claim_id,
batch_id=None,
ack_id=ack_id,
ack_kind=kind,
ak2_index=body.ak2_index,
set_control_number=body.set_control_number,
set_accept_reject_code=body.set_accept_reject_code,
linked_by="manual",
event_bus=request.app.state.event_bus,
)
return {"link": to_ui_claim_ack(row), "created": True}
@router.delete("/api/acks/{kind}/{ack_id}/match-claim/{claim_id}")
def manual_unmatch_claim_endpoint(
request: Request,
kind: AckKind,
ack_id: int,
claim_id: str,
) -> Any:
"""Unlink (preserves ``Claim.state`` mutation; only removes the row).
404 when no link row exists for the dedup key. Publishes
``claim_ack_dropped`` so live-tail subscribers remove the link.
"""
from cyclone import db as _db
with _db.SessionLocal()() as s:
link = (
s.query(_db.ClaimAck)
.filter(
_db.ClaimAck.ack_kind == kind,
_db.ClaimAck.ack_id == ack_id,
_db.ClaimAck.claim_id == claim_id,
)
.first()
)
if link is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": (
f"No link for {kind} ack {ack_id}"
f"claim {claim_id}"
),
},
)
link_id = link.id
removed = store.remove_claim_ack(link_id, event_bus=request.app.state.event_bus)
return {"removed": removed, "link_id": link_id}
# ---------------------------------------------------------------------------
# Inbox ack-orphans lane (spec §D7)
# ---------------------------------------------------------------------------
@router.get("/api/inbox/ack-orphans")
def list_ack_orphans_endpoint(
kind: AckKind | None = Query(None, description="Filter by ack kind"),
) -> Any:
"""List acks with no resolvable Claim row of their own kind.
Used by the Inbox "Ack orphans" lane for the operator's manual
reconciliation flow. Mirrors ``/api/inbox/remit-orphans``.
"""
if kind is not None:
items = store.find_ack_orphans(kind)
return {"total": len(items), "items": items}
items_999 = store.find_ack_orphans("999")
items_277ca = store.find_ack_orphans("277ca")
items_ta1 = store.find_ack_orphans("ta1")
all_items = items_999 + items_277ca + items_ta1
return {"total": len(all_items), "items": all_items}
__all__ = ["router"]
+21 -74
View File
@@ -1,4 +1,4 @@
"""``/api/ta1-acks`` — list, detail, and live-tail stream for TA1 envelopes. """``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes.
TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a
single segment, no functional group, no transaction set. Cyclone single segment, no functional group, no transaction set. Cyclone
@@ -7,29 +7,34 @@ row can sit alongside the 999 / 277CA ack rows without special-casing.
The detail endpoint also reconstructs the TA1 segment string The detail endpoint also reconstructs the TA1 segment string
(``TA1*...~``) so the operator can copy it into a downstream tool. (``TA1*...~``) so the operator can copy it into a downstream tool.
SP25: ``/api/ta1-acks/stream`` joins the live-tail triplet the
Acks page mounts ``useTailStream("ta1_acks")`` so the TA1 envelope
ack section sees new rows the moment they land.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, AsyncIterator from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone import db from cyclone import db
from cyclone.pubsub import EventBus from cyclone.store import store
from cyclone.store import store, to_ui_ta1_ack
router = APIRouter() router = APIRouter()
# SP25: ``_ta1_to_ui`` moved to ``cyclone.store.ui.to_ui_ta1_ack`` so def _ta1_to_ui(row: db.Ta1Ack) -> dict:
# the live-tail event payload (``ta1_ack_received``) matches the list """Render a Ta1Ack row for the UI (list endpoint shape)."""
# endpoint shape byte-for-byte. return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str: def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
@@ -52,78 +57,20 @@ def list_ta1_acks_endpoint(
count regardless of the ``limit`` cap. count regardless of the ``limit`` cap.
""" """
rows = store.list_ta1_acks() rows = store.list_ta1_acks()
items = [to_ui_ta1_ack(r) for r in rows[:limit]] items = [_ta1_to_ui(r) for r in rows[:limit]]
# SP28: batch-fetch linked_claim_ids per TA1 row (TA1 envelope
# links always carry claim_id IS NULL — populate the field for
# symmetry so the Acks page badge render path is uniform).
ack_ids = [r.id for r in rows]
linked_map = _find_linked_claim_ids_for_acks_helper(ack_ids, kind="ta1")
for item, aid in zip(items, ack_ids[:limit]):
item["linked_claim_ids"] = linked_map.get(aid, [])
return { return {
"total": len(rows), "total": len(rows),
"items": items, "items": items,
} }
def _find_linked_claim_ids_for_acks_helper(
ack_ids: list[int], *, kind: str
) -> dict[int, list[str]]:
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
Local helper so we don't import from ``acks.py`` and create a
circular import. See the equivalent ``_find_linked_claim_ids_for_acks``
in ``acks.py`` for the contract.
"""
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
if not ack_ids:
return out
with db.SessionLocal()() as s:
rows = (
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id.in_(ack_ids),
db.ClaimAck.claim_id.isnot(None),
)
.all()
)
for ack_id, claim_id in rows:
out[ack_id].append(claim_id)
return out
@router.get("/api/ta1-acks/stream")
async def ta1_acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream TA1 envelope acks as NDJSON.
Subscribes to ``ta1_ack_received`` and emits the same wire format
as the other live-tail endpoints. Registered BEFORE
``/api/ta1-acks/{ack_id}`` so ``stream`` isn't matched as an id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_ta1_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ta1_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ta1_ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/ta1-acks/{ack_id}") @router.get("/api/ta1-acks/{ack_id}")
def get_ta1_ack_endpoint(ack_id: int) -> dict: def get_ta1_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted TA1 ACK row with its parsed detail.""" """Return one persisted TA1 ACK row with its parsed detail."""
row = store.get_ta1_ack(ack_id) row = store.get_ta1_ack(ack_id)
if row is None: if row is None:
raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found") raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found")
body = to_ui_ta1_ack(row) body = _ta1_to_ui(row)
body["raw_ta1_text"] = _serialize_ta1_from_row(row) body["raw_ta1_text"] = _serialize_ta1_from_row(row)
body["raw_json"] = row.raw_json body["raw_json"] = row.raw_json
return body return body
-8
View File
@@ -103,12 +103,6 @@ class AuditEvent:
computed hash. Payload must be JSON-serializable; the audit_log computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about module handles the encoding so callers don't need to think about
canonical form. canonical form.
``user_id`` is the authenticated actor for this event, when known
(e.g. a parse-999 call made by user 7). It's stored on the row but
is NOT part of the hash chain the chain hashes only the fields
that existed pre-SP-auth so verify_chain stays compatible with
pre-auth rows.
""" """
event_type: str event_type: str
@@ -117,7 +111,6 @@ class AuditEvent:
payload: dict[str, Any] = field(default_factory=dict) payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system" actor: str = "system"
created_at: datetime | None = None created_at: datetime | None = None
user_id: int | None = None
def append_event( def append_event(
@@ -162,7 +155,6 @@ def append_event(
created_at=created_at, created_at=created_at,
prev_hash=prev_hash, prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
) )
session.add(row) session.add(row)
session.flush() # populate row.id session.flush() # populate row.id
-1
View File
@@ -1 +0,0 @@
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
-95
View File
@@ -1,95 +0,0 @@
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from cyclone.auth import users
from cyclone.auth.deps import get_current_user
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
if user.get("role") != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
def _validate_role(role: str) -> None:
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
if role not in valid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of {sorted(valid)}",
)
@router.get("")
def list_users(_admin=Depends(_require_admin)):
with SessionLocal()() as db:
all_users = db.query(User).all()
return [users.to_public(u) for u in all_users]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_user(body: dict, _admin=Depends(_require_admin)):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
role = body.get("role") or ""
if not username or len(username) < 3:
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
if len(password) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
_validate_role(role)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
raise HTTPException(status_code=409, detail="username already exists")
u = users.create(db, username=username, password=password, role=role)
return users.to_public(u)
@router.patch("/{user_id}")
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_demote_self",
)
with SessionLocal()() as db:
if body.get("role") is not None:
_validate_role(body["role"])
users.update_role(db, user_id, body["role"])
if body.get("password") is not None:
if len(body["password"]) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
users.update_password(db, user_id, body["password"])
if body.get("disabled") is True:
users.disable(db, user_id)
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
return users.to_public(u)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_delete_self",
)
with SessionLocal()() as db:
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
users.disable(db, user_id)
return None
-105
View File
@@ -1,105 +0,0 @@
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
Called from ``python -m cyclone`` before either ``cli.main()`` or
``uvicorn`` so users exist by the time the API serves requests.
Precedence:
1. ``CYCLONE_AUTH_DISABLED=1`` dev escape hatch. Flip the
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
synthetic admin user without checking credentials. Never raises.
2. Users table non-empty no-op.
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
(password >= 12 chars) create the admin and print confirmation.
Each env var can also be replaced by a ``*_FILE`` companion
(``CYCLONE_ADMIN_USERNAME_FILE`` / ``CYCLONE_ADMIN_PASSWORD_FILE``)
that points at a file on disk the standard Docker-secret pattern,
used in production to avoid embedding secrets in ``docker-compose.yml``.
``_FILE`` takes precedence when set.
4. Otherwise raise ``RuntimeError`` with a remediation hint that
points operators at ``python -m cyclone users create``.
"""
from __future__ import annotations
import os
from pathlib import Path
from sqlalchemy import select
from cyclone.auth import users
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
def _read_secret(env_var: str, file_var: str) -> str | None:
"""Read a secret from a ``*_FILE`` env var (Docker-secret pattern) first,
falling back to the plain env var. Returns None if neither is set.
"""
file_path = os.environ.get(file_var)
if file_path:
try:
return Path(file_path).read_text().strip()
except OSError as exc:
raise RuntimeError(
f"failed to read {file_var}={file_path}: {exc}"
) from exc
return os.environ.get(env_var)
def run() -> None:
"""Bootstrap the first admin user, or no-op.
See module docstring for behavior. Idempotent: safe to call on
every startup it short-circuits as soon as the users table is
non-empty.
"""
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
# Dev escape hatch — skip bootstrap entirely and tell the API
# to also short-circuit auth checks.
import cyclone.auth.deps as _deps
_deps.AUTH_DISABLED = True
return
username = _read_secret(
"CYCLONE_ADMIN_USERNAME", "CYCLONE_ADMIN_USERNAME_FILE"
)
password = _read_secret(
"CYCLONE_ADMIN_PASSWORD", "CYCLONE_ADMIN_PASSWORD_FILE"
)
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
# DB ``SessionLocal()`` raises "init_db() has not been called".
# Initialize here so ``serve``, ``users create``, and friends can
# all reach the DB without the operator having to know about
# migrations. Idempotent — no-op when the schema is already current.
from cyclone import db as _db
_db.init_db()
with SessionLocal()() as db:
existing = db.execute(select(User)).scalars().first()
if existing is not None:
return # users exist — nothing to bootstrap
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
"`python -m cyclone users create <username> --role admin`."
)
if len(password) < 12:
raise RuntimeError(
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
)
users.create(
db,
username=username,
password=password,
role=Role.ADMIN.value,
)
print(f"[cyclone] bootstrap admin user '{username}' created")
-183
View File
@@ -1,183 +0,0 @@
"""CLI subcommand: ``python -m cyclone users ...``.
Click-based to match the existing parse-837 / parse-835 convention in
``cyclone.cli``. Provides operator-side user management without going
through the admin HTTP API.
Subcommands
-----------
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
Create a user. If ``--password`` is omitted, prompts (with
confirmation) on the controlling terminal.
- ``users list`` tab-separated ``id / username / role / state``.
- ``users disable USERNAME`` set ``disabled_at`` to now.
- ``users reset-password USERNAME [--password PW]`` replace the hash.
- ``users set-role USERNAME --role {admin,user,viewer}`` change role.
Exit codes
----------
* 0 success
* 1 validation error (unknown username, duplicate username)
* 2 usage error (missing arg, bad role, short password, unknown
subcommand). Click itself uses 2 for usage errors so the conventional
shell tools (``set -e``, etc.) recognize them.
Passwords shorter than 12 chars are rejected everywhere they appear.
"""
from __future__ import annotations
import getpass
import sys
import click
from cyclone.auth import users
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
MIN_PASSWORD_LEN = 12
def _prompt_password(label: str) -> str:
pw = getpass.getpass(f"{label}: ")
if not pw:
click.echo("Password required.", err=True)
sys.exit(2)
return pw
def _validate_password(pw: str) -> None:
if len(pw) < MIN_PASSWORD_LEN:
click.echo(
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
err=True,
)
sys.exit(2)
# --------------------------------------------------------------------------- #
# Group
# --------------------------------------------------------------------------- #
@click.group(name="users")
def users_cli() -> None:
"""Manage Cyclone users from the command line."""
# --------------------------------------------------------------------------- #
# create
# --------------------------------------------------------------------------- #
@users_cli.command("create")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant the new user.",
)
@click.option(
"--password",
default=None,
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def create_user(username: str, role: str, password: str | None) -> None:
"""Create a new user with the given USERNAME and ROLE."""
pw = password or _prompt_password("Password")
_validate_password(pw)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
click.echo(f"User '{username}' already exists.", err=True)
sys.exit(1)
u = users.create(db, username=username, password=pw, role=role)
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
# --------------------------------------------------------------------------- #
# list
# --------------------------------------------------------------------------- #
@users_cli.command("list")
def list_users() -> None:
"""List all users as id / username / role / state."""
from cyclone.db import User
with SessionLocal()() as db:
for u in db.query(User).order_by(User.id.asc()).all():
state = "disabled" if u.disabled_at else "active"
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
# --------------------------------------------------------------------------- #
# disable
# --------------------------------------------------------------------------- #
@users_cli.command("disable")
@click.argument("username")
def disable_user(username: str) -> None:
"""Disable USERNAME (sets disabled_at to now)."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.disable(db, u.id)
click.echo(f"Disabled '{username}'.")
# --------------------------------------------------------------------------- #
# reset-password
# --------------------------------------------------------------------------- #
@users_cli.command("reset-password")
@click.argument("username")
@click.option(
"--password",
default=None,
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def reset_password(username: str, password: str | None) -> None:
"""Replace USERNAME's password."""
pw = password or _prompt_password("New password")
_validate_password(pw)
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_password(db, u.id, pw)
click.echo(f"Password reset for '{username}'.")
# --------------------------------------------------------------------------- #
# set-role
# --------------------------------------------------------------------------- #
@users_cli.command("set-role")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant.",
)
def set_role(username: str, role: str) -> None:
"""Change USERNAME's role."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_role(db, u.id, role)
click.echo(f"Role for '{username}' set to '{role}'.")
-140
View File
@@ -1,140 +0,0 @@
"""FastAPI dependencies for auth."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session as DbSession
from cyclone.auth import sessions, users
from cyclone.auth.permissions import Role, allowed_roles
from cyclone.db import SessionLocal
def _db():
db = SessionLocal()()
try:
yield db
finally:
db.close()
DbSessionDep = Annotated[DbSession, Depends(_db)]
AUTH_DISABLED = False
async def get_current_user(
request: Request,
db: DbSessionDep,
) -> dict:
"""Return the public User shape. Raises 401 if session is missing/expired.
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
user without checking credentials.
"""
if AUTH_DISABLED:
return {
"id": 0,
"username": "dev",
"role": Role.ADMIN.value,
"createdAt": None,
"disabledAt": None,
}
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
sess = sessions.get_valid(db, sid)
if sess is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
user = users.get(db, sess.user_id)
if user is None or user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="account_disabled",
)
# Sliding expiry: refresh both DB and cookie.
sessions.touch(db, sid)
request.state.user = user
request.state.session_id = sid
return users.to_public(user)
def require_role(*allowed: Role):
"""Dependency factory: gate the endpoint to specific roles.
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
"""
async def _dep(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
user_role = user.get("role")
if allowed:
if user_role not in {r.value for r in allowed}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
# Otherwise consult the matrix.
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
return _dep
async def matrix_gate(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
Behavior:
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
* No session cookie 401 from get_current_user.
* Endpoint not in the matrix 403 (fail-closed).
* User role not allowed for (method, path) 403.
* Empty allowed-roles set (e.g. /api/healthz) public, no role check.
Used as ``dependencies=[Depends(matrix_gate)]`` on every authenticated
route. Centralizing the gate here means the matrix is the source of
truth no need to wire per-route ``require_role(...)`` calls.
"""
if AUTH_DISABLED:
return user
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
user_role = user.get("role")
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
-108
View File
@@ -1,108 +0,0 @@
"""Role enum + PERMISSIONS matrix."""
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
WRITE_ROLES = {Role.ADMIN, Role.USER}
ADMIN_ONLY = {Role.ADMIN}
# (method, path-prefix) → allowed roles.
# Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths.
("GET", "/api/health"): set(),
("POST", "/api/auth/login"): set(),
# Auth surface.
("POST", "/api/auth/logout"): ALL_ROLES,
("GET", "/api/auth/me"): ALL_ROLES,
# Admin-only user management.
("GET", "/api/admin/users"): ADMIN_ONLY,
("POST", "/api/admin/users"): ADMIN_ONLY,
("PATCH", "/api/admin/users"): ADMIN_ONLY,
("DELETE", "/api/admin/users"): ADMIN_ONLY,
# Read endpoints (all authenticated roles).
("GET", "/api/claims"): ALL_ROLES,
("GET", "/api/remittances"): ALL_ROLES,
("GET", "/api/providers"): ALL_ROLES,
("GET", "/api/batches"): ALL_ROLES,
("GET", "/api/dashboard/summary"): ALL_ROLES,
("GET", "/api/activity"): ALL_ROLES,
("GET", "/api/inbox/lanes"): ALL_ROLES,
("GET", "/api/inbox/export.csv"): ALL_ROLES,
("GET", "/api/reconcile"): ALL_ROLES,
("GET", "/api/reconciliation"): ALL_ROLES,
("GET", "/api/acks"): ALL_ROLES,
("GET", "/api/ta1-acks"): ALL_ROLES,
("GET", "/api/277ca-acks"): ALL_ROLES,
("GET", "/api/batch-diff"): ALL_ROLES,
("GET", "/api/config"): ALL_ROLES,
("GET", "/api/payers"): ALL_ROLES,
("GET", "/api/audit-log"): ADMIN_ONLY,
# Clearhouse (SFTP creds + dzinesco identity) — admin only.
("GET", "/api/clearhouse"): ADMIN_ONLY,
("PATCH", "/api/clearhouse"): ADMIN_ONLY,
("POST", "/api/clearhouse/submit"): ADMIN_ONLY,
# Admin ops (audit log, backup, scheduler, db rotate, reload-config).
("GET", "/api/admin/audit-log"): ADMIN_ONLY,
("GET", "/api/admin/audit-log/verify"): ADMIN_ONLY,
("GET", "/api/admin/backup"): ADMIN_ONLY,
("POST", "/api/admin/backup"): ADMIN_ONLY,
("GET", "/api/admin/backup/scheduler"): ADMIN_ONLY,
("POST", "/api/admin/backup/scheduler"): ADMIN_ONLY,
("GET", "/api/admin/scheduler"): ADMIN_ONLY,
("POST", "/api/admin/scheduler"): ADMIN_ONLY,
("POST", "/api/admin/db/rotate-key"): ADMIN_ONLY,
("POST", "/api/admin/reload-config"): ADMIN_ONLY,
("GET", "/api/admin/validate-provider"): ADMIN_ONLY,
# Write endpoints (admin + user, no viewer).
("POST", "/api/parse-837"): WRITE_ROLES,
("POST", "/api/parse-835"): WRITE_ROLES,
("POST", "/api/parse-999"): WRITE_ROLES,
("POST", "/api/parse-ta1"): WRITE_ROLES,
("POST", "/api/parse-277ca"): WRITE_ROLES,
("POST", "/api/inbox"): WRITE_ROLES,
("POST", "/api/inbox/candidates"): WRITE_ROLES,
("POST", "/api/inbox/rejected"): WRITE_ROLES,
("POST", "/api/inbox/payer-rejected"): WRITE_ROLES,
("POST", "/api/reconcile"): WRITE_ROLES,
("POST", "/api/reconciliation"): WRITE_ROLES,
("POST", "/api/resubmit"): WRITE_ROLES,
("POST", "/api/acks"): WRITE_ROLES,
("POST", "/api/batches"): WRITE_ROLES, # /export-837 regenerates X12 from DB rows
("POST", "/api/eligibility"): WRITE_ROLES,
# CSV export — read-only.
("GET", "/api/export.csv"): ALL_ROLES,
}
def allowed_roles(method: str, path: str) -> set[Role] | None:
"""Return the set of roles allowed to call (method, path), or None if denied.
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
"""
candidates = [
(len(prefix), roles)
for (m, prefix), roles in PERMISSIONS.items()
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
]
if not candidates:
return None
candidates.sort(key=lambda x: -x[0])
return candidates[0][1]
-34
View File
@@ -1,34 +0,0 @@
"""Per-username login rate limiter (in-memory, per-process)."""
from __future__ import annotations
import time
from threading import Lock
WINDOW_SECONDS = 300
MAX_FAILS = 5
_FAILS: dict[str, list[float]] = {}
_LOCK = Lock()
def check(username: str) -> int:
"""Return retry-after seconds, or 0 if allowed."""
now = time.monotonic()
with _LOCK:
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
_FAILS[username] = fails
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
now = time.monotonic()
with _LOCK:
_FAILS.setdefault(username, []).append(now)
def reset(username: str) -> None:
with _LOCK:
_FAILS.pop(username, None)
-97
View File
@@ -1,97 +0,0 @@
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from cyclone.auth import rate_limit, sessions, users
from cyclone.auth.deps import get_current_user
from cyclone.db import SessionLocal
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "cyclone_session"
COOKIE_MAX_AGE = 86400 # 24h
def _is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
def _set_cookie(response: Response, sid: str, request: Request) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=sid,
max_age=COOKIE_MAX_AGE,
path="/api",
httponly=True,
samesite="lax",
secure=_is_https(request),
)
def _clear_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/api")
@router.post("/login")
def login(body: dict, request: Request, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username and password are required",
)
retry_after = rate_limit.check(username)
if retry_after > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="rate_limited",
headers={"Retry-After": str(retry_after)},
)
with SessionLocal()() as db:
user = users.get_by_username(db, username)
if user is None or not users.verify_password(password, user.password_hash):
rate_limit.record_failure(username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_credentials",
)
if user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="account_disabled",
)
sid, _ = sessions.create(db, user_id=user.id)
rate_limit.reset(username)
public = users.to_public(user)
_set_cookie(response, sid, request)
return public
@router.post("/logout")
def logout(
request: Request,
response: Response,
_user: dict = Depends(get_current_user),
):
sid = request.cookies.get(COOKIE_NAME)
if sid:
with SessionLocal()() as db:
sessions.delete(db, sid)
_clear_cookie(response)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
def me(user: dict = Depends(get_current_user)):
return user
-60
View File
@@ -1,60 +0,0 @@
"""Session create/validate/expire/touch."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from cyclone.db import Session
SESSION_LIFETIME = timedelta(hours=24)
def create(db, *, user_id: int) -> tuple[str, Session]:
sid = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
expires_at = now + SESSION_LIFETIME
sess = Session(
id=sid,
user_id=user_id,
expires_at=expires_at,
created_at=now,
)
db.add(sess)
db.commit()
db.refresh(sess)
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
sess.expires_at = expires_at
return sid, sess
def get_valid(db, sid: str) -> Session | None:
sess = db.execute(
select(Session).where(Session.id == sid)
).scalar_one_or_none()
if sess is None:
return None
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
if sess.expires_at.tzinfo is None:
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
if sess.expires_at <= datetime.now(timezone.utc):
return None
return sess
def delete(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
db.delete(sess)
db.commit()
def touch(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
db.commit()
-79
View File
@@ -1,79 +0,0 @@
"""User CRUD + bcrypt password hashing."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from passlib.hash import bcrypt
from sqlalchemy import select
from cyclone.db import User
def hash_password(plaintext: str) -> str:
return bcrypt.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
try:
return bcrypt.verify(plaintext, hashed)
except (ValueError, TypeError):
return False
def create(db, *, username: str, password: str, role: str) -> User:
user = User(
username=username,
password_hash=hash_password(password),
role=role,
created_at=datetime.now(timezone.utc),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_by_username(db, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
def get(db, user_id: int) -> User | None:
return db.get(User, user_id)
def disable(db, user_id: int) -> None:
user = db.get(User, user_id)
if user is None:
return
user.disabled_at = datetime.now(timezone.utc)
db.commit()
def update_role(db, user_id: int, role: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.role = role
db.commit()
def update_password(db, user_id: int, new_password: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.password_hash = hash_password(new_password)
db.commit()
def to_public(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"role": user.role,
"createdAt": user.created_at.isoformat() if user.created_at else None,
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
}
-492
View File
@@ -1,492 +0,0 @@
"""SP28: per-ACK auto-linker.
Three helpers, one per ACK kind, all run inside the same DB
transaction that persists the Ack row. Each returns a slice of
:class:`ClaimAckLinkRow` dataclasses describing what to link
the CALLER persists those rows via
:func:`cyclone.store.claim_acks.add_claim_ack` (which owns the
publish-from-store contract).
The two-pass join lives in
:func:`lookup_claims_for_ack_set_response` (D10): ST02 via the
batch envelope index (primary) + ``Claim.patient_control_number``
(fallback). Plus :func:`link_manual` for the manual-fallback
endpoint.
The helpers do NOT write to the DB session they are pure
readers over the session + parse result + the supplied
``batch_envelope_index`` / ``pc_claim_lookup`` / ``batch_lookup``
closures. This matches the existing
``cyclone.inbox_state.apply_999_rejections`` pattern and lets the
store facade own the publish-from-store contract for live-tail.
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
§3 for the per-AK2 granularity, the two-pass join, and the
idempotency contract enforced by ``ux_claim_acks_dedup``.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Optional
from sqlalchemy.orm import Session
from cyclone.db import Batch, Claim
log = logging.getLogger(__name__)
@dataclass
class ClaimAckLinkRow:
"""One row to insert into ``claim_acks``.
Populated by the helpers; persisted by the caller via
:func:`cyclone.store.claim_acks.add_claim_ack`. Carries every
column the store needs to build the ORM row + the
``claim_ack_written`` event payload.
"""
claim_id: Optional[str] = None
batch_id: Optional[str] = None
ak2_index: Optional[int] = None
set_control_number: Optional[str] = None
set_accept_reject_code: Optional[str] = None
@dataclass
class ClaimAckLinkResult:
"""Outcome of a single helper call.
``linked`` is the list of :class:`ClaimAckLinkRow` rows the
caller should persist via ``cycl_store.add_claim_ack``.
``orphans`` is a list of free-form strings the join couldn't
resolve for 999/277CA these are ``set_control_number``
values; for TA1 they're the TA1 ICN when no matching batch was
found.
"""
linked: list[ClaimAckLinkRow] = field(default_factory=list)
orphans: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# D10 two-pass join
# ---------------------------------------------------------------------------
def lookup_claims_for_ack_set_response(
session: Session,
set_control_number: str,
*,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
) -> list[Claim]:
"""Two-pass join for a single AK2 set_response / 277CA claim_status.
D10 (spec): for a 999 AK2-2 ``set_control_number`` or a 277CA REF*1K
``payer_claim_control_number``, return every claim this ack
acknowledges. The primary join is
``Batch.envelope.control_number == set_control_number`` (== source
837's ST02 on Gainwell batches); the fallback is
``Claim.patient_control_number == set_control_number``.
Returns 0..N matching claims (one-ack-to-many when one 837 batch
shipped multiple claims under one ST02).
Args:
session: SQLAlchemy session the caller owns.
set_control_number: the AK2-2 / REF*1K value to resolve.
batch_envelope_index: optional pre-built index that maps
``Batch.envelope.control_number`` ``batch.id`` (built
once per ingest via ``store.batch_envelope_index``).
Pass to skip the per-set-response ``Batch`` scan in Pass 1.
pc_claim_lookup: optional pre-built callable that maps a PCN
to a single claim. Falls back to a session-wide query
when not supplied.
Pass 1 wins. The two paths cannot both fire for the same
``set_control_number`` if Pass 1 returns one or more claims,
Pass 2 is skipped. This is the false-positive guard from
spec §7.
"""
if not set_control_number:
return []
# -- Pass 1: Batch.envelope.control_number primary --------------
# Accept either a plain dict (the common case — built once per
# ingest via ``store.batch_envelope_index``) or a callable for
# test-side closures. Normalize to ``idx.get`` so the rest of
# the function stays uniform.
idx: Optional[Callable[[str], Optional[str]]] = None
if batch_envelope_index is not None:
if callable(batch_envelope_index):
idx = batch_envelope_index
else:
idx = batch_envelope_index.get
matched_ids: list[str] = []
if idx is not None:
batch_id = idx(set_control_number)
if batch_id is not None:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == batch_id)
.all()
)
]
else:
# Fallback: scan all batches once per call. Slow but correct;
# callers SHOULD pass the index.
rows = (
session.query(Batch.id, Batch.raw_result_json)
.filter(Batch.kind == "837p")
.all()
)
for bid, raw in rows:
env = (raw or {}).get("envelope") or {}
if env.get("control_number") == set_control_number:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == bid)
.all()
)
]
break
if matched_ids:
claims = (
session.query(Claim)
.filter(Claim.id.in_(matched_ids))
.all()
)
if claims:
return list(claims)
# -- Pass 2: Claim.patient_control_number fallback ---------------
if pc_claim_lookup is not None:
single = pc_claim_lookup(set_control_number)
if single is not None:
return [single]
return []
matches = (
session.query(Claim)
.filter(Claim.patient_control_number == set_control_number)
.all()
)
return list(matches)
# ---------------------------------------------------------------------------
# Per-ACK helpers — walk the parsed result and produce ClaimAckLinkRow
# dataclasses. The CALLER persists via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event emission.
# ---------------------------------------------------------------------------
def _existing_link_claim_ids(
session: Session,
*,
claim_ids: list[str],
ack_kind: str,
ack_id: int,
) -> set[str]:
"""Return the subset of ``claim_ids`` that already have a link row.
Mirrors the partial unique index
``ux_claim_acks_dedup(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL``. The
pre-check is here so we skip ``session.add`` and avoid
IntegrityError log noise on re-ingest. For TA1 (claim_id IS
NULL) the helpers do their own check.
"""
if not claim_ids:
return set()
rows = (
session.query(Claim.claim_id) # placeholder; replaced below
if False else
session.query(Claim.id)
.filter(Claim.id.in_([]))
.all()
)
# Replace the placeholder with the real query — needed because
# ClaimAck is not imported above (avoids circular import).
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.claim_id)
.filter(
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id.in_(claim_ids),
)
.all()
)
return {cid for (cid,) in existing if cid is not None}
def apply_999_acceptances(
session: Session,
parsed_999,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every AK2 set-response, build one ``ClaimAckLinkRow`` per matched claim.
Both accepted AND rejected AK2s produce a link row (so the
ClaimDrawer panel can show the rejection inline via the
``set_accept_reject_code`` color-coded chip). Orphans are returned
but not linked.
Idempotent: rows the dedup index already covers (re-ingest of an
identical file) are skipped silently the pre-check is here to
avoid ``IntegrityError`` log noise.
One AK2 can produce multiple ``claim_acks`` rows when the source
837 batch carried more than one claim under a shared ST02 (rare
on this codebase but supported by D10 / the schema).
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per AK2-to-claim match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
set_responses = getattr(parsed_999, "set_responses", None) or []
# Resolve all set_control_numbers up front so we can do one
# batched dedup query per (ack_kind, ack_id).
resolved: list[tuple[int, "object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
for idx, sr in enumerate(set_responses):
scn = getattr(sr, "set_control_number", None) or ""
code = getattr(sr.set_accept_reject, "code", None) or ""
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
resolved.append((idx, sr, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="999",
ack_id=ack_id,
)
for idx, sr, claims, scn, code in resolved:
for claim in claims:
if claim.id in existing_ids:
continue
result.linked.append(ClaimAckLinkRow(
claim_id=claim.id,
batch_id=None,
ak2_index=idx,
set_control_number=scn,
set_accept_reject_code=code,
))
return result
def apply_277ca_acks(
session: Session,
parsed_277ca,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every ClaimStatus with a payer_claim_control_number, build a ClaimAckLinkRow.
Accepted AND rejected ClaimStatuses both link the
``set_accept_reject_code`` carries the STC category code. The
``claim_acks`` row is independent of the existing
``Claim.payer_rejected_at`` mutation from
:func:`cyclone.inbox_state_277ca.apply_277ca_rejections` (which
fires before this helper in the handler).
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per ClaimStatus match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
statuses = getattr(parsed_277ca, "claim_statuses", None) or []
resolved: list[tuple["object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
for status in statuses:
scn = getattr(status, "payer_claim_control_number", None) or ""
raw_code = getattr(status, "status_code", None) or ""
# ``status_code`` may be a category like "A6:19:PR" — keep
# the whole STC composite so the UI can render the
# category without re-parsing raw_json. Truncate to 8 chars
# (column width).
code = (raw_code or "")[:8]
if not scn:
# No REF*1K — orphan. Surface the STC composite so the
# operator can correlate via the ack's raw_json.
orphan_key = code or "(no REF*1K)"
result.orphans.append(orphan_key)
continue
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
resolved.append((status, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="277ca",
ack_id=ack_id,
)
for status, claims, scn, code in resolved:
for claim in claims:
if claim.id in existing_ids:
continue
result.linked.append(ClaimAckLinkRow(
claim_id=claim.id,
batch_id=None,
ak2_index=None,
set_control_number=scn,
set_accept_reject_code=code or None,
))
return result
def apply_ta1_envelope_link(
session: Session,
parsed_ta1,
*,
ack_id: int,
batch_lookup: Callable[[str, str], Optional[Batch]],
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""Build a TA1 envelope-level link row to the most-recent matching Batch.
TA1 is envelope-level only (ISA/IEA, no per-claim granularity).
The link row has ``claim_id IS NULL`` and ``batch_id`` populated.
Args:
parsed_ta1: a :class:`cyclone.parsers.models_ta1.ParseResultTa1`.
batch_lookup: ``(sender_id, receiver_id) -> Batch | None``.
The handler supplies a closure that walks
``session.query(Batch).order_by(parsed_at.desc())``. Returning
``None`` produces an orphan (no batch match).
Returns:
A :class:`ClaimAckLinkResult` with 0..1 row in ``linked``.
Idempotent via dedup on ``(ack_kind='ta1', ack_id)`` (claim_id
IS NULL so the partial unique index doesn't catch it; the
pre-check here is a Python-side query).
"""
result = ClaimAckLinkResult()
envelope = getattr(parsed_ta1, "envelope", None)
if envelope is None:
return result
ta1_obj = getattr(parsed_ta1, "ta1", None)
ack_code = getattr(ta1_obj, "ack_code", None) or ""
# Dedup: same (ack_kind, ack_id) → at most one TA1 envelope link.
# Done in Python because the partial unique index requires
# claim_id IS NOT NULL.
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.id)
.filter(
ClaimAck.ack_kind == "ta1",
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id.is_(None),
)
.first()
)
if existing is not None:
return result
batch = batch_lookup(envelope.sender_id or "", envelope.receiver_id or "")
if batch is None:
orphan_key = (
getattr(ta1_obj, "control_number", None)
or envelope.control_number
or ""
)
result.orphans.append(orphan_key)
return result
result.linked.append(ClaimAckLinkRow(
claim_id=None,
batch_id=batch.id,
ak2_index=None,
set_control_number=None,
set_accept_reject_code=ack_code,
))
return result
def link_manual(
session: Session,
*,
claim_id: str,
ack_kind: str,
ack_id: int,
set_control_number: Optional[str] = None,
set_accept_reject_code: Optional[str] = None,
ak2_index: Optional[int] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkRow:
"""Return one manual link row (the caller persists it via the store).
Used by ``POST /api/acks/{kind}/{ack_id}/match-claim``. Returns a
:class:`ClaimAckLinkRow` describing the row to insert. The caller
is responsible for persistence so it can own the publish-from-store
contract.
Idempotency: callers should pre-check via ``session.query(ClaimAck)
.filter(...).first()`` and skip when a row already exists; the
:class:`cyclone.store.claim_acks.add_claim_ack` implementation also
re-checks via the partial unique index.
Raises ``LookupError`` when the referenced claim doesn't exist
(the caller maps that to 404).
"""
if ack_kind not in ("999", "277ca", "ta1"):
raise ValueError(f"link_manual: unknown ack_kind={ack_kind!r}")
claim = session.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
return ClaimAckLinkRow(
claim_id=claim_id,
batch_id=None,
ak2_index=ak2_index,
set_control_number=set_control_number,
set_accept_reject_code=set_accept_reject_code,
)
__all__ = [
"ClaimAckLinkResult",
"ClaimAckLinkRow",
"apply_999_acceptances",
"apply_277ca_acks",
"apply_ta1_envelope_link",
"link_manual",
"lookup_claims_for_ack_set_response",
]
-200
View File
@@ -24,7 +24,6 @@ stub secret and the paramiko auth will fail loudly at connect time.
from __future__ import annotations from __future__ import annotations
import asyncio
import io import io
import logging import logging
import os import os
@@ -41,59 +40,6 @@ from cyclone.providers import SftpBlock
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Per-op SFTP timeout (SP27 Task 8)
#
# paramiko is synchronous; without a timeout, a hung ``listdir_attr``
# freezes the worker thread indefinitely. The 06/25 silent hang was
# exactly this — the MFT server TCP-acked but stopped responding, the
# scheduler's ``asyncio.to_thread`` waited forever, and the operator
# had no signal that polling had stalled.
#
# The async wrappers below apply ``asyncio.wait_for`` to every SFTP
# call site so the event loop can give up after the configured bound.
# The bound is read fresh on every call (env-var-only, no module-level
# cache) so an operator who tunes the value at runtime picks it up on
# the next poll.
# ---------------------------------------------------------------------------
_DEFAULT_SFTP_OP_TIMEOUT_SECONDS = 30.0
def _op_timeout_seconds() -> float:
"""Per-op SFTP timeout from ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS``.
Default 30s. Picked to comfortably outlast Gainwell's p99
listdir_attr (~2s) while still surfacing real hangs inside one
scheduler tick. Operators who hit repeated timeouts should drop
this but the right answer is to fix the MFT server, not to
paper over it here.
"""
raw = os.environ.get("CYCLONE_SFTP_OP_TIMEOUT_SECONDS")
if not raw:
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
try:
value = float(raw)
except ValueError:
log.warning(
"CYCLONE_SFTP_OP_TIMEOUT_SECONDS=%r is not a float; using default %.1fs",
raw, _DEFAULT_SFTP_OP_TIMEOUT_SECONDS,
)
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
if value <= 0:
# ``asyncio.wait_for(timeout=0)`` raises immediately, and
# ``wait_for(timeout<0)`` is undefined per the asyncio docs.
# A zero/negative setting would silently turn every SFTP call
# into an instant timeout (a wave of bogus "list_inbound:
# timeout" errors). Treat the value as bad and fall back.
log.warning(
"CYCLONE_SFTP_OP_TIMEOUT_SECONDS=%r must be positive; using default %.1fs",
raw, _DEFAULT_SFTP_OP_TIMEOUT_SECONDS,
)
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
return value
@dataclass @dataclass
class InboundFile: class InboundFile:
"""A single file observed in the inbound MFT path.""" """A single file observed in the inbound MFT path."""
@@ -141,70 +87,11 @@ class SftpClient:
dir and returns :class:`InboundFile` records pointing at the dir and returns :class:`InboundFile` records pointing at the
cache copy. The remote file is *not* deleted the operator cache copy. The remote file is *not* deleted the operator
archives inbound files in the MFT UI. archives inbound files in the MFT UI.
Gainwell's MFT puts advisory ``*_warn.txt`` files in the same
inbound path. They're text-format side-channel notes (not X12
envelopes) and are skipped at list time. Use
:meth:`list_inbound_names` if you need the raw list without
the download.
""" """
if self._stub: if self._stub:
return self._list_inbound_stub() return self._list_inbound_stub()
return self._list_inbound_paramiko() return self._list_inbound_paramiko()
def list_inbound_names(self) -> list[InboundFile]:
"""Lightweight listing: returns metadata only, no file download.
Use this when you want to filter the inbound set (e.g. by date)
before paying the download cost. Pair with
:meth:`download_inbound` to fetch a filtered subset on demand.
Real mode is implemented as a single SFTP ``listdir_attr`` call
sub-second on Gainwell's MFT — versus the full
:meth:`list_inbound` which downloads every file. The
``*_warn.txt`` advisory files are filtered out the same way.
Stub mode returns the same as :meth:`list_inbound` (the stub
only knows about local files; no download cost).
"""
if self._stub:
return self._list_inbound_stub()
return self._list_inbound_names_paramiko()
def download_inbound(self, f: InboundFile) -> Path:
"""Download a single inbound file to its ``local_path``.
Idempotent: if ``f.local_path`` already exists and is
non-empty, the download is skipped. Callers should use the
``InboundFile`` returned by :meth:`list_inbound_names` and pass
it back here ``local_path`` is the planned cache location
and matches the path the scheduler will read from.
Returns:
The on-disk path (same as ``f.local_path``).
Raises:
FileNotFoundError: if the file is missing locally in stub
mode, or if the remote file disappears between list
and download in real mode.
"""
if f.local_path.exists() and f.local_path.stat().st_size > 0:
log.debug(
"SFTP: %s already cached at %s, skipping download",
f.name, f.local_path,
)
return f.local_path
if self._stub:
# Stub mode: no remote — the file is supposed to already be
# at f.local_path (operator-dropped). If it isn't there, the
# operator hasn't seeded the stub; raise loudly.
if not f.local_path.is_file():
raise FileNotFoundError(
f"inbound stub file not found: {f.local_path}"
)
return f.local_path
return self._download_inbound_paramiko(f)
def read_file(self, remote_path: str) -> bytes: def read_file(self, remote_path: str) -> bytes:
"""Read bytes from a remote path. """Read bytes from a remote path.
@@ -232,37 +119,6 @@ class SftpClient:
return secrets.STUB_SECRET return secrets.STUB_SECRET
return value return value
# ---- Async surface (SP27 Task 8) -----------------------------------
#
# Every sync SFTP call has an async wrapper that runs the paramiko
# call on a worker thread and applies an ``asyncio.wait_for(...
# timeout=N)`` around it. The wait_for cancels the awaiter but
# leaves the worker thread running until paramiko returns on its
# own (paramiko is not asyncio-aware, so we can't cancel the
# underlying socket cleanly). The scheduler should treat
# ``asyncio.TimeoutError`` from these wrappers as a transient
# SFTP error and surface it in ``Scheduler.status()`` (Task 9).
#
# The timeout is read on every call (see ``_op_timeout_seconds``)
# so an operator who tunes ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` at
# runtime sees the new value on the next tick.
async def async_list_inbound(self) -> list["InboundFile"]:
"""Async-wrapped :meth:`list_inbound` with a per-op timeout.
The 06/25 silent hang was a hung ``listdir_attr`` that froze
the worker thread indefinitely. This wrapper applies
``asyncio.wait_for(...)`` so the event loop can give up after
``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` (default 30s) and the
scheduler tick can surface the timeout in
``result.errors`` (and, once Task 9 lands, in
``Scheduler.status()``).
"""
return await asyncio.wait_for(
asyncio.to_thread(self.list_inbound),
timeout=_op_timeout_seconds(),
)
# ---- Stub implementations (SP9) ------------------------------------- # ---- Stub implementations (SP9) -------------------------------------
def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path: def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path:
@@ -428,12 +284,6 @@ class SftpClient:
if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000: if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
# Directory entry — skip. # Directory entry — skip.
continue continue
if attr.filename.endswith("_warn.txt"):
# Gainwell's MFT drops text-format advisory notes in
# the same inbound path. They're side-channel noise,
# not X12 envelopes — skip at list time so we don't
# download ~600 advisory files per poll.
continue
remote = f"{inbound_dir.rstrip('/')}/{attr.filename}" remote = f"{inbound_dir.rstrip('/')}/{attr.filename}"
cache_path = cache_dir / attr.filename cache_path = cache_dir / attr.filename
# Download into cache. We use ``prefetch`` to keep memory # Download into cache. We use ``prefetch`` to keep memory
@@ -449,56 +299,6 @@ class SftpClient:
)) ))
return files return files
def _list_inbound_names_paramiko(self) -> list[InboundFile]:
"""List inbound names via paramiko; do NOT download (lightweight).
Same ``listdir_attr`` iteration as
:meth:`_list_inbound_paramiko`, but the returned
:class:`InboundFile` records have ``local_path`` set to the
planned cache location without actually fetching the file.
Pair with :meth:`_download_inbound_paramiko` to fetch on
demand. Skips ``*_warn.txt`` advisory files the same way.
"""
with self._connect() as (ssh, sftp):
inbound_dir = self._block.paths.get("inbound", "/")
staging = Path(self._block.staging_dir).resolve()
inbound_rel = inbound_dir.lstrip("/")
cache_dir = staging / inbound_rel
cache_dir.mkdir(parents=True, exist_ok=True)
files: list[InboundFile] = []
try:
attrs = sftp.listdir_attr(inbound_dir)
except IOError as exc:
log.warning("SFTP: cannot list %s: %s", inbound_dir, exc)
return []
for attr in sorted(attrs, key=lambda a: a.filename):
if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
# Directory entry — skip.
continue
if attr.filename.endswith("_warn.txt"):
continue
cache_path = cache_dir / attr.filename
files.append(InboundFile(
name=attr.filename,
size=attr.st_size or 0,
modified_at=datetime.fromtimestamp(attr.st_mtime or 0),
local_path=cache_path,
))
return files
def _download_inbound_paramiko(self, f: InboundFile) -> Path:
"""Download a single ``f`` to ``f.local_path`` (idempotent on size>0)."""
with self._connect() as (ssh, sftp):
inbound_dir = self._block.paths.get("inbound", "/")
remote = f"{inbound_dir.rstrip('/')}/{f.name}"
f.local_path.parent.mkdir(parents=True, exist_ok=True)
with sftp.open(remote, "rb") as src, open(f.local_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=64 * 1024)
log.info("SFTP: downloaded %d bytes for %s", f.local_path.stat().st_size, f.name)
return f.local_path
def _read_file_paramiko(self, remote_path: str) -> bytes: def _read_file_paramiko(self, remote_path: str) -> bytes:
with self._connect() as (ssh, sftp): with self._connect() as (ssh, sftp):
buf = io.BytesIO() buf = io.BytesIO()
+2 -638
View File
@@ -74,18 +74,6 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
ctx.ensure_object(dict) ctx.ensure_object(dict)
# Register the auth users subgroup. Imported here (not at module top) to
# avoid pulling passlib / bcrypt at CLI parse-only import time.
from cyclone.auth.cli import users_cli # noqa: E402
main.add_command(users_cli)
# Register the dev seed subcommand. Imported here for the same lazy-load
# reason as users_cli — keeps passlib/bcrypt + SQLAlchemy out of the
# parse-only path so ``python -m cyclone --help`` stays snappy.
from cyclone.seed_cli import seed_cli # noqa: E402
main.add_command(seed_cli)
@main.command("parse-837") @main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path)) @click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
@@ -297,95 +285,8 @@ def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------- if __name__ == "__main__":
# SP32: `cyclone backfill-rendering-npi` (Task 6) main()
#
# Re-parses on-disk 837p/835 files and patches up the typed NPI columns
# (``Claim.rendering_provider_npi`` / ``Remittance.rendering_provider_npi``)
# for rows that were ingested before the T4 writers took effect.
# ---------------------------------------------------------------------------
@main.command("backfill-rendering-npi")
@click.option(
"--file", "files",
multiple=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help=(
"Path to a specific file to re-parse. May be passed multiple times. "
"Mutually informative with --input-dir (both are processed)."
),
)
@click.option(
"--input-dir",
type=click.Path(file_okay=False, path_type=Path),
default=None,
help=(
"Directory to scan one level deep for *.txt / *.edi / *.x12 files. "
"Honors CYCLONE_BACKFILL_INPUT_DIR if --input-dir is not passed."
),
)
@click.option(
"--type", "transaction_type",
type=click.Choice(["837p", "835"]),
default=None,
help=(
"Pin the parser to use. Without --type, each file's transaction "
"kind is sniffed (filename hint + ISA/ST prefix)."
),
)
@click.option(
"--log-level",
default="INFO",
show_default=True,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
)
def backfill_rendering_npi(
files: tuple[Path, ...],
input_dir: Path | None,
transaction_type: str | None,
log_level: str,
) -> None:
"""Re-parse on-disk X12 files to populate the rendering NPI columns.
Idempotent: rows whose ``rendering_provider_npi`` is already non-NULL
are left untouched. After patching, runs :func:`cyclone.reconcile.run`
once across every 835 batch so the T5 scoring arm can fire
retroactively on the touched pairs.
Exit code: 0 on a successful run (zero populated rows is still exit 0).
"""
from cyclone import db as _db
from cyclone.store.backfill import backfill_rendering_provider_npi
# SP18: re-run setup so per-command --log-level overrides the
# group default. ``setup_logging`` is idempotent.
setup_logging(level=log_level)
# The backup pattern: each CLI subcommand that touches the DB
# initializes it explicitly so ``python -m cyclone.cli backup list``
# works on a fresh machine with no prior app boot.
_db.init_db()
# --input-dir falls back to CYCLONE_BACKFILL_INPUT_DIR.
if input_dir is None and not files:
env_dir = os.environ.get("CYCLONE_BACKFILL_INPUT_DIR", "").strip()
if env_dir:
input_dir = Path(env_dir)
summary = backfill_rendering_provider_npi(
files=list(files) if files else None,
input_dir=input_dir,
transaction_type=transaction_type,
)
# One-line summary so operators can grep a log scrape quickly.
click.echo(
f"claims_updated={summary.claims_updated} "
f"remits_updated={summary.remits_updated} "
f"files_processed={summary.files_processed} "
f"files_skipped={summary.files_skipped}"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -399,375 +300,6 @@ def backfill_rendering_npi(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# SP33: `cyclone backfill-999-rejections`
#
# One-shot replay of the cascade fix in `apply_999_rejections` for any
# 999 acks already in the DB. Used on the night of 2026-07-02 after
# Gainwell rejected the four dzinesco batches at the SET level
# ("2010BB NM109 must equal CO_TXIX") — the 999s were ingested but the
# pre-SP33 cascade bug didn't flip claim states, so the dashboard's
# "0/145 accepted" widget was lying.
#
# Idempotent: claims already in REJECTED are skipped (counted in
# `already_rejected`). Safe to re-run.
# ---------------------------------------------------------------------------
@main.command("backfill-999-rejections")
@click.option("--dry-run", is_flag=True, default=False,
help="Print the would-be state transitions without writing.")
@click.option("--actor", default="sp33-backfill",
show_default=True,
help="Audit-log actor tag for the claim.rejected events.")
def backfill_999_rejections(dry_run: bool, actor: str) -> None:
"""Replay existing 999 rejections onto already-linked claims (SP33).
Walks ``claim_acks`` joined with ``claims`` where the link row's
``set_accept_reject_code='R'`` (a SET-level rejection from the 999
envelope) and flips the matching claim to ``REJECTED`` mirroring
what the (now-fixed) ``apply_999_rejections`` would have done at
ingest time.
Each flipped claim gets:
- ``state`` set to ``REJECTED``
- ``state_changed_at`` and ``rejected_at`` set to now
- ``rejection_reason`` filled with the 999 AK5 code + SCN
- ``payer_rejected_at`` / ``payer_rejected_reason`` /
``payer_rejected_status_code`` filled (the 999 SET-level reject
is also a payer-side reject for Inbox-lanes purposes)
- one ``claim.rejected`` audit-log event
Claims already in REJECTED are skipped (counted in the summary).
"""
from datetime import datetime, timezone
from sqlalchemy import select, func
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
db_mod.init_db()
now = datetime.now(timezone.utc)
with db_mod.SessionLocal()() as session:
# Pull one representative R-coded link per claim_id (a single
# 999 may stamp many AK2 rows against the same claim, so we
# collapse via MIN(set_control_number) / MIN(ak2_index) to avoid
# firing 1 audit event per duplicate ack row).
#
# 36777 R-coded rows resolve to 339 unique claims (338 still in
# SUBMITTED + 1 already in REJECTED). Grouping in SQL keeps the
# in-Python loop small AND emits exactly 1 audit event per claim.
rows = session.execute(
select(
db_mod.Claim.id,
db_mod.Claim.state,
func.min(db_mod.ClaimAck.set_control_number).label("scn"),
func.min(db_mod.ClaimAck.ak2_index).label("ak2"),
func.min(db_mod.Ack.ack_code).label("ack_code"),
)
.join(db_mod.ClaimAck, db_mod.ClaimAck.claim_id == db_mod.Claim.id)
.join(db_mod.Ack, db_mod.Ack.id == db_mod.ClaimAck.ack_id)
.where(db_mod.ClaimAck.set_accept_reject_code == "R")
.where(db_mod.ClaimAck.claim_id.is_not(None))
.group_by(db_mod.Claim.id, db_mod.Claim.state)
).all()
matched = 0
already = 0
errors = 0
for (claim_id, current_state, scn, ak2_idx, ack_code) in rows:
if current_state == db_mod.ClaimState.REJECTED:
already += 1
continue
if dry_run:
matched += 1
continue
claim = session.get(db_mod.Claim, claim_id)
if claim is None:
errors += 1
continue
claim.state = db_mod.ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = (
f"999 AK5={ack_code or 'R'} SCN={scn} ak2={ak2_idx}"
)
# Mirror the 999 SET-level reject into the payer-rejected
# lane so the Inbox sees it as a Payer-Rejected claim too.
claim.payer_rejected_at = now
claim.payer_rejected_reason = f"999 SET-level reject at SCN={scn}"
claim.payer_rejected_status_code = "R"
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=claim_id,
payload={"source": "backfill-999-rejections",
"scn": scn, "ak2_index": ak2_idx,
"ack_code": ack_code},
actor=actor,
))
matched += 1
if not dry_run:
session.commit()
click.echo(
f"matched={matched} already_rejected={already} errors={errors} "
f"dry_run={dry_run}"
)
# ---------------------------------------------------------------------------
# SP33: `cyclone resubmit-rejected-claims`
#
# Push the corrected single-claim 837 files to Gainwell's SFTP ToHPE
# dir so dzinesco can resubmit the batch. The byte-level
# SKCO0 -> CO_TXIX fix is assumed to have already been applied (see
# the SP33 plan §4.4 / `docs/ingest/corrected/`). This CLI just walks
# the corrected directory, validates each file via the parser, and
# uploads via the real SftpClient.
#
# Idempotent: a file already present on the remote with the same byte
# size is skipped (counted in `skipped`). Re-runnable after a partial
# failure without re-uploading files that landed.
# ---------------------------------------------------------------------------
@main.command("resubmit-rejected-claims")
@click.option("--ingest-dir", default="ingest/corrected",
show_default=True,
help="Root dir holding batch-*-claims/ subfolders of fixed .x12 files.")
@click.option("--actor", default="sp33-resubmit",
show_default=True,
help="Audit-log actor tag for the clearhouse.submitted events.")
@click.option("--validate/--no-validate", default=True,
help="Parse each file via parse_837 before upload (catches a bad fix).")
@click.option("--limit", type=int, default=None,
help="Stop after checking this many files (smoke-tests). Counts "
"all attempts, not just successful uploads.")
@click.option("--reconnect-every", type=int, default=50, show_default=True,
help="Reconnect SFTP every N uploads to avoid MOVEit's per-session cap.")
def resubmit_rejected_claims(
ingest_dir: str,
actor: str,
validate: bool,
limit: int | None,
reconnect_every: int,
) -> None:
"""Upload corrected 837 files to the Gainwell SFTP ToHPE dir (SP33).
Walks every ``batch-*-claims/*.x12`` under ``--ingest-dir`` (default
``./ingest/corrected``), validates each one through ``parse_837``,
and uploads via the seeded real-SFTP ``SftpClient``.
Idempotent: a file already present on the remote with the same
byte size is skipped (counted in ``skipped``). Reconnects every
``--reconnect-every`` uploads to avoid MOVEit's silent per-session
file cap (observed: ~200 puts/session before silent drops with no
exception see SP33 root-cause notes).
Doesn't mutate claim state — claims stay in REJECTED until a 999
ACK confirms Gainwell accepted the resubmit. Emits one
``clearhouse.submitted`` audit event per successful upload.
"""
import time
from cyclone import db as db_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.parsers.parse_837 import parse as parse_837_text
from cyclone.parsers.payer import PayerConfig
from cyclone.store import store as cycl_store
db_mod.init_db()
cycl_store.ensure_clearhouse_seeded()
clearhouse = cycl_store.get_clearhouse()
if clearhouse is None:
click.echo("No clearhouse seeded; cannot resolve SFTP block.", err=True)
sys.exit(2)
sftp_block = clearhouse.sftp_block
if sftp_block.stub:
click.echo("Clearhouse SFTP block is in stub mode; refusing to upload.", err=True)
sys.exit(2)
remote_root = sftp_block.paths["outbound"]
root = Path(ingest_dir).resolve()
if not root.exists():
click.echo(f"--ingest-dir does not exist: {root}", err=True)
sys.exit(2)
files: list[Path] = []
for batch_dir in sorted(root.glob("batch-*-claims")):
files.extend(sorted(p for p in batch_dir.glob("*.x12")
if not p.name.startswith("._")))
if not files:
click.echo(f"No .x12 files under {root}", err=True)
sys.exit(2)
click.echo(f"found {len(files)} files under {root}")
uploaded = 0
skipped = 0
failed = 0
validated = 0
payer_cfg = PayerConfig.co_medicaid()
start = time.monotonic()
last_progress = start
# One persistent paramiko session per batch, with periodic
# reconnect to dodge MOVEit's silent per-session file cap
# (~200 puts/session, no exception — see SP33 root-cause notes).
import paramiko
from cyclone.secrets import get_secret
def _open_session() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]:
pw = get_secret(sftp_block.auth.get("password_keychain_account", ""))
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
sftp_block.host, port=sftp_block.port,
username=sftp_block.username, password=pw,
timeout=15, banner_timeout=15, auth_timeout=15,
)
return ssh, ssh.open_sftp()
def _close_session(ssh: paramiko.SSHClient | None) -> None:
if ssh is None:
return
try: ssh.close()
except Exception: pass
ssh: paramiko.SSHClient | None = None
sftp: paramiko.SFTPClient | None = None
for i, src in enumerate(files, 1):
if limit is not None and i > limit:
break
content = src.read_bytes()
# Validate: parse must succeed AND payer_id must match the
# companion-guide CO_TXIX (catches a bad byte-fix early).
if validate:
try:
parsed = parse_837_text(content.decode(), payer_cfg)
except Exception as exc: # noqa: BLE001
failed += 1
click.echo(f"PARSE FAIL {src.name}: {exc.__class__.__name__}: {exc}", err=True)
continue
mismatch = next(
(c for c in parsed.claims if c.payer.id != "CO_TXIX"), None
)
if mismatch is not None:
failed += 1
click.echo(
f"PAYER MISMATCH {src.name}: payer.id={mismatch.payer.id!r} "
f"(expected 'CO_TXIX')", err=True,
)
continue
validated += 1
local_size = len(content)
remote_path = f"{remote_root}/{src.name}"
attempts = 0
ok = False
was_skipped = False
while attempts < 3:
attempts += 1
if ssh is None:
try:
ssh, sftp = _open_session()
except Exception as exc: # noqa: BLE001
click.echo(
f"SFTP CONNECT FAIL attempt {attempts}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
try:
# Idempotency check.
try:
rs = sftp.stat(remote_path) # type: ignore[union-attr]
if rs.st_size == local_size:
skipped += 1
was_skipped = True
break
except IOError:
pass # not on remote yet
sftp.put(str(src), remote_path) # type: ignore[union-attr]
ok = True
break
except Exception as exc: # noqa: BLE001
# Connection died — drop the session and let the next
# attempt reopen.
_close_session(ssh)
ssh, sftp = None, None
if attempts >= 3:
click.echo(
f"UPLOAD FAIL {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
continue
if not ok and not was_skipped:
failed += 1
continue
# Audit + reconnect cadence apply only to real uploads.
if not was_skipped:
# Audit (best-effort; if the DB is unavailable we still
# keep the file on the wire).
try:
with db_mod.SessionLocal()() as session:
append_event(session, AuditEvent(
event_type="clearhouse.submitted",
entity_type="claim_file",
entity_id=src.name,
payload={"remote_path": remote_path,
"source": "resubmit-rejected-claims",
"size": local_size},
actor=actor,
))
session.commit()
except Exception as exc: # noqa: BLE001
click.echo(
f"audit-log write failed for {src.name}: "
f"{exc.__class__.__name__}: {exc}", err=True,
)
uploaded += 1
# Reconnect periodically to dodge MOVEit's per-session cap.
if uploaded % reconnect_every == 0:
click.echo(f"reconnecting (after {uploaded} uploads)", err=True)
_close_session(ssh)
ssh, sftp = None, None
# Progress every 10s of wall-clock (or at end).
now = time.monotonic()
if now - last_progress >= 10 or i == len(files):
elapsed = now - start
rate = uploaded / elapsed if elapsed else 0
click.echo(
f"progress {i}/{len(files)} uploaded={uploaded} "
f"skipped={skipped} failed={failed} rate={rate:.2f}/s "
f"elapsed={elapsed:.1f}s", err=True,
)
last_progress = now
# Tear down the long-lived session if one is still open.
if ssh is not None:
_close_session(ssh)
elapsed = time.monotonic() - start
click.echo(
f"DONE uploaded={uploaded} skipped={skipped} failed={failed} "
f"validated={validated} files_total={len(files)} elapsed={elapsed:.1f}s"
)
@main.group() @main.group()
def backup() -> None: def backup() -> None:
"""Encrypted DB backup management (SP17).""" """Encrypted DB backup management (SP17)."""
@@ -1025,171 +557,3 @@ def backup_status() -> None:
snap = svc.status() snap = svc.status()
import json import json
click.echo(json.dumps(snap, indent=2, default=str)) click.echo(json.dumps(snap, indent=2, default=str))
# ---------------------------------------------------------------------------
# SP-N fix: `cyclone pull-inbound` — targeted inbound pull + process
#
# Mirrors POST /api/admin/scheduler/pull-inbound. For operators who
# want to drive the daily "process today's 999s" workflow from a cron
# job or shell script without going through the HTTP API.
#
# Exit codes:
# 0 — processed ≥1 file (or processed 0 with no matches: not an
# error, just nothing to do)
# 1 — unexpected exception
# 2 — SFTP / config error
# ---------------------------------------------------------------------------
@main.command("pull-inbound")
@click.option(
"--date", "date_str",
required=True,
help="Date filter YYYYMMDD — only files whose 8-digit timestamp "
"substring matches are downloaded and processed.",
)
@click.option(
"--block", "sftp_block_name",
default="dzinesco",
show_default=True,
help="Name of the SftpBlock in config/payers.yaml to use.",
)
@click.option(
"--file-types", "file_types_csv",
default=None,
help="Comma-separated whitelist (default: 999,TA1).",
)
@click.option(
"--limit", default=2000, show_default=True, type=int,
)
def pull_inbound(
date_str: str,
sftp_block_name: str,
file_types_csv: str | None,
limit: int,
) -> None:
"""List, date-filter, download, and process inbound MFT files.
Bypasses the alphabetical full-listing pass so a daily pull of
~400 files takes seconds, not hours. Files already in the local
cache are skipped (idempotent).
"""
import asyncio as _asyncio
from cyclone import db as db_mod
from cyclone import scheduler as scheduler_mod
from cyclone.edi.filenames import ALLOWED_FILE_TYPES
from cyclone.clearhouse import SftpClient
from cyclone.providers import SftpBlock
# Validate the date filter.
if not (len(date_str) == 8 and date_str.isdigit()):
click.echo(f"--date must be YYYYMMDD, got {date_str!r}", err=True)
sys.exit(2)
db_mod.init_db()
# Find the SftpBlock. The dzinesco clearhouse singleton is seeded
# by store.ensure_clearhouse_seeded() (SP9) and carries the
# production SFTP block; that's the only one we have at the
# moment. For multi-provider SFTP, a config-loader is the right
# thing — out of scope for this CLI which is the daily-pull path.
from cyclone import store as store_mod
store_mod.store.ensure_clearhouse_seeded()
clearhouse = store_mod.store.get_clearhouse()
if clearhouse is None or clearhouse.name != sftp_block_name:
# Fall back: try the named block, but v1 only ships the
# dzinesco singleton.
if clearhouse is None:
click.echo(
f"No clearhouse seeded — cannot find SftpBlock "
f"{sftp_block_name!r}.",
err=True,
)
sys.exit(2)
click.echo(
f"SftpBlock {sftp_block_name!r} not seeded; only "
f"{clearhouse.name!r} is available.",
err=True,
)
sys.exit(2)
block: SftpBlock = clearhouse.sftp_block
if file_types_csv:
wanted = {t.strip().upper() for t in file_types_csv.split(",") if t.strip()}
unknown = wanted - ALLOWED_FILE_TYPES
if unknown:
click.echo(
f"file_types {sorted(unknown)!r} not in {sorted(ALLOWED_FILE_TYPES)}",
err=True,
)
sys.exit(2)
else:
wanted = {"999", "TA1"}
# Wire up the scheduler singleton (re-use the same pipeline the
# HTTP endpoint uses, including the dedup via processed_inbound_files).
scheduler_mod.configure_scheduler(
block, sftp_block_name=sftp_block_name, force=True,
)
sched = scheduler_mod.get_scheduler()
client = SftpClient(block)
async def _run() -> dict:
from cyclone.edi.filenames import parse_inbound_filename
all_files = await _asyncio.to_thread(client.list_inbound_names)
matched: list = []
for f in all_files:
if f.name.find(date_str) == -1:
continue
try:
parsed = parse_inbound_filename(f.name)
except ValueError:
continue
if parsed.file_type not in wanted:
continue
matched.append(f)
if len(matched) >= limit:
break
download_errors: list[str] = []
downloaded = 0
for f in matched:
try:
await _asyncio.to_thread(client.download_inbound, f)
downloaded += 1
except Exception as exc: # noqa: BLE001
download_errors.append(f"{f.name}: {type(exc).__name__}: {exc}")
tick = await sched.process_inbound_files(matched)
return {
"listed": len(all_files),
"matched": len(matched),
"downloaded": downloaded,
"download_errors": download_errors,
"processed": tick.files_processed,
"skipped": tick.files_skipped,
"errored": tick.files_errored,
}
try:
summary = _asyncio.run(_run())
except Exception as exc: # noqa: BLE001
click.echo(f"pull-inbound failed: {type(exc).__name__}: {exc}", err=True)
sys.exit(1)
click.echo(
f"date={date_str} file_types={sorted(wanted)} block={sftp_block_name} "
f"listed={summary['listed']} matched={summary['matched']} "
f"downloaded={summary['downloaded']} processed={summary['processed']} "
f"skipped={summary['skipped']} errored={summary['errored']}"
)
if summary["download_errors"]:
click.echo("download errors:", err=True)
for e in summary["download_errors"]:
click.echo(f" {e}", err=True)
if __name__ == "__main__":
main()
+208 -124
View File
@@ -30,11 +30,11 @@ from sqlalchemy import (
Numeric, Numeric,
String, String,
Text, Text,
func,
text, text,
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
from sqlalchemy.types import TypeDecorator from sqlalchemy.types import TypeDecorator
from sqlalchemy.schema import PrimaryKeyConstraint
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db" DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
@@ -240,16 +240,21 @@ class Batch(Base):
class Claim(Base): class Claim(Base):
__tablename__ = "claims" __tablename__ = "claims"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Migration 0014: composite PRIMARY KEY (batch_id, id). Enables
# resubmits (same CLM01 in different batches) and makes the
# pre-flight dedup workflow exercisable. The composite PK is declared
# explicitly in __table_args__ below so the column order matches the
# migration (`PRIMARY KEY (batch_id, id)`).
id: Mapped[str] = mapped_column(String(64))
batch_id: Mapped[str] = mapped_column( batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False String(32),
ForeignKey("batches.id", ondelete="CASCADE"),
) )
patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False) patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True) service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True) service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
state: Mapped[ClaimState] = mapped_column( state: Mapped[ClaimState] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
@@ -292,60 +297,63 @@ class Claim(Base):
resubmit_count: Mapped[int] = mapped_column( resubmit_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0") Integer, nullable=False, default=0, server_default=text("0")
) )
# Back-reference to remittances. The composite pointer is split into
# matched_remittance_id + matched_remittance_batch_id. Migration 0014
# drops the SQL-level FK (composite FK to remittances(batch_id, id)
# cannot be declared as inline REFERENCES in SQLite; no ALTER CONSTRAINT).
# App-layer invariants in store.manual_match / manual_unmatch / dedup
# keep these consistent. matched_remittance_id points at
# remittances.id (a non-PK column under composite PK) — SQLAlchemy
# accepts this because FKs reference any column, not just PKs.
matched_remittance_id: Mapped[Optional[str]] = mapped_column( matched_remittance_id: Mapped[Optional[str]] = mapped_column(
# ORM policy: SET NULL on remittance delete. The claim may outlive its
# remittance during reversal/reimport flows; without this, deleting a
# remittance with matched claims raises IntegrityError.
# Note: 0001_initial.sql predates this policy (no ON DELETE clause).
# SQLite ignores FK direction by default unless PRAGMA foreign_keys=ON,
# so the inconsistency is benign there; the ORM is the source of truth
# for PostgreSQL/test environments with FK enforcement. Amendment to the
# migration is deferred to post-SQLite rollout.
String(64), String(64),
ForeignKey("remittances.id", ondelete="SET NULL"), ForeignKey("remittances.id", ondelete="SET NULL"),
nullable=True, nullable=True,
) )
matched_remittance_batch_id: Mapped[Optional[str]] = mapped_column(
String(32),
nullable=True,
)
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
batch: Mapped["Batch"] = relationship(back_populates="claims") batch: Mapped["Batch"] = relationship(back_populates="claims")
__table_args__ = ( __table_args__ = (
# Migration 0014: explicit composite PK column order (batch_id, id).
# SQLAlchemy's default composite-PK assembly is by declaration order,
# but `id` is declared first for readability; we override here so
# ``s.get(Claim, (batch_id, id))`` looks up by the right key order.
PrimaryKeyConstraint("batch_id", "id", name="pk_claims"),
# NOTE: no (batch_id, patient_control_number) unique constraint. # NOTE: no (batch_id, patient_control_number) unique constraint.
# X12 837P allows any number of CLM segments inside one 2000B # X12 837P allows any number of CLM segments inside one 2000B
# subscriber loop, so a single member routinely has multiple # subscriber loop, so a single member routinely has multiple
# claims in a single submission batch. Claim identity is provided # claims in a single submission batch. Claim identity is provided
# by ``id`` (= CLM01 claim_id), which is the primary key. # by the composite primary key (batch_id, id).
Index("ix_claims_state", "state"), Index("ix_claims_state", "state"),
Index("ix_claims_patient_control_number", "patient_control_number"), Index("ix_claims_patient_control_number", "patient_control_number"),
Index("ix_claims_service_date_from", "service_date_from"), Index("ix_claims_service_date_from", "service_date_from"),
# SP27 Task 11: matched-pair drift check (run at startup)
# scans ``WHERE matched_remittance_id IS NOT NULL``. Without
# this index it's a full claim scan. The reverse side
# (``ix_remittances_claim_id``) is added in 0007. Pure index
# (non-unique) — a claim without a match is fine, reversals
# leave the previous claim/claim match intact.
Index("ix_claims_matched_remittance_id", "matched_remittance_id"),
) )
class Remittance(Base): class Remittance(Base):
__tablename__ = "remittances" __tablename__ = "remittances"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Migration 0014: composite PRIMARY KEY (batch_id, id). Same rationale
# as Claim: enables resubmits (same CLP01 in different batches). The
# composite PK is declared explicitly in __table_args__ below so the
# column order matches the migration.
id: Mapped[str] = mapped_column(String(64))
batch_id: Mapped[str] = mapped_column( batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False String(32),
ForeignKey("batches.id", ondelete="CASCADE"),
) )
payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False) payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
# ORM policy: no ON DELETE (forward FK from Remittance → Claim). A claim # Forward FK from Remittance → Claim. As with Claim.matched_remittance_id,
# is unlikely to be deleted in normal flow (audit trail); if it ever is, # migration 0014 drops the SQL-level FK (cannot declare composite FK
# the application layer (T7 reconciliation) must clear this FK. The # inline in SQLite). App-layer invariants keep these consistent.
# migration predates this rationale; amendment deferred to post-SQLite
# rollout. SQLite without `PRAGMA foreign_keys=ON` does not enforce,
# so the divergence is benign in this engine.
claim_id: Mapped[Optional[str]] = mapped_column( claim_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("claims.id"), nullable=True String(64), ForeignKey("claims.id"), nullable=True
) )
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
status_code: Mapped[str] = mapped_column(String(4), nullable=False) status_code: Mapped[str] = mapped_column(String(4), nullable=False)
status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0")) total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
@@ -362,14 +370,24 @@ class Remittance(Base):
batch: Mapped["Batch"] = relationship(back_populates="remittances") batch: Mapped["Batch"] = relationship(back_populates="remittances")
cas_adjustments: Mapped[list["CasAdjustment"]] = relationship( cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
back_populates="remittance", cascade="all, delete-orphan" back_populates="remittance",
cascade="all, delete-orphan",
# Migration 0014: CasAdjustment has TWO FKs to remittances
# (remittance_id and remittance_batch_id). Tell SQLAlchemy to use
# remittance_id for the relationship (the batch_id side is just a
# denormalized copy used for the composite FK constraint).
foreign_keys="CasAdjustment.remittance_id",
) )
__table_args__ = ( __table_args__ = (
# Migration 0014: explicit composite PK column order (batch_id, id).
# See Claim.__table_args__ for the rationale — same as for Claim.
PrimaryKeyConstraint("batch_id", "id", name="pk_remittances"),
# NOTE: no (batch_id, payer_claim_control_number) unique constraint. # NOTE: no (batch_id, payer_claim_control_number) unique constraint.
# An 835 ERA can contain multiple CLP segments that share a # An 835 ERA can contain multiple CLP segments that share a
# payer_claim_control_number (e.g. reversals re-referencing the # payer_claim_control_number (e.g. reversals re-referencing the
# original PCN). Remittance identity is provided by ``id``. # original PCN). Remittance identity is provided by the composite
# PK (batch_id, id).
Index("ix_remittances_claim_id", "claim_id"), Index("ix_remittances_claim_id", "claim_id"),
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"), Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
Index("ix_remittances_status_code", "status_code"), Index("ix_remittances_status_code", "status_code"),
@@ -383,6 +401,15 @@ class CasAdjustment(Base):
remittance_id: Mapped[str] = mapped_column( remittance_id: Mapped[str] = mapped_column(
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
) )
# Migration 0014: remittances.id is no longer a single-column PK; we add
# the batch side of the composite FK to make the constraint declarative
# again. The SQL migration declares it as a real composite FK; here we
# use a plain column reference because the application always queries
# via remittance_id alone (the batch_id is denormalized for FK
# enforcement only — same value for every row with a given remittance_id).
remittance_batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("remittances.batch_id"), nullable=False
)
group_code: Mapped[str] = mapped_column(String(4), nullable=False) group_code: Mapped[str] = mapped_column(String(4), nullable=False)
reason_code: Mapped[str] = mapped_column(String(8), nullable=False) reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
@@ -393,7 +420,12 @@ class CasAdjustment(Base):
nullable=True, nullable=True,
) )
remittance: Mapped["Remittance"] = relationship(back_populates="cas_adjustments") remittance: Mapped["Remittance"] = relationship(
back_populates="cas_adjustments",
# See Remittance.cas_adjustments for the rationale: disambiguate
# between the two FKs from CasAdjustment to remittances.
foreign_keys="CasAdjustment.remittance_id",
)
__table_args__ = ( __table_args__ = (
Index("ix_cas_adjustments_remittance_id", "remittance_id"), Index("ix_cas_adjustments_remittance_id", "remittance_id"),
@@ -415,6 +447,10 @@ class ServiceLinePayment(Base):
remittance_id: Mapped[str] = mapped_column( remittance_id: Mapped[str] = mapped_column(
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
) )
# Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
remittance_batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("remittances.batch_id"), nullable=False
)
line_number: Mapped[int] = mapped_column(Integer, nullable=False) line_number: Mapped[int] = mapped_column(Integer, nullable=False)
procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False) procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False) procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
@@ -463,6 +499,10 @@ class LineReconciliation(Base):
claim_id: Mapped[str] = mapped_column( claim_id: Mapped[str] = mapped_column(
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
) )
# Migration 0014: see CasAdjustment.remittance_batch_id for the same rationale.
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("claims.batch_id"), nullable=False
)
claim_service_line_number: Mapped[Optional[int]] = mapped_column( claim_service_line_number: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True Integer, nullable=True
) )
@@ -501,9 +541,17 @@ class Match(Base):
String(64), ForeignKey("claims.id", ondelete="CASCADE"), String(64), ForeignKey("claims.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
# Migration 0014: batch side of the composite FK to claims.
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("claims.batch_id"), nullable=False
)
remittance_id: Mapped[str] = mapped_column( remittance_id: Mapped[str] = mapped_column(
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
) )
# Migration 0014: batch side of the composite FK to remittances.
remittance_batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("remittances.batch_id"), nullable=False
)
strategy: Mapped[str] = mapped_column(String(16), nullable=False) strategy: Mapped[str] = mapped_column(String(16), nullable=False)
matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column( prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
@@ -647,76 +695,6 @@ class Two77caAck(Base):
) )
# ---------------------------------------------------------------------------
# SP28: per-ACK auto-link join table (claim_acks)
# ---------------------------------------------------------------------------
class ClaimAck(Base):
"""One row per AK2 set-response / ClaimStatus / TA1 envelope link.
SP28. The durable record of "this ACK acknowledges this claim (or
set, or batch)". One 999 row carries many AK2s; one 277CA carries
many ClaimStatuses; each gets its own ClaimAck row so the operator
can answer "which claims does this ack acknowledge?" with a single
SELECT on ``claim_acks``.
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
§3.1 for the schema decisions (per-AK2 granularity, the two-pass
join, idempotency via the unique index).
"""
__tablename__ = "claim_acks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# FK to claims.id with ON DELETE CASCADE so removing a claim
# drops every link row referencing it. NULLable so TA1 envelope-level
# rows can populate ``batch_id`` instead (the table CHECK constraint
# requires at least one of the two).
claim_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=True,
)
# FK to batches.id (a Batch row in the 837 case, or the synthetic
# inbound-batch id when the ack arrived outside the SFTP pipeline).
batch_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=True,
)
# Discriminated union over acks / ta1_acks / two77ca_acks. No FK
# constraint because the three target tables are separate; the
# application enforces the discriminator + the matching row's id.
ack_id: Mapped[int] = mapped_column(Integer, nullable=False)
ack_kind: Mapped[str] = mapped_column(String(8), nullable=False)
ak2_index: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# The set_control_number the upstream ack ACTUALLY CARRIED
# (== source 837 ST02 for Gainwell batches). Preserved on the link
# row for orphan traceability — the join may have resolved the
# link via the PCN fallback instead, but the operator still sees
# the value the 999/277CA originally carried.
set_control_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
# AK5 code (A/E/R/X) for 999, STC category (A1/A2/A3/A4/A6/A7)
# for 277CA, envelope ack_code for TA1.
set_accept_reject_code: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
linked_by: Mapped[str] = mapped_column(String(16), nullable=False)
__table_args__ = (
Index("ix_claim_acks_claim_id", "claim_id"),
Index("ix_claim_acks_batch_id", "batch_id"),
Index("ix_claim_acks_ack", "ack_kind", "ack_id"),
# Mirror the dedup unique index declared in 0018_claim_acks.sql so
# ``Base.metadata.create_all`` (the test-time safety net) emits the
# same partial-unique constraint that the production migration runner
# applies. Without this a fresh in-memory test DB would not enforce
# idempotency at the DB layer.
Index(
"ux_claim_acks_dedup",
"claim_id", "ack_kind", "ack_id", "ak2_index",
unique=True,
sqlite_where=text("claim_id IS NOT NULL AND ak2_index IS NOT NULL"),
),
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SP11: tamper-evident hash-chained audit_log # SP11: tamper-evident hash-chained audit_log
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -748,11 +726,6 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False) prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
hash: Mapped[str] = mapped_column(String(64), nullable=False) hash: Mapped[str] = mapped_column(String(64), nullable=False)
# SP-auth: which authenticated user performed this action. Nullable
# so existing (pre-auth) rows and system-initiated events stay valid.
# NOT part of the hash chain — verify_chain must continue to work on
# legacy rows that pre-date this column.
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
__table_args__ = ( __table_args__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"), Index("idx_audit_log_entity", "entity_type", "entity_id"),
@@ -923,25 +896,136 @@ class ClearhouseORM(Base):
updated_at: Mapped[str] = mapped_column(String(32), nullable=False) updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
class User(Base): # =============================================================================
"""Auth user (admin / user / viewer).""" # Migration 0014: ORM `before_insert` events auto-populate the batch side
# of composite FKs.
# =============================================================================
#
# **What.** The four listeners below (``_match_before_insert``,
# ``_cas_before_insert``, ``_slp_before_insert``, ``_lr_before_insert``)
# run on every INSERT of a ``Match`` / ``CasAdjustment`` /
# ``ServiceLinePayment`` / ``LineReconciliation`` row. They look up the
# parent ``Claim`` / ``Remittance`` and copy its ``batch_id`` (and
# ``remittance_batch_id``) into the new row's composite-FK column.
#
# **Why.** After migration 0014, every child table has a composite FK to
# its parent: ``(batch_id, claim_id)`` for Match / LineReconciliation and
# ``(remittance_batch_id, remittance_id)`` for CasAdjustment /
# ServiceLinePayment. Both sides are ``NOT NULL`` at the SQL level, so
# every insert must supply ``batch_id``. Application code that creates
# these rows usually has the parent ``claim_id`` / ``remittance_id`` in
# scope (often as a string) but does not always have ``batch_id``
# readily available — the batch is created elsewhere in the same ingest
# transaction, or the parent is fetched by id alone. Threading
# ``batch_id`` through every call site would be error-prone. The events
# solve this transparently: caller passes the parent id as before, and
# the event fills in the batch side.
#
# **Lookup order** (preferred-to-fallback, to minimize round-trips):
# 1. ``session.new`` — pending parents added in this transaction
# (covers the common ingest/match flow where parent + child are
# added in the same session).
# 2. ``session.identity_map`` — already-persisted parents loaded
# earlier in this session.
# 3. ``session.execute(select(...))`` — DB query as a last resort.
# ``autoflush`` is OFF on our ``SessionLocal``, so this won't
# trigger an unintended flush of ``session.new``.
#
# **When it fails.** If the parent is in none of the above (e.g. the
# caller passed a parent id that was never added to the session and is
# not in the DB), the column stays NULL and the INSERT fails with
# ``IntegrityError: NOT NULL constraint failed: <table>.batch_id`` (or
# ``remittance_batch_id``). The error message is misleading — it looks
# like the column was forgotten, not that the parent id was wrong.
# Workarounds:
# * If the parent exists in another session, ``session.add(parent)``
# first (or use ``session.merge(parent)``) so the lookup finds it
# in ``session.new`` / ``session.identity_map``.
# * If you have the parent object in scope and know its ``batch_id``,
# set ``child.batch_id = ...`` explicitly — the event only fills
# in when the column is None, so explicit values win.
# * If the parent is on a different connection entirely, populate the
# column by hand before ``session.add(child)``.
#
# **Idempotency.** The events only act when ``target.batch_id is None``
# (or ``remittance_batch_id``). Application code may explicitly set
# either column — e.g., a bulk-loader that already has batch_id in hand
# skips the lookup.
#
# **Scope.** ``propagate=True`` so subclasses of these mappers (in
# tests, mainly) inherit the listeners. Production code does not use
# mapper inheritance for these tables.
__tablename__ = "users" from sqlalchemy import event as _sa_event # noqa: E402
from sqlalchemy.orm import Session as _Session # noqa: E402
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base): def _resolve_claim_batch_id(session: "_Session", claim_id: "str | None") -> "str | None":
"""Server-side auth session (HttpOnly cookie holds the id).""" """Look up a Claim's batch_id, preferring session-cached state to SQL."""
if claim_id is None:
return None
for obj in session.new:
if isinstance(obj, Claim) and obj.id == claim_id:
return obj.batch_id
for obj in session.identity_map.values():
if isinstance(obj, Claim) and obj.id == claim_id:
return obj.batch_id
from sqlalchemy import select as _select
return session.execute(
_select(Claim.batch_id).where(Claim.id == claim_id)
).scalar_one_or_none()
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True) def _resolve_remit_batch_id(session: "_Session", remittance_id: "str | None") -> "str | None":
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) """Look up a Remittance's batch_id, preferring session-cached state to SQL."""
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) if remittance_id is None:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) return None
for obj in session.new:
if isinstance(obj, Remittance) and obj.id == remittance_id:
return obj.batch_id
for obj in session.identity_map.values():
if isinstance(obj, Remittance) and obj.id == remittance_id:
return obj.batch_id
from sqlalchemy import select as _select
return session.execute(
_select(Remittance.batch_id).where(Remittance.id == remittance_id)
).scalar_one_or_none()
@_sa_event.listens_for(Match, "before_insert", propagate=True)
def _match_before_insert(mapper, connection, target):
if target.batch_id is None or target.remittance_batch_id is None:
session = _Session.object_session(target)
if session is None:
return
if target.batch_id is None:
target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
if target.remittance_batch_id is None:
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
@_sa_event.listens_for(CasAdjustment, "before_insert", propagate=True)
def _cas_before_insert(mapper, connection, target):
if target.remittance_batch_id is None:
session = _Session.object_session(target)
if session is None:
return
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
@_sa_event.listens_for(ServiceLinePayment, "before_insert", propagate=True)
def _slp_before_insert(mapper, connection, target):
if target.remittance_batch_id is None:
session = _Session.object_session(target)
if session is None:
return
target.remittance_batch_id = _resolve_remit_batch_id(session, target.remittance_id)
@_sa_event.listens_for(LineReconciliation, "before_insert", propagate=True)
def _lr_before_insert(mapper, connection, target):
if target.batch_id is None:
session = _Session.object_session(target)
if session is None:
return
target.batch_id = _resolve_claim_batch_id(session, target.claim_id)
+39 -12
View File
@@ -47,31 +47,58 @@ def _strip_comments(sql: str) -> str:
return "\n".join(lines) return "\n".join(lines)
def run(engine: sa.Engine) -> None: def _apply_migrations_until(engine: sa.Engine, target_version: int | None) -> None:
"""Apply any pending migrations. Idempotent.""" """Apply migrations up to ``target_version`` (inclusive).
migrations_dir = MIGRATIONS_DIR
if not migrations_dir.exists():
return # no migrations directory yet — fresh checkout, no-op
migration_files = sorted(migrations_dir.glob("*.sql"))
if not migration_files:
return
If ``target_version`` is ``None``, apply every pending migration. If a
migration's version is greater than ``target_version``, stop iterating.
Idempotent: migrations whose version is <= the current ``PRAGMA user_version``
are skipped.
"""
with engine.begin() as conn: with engine.begin() as conn:
current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0 current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
for path in migration_files: for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
sql = path.read_text() sql = path.read_text()
version = _migration_version(sql, path.name) version = _migration_version(sql, path.name)
if version <= current: if version <= current:
continue continue
if target_version is not None and version > target_version:
break
statements_sql = _strip_comments(sql) statements_sql = _strip_comments(sql)
statements = [s.strip() for s in statements_sql.split(";") if s.strip()] statements = [s.strip() for s in statements_sql.split(";") if s.strip()]
with engine.begin() as conn: with engine.begin() as conn:
for stmt in statements: for stmt in statements:
conn.exec_driver_sql(stmt) conn.exec_driver_sql(stmt)
conn.exec_driver_sql(f"PRAGMA user_version = {version}") conn.exec_driver_sql(f"PRAGMA user_version = {version}")
current = version current = version
def run(engine: sa.Engine) -> None:
"""Apply any pending migrations. Idempotent."""
if not MIGRATIONS_DIR.exists():
return # no migrations directory yet — fresh checkout, no-op
if not list(MIGRATIONS_DIR.glob("*.sql")):
return
_apply_migrations_until(engine, None)
def migrate_to_version(engine: sa.Engine, target_version: int) -> None:
"""Apply migrations up to and including ``target_version``. Idempotent.
Reads the current ``PRAGMA user_version`` and applies any migrations
whose version is in the range ``(current, target_version]``. Stops
before applying migrations with version > ``target_version`` so the
caller can seed data between N and N+1.
Useful for tests that need to seed data after migrations ``0001..N``
have applied but before ``N+1`` runs exercise a migration against
real, pre-existing rows rather than only verifying that post-migration
inserts round-trip.
Calling with ``target_version`` <= the current version is a no-op.
"""
if not MIGRATIONS_DIR.exists():
return
_apply_migrations_until(engine, target_version)
+20 -97
View File
@@ -4,15 +4,12 @@ SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide) https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send): Outbound (we send):
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext} {tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: tp11525703-837P-20260620132243505-1of1.x12 Example: 11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our FromHPE): Inbound (HPE sends to our ToHPE):
[Tt][Pp]{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12 TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Example: tp11525703-837P_M019048402-20260520231513488-1of1_999.x12 Example: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
(legacy / prodfiles may use uppercase TP; the regex is case-insensitive
on the prefix to accept both Gainwell's filer has used both
casings over time.)
Both use Mountain Time (MT) timestamps with 17-digit millisecond precision Both use Mountain Time (MT) timestamps with 17-digit millisecond precision
(yyyymmddhhmmssSSS = 4+2+2+2+2+2+3 = 17 digits). Sequence is always "1of1" (yyyymmddhhmmssSSS = 4+2+2+2+2+2+3 = 17 digits). Sequence is always "1of1"
@@ -31,59 +28,29 @@ from cyclone.providers import InboundFilename
# Regexes # Regexes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Outbound: tp11525703-837P-20260620132243505-1of1.x12 # Outbound: 11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# - tpid: 1+ digits # - tpid: 1+ digits
# - tx: 1+ alnum # - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS) # - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1" # - seq: literal "1of1"
# - ext: 1+ alnum # - ext: 1+ alnum
OUTBOUND_RE = re.compile( OUTBOUND_RE = re.compile(
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$" r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
) )
# Inbound: [Tt][Pp]11525703-837P_M019048402-20260520231513488-1of1_999.x12 # Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
# - prefix: literal "TP" or "tp" (case-insensitive — Gainwell's
# production filer has used both)
# - tpid: 1+ digits (inside TP<...>) # - tpid: 1+ digits (inside TP<...>)
# - orig_tx: 1+ uppercase alnum # - orig_tx: 1+ alnum
# - track: M + 1+ uppercase alnum (e.g. M019048402) — the M is # - track: M + 1+ alnum (e.g. M019048402) — the M is part of the
# part of the tracking value, not a separator. # tracking value, not a separator.
# - ts: 17 digits # - ts: 17 digits
# - seq: literal "1of1" # - seq: literal "1of1"
# - ft: 1+ uppercase alnum (e.g. 999, TA1, 271, 277, 277CA, # - ft: 1+ alnum (e.g. 999, TA1, 271, 277, 277CA, 820, 834, 835, ENCR)
# 820, 834, 835, ENCR)
#
# Case insensitivity is scoped to the ``TP`` prefix via ``(?i:TP)``
# — the rest of the pattern is case-sensitive so we still reject a
# stray ``837p`` or ``m019048402`` (they'd be invalid HCPF).
INBOUND_RE = re.compile( INBOUND_RE = re.compile(
r"^(?i:TP)(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)" r"^TP(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)"
r"-(?P<ts>\d{17})-1of1_(?P<file_type>[A-Z0-9]+)\.(?P<ext>x12)$" r"-(?P<ts>\d{17})-1of1_(?P<file_type>[A-Z0-9]+)\.(?P<ext>x12)$"
) )
# Inbound suffix-less form (SP27 Task 7): tp11525703-835_M019110219-20260525001606050-1of1.x12
# Gainwell's production filer has shipped this shorter form in addition
# to the spec form above — the 6/15-6/19 835 batch arrived this way and
# was silently dropped by the strict INBOUND_RE. The loose form omits
# the `_{file_type}.x12` suffix; the token between `-` and `_M` doubles
# as both `orig_tx` and `file_type` (since there's no separate suffix
# to disambiguate). Allowed values still must be in ALLOWED_FILE_TYPES
# — the suffix is optional, the type-set is not.
# - prefix: literal "TP" or "tp" (case-insensitive — same as INBOUND_RE)
# - tpid: 1+ digits
# - file_type: 3-5 char alphanumeric (covers 999, TA1, 835, 277CA, ENCR;
# capped at 5 to avoid swallowing the next `_M` token)
# - tracking: M + 1+ uppercase alnum
# - ts: 17 digits
# - seq: literal "1of1"
# - ext: literal "x12" (relaxing this would also relax the strict
# form's contract; out of scope for SP27 Task 7)
INBOUND_RE_LOOSE = re.compile(
r"^(?i:TP)(?P<tpid>\d+)-(?P<file_type>[A-Z0-9]{3,5})"
r"_(?P<tracking>M[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>x12)$"
)
ALLOWED_FILE_TYPES = frozenset({ ALLOWED_FILE_TYPES = frozenset({
"999", "TA1", "270", "271", "276", "277", "277CA", "278", "999", "TA1", "270", "271", "276", "277", "277CA", "278",
"820", "834", "835", "ENCR", "820", "834", "835", "ENCR",
@@ -112,8 +79,7 @@ def build_outbound_filename(
time in ``America/Denver`` is used. time in ``America/Denver`` is used.
Returns: Returns:
Filename like "tp11525703-837P-20260620132243505-1of1.x12" Filename like "11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Raises: Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -133,10 +99,7 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total # Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}" ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17 assert len(ts) == 17
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# we receive from HPE inbound (which uses uppercase TP) and the
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -147,49 +110,16 @@ def build_outbound_filename(
def parse_inbound_filename(name: str) -> InboundFilename: def parse_inbound_filename(name: str) -> InboundFilename:
"""Parse an inbound HCPF filename. """Parse an inbound HCPF filename.
Accepts both forms (Gainwell ships both):
* Spec form with ``_{file_type}.x12`` suffix:
``tp11525703-837P_M019048402-20260520231513488-1of1_999.x12``
* Suffix-less form (SP27 Task 7): the token between ``-`` and
``_M`` doubles as both ``orig_tx`` and ``file_type``:
``tp11525703-835_M019110219-20260525001606050-1of1.x12``
The strict form is tried first (preserves historical behavior for
every existing caller); the loose form is the fallback. The
``.x12`` extension and ``ALLOWED_FILE_TYPES`` set are enforced in
both forms.
Args: Args:
name: Filename like ``tp11525703-837P_M019048402-20260520231513488-1of1_999.x12`` name: Filename like "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
(case-insensitive on the ``TP`` prefix; both ``TP`` and
``tp`` are accepted.)
Returns: Returns:
InboundFilename with tpid, orig_tx, tracking, ts, file_type, ext. InboundFilename with tpid, orig_tx, tracking, ts, file_type, ext.
Raises: Raises:
ValueError: If the filename doesn't match either HCPF inbound ValueError: If the filename doesn't match the HCPF inbound format.
form, or if the derived file_type isn't in
ALLOWED_FILE_TYPES.
""" """
m = INBOUND_RE.match(name) m = INBOUND_RE.match(name)
if m:
file_type = m.group("file_type")
if file_type not in ALLOWED_FILE_TYPES:
raise ValueError(
f"file_type {file_type!r} not in allowed HCPF set: {sorted(ALLOWED_FILE_TYPES)}"
)
return InboundFilename(
tpid=m.group("tpid"),
orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"),
ts=m.group("ts"),
file_type=file_type,
ext=m.group("ext"),
)
# Fall back to the suffix-less form.
m = INBOUND_RE_LOOSE.match(name)
if not m: if not m:
raise ValueError(f"Not a valid HCPF inbound filename: {name!r}") raise ValueError(f"Not a valid HCPF inbound filename: {name!r}")
file_type = m.group("file_type") file_type = m.group("file_type")
@@ -199,7 +129,7 @@ def parse_inbound_filename(name: str) -> InboundFilename:
) )
return InboundFilename( return InboundFilename(
tpid=m.group("tpid"), tpid=m.group("tpid"),
orig_tx=m.group("file_type"), # preserve the historical shape orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"), tracking=m.group("tracking"),
ts=m.group("ts"), ts=m.group("ts"),
file_type=file_type, file_type=file_type,
@@ -218,12 +148,5 @@ def is_outbound_filename(name: str) -> bool:
def is_inbound_filename(name: str) -> bool: def is_inbound_filename(name: str) -> bool:
"""True if the given string matches either HCPF inbound form. """True if the given string matches the HCPF inbound filename regex."""
return INBOUND_RE.match(name) is not None
Accepts both the spec form (with ``_{file_type}.x12`` suffix) and
the suffix-less form (SP27 Task 7). Cheap fast-path check used by
callers that want to pre-filter a directory listing before invoking
:func:`parse_inbound_filename`; mirrors the parser's own fallback
so the two never disagree on what counts as an HCPF inbound file.
"""
return INBOUND_RE.match(name) is not None or INBOUND_RE_LOOSE.match(name) is not None
-113
View File
@@ -1,113 +0,0 @@
"""File-type handlers for inbound MFT files (SP27).
Each handler is a pure function that parses + persists + dispatches
events for one file type. The scheduler and the FastAPI endpoints
both delegate here; the ``HANDLERS`` registry maps ``file_type``
handler function.
Public API:
HandleResult dataclass returned by every handler
HANDLERS ``{"999": handle_999, "835": handle_835, ...}``
handle_999, handle_ta1, handle_277ca, handle_835
call signatures: ``handle(text: str, source_file: str) -> HandleResult``
The handlers own their own DB session lifecycle. They emit pubsub
events via the optional ``event_bus`` parameter (the FastAPI endpoint
injects ``app.state.event_bus``; the scheduler passes ``None``).
They never raise on per-segment problems; per-segment issues are
logged and folded into the result. Whole-document failures
(missing ISA, bad encoding) surface as ``CycloneParseError``, which
the caller catches and records as ``STATUS_ERROR``.
"""
from __future__ import annotations
import importlib
import logging
from ._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
two77ca_synthetic_source_batch_id,
)
from .handle_result import HandleResult
log = logging.getLogger(__name__)
# Module-level HANDLERS dict populated lazily once handler modules
# ship. Keys are file_type strings, values are the ``handle``
# callable for that type.
HANDLERS: dict[str, object] = {}
# Candidate handlers, in registration order. Each tuple is
# (module-path, file_type). The 277/277CA mapping is added explicitly
# after registration when the 277CA handler is present.
_CANDIDATES: list[tuple[str, str]] = [
("cyclone.handlers.handle_999", "999"),
("cyclone.handlers.handle_ta1", "TA1"),
("cyclone.handlers.handle_277ca", "277CA"),
("cyclone.handlers.handle_835", "835"),
]
def register_handlers() -> None:
"""Populate ``HANDLERS`` from the per-type handler modules.
Tolerates missing or broken handler modules so the package can be
imported incrementally as each handler ships (Tasks 2-5), and so
a partial-modification error in one handler module can't break
scheduler / API import. Safe to call multiple times.
"""
if HANDLERS:
return
for mod_path, file_type in _CANDIDATES:
try:
mod = importlib.import_module(mod_path)
fn = getattr(mod, "handle")
except Exception as exc: # noqa: BLE001 — best-effort registry
log.debug(
"handler %s unavailable: %s",
mod_path, exc,
)
continue
HANDLERS[file_type] = fn
# The 277 filename maps to the same 277CA handler.
if file_type == "277CA":
HANDLERS["277"] = fn
register_handlers()
# Re-export handler functions on this package so callers can use the
# flat import (``from cyclone.handlers import handle_999``) once each
# module ships. Set after registration so we know what's present.
def _reexport_handlers() -> None:
"""Re-export each handler's ``handle`` fn as ``handle_<type>``.
No-op for absent handlers. Re-run on each import so freshly-
installed handler modules (e.g. Tasks 2-5 commits) are visible
after ``register_handlers()`` without a process restart.
"""
for file_type, fn in list(HANDLERS.items()):
if file_type in ("277",): # alias of 277CA; don't re-export twice
continue
globals()[f"handle_{file_type.lower()}"] = fn
_reexport_handlers()
__all__ = [
"HANDLERS",
"HandleResult",
"register_handlers",
"ack_count_summary",
"ack_synthetic_source_batch_id",
"two77ca_synthetic_source_batch_id",
# handler_* names are added at module load via _reexport_handlers
]
# Populate __all__ with the present handler symbols.
for _h in ("handle_999", "handle_ta1", "handle_277ca", "handle_835"):
if _h in globals():
__all__.append(_h) # noqa: PYI056
-87
View File
@@ -1,87 +0,0 @@
"""Ack ID helpers shared between the scheduler and the FastAPI
endpoints (SP27 Task 1).
Lifted from ``scheduler.py:_ack_count_summary`` +
``_ack_synthetic_source_batch_id`` + ``_277ca_synthetic_source_batch_id``
all three had inline copies in both ``scheduler.py`` and ``api.py``
prior to this SP. Both callers now import from this module.
Helpers
-------
``ack_count_summary(result)``
Aggregate ``(received, accepted, rejected, ack_code)`` from a
parsed ``ParseResult999``, trusting set-level IK5 over the
functional-group AK9. Gainwell's MFT ships AK9 segments that
contradict the per-set IK5 (e.g. ``AK9*A*1*1*1`` with
``IK5*A``), so trusting AK9's rejected count over-reports.
See scheduler commit ``6507a8c`` for the operational context.
``ack_synthetic_source_batch_id(icn, *, pcn, source_filename)``
Build a unique-per-file ``batches.id`` for a 999 that ships
without its own source batch. Falls back to a hash suffix of
the filename so daily pulls don't all collapse onto the same
ICN. Gainwell's MFT ships every 999 with the default ICN
``000000001`` (per the scheduler docstring).
``two77ca_synthetic_source_batch_id(icn)``
Same idea for a 277CA without its own source batch.
"""
from __future__ import annotations
import hashlib
from typing import Any
def ack_count_summary(result: Any) -> tuple[int, int, int, str]:
"""Aggregate ``(received, accepted, rejected, ack_code)`` from
a ``ParseResult999``.
Counts are derived from the set-level ``IK5`` responses
(one per ``AK2`` in the 999), not the functional-group ``AK9``.
Gainwell's MFT ships contradictory AK9 segments; the per-set
IK5 is the authoritative per-claim accept/reject signal.
"""
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
rejected = received - accepted
if rejected == 0:
code = "A"
elif accepted == 0:
code = "R"
else:
code = "P"
return (received, accepted, rejected, code)
def ack_synthetic_source_batch_id(
interchange_control_number: str,
*,
pcn: str | None = None,
source_filename: str | None = None,
) -> str:
"""Synthetic ``batches.id`` for a received 999 with no source batch.
Precedence for the human-readable part of the id (column is
``VARCHAR(32)``):
1. ``999-{pcn}-{hash8}`` if ``AK2`` set_control_number is
present (the common case). 4 + 9 + 1 + 8 = 22 chars max.
2. ``999-{icn}-{hash8}`` if no AK2 (envelope-only 999).
3. ``999-{hash12}`` if no filename either (shouldn't happen
in production).
"""
short_hash = ""
if source_filename:
short_hash = hashlib.sha1(source_filename.encode("utf-8")).hexdigest()[:8]
if pcn and pcn.strip():
return f"999-{pcn.strip()}-{short_hash}"
icn = (interchange_control_number or "").strip() or "000000001"
if short_hash:
return f"999-{icn}-{short_hash}"
return f"999-{short_hash or icn}"
def two77ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Synthetic ``batches.id`` for a received 277CA with no source batch."""
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
@@ -1,152 +0,0 @@
"""Handle a 277CA Claim Acknowledgment file (SP27 Task 4).
Lifted verbatim from ``scheduler.py:_handle_277ca``. The handler owns
its own DB session, dispatches to ``parse_277ca_text``, persists the
277CA ack row, applies 277CA rejections to matched claims via
``inbox_state.apply_277ca_rejections``, and emits
``claim.payer_rejected`` audit events for each newly-stamped claim.
The actor tag (``"277ca-parser-scheduler"``) is preserved so the
audit log keeps tracing back to the same source after extraction.
Both the FastAPI endpoint and the scheduler path (re)use this module
the API migration drops the inline copy in Task 6.
``claim.rejected_after_remit`` audit emission (when a 277CA rejects
a claim that already has ``matched_remittance_id`` set) is deferred
to SP27 Task 13. Today the handler only emits ``claim.payer_rejected``.
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_277ca_acks
from cyclone.handlers._ack_id import two77ca_synthetic_source_batch_id
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a 277CA, persist ack + stamp payer-rejected claims.
Args:
text: Raw 277CA document bytes (decoded).
source_file: Filename the 277CA came from.
Returns:
``(parser_used, claim_count)`` tuple where ``claim_count`` is
the number of STC statuses in the file (one per claim).
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_277ca_ack``) owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg.
"""
try:
result = parse_277ca_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"277CA parse error: {exc}") from exc
icn = result.envelope.control_number
synthetic_id = two77ca_synthetic_source_batch_id(icn)
accepted = sum(
1 for s in result.claim_statuses if s.classification == "accepted"
)
paid = sum(
1 for s in result.claim_statuses if s.classification == "paid"
)
rejected = sum(
1 for s in result.claim_statuses if s.classification == "rejected"
)
pended = sum(
1 for s in result.claim_statuses if s.classification == "pended"
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
row = cycl_store.add_277ca_ack(
source_batch_id=synthetic_id,
control_number=icn,
accepted_count=accepted,
rejected_count=rejected,
paid_count=paid,
pended_count=pended,
raw_json=json.loads(result.model_dump_json()),
)
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
apply_result = apply_277ca_rejections(
session, result, claim_lookup=_lookup, two77ca_id=row.id,
)
if apply_result.matched:
for cid in apply_result.matched:
append_event(session, AuditEvent(
event_type="claim.payer_rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
actor="277ca-parser-scheduler",
))
# SP28: auto-link the 277CA ClaimStatus entries to claims (D10
# two-pass join). The helper builds dataclass rows; the
# caller persists each via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
link_result = apply_277ca_acks(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="277ca",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"277CA had %d orphan status entries (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_277ca", len(result.claim_statuses))
-107
View File
@@ -1,107 +0,0 @@
"""Handle an 835 ERA Remittance file (SP27 Task 5).
Lifted verbatim from ``scheduler.py:_handle_835``. The handler owns
its own state, dispatches to ``parse_835`` (raises
``CycloneParseError`` on bad EDI), runs the per-payer 835 validator,
stamps the validation report into ``result.summary``, and persists
the BatchRecord via ``cycl_store.add``.
The 835 handler is the largest of the four because the schema
covers per-claim remittances + CAS adjustments + validation.
``PAYER_FACTORIES_835`` lives in ``cyclone.api`` (it was always
intended to live in ``cyclone.payers`` a TODO pre-dating SP27
but moving it is out of Task 5 scope). We import it lazily inside
``handle`` to avoid a module-load-time cycle; the cyclic risk is
acceptable because api.py also imports scheduler lazily (inside its
lifespan handler).
Two-phase ingest closed in SP27 Task 10: the placeholder
``adjustment_amount`` is overwritten by reconcile in the same DB
session as the insert (before commit), so a reader never sees a
half-reconciled Remittance row. If reconcile raises, the whole
ingest rolls back and the scheduler records the per-file error.
``event_bus`` is best-effort and follows the same TODO(sp27-task-6)
sync/async gap as handle_999 / handle_ta1 / handle_277ca.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import BatchRecord
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse an 835, run validation, persist batch + remittances.
Args:
text: Raw 835 document bytes (decoded).
source_file: Filename the 835 came from.
Returns:
``(parser_used, claim_count)`` tuple. ``claim_count`` is the
number of CLP claims parsed; the BatchRecord's summary's
``passed`` / ``failed`` fields are derived from the
validator's ``report.passed`` flag.
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
Validation failures don't raise — they're stamped into
``summary.passed = 0``.
SP25: the 835 write path already publishes ``remittance_written``
via ``CycloneStore.add`` (SP21 split). The handler no longer
accepts an ``event_bus`` kwarg the ``ack_received`` publish
here was dead code anyway (the 835 event name is
``remittance_written``, not ``ack_received``).
"""
# TODO(sp27-pre-t5): move PAYER_FACTORIES_835 out of api.py into
# ``cyclone.payers`` to remove the lazy cyclic import below. The
# import works today because api.py also imports scheduler lazily.
from cyclone.api import PAYER_FACTORIES_835
config = PAYER_FACTORIES_835["co_medicaid_835"]()
try:
result = parse_835(text, config, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"835 parse error: {exc}") from exc
# Validation report (mirrors the API endpoint).
report = validate_835(result, config)
n = len(result.claims)
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, [
c.payer_claim_control_number for c in result.claims
]
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=source_file,
parsed_at=datetime.now(timezone.utc),
result=result,
)
cycl_store.add(rec)
return ("parse_835", len(result.claims))
-157
View File
@@ -1,157 +0,0 @@
"""Handle a 999 Implementation Acknowledgment file (SP27 Task 2).
Lifted verbatim from ``scheduler.py:_handle_999``. The handler owns
its own DB session, dispatches to ``parse_999_text``, applies 999
rejections to any matched claims via ``inbox_state.apply_999_rejections``,
persists the ack row, and returns a ``(parser_used, claim_count)``
tuple.
The actor tag (``"999-parser-scheduler"``) is preserved so the audit
log keeps tracing back to the same source after extraction. Both
the FastAPI endpoint and the scheduler path (re)use this module
see Task 6 for the API migration that drops the inline copy.
``claim_count`` mirrors ``parsed.received`` the count of AK2
(set-level) responses in the 999, which is the authoritative
per-claim accept/reject signal (see ``_ack_id`` for why AK9 is
ignored).
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_999_acceptances
from cyclone.handlers._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
)
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a 999, apply rejections, persist ack row.
Args:
text: Raw 999 document bytes (decoded).
source_file: Filename the 999 came from. Used to derive a
unique synthetic ``batches.id`` (see ``_ack_id``).
Returns:
``(parser_used, claim_count)`` tuple. Matches the scheduler
``_download_and_parse`` destructure; the HandleResult
migration happens in Task 7.
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_999_ack``) now owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg. Both the scheduler path (no bus) and the
FastAPI endpoint path (passes the bus to the store directly) see
the same row, the same event, and the same payload.
"""
try:
result = parse_999_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"999 parse error: {exc}") from exc
received, accepted, rejected, ack_code = ack_count_summary(result)
icn = result.envelope.control_number
pcn = (
result.set_responses[0].set_control_number
if result.set_responses else None
)
synthetic_id = ack_synthetic_source_batch_id(
icn, pcn=pcn, source_filename=source_file,
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
rejection_result = apply_999_rejections(
session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
)
if rejection_result.matched:
for cid in rejection_result.matched:
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id},
actor="999-parser-scheduler",
))
row = cycl_store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: auto-link the 999 AK2 set-responses to claims (D10 two-pass
# join). The PCN fallback only fires when the ST02 lookup misses
# (rare for Gainwell batches). Each created ClaimAck row is
# persisted via cycl_store.add_claim_ack so the publish-from-store
# contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
link_result = apply_999_acceptances(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="999",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"999 had %d orphan set refs (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_999", received)
@@ -1,37 +0,0 @@
"""Shared ``HandleResult`` dataclass for handlers (SP27).
Every per-file-type handler returns the same shape so the
scheduler's ``_handle_one`` and the FastAPI parse endpoints can
process them uniformly.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class HandleResult:
"""Outcome of one handler invocation.
Attributes:
parser_used: The parser name written to
``processed_inbound_files.parser_used`` (e.g. ``"parse_999"``)
and surfaced in the UI.
claim_count: The number of claim rows persisted (or batch
records, depending on the file type). For 999/TA1 this
is the receipt count; for 835 this is the per-claim
remittance count; for 277CA this is the per-claim
status count.
batch_id: The persisted batch id (when the handler creates a
row in ``batches``). ``None`` for handlers that persist
into per-file-type ack tables (999/TA1/277CA) rather
than into the unified ``batches`` table.
matched_count: For 835, the number of remits that were
matched to an existing claim by ``reconcile.match``.
Zero for other handlers.
"""
parser_used: str
claim_count: int
batch_id: str | None = None
matched_count: int = 0
-123
View File
@@ -1,123 +0,0 @@
"""Handle a TA1 Interchange Acknowledgment file (SP27 Task 3).
Lifted verbatim from ``scheduler.py:_handle_ta1``. The handler owns
its own DB session, dispatches to ``parse_ta1_text``, persists the
interchange ack row in ``ta1_acks``, and returns a
``(parser_used, claim_count)`` tuple.
Unlike the 999 handler, TA1 is envelope-only there is one TA1 per
ISA/IEA interchange, no set-level (AK2) or claim-level matching.
The claim_count is always 1 (one TA1 ack row per file).
The actor tag is implicit (no audit event here TA1 doesn't tag
anything in the activity log; it's an infrastructure-level ack).
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.claim_acks import apply_ta1_envelope_link
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a TA1, persist the interchange ack row.
Args:
text: Raw TA1 document bytes (decoded).
source_file: Filename the TA1 came from. Used for audit
attribution; the ``batches.id`` for TA1 rows is derived
internally from the parsed envelope's control number
(``TA1-{ICN}``).
Returns:
``(parser_used, claim_count)`` tuple. TA1 always returns
``claim_count=1`` (one TA1 ack row per interchange file).
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_ta1_ack``) owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg.
"""
try:
result = parse_ta1_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"TA1 parse error: {exc}") from exc
with db.SessionLocal()() as session:
ta1_ack_row = cycl_store.add_ta1_ack(
source_batch_id=result.source_batch_id,
control_number=result.ta1.control_number,
interchange_date=result.ta1.interchange_date,
interchange_time=result.ta1.interchange_time,
ack_code=result.ta1.ack_code,
note_code=result.ta1.note_code,
ack_generated_date=result.ta1.ack_generated_date,
sender_id=result.envelope.sender_id,
receiver_id=result.envelope.receiver_id,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: TA1 envelope-level link to the originating Batch. The
# closure here matches the most-recent Batch whose envelope
# sender_id/receiver_id matches the TA1 — see spec §D4.
def _batch_lookup(sender_id, receiver_id):
rows = (
session.query(db.Batch)
.filter(
db.Batch.kind == "837p",
db.Batch.raw_result_json.isnot(None),
)
.order_by(db.Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
link_result = apply_ta1_envelope_link(
session, result, ack_id=ta1_ack_row.id,
batch_lookup=_batch_lookup,
)
# Snapshot rows before closing the work session — SQLite +
# concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=ta1_ack_row.id,
ack_kind="ta1",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"TA1 had %d orphan envelope refs (no matching batch): %s",
len(orphans),
orphans[:5],
)
return ("parse_ta1", 1)
-87
View File
@@ -175,82 +175,6 @@ def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dic
return matched_counts, total_lines_by_claim return matched_counts, total_lines_by_claim
def _ack_summary_for_claims(
session: Session, claim_ids: list[str],
) -> dict[str, dict]:
"""Build a {claim_id: {total, rejected, items: [...]}} map for 999 acks.
SP29: the Inbox `rejected` lane needs to render AK2 evidence
inline per row, plus a per-row Resubmit button. The data lives
in ``claim_acks`` (SP28) we don't want to N+1 fetch per row,
so the whole rejected-claim set is summarized in one batched
query here.
Filters to ``ack_kind='999'`` because the rejected lane is the
999 envelope reject lane; the 277CA STC A4/A6/A7 evidence flows
through the ``payer_rejected_*`` fields on a separate lane and
isn't part of this scope (see SP29 spec D4 / scope).
Returns:
``{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}``
Claims with zero linked 999 acks are NOT in the returned
dict the caller maps via ``.get(cid)`` and treats absence
as "no 999 acks linked" (renders as ``null`` in the
payload, ``999 not linked`` in the UI).
Args:
session: SQLAlchemy session the caller owns.
claim_ids: list of claim.id values to summarize. Typically
the rejected-lane claim ids. Empty list empty dict.
"""
if not claim_ids:
return {}
from cyclone.db import ClaimAck # late import — DB model registered
rows = (
session.query(
ClaimAck.claim_id,
ClaimAck.ack_id,
ClaimAck.set_control_number,
ClaimAck.set_accept_reject_code,
ClaimAck.ak2_index,
ClaimAck.linked_at,
)
.filter(
ClaimAck.claim_id.in_(claim_ids),
ClaimAck.ack_kind == "999",
)
.order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc())
.all()
)
grouped: dict[str, list[tuple]] = {}
for cid, aid, scn, code, ak2i, lat in rows:
grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat))
rejected_codes = {"R", "E", "X"}
out: dict[str, dict] = {}
for cid, items in grouped.items():
total = len(items)
rejected_count = sum(1 for it in items if (it[2] or "") in rejected_codes)
# Keep 5 most recent items for the chip column. The full count
# is in ``total`` so the UI can show ``+N more`` honestly.
trimmed = items[:5]
out[cid] = {
"total": total,
"rejected": rejected_count,
"items": [
{
"ack_id": aid,
"set_control_number": scn,
"set_accept_reject_code": code or "",
"ak2_index": ak2i,
"linked_at": _isoformat(lat),
}
for (aid, scn, code, ak2i, lat) in trimmed
],
}
return out
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
lanes = Lanes() lanes = Lanes()
dismissed = set(dismissed_pairs) dismissed = set(dismissed_pairs)
@@ -268,17 +192,6 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
), ),
)) ))
# SP29: attach the 999 ack-evidence summary (total / rejected /
# 5 most recent AK2 set_responses) to every rejected row so the
# Inbox can render AK2 chips inline + a per-row Resubmit button
# without an extra round-trip. One batched query, keyed off the
# rejected-claim id set.
rejected_ack_summary = _ack_summary_for_claims(
session, [r["id"] for r in lanes.rejected]
)
for row in lanes.rejected:
row["claim_acks"] = rejected_ack_summary.get(row["id"])
# --- Payer-Rejected (SP10) --- # --- Payer-Rejected (SP10) ---
# Distinct from the 999 envelope "rejected" lane above. A claim # Distinct from the 999 envelope "rejected" lane above. A claim
# lands here when a 277CA STC category code is A4/A6/A7 (rejected # lands here when a 277CA STC category code is A4/A6/A7 (rejected
+4 -25
View File
@@ -33,56 +33,35 @@ def apply_999_rejections(
parsed_999, parsed_999,
*, *,
claim_lookup: Callable[[str], Claim | None], claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result: ) -> Apply999Result:
"""For each set response with code R, E, or X, look up the matching claim and """For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims. move it to REJECTED. Idempotent on already-rejected claims.
Args: Args:
session: SQLAlchemy session. session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses). parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number Claim or None. claim_lookup: callable from patient_control_number Claim or None.
Legacy fallback; rarely hits when batch_envelope_index is present.
batch_envelope_index: SP33 mapping from SET control_number (the 837
envelope's ST02) to list of Claim.id for the claims in that SET.
Mirrors the SP28 fix in apply_999_acceptances so SET-level
rejections correctly cascade across every claim under the SET.
Returns: Returns:
Apply999Result with lists of matched claim ids and orphan PCNs. Apply999Result with lists of matched claim ids and orphan PCNs.
""" """
result = Apply999Result() result = Apply999Result()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses: for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code code = sr.set_accept_reject.code
if code not in ("R", "E", "X"): if code not in ("R", "E", "X"):
continue continue
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level claim = claim_lookup(sr.set_control_number)
# rejection correctly flips every claim in the SET. Fall back to if claim is None:
# the legacy claim_lookup when the index is empty for this SCN.
candidate_ids = index.get(sr.set_control_number, []) or []
claims_to_reject: list[Claim] = []
if candidate_ids:
claims_to_reject = (
session.query(Claim)
.filter(Claim.id.in_(candidate_ids))
.all()
)
else:
legacy = claim_lookup(sr.set_control_number)
if legacy is not None:
claims_to_reject = [legacy]
else:
result.orphans.append(sr.set_control_number) result.orphans.append(sr.set_control_number)
continue continue
for claim in claims_to_reject:
if claim.state == ClaimState.REJECTED: if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate. # Idempotent: don't double-mutate.
continue continue
claim.state = ClaimState.REJECTED claim.state = ClaimState.REJECTED
claim.state_changed_at = now claim.state_changed_at = now
claim.rejected_at = now claim.rejected_at = now
@@ -1,31 +0,0 @@
-- version: 13
-- Auth (SP-auth): users + sessions tables.
--
-- `users` holds the local credential store: bcrypt-hashed password,
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
-- (disabled_at) so admins can revoke access without losing history.
--
-- `sessions` holds the server-side session rows; the browser only
-- carries an opaque token cookie (cyclone_session) that points here.
-- expires_at index lets us cheaply reap stale sessions.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
disabled_at TEXT
);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
@@ -1,19 +1,9 @@
-- version: 15 -- version: 13
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims. -- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
--
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but -- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
-- the constraint is inline in CREATE TABLE, so the drop was a no-op. -- the constraint is inline in CREATE TABLE, so the drop was a no-op.
-- The only way to remove an inline UNIQUE in SQLite is table recreation. -- The only way to remove an inline UNIQUE in SQLite is table recreation.
-- --
-- Discovery 2026-06-23: the inline UNIQUE does NOT exist in the current
-- production DB at user_version=14 (or in main's fresh-DB schema). The
-- 32 "Duplicate claim" warnings in /tmp/cyclone-uvicorn.log are PK
-- collisions on claims.id (CLM01) when an operator re-uploads the same
-- file — not UNIQUE violations. This migration is therefore a defensive
-- no-op against the current schema, but keeps the 0003 intent alive
-- (drop the constraint if it ever reappears) and lets the SP22 spec
-- ship as designed.
--
-- X12 837P allows any number of CLM segments per 2000B subscriber loop; -- X12 837P allows any number of CLM segments per 2000B subscriber loop;
-- claim identity is provided by the primary key (claims.id = CLM01). -- claim identity is provided by the primary key (claims.id = CLM01).
-- The remittances table had a parallel constraint already removed in 0003 -- The remittances table had a parallel constraint already removed in 0003
@@ -24,11 +14,6 @@
-- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT. -- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT.
-- PRAGMA defer_foreign_keys defers FK checks to commit, which is the -- PRAGMA defer_foreign_keys defers FK checks to commit, which is the
-- only way to drop a referenced table inside a transaction in SQLite. -- only way to drop a referenced table inside a transaction in SQLite.
-- Other tables referencing claims:
-- remittances.claim_id
-- matches.claim_id
-- line_reconciliations.claim_id
-- activity_events.claim_id
PRAGMA defer_foreign_keys = ON; PRAGMA defer_foreign_keys = ON;
@@ -1,10 +0,0 @@
-- version: 14
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
--
-- Backwards-compatible: existing rows get NULL user_id (they were
-- written by the pre-auth `system` actor). Going forward, the FastAPI
-- get_current_user dependency injects the id into every audit log call.
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
@@ -0,0 +1,253 @@
-- version: 14
-- Relax PRIMARY KEYs on `claims` and `remittances` from single-column (id)
-- to composite (batch_id, id). Enables resubmits (same CLM01 / CLP01 in
-- different batches) and makes the pre-flight dedup workflow exercisable.
--
-- Strategy (mirrors 0013): table recreation with PRAGMA
-- defer_foreign_keys. We must recreate every table that has an FK pointing
-- at `claims(id)` or `remittances(id)` so that FK constraints can be
-- updated to point at the new composite PK.
--
-- FKs that need updating (verified via pragma_foreign_key_list on the
-- pre-migration schema):
-- - remittances.claim_id -> claims(id) becomes -> (batch_id, id)
-- - claims.matched_remittance_id -> remittances(id) becomes -> (batch_id, id)
-- - matches.claim_id -> claims(id) ON DELETE CASCADE becomes -> (batch_id, id)
-- - matches.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
-- - cas_adjustments.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
-- - service_line_payments.remittance_id -> remittances(id) ON DELETE CASCADE becomes -> (batch_id, id)
-- - line_reconciliations.claim_id -> claims(id) ON DELETE CASCADE becomes -> (batch_id, id)
--
-- The cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
-- can NOT be SQL-enforced with composite PKs because SQLite has no
-- ALTER TABLE ADD CONSTRAINT and DROP/ADD CONSTRAINT for an existing FK.
-- We keep them as plain TEXT columns (no REFERENCES clause) and rely on
-- application-layer invariants (store.manual_match / manual_unmatch,
-- dedup.preflight_*) to keep them consistent. The application code already
-- owns those paths; this is a deliberate trade-off documented in 0001's
-- comments ("amendment deferred to post-SQLite rollout").
--
-- The single-column FKs from claims/remittances DOWN to other tables
-- (matches, cas_adjustments, service_line_payments, line_reconciliations)
-- MUST be updated to composite FKs because those tables are recreated with
-- composite FK columns. We add a `batch_id` (or `remittance_batch_id`)
-- column to each and JOIN against the parent table to populate it during
-- the migration.
--
-- Why PRAGMA defer_foreign_keys (and not foreign_keys=OFF): the migration
-- runner wraps each .sql file in `engine.begin()` (a transaction). Inside a
-- transaction, `PRAGMA defer_foreign_keys = ON` defers FK checks to COMMIT,
-- which is the only safe way to drop a referenced table. `PRAGMA
-- foreign_keys` cannot be changed inside a transaction in SQLite, so the
-- defer_foreign_keys approach is what works inside the runner's transaction.
PRAGMA defer_foreign_keys = ON;
-- ============================================================================
-- Step 1: recreate `remittances` with composite PK (batch_id, id).
-- ============================================================================
-- Column shape: every column from 0001_initial.sql + claim_level_adjustment_amount
-- (added by 0006_line_reconciliation.sql). The `claim_id` column is kept
-- without an SQL-level FK — composite-FK to claims is enforced at the
-- application layer (see plan).
CREATE TABLE remittances_new (
id TEXT NOT NULL,
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
payer_claim_control_number TEXT NOT NULL,
claim_id TEXT,
status_code TEXT NOT NULL,
status_label TEXT,
total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,
total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,
patient_responsibility NUMERIC(12, 2),
adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
received_at DATETIME NOT NULL,
service_date DATE,
is_reversal INTEGER NOT NULL DEFAULT 0,
raw_json TEXT,
PRIMARY KEY (batch_id, id)
);
INSERT INTO remittances_new
SELECT id, batch_id, payer_claim_control_number, claim_id, status_code,
status_label, total_charge, total_paid, patient_responsibility,
adjustment_amount, claim_level_adjustment_amount, received_at,
service_date, is_reversal, raw_json
FROM remittances;
DROP TABLE remittances;
ALTER TABLE remittances_new RENAME TO remittances;
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
CREATE INDEX ix_remittances_status_code ON remittances(status_code);
-- ============================================================================
-- Step 2: recreate `claims` with composite PK (batch_id, id).
-- ============================================================================
-- Column shape: every column from 0013_drop_claims_unique_constraint.sql
-- (which itself consolidated 0001 + 0004 + 0008 + 0010), plus a new
-- `matched_remittance_batch_id` column. The back-reference to remittances
-- becomes a 2-column pointer (matched_remittance_id + matched_remittance_batch_id)
-- but the SQL-level FK is dropped (no ALTER CONSTRAINT in SQLite); see plan.
--
-- Column order MUST match the 0013 column order exactly so that
-- `INSERT INTO claims_new SELECT ... FROM claims` populates the right
-- columns. The trailing NULL fills the new matched_remittance_batch_id.
CREATE TABLE claims_new (
id TEXT NOT NULL,
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
patient_control_number TEXT NOT NULL,
service_date_from DATE,
service_date_to DATE,
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
provider_npi TEXT,
payer_id TEXT,
state TEXT NOT NULL DEFAULT 'submitted',
state_before_reversal TEXT,
-- matched_remittance_id: kept without an SQL-level FK (composite
-- back-reference requires the application layer; see plan header).
matched_remittance_id TEXT,
raw_json TEXT,
rejection_reason TEXT,
rejected_at TIMESTAMP,
resubmit_count INTEGER NOT NULL DEFAULT 0,
state_changed_at TIMESTAMP,
payer_rejected_at TEXT,
payer_rejected_reason TEXT,
payer_rejected_status_code TEXT,
payer_rejected_by_277ca_id TEXT,
payer_rejected_acknowledged_at TEXT,
payer_rejected_acknowledged_actor TEXT,
-- matched_remittance_batch_id: NEW. Always NULL on migration because
-- the pre-0014 schema only stored matched_remittance_id (single column).
-- App code populates it on manual_match / match_auto going forward.
matched_remittance_batch_id TEXT,
PRIMARY KEY (batch_id, id)
);
INSERT INTO claims_new
SELECT id, batch_id, patient_control_number, service_date_from,
service_date_to, charge_amount, provider_npi, payer_id, state,
state_before_reversal, matched_remittance_id, raw_json,
rejection_reason, rejected_at, resubmit_count, state_changed_at,
payer_rejected_at, payer_rejected_reason,
payer_rejected_status_code, payer_rejected_by_277ca_id,
payer_rejected_acknowledged_at, payer_rejected_acknowledged_actor,
NULL
FROM claims;
DROP TABLE claims;
ALTER TABLE claims_new RENAME TO claims;
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
CREATE INDEX idx_claims_payer_rejected_unack
ON claims(payer_rejected_at)
WHERE payer_rejected_acknowledged_at IS NULL;
-- ============================================================================
-- Step 3: recreate `matches`, `cas_adjustments`, `service_line_payments`,
-- `line_reconciliations` so their FKs point at the new composite PKs.
--
-- At this point the OLD `claims` and `remittances` tables are gone (dropped
-- in Steps 1 and 2) — only the recreated (composite-PK) versions exist.
-- The JOIN below reads the NEW parents, which have identical data
-- (same row count, same id values, same batch_id values); we only need
-- `batch_id` from each parent to populate the composite-FK column.
-- ============================================================================
CREATE TABLE matches_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
remittance_id TEXT NOT NULL,
remittance_batch_id TEXT NOT NULL,
strategy TEXT NOT NULL,
matched_at DATETIME NOT NULL,
prior_claim_state TEXT,
is_reversal INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE,
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
);
INSERT INTO matches_new
SELECT m.id, m.claim_id, c.batch_id, m.remittance_id, r.batch_id,
m.strategy, m.matched_at, m.prior_claim_state, m.is_reversal
FROM matches m
JOIN claims c ON c.id = m.claim_id
JOIN remittances r ON r.id = m.remittance_id;
DROP TABLE matches;
ALTER TABLE matches_new RENAME TO matches;
CREATE INDEX ix_matches_claim_id ON matches(claim_id);
CREATE INDEX ix_matches_remittance_id ON matches(remittance_id);
CREATE INDEX ix_matches_matched_at ON matches(matched_at);
CREATE TABLE cas_adjustments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remittance_id TEXT NOT NULL,
remittance_batch_id TEXT NOT NULL,
group_code TEXT NOT NULL,
reason_code TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
quantity NUMERIC(10, 2),
service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
);
INSERT INTO cas_adjustments_new
SELECT ca.id, ca.remittance_id, r.batch_id, ca.group_code, ca.reason_code,
ca.amount, ca.quantity, ca.service_line_payment_id
FROM cas_adjustments ca
JOIN remittances r ON r.id = ca.remittance_id;
DROP TABLE cas_adjustments;
ALTER TABLE cas_adjustments_new RENAME TO cas_adjustments;
CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);
CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id);
CREATE TABLE service_line_payments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remittance_id TEXT NOT NULL,
remittance_batch_id TEXT NOT NULL,
line_number INTEGER NOT NULL,
procedure_qualifier VARCHAR(4) NOT NULL,
procedure_code VARCHAR(16) NOT NULL,
modifiers_json TEXT NOT NULL DEFAULT '[]',
charge NUMERIC(12, 2) NOT NULL,
payment NUMERIC(12, 2) NOT NULL,
units NUMERIC(10, 2),
unit_type VARCHAR(8),
service_date DATE,
ref_benefit_plan VARCHAR(64),
superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
);
INSERT INTO service_line_payments_new
SELECT slp.id, slp.remittance_id, r.batch_id, slp.line_number,
slp.procedure_qualifier, slp.procedure_code, slp.modifiers_json,
slp.charge, slp.payment, slp.units, slp.unit_type, slp.service_date,
slp.ref_benefit_plan, slp.superseded_by_id
FROM service_line_payments slp
JOIN remittances r ON r.id = slp.remittance_id;
DROP TABLE service_line_payments;
ALTER TABLE service_line_payments_new RENAME TO service_line_payments;
CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id);
CREATE INDEX ix_service_line_payments_procedure_code_date ON service_line_payments(procedure_code, service_date);
CREATE TABLE line_reconciliations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id VARCHAR(64) NOT NULL,
batch_id VARCHAR(64) NOT NULL,
claim_service_line_number INTEGER,
service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
status VARCHAR(16) NOT NULL,
match_score INTEGER,
reconciled_at TIMESTAMP NOT NULL,
FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE
);
INSERT INTO line_reconciliations_new
SELECT lr.id, lr.claim_id, c.batch_id, lr.claim_service_line_number,
lr.service_line_payment_id, lr.status, lr.match_score, lr.reconciled_at
FROM line_reconciliations lr
JOIN claims c ON c.id = lr.claim_id;
DROP TABLE line_reconciliations;
ALTER TABLE line_reconciliations_new RENAME TO line_reconciliations;
CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id);
CREATE INDEX ix_line_reconciliations_claim_service_line_number ON line_reconciliations(claim_service_line_number);
CREATE INDEX ix_line_reconciliations_service_line_payment_id ON line_reconciliations(service_line_payment_id);
@@ -1,15 +0,0 @@
-- version: 16
-- Add the missing index on claims.matched_remittance_id.
--
-- SP27 Task 11 added ``check_matched_pair_drift`` at startup, which
-- scans ``WHERE Claim.matched_remittance_id IS NOT NULL``. Without an
-- index this is a full-table scan; becomes a noticeable boot
-- latency cost past ~10k claims. The companion index on the
-- reverse side (``remittances.claim_id``) was added in 0007.
--
-- A plain index is enough — neither side is unique (reversals
-- re-reference the original PCN, and a claim without a match is
-- fine).
CREATE INDEX ix_claims_matched_remittance_id
ON claims(matched_remittance_id);
@@ -1,28 +0,0 @@
-- version: 17
-- Backfill claims.patient_control_number = claims.id.
--
-- SP27 Task 17 fixed the 837 ingest in store.py:_claim_837_row so
-- ``Claim.patient_control_number`` is populated from
-- ``claim.claim_id`` (CLM01) instead of ``claim.subscriber.member_id``
-- (the 2010BA NM109). The reconcile matcher joins on
-- ``Claim.patient_control_number == Remittance.payer_claim_control_number``
-- and the 835 echoes CLM01 in CLP01 per X12 spec, so the wrong field
-- silently broke every auto-match in production.
--
-- For rows written BEFORE the fix, the stored value is the member_id
-- (e.g. "W953474") which never matches any remit's CLP01. This
-- migration backfills those rows by aligning
-- ``patient_control_number`` with the row's own ``id`` (= CLM01).
-- After this, every claim row's PCN is consistent with the value the
-- 837 actually sent, and any newly-ingested 835 whose CLP01 echoes
-- that CLM01 will auto-pair.
--
-- Idempotent: only touches rows where the stored PCN doesn't already
-- match the row's id, so re-running on already-fixed rows is a no-op.
--
-- Reversal safety: the migration does NOT clear matched_remittance_id,
-- so any pre-existing manual_match pairs stay intact.
UPDATE claims
SET patient_control_number = id
WHERE patient_control_number IS DISTINCT FROM id;
@@ -1,52 +0,0 @@
-- version: 18
-- SP28: per-ACK auto-link join table.
--
-- Closes the operator gap where every inbound 999 / 277CA / TA1 ack was
-- persisted but never durably linked back to the claim it
-- acknowledges. One row per AK2 set-response for 999, per ClaimStatus
-- for 277CA, per TA1 envelope (with claim_id NULL + batch_id set).
--
-- Granularity (per-AK2) is preserved by ``ak2_index`` and the unique
-- index ``ux_claim_acks_dedup`` — an auto-link of
-- (claim, 999, ak2_index=3) is idempotent on re-ingest of the same
-- 999 file (the index enforces this at the DB layer; the helper
-- pre-checks to avoid IntegrityError log noise).
--
-- Notes:
-- * ``claim_id`` is nullable so TA1 envelope-level links to the
-- originating Batch can land here (FK to batches.id). The
-- CHECK constraint makes sure at least one of (claim_id,
-- batch_id) is set on every row — see spec §3.1.
-- * ``set_control_number`` records the value the upstream ACK
-- ACTUALLY CARRIED (== source 837 ST02 for Gainwell batches). It
-- is the orphan-traceability field — the link survives even when
-- the join had to fall back from ST02 to PCN matching.
-- * ``set_accept_reject_code`` carries the AK5 code (A/E/R/X) for
-- 999 or the STC category code (A1/A2/A3/A4/A6/A7 etc.) for 277CA.
-- TA1 stores the envelope-level ack code here (A/R/E).
-- * No FK constraint on ``(ack_kind, ack_id)`` — there are three
-- separate ack tables (``acks``, ``ta1_acks``, ``two77ca_acks``).
-- Application code enforces the discriminator.
CREATE TABLE claim_acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id TEXT REFERENCES claims(id) ON DELETE CASCADE,
batch_id TEXT REFERENCES batches(id) ON DELETE CASCADE,
ack_id INTEGER NOT NULL,
ack_kind TEXT NOT NULL CHECK (ack_kind IN ('999', '277ca', 'ta1')),
ak2_index INTEGER,
set_control_number TEXT,
set_accept_reject_code TEXT,
linked_at DATETIME NOT NULL,
linked_by TEXT NOT NULL CHECK (linked_by IN ('auto', 'manual')),
CHECK ((claim_id IS NOT NULL) OR (batch_id IS NOT NULL))
);
CREATE INDEX ix_claim_acks_claim_id ON claim_acks(claim_id);
CREATE INDEX ix_claim_acks_batch_id ON claim_acks(batch_id);
CREATE INDEX ix_claim_acks_ack ON claim_acks(ack_kind, ack_id);
-- Dedup: an auto-link of (claim, 999, ak2_index=3) is idempotent on re-ingest.
CREATE UNIQUE INDEX ux_claim_acks_dedup
ON claim_acks(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL;
@@ -1,7 +0,0 @@
-- version: 19
-- SP32: render & service-provider NPI extraction.
-- Nullable: existing rows stay NULL until backfill runs.
-- No indexes (used for set-equality, not range queries; nullable).
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
-10
View File
@@ -63,7 +63,6 @@ class ClaimHeader(_Base):
frequency_code: str | None = None frequency_code: str | None = None
provider_signature: str | None = None provider_signature: str | None = None
assignment: str | None = None assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None release_of_info: str | None = None
prior_auth: str | None = None prior_auth: str | None = None
@@ -88,14 +87,6 @@ class ServiceLine(_Base):
place_of_service: str | None = None place_of_service: str | None = None
service_date: date | None = None service_date: date | None = None
provider_reference: str | None = None provider_reference: str | None = None
# SV1-07 — Diagnosis Code Pointer. Points to one or more
# diagnosis codes in the parent claim's HI segment ("1".. "12",
# space-separated when multiple). For 837P with a non-empty HI
# segment, SV1-07 is required by HCPF / Gainwell. The parser
# captures it from the source; the serializer defaults to "1"
# when the claim has at least one diagnosis and no explicit
# pointer was captured (matches the common single-dx case).
dx_pointer: str | None = None
class ValidationIssue(_Base): class ValidationIssue(_Base):
@@ -142,7 +133,6 @@ class ClaimOutput(_Base):
subscriber: Subscriber subscriber: Subscriber
payer: Payer payer: Payer
claim: ClaimHeader claim: ClaimHeader
rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A)
diagnoses: list[Diagnosis] = Field(default_factory=list) diagnoses: list[Diagnosis] = Field(default_factory=list)
service_lines: list[ServiceLine] = Field(default_factory=list) service_lines: list[ServiceLine] = Field(default_factory=list)
validation: ValidationReport validation: ValidationReport
@@ -160,7 +160,6 @@ class ClaimPayment(_Base):
ref_benefit_plan: str | None = None # REF*CE per the CO guide ref_benefit_plan: str | None = None # REF*CE per the CO guide
service_payments: list[ServicePayment] = Field(default_factory=list) service_payments: list[ServicePayment] = Field(default_factory=list)
raw_segments: list[list[str]] = Field(default_factory=list) raw_segments: list[list[str]] = Field(default_factory=list)
service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
+3 -12
View File
@@ -376,7 +376,6 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
service_payments: list[ServicePayment] = [] service_payments: list[ServicePayment] = []
ref_benefit_plan: str | None = None ref_benefit_plan: str | None = None
service_provider_npi: str | None = None
per_diem: Decimal | None = None per_diem: Decimal | None = None
status_label = claim_status_label(status) status_label = claim_status_label(status)
@@ -423,16 +422,9 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
except ValueError: except ValueError:
per_diem = None per_diem = None
elif s[0] == "NM1": elif s[0] == "NM1":
# SP32: capture service-provider NPI from NM1*1P (Loop 2100). # Patient (QC) / service-provider (1P) — captured in raw_segments.
# NM108 (idx 8) carries the ID qualifier (typically "XX"); # The 835 spec doesn't require a structured patient model in v1.
# NM109 (idx 9) is the value. Some senders omit NM108; accept pass
# both forms but require a 10-digit ID.
if len(s) > 9 and s[1] == "1P":
if len(s) > 8 and s[8] == "XX" and s[9]:
if s[9].isdigit() and len(s[9]) == 10:
service_provider_npi = s[9]
elif s[9] and s[9].isdigit() and len(s[9]) == 10:
service_provider_npi = s[9]
elif s[0] == "DTM": elif s[0] == "DTM":
# Claim-level dates — captured in raw_segments. # Claim-level dates — captured in raw_segments.
pass pass
@@ -454,7 +446,6 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
ref_benefit_plan=ref_benefit_plan, ref_benefit_plan=ref_benefit_plan,
service_payments=service_payments, service_payments=service_payments,
raw_segments=raw, raw_segments=raw,
service_provider_npi=service_provider_npi,
), ),
idx, idx,
) )
+1 -16
View File
@@ -202,7 +202,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None, frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 else None, provider_signature=clm[6] if len(clm) > 6 else None,
assignment=clm[7] if len(clm) > 7 else None, assignment=clm[7] if len(clm) > 7 else None,
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
release_of_info=clm[9] if len(clm) > 9 else None, release_of_info=clm[9] if len(clm) > 9 else None,
) )
@@ -210,7 +209,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
service_lines: list[ServiceLine] = [] service_lines: list[ServiceLine] = []
raw: list[list[str]] = [seg] raw: list[list[str]] = [seg]
prior_auth: str | None = None prior_auth: str | None = None
rendering_provider_npi: str | None = None
idx += 1 idx += 1
while idx < len(segments) and segments[idx][0] not in {"HL", "CLM", "SE"}: while idx < len(segments) and segments[idx][0] not in {"HL", "CLM", "SE"}:
@@ -227,11 +225,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
for code in parts[1:]: for code in parts[1:]:
if code: if code:
diagnoses.append(Diagnosis(code=code, qualifier=qualifier)) diagnoses.append(Diagnosis(code=code, qualifier=qualifier))
elif s[0] == "NM1" and len(s) > 1 and s[1] == "82":
# SP32: capture rendering provider NPI from Loop 2420A NM1*82.
# NM108 (idx 8) is the qualifier (typically "XX"), NM109 (idx 9) is the NPI.
if len(s) > 9 and s[8] == "XX" and len(s[9]) == 10 and s[9].isdigit():
rendering_provider_npi = s[9]
elif s[0] == "LX": elif s[0] == "LX":
line_no = int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_lines) + 1 line_no = int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_lines) + 1
# LX is just a separator — the actual service line data is in the next SV1. # LX is just a separator — the actual service line data is in the next SV1.
@@ -261,7 +254,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
subscriber=Subscriber(first_name="", last_name="", member_id="", address=Address(line1="", city="", state="", zip="")), subscriber=Subscriber(first_name="", last_name="", member_id="", address=Address(line1="", city="", state="", zip="")),
payer=Payer(name="", id=""), payer=Payer(name="", id=""),
claim=claim_header, claim=claim_header,
rendering_provider_npi=rendering_provider_npi,
diagnoses=diagnoses, diagnoses=diagnoses,
service_lines=service_lines, service_lines=service_lines,
validation=ValidationReport(passed=True, errors=[], warnings=[]), validation=ValidationReport(passed=True, errors=[], warnings=[]),
@@ -293,17 +285,11 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
except Exception: except Exception:
units = None units = None
place_of_service = seg[5] if len(seg) > 5 else None place_of_service = seg[5] if len(seg) > 5 else None
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
# already use unit_type in SV1-03; SV1-06 is rarely populated and
# is not required by HCPF.
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
# diagnosis). Required by HCPF when the claim has diagnoses.
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
service_date: date | None = None service_date: date | None = None
provider_ref: str | None = None provider_ref: str | None = None
idx += 1 idx += 1
while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE", "NM1"}: while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE"}:
s = segments[idx] s = segments[idx]
raw.append(s) raw.append(s)
if s[0] == "DTP" and len(s) > 2 and s[1] == "472": if s[0] == "DTP" and len(s) > 2 and s[1] == "472":
@@ -325,7 +311,6 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
place_of_service=place_of_service, place_of_service=place_of_service,
service_date=service_date, service_date=service_date,
provider_reference=provider_ref, provider_reference=provider_ref,
dx_pointer=dx_pointer,
), ),
idx, idx,
) )
+2 -19
View File
@@ -8,12 +8,6 @@ Single-pass walker over the tokenized segment list:
- AK3 (Segment Context) + AK4 (Element Context) optional per-segment errors - AK3 (Segment Context) + AK4 (Element Context) optional per-segment errors
- AK5 (Transaction Set Response Status) per-set accept/reject - AK5 (Transaction Set Response Status) per-set accept/reject
- AK9 (Functional Group Response Status) per-group counts + ack code - AK9 (Functional Group Response Status) per-group counts + ack code
- IK5 a non-standard synonym for ``AK5`` that Gainwell's MFT ships
in place of the spec-defined ``AK5``. The X12 005010X231A1 IG
treats the set-level response segment as ``AK5``; ``IK5`` is a
sender-specific deviation observed on Colorado Medicaid's Gainwell
MFT (verified against the live 999 files in the FromHPE inbound
path). We accept either.
- SE / GE / IEA - SE / GE / IEA
Errors at the file level raise :class:`CycloneParseError`. The parser Errors at the file level raise :class:`CycloneParseError`. The parser
@@ -152,11 +146,6 @@ def _consume_ak3_ak4(segments: list[list[str]], idx: int) -> tuple[list[SegmentE
def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None: def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None:
"""Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse. """Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse.
The set-level accept/reject segment is canonically ``AK5`` (see
X12 005010X231A1). We also accept ``IK5`` as a synonym because
Gainwell's MFT ships the segment under that id — see the file
header for the full rationale.
Returns None when called with a non-AK2 segment (defensive the Returns None when called with a non-AK2 segment (defensive the
orchestrator only calls this when it sees AK2). orchestrator only calls this when it sees AK2).
""" """
@@ -175,11 +164,8 @@ def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupRespo
if idx < len(segments) and segments[idx][0] == "AK3": if idx < len(segments) and segments[idx][0] == "AK3":
seg_errors, idx = _consume_ak3_ak4(segments, idx) seg_errors, idx = _consume_ak3_ak4(segments, idx)
# AK5 (set accept/reject) — required by the spec; default to "R" if missing. # AK5 (set accept/reject) — required by the spec; default to "R" if missing.
# Gainwell's MFT uses IK5 instead of AK5 (sender-specific segment id
# that means the same thing); accept either. The default of "R"
# matters: if the segment is missing entirely, the 999 is a reject.
accept_code = "R" accept_code = "R"
if idx < len(segments) and segments[idx][0] in ("AK5", "IK5"): if idx < len(segments) and segments[idx][0] == "AK5":
ak5 = segments[idx] ak5 = segments[idx]
if len(ak5) > 1 and ak5[1]: if len(ak5) > 1 and ak5[1]:
accept_code = ak5[1] accept_code = ak5[1]
@@ -270,11 +256,8 @@ def parse_999_text(text: str, *, input_file: str = "") -> ParseResult999:
set_responses.append(sr) set_responses.append(sr)
# Advance past the AK2 + AK3*/AK4*/AK5 cluster # Advance past the AK2 + AK3*/AK4*/AK5 cluster
# (re-walk from i+1 because _consume_ak2 doesn't return idx). # (re-walk from i+1 because _consume_ak2 doesn't return idx).
# ``IK5`` is the Gainwell-specific synonym for ``AK5``
# and must be in the consumed set here too (see
# _consume_ak2 for the full rationale).
i += 1 i += 1
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5", "IK5"}: while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5"}:
i += 1 i += 1
else: else:
i += 1 i += 1
+2 -2
View File
@@ -66,8 +66,8 @@ class PayerConfig(BaseModel):
# Lenient in v1 — see spec §9 R031. # Lenient in v1 — see spec §9 R031.
require_ref_g1_for_adjustments=False, require_ref_g1_for_adjustments=False,
allowed_bht06={"CH"}, allowed_bht06={"CH"},
payer_id="CO_TXIX", payer_id="SKCO0",
payer_name="CO_TXIX", payer_name="COHCPF",
no_patient_loop=True, no_patient_loop=True,
encounter_claim_in_same_batch=False, encounter_claim_in_same_batch=False,
allowed_facility_qualifiers={"B"}, allowed_facility_qualifiers={"B"},
+48 -148
View File
@@ -112,10 +112,7 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
_FUNCTIONAL_ID_HEALTH_CARE, _FUNCTIONAL_ID_HEALTH_CARE,
sender_id, sender_id,
receiver_id, receiver_id,
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD _today_yymmdd(),
# (6 digits) for the older format, but the GS segment is the
# newer ANSI X12 format and requires the full year.
_today_yyyymmdd(),
_today_hhmm(), _today_hhmm(),
group_control_number, group_control_number,
"X", "X",
@@ -189,30 +186,17 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
def _build_per( def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
contact_name: str | None, """PER segment — submitter contact. Returns empty when no contact info."""
contact_phone: str | None, if not contact_name and not contact_phone:
contact_email: str | None = None, return ""
email_qual: str = "EM", parts = [
) -> str: "PER",
"""PER segment — submitter contact (Loop 1000A). "IC", # PER01 — contact function code (Information Contact)
contact_name or "",
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A "TE", # PER03 — phone qualifier
(Submitter Name) and at least PER01 must be present, so this contact_phone or "",
builder always emits a segment. PER01 = "IC" (Information Contact). ]
The remaining elements are filled from the available contact info:
name, then email (preferred Gainwell/HCPF expect this), then phone.
"""
parts = ["PER", "IC"]
if contact_name:
parts.append(contact_name)
if contact_email:
parts.append(email_qual) # PER03 — email qualifier (default "EM")
parts.append(contact_email) # PER04 — the email itself
elif contact_phone:
parts.append("TE") # PER03 — phone qualifier
parts.append(contact_phone) # PER04 — the phone itself
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -252,40 +236,26 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
def _build_sbr( def _build_sbr(relationship_code: str | None, member_id: str | None,
individual_relationship_code: str | None, payer_name: str | None) -> str:
claim_filing_indicator_code: str | None,
) -> str:
"""SBR segment — subscriber information. """SBR segment — subscriber information.
Slot layout (X12 005010X222A1): SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
SBR01 Payer Responsibility Sequence Number Code. Default ``"P"`` the most common case for professional claims; the parser does not store
(Patient = primary). The parser does not capture this this on the canonical Subscriber model so we cannot thread it through
field on ``ClaimOutput`` so we default it. without adding a model field.
SBR02 Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
The parser does not capture this either; we default ``"18"``
for the common self-pay case.
SBR09 Claim Filing Indicator Code. ``"MC"`` for Medicaid,
``"16"`` for Medicare Part B, etc. The canonical
PayerConfig837 carries ``sbr09_default``; we thread it in
from the caller.
The member_id and payer name do NOT belong in SBR the member_id
lives in NM109 of the NM1*IL segment, and the payer name is in
NM103 of NM1*PR. (Earlier revisions of this function put them in
SBR06 / SBR09, which is wrong and rejected by HCPF.)
""" """
parts = [ parts = [
"SBR", "SBR",
"P", # SBR01 — primary relationship_code or "P",
individual_relationship_code or "18", # SBR02 — self "", # SBR02 — group number
"", # SBR03 — group number "", # SBR03 — group name
"", # SBR04 — group name "", # SBR04 — claim filing indicator code
"", # SBR05 — insurance type code "", # SBR05 — sequence number code
"", # SBR06 — coordination of benefits payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
"", # SBR07 — yes/no condition "", # SBR07
"", # SBR08 — employment status code "", # SBR08
claim_filing_indicator_code or "", # SBR09 — claim filing indicator member_id or "", # SBR09 — claim submitter's id
] ]
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -330,11 +300,7 @@ def _build_clm(claim) -> str:
clm05, # CLM05 — composite POS:qualifier:frequency_code clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06 claim.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07 claim.assignment or "Y", # CLM07
# CLM08 — Benefits Assignment Certification. X12 837P requires "", # CLM08 — benefit assignment certification
# this when CLM07 = "Y" (the common case for in-network
# professional claims). Default to "Y" when the source did
# not capture one — matches what 99% of HCPF files look like.
claim.benefits_assignment_certification or "Y", # CLM08
claim.release_of_info or "Y", # CLM09 claim.release_of_info or "Y", # CLM09
] ]
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -359,41 +325,21 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line, *, dx_pointer: str | None = None) -> str: def _build_sv1(line) -> str:
"""SV1 segment — professional service line. """SV1 segment — professional service line."""
X12 005010X222A1 layout (837P):
SV1-01 composite procedure identifier
SV1-02 monetary amount (charge)
SV1-03 unit of basis measurement (UN, MJ, etc.) ``line.unit_type``
SV1-04 service unit count ``line.units``
SV1-05 place of service code ``line.place_of_service``
SV1-06 **NOT USED** by this guide (must be empty)
SV1-07 diagnosis code pointer ``dx_pointer`` (required when the
parent claim has an HI segment)
The parser captures the original SV1-07 pointer when present; the
serializer defaults it to ``"1"`` (pointing at the first HI
diagnosis) when the claim has diagnoses and no explicit pointer
was captured. When the claim has no HI segment we leave SV1-07
empty to match the spec.
"""
proc = line.procedure proc = line.procedure
code = proc.code if proc else "" code = proc.code if proc else ""
mods = proc.modifiers if proc else [] mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4]) composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}" charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1" units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [ parts = [
"SV1", "SV1",
composite, # SV1-01 composite,
charge, # SV1-02 charge,
line.unit_type or "UN", # SV1-03 — unit basis code line.unit_type or "UN",
units, # SV1-04 units,
line.place_of_service or "", # SV1-05 line.place_of_service or "",
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
] ]
return _ELEM.join(parts) + _SEG return _ELEM.join(parts) + _SEG
@@ -411,20 +357,15 @@ def _build_dtp_472(service_date: date | None) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_submitter_block( def _build_submitter_block(sender_id: str, submitter_name: str | None,
sender_id: str,
submitter_name: str | None,
contact_name: str | None, contact_name: str | None,
contact_phone: str | None, contact_phone: str | None) -> list[str]:
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
out = [ out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id), _build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
] ]
# PER is required by X12 (at least PER01). _build_per always emits per = _build_per(contact_name, contact_phone)
# the segment; the submitter block always has exactly one. if per:
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual)) out.append(per)
return out return out
@@ -454,14 +395,11 @@ def _build_billing_provider_block(provider) -> list[str]:
return out return out
def _build_subscriber_block( def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
subscriber,
claim_filing_indicator_code: str | None,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children.""" """HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [ out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children _build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", claim_filing_indicator_code), _build_sbr("18", subscriber.member_id, payer_name),
_build_nm1( _build_nm1(
"IL", "IL", "IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(), f"{subscriber.last_name} {subscriber.first_name}".strip(),
@@ -489,24 +427,12 @@ def _build_payer_block(payer) -> list[str]:
] ]
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]: def _build_service_lines_block(service_lines) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R. """Per line: LX / SV1 / DTP*472 / REF*6R."""
``has_diagnoses`` is True when the parent claim emits an HI segment;
in that case SV1-07 is required by X12 and we default each line's
pointer to ``"1"`` (the first HI diagnosis) unless the source
captured a different pointer on the line itself.
"""
out: list[str] = [] out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1): for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx)) out.append(_build_lx(idx))
# Prefer the line's captured pointer (parser pulled SV1-07 out.append(_build_sv1(line))
# when present). Fall back to "1" only when the claim has
# diagnoses and the source had no explicit pointer — the
# common single-diagnosis case.
line_pointer = getattr(line, "dx_pointer", None)
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
out.append(_build_sv1(line, dx_pointer=effective_pointer))
dtp = _build_dtp_472(line.service_date) dtp = _build_dtp_472(line.service_date)
if dtp: if dtp:
out.append(dtp) out.append(dtp)
@@ -524,10 +450,7 @@ def serialize_837(
submitter_name: str | None = None, submitter_name: str | None = None,
submitter_contact_name: str | None = None, submitter_contact_name: str | None = None,
submitter_contact_phone: str | None = None, submitter_contact_phone: str | None = None,
submitter_contact_email: str | None = None,
submitter_contact_email_qual: str = "EM",
receiver_name: str | None = None, receiver_name: str | None = None,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001", interchange_control_number: str = "000000001",
group_control_number: str = "1", group_control_number: str = "1",
) -> str: ) -> str:
@@ -539,16 +462,6 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass (``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
the configured values. the configured values.
The submitter block (Loop 1000A) always emits a PER segment per the
X12 spec the canonical clearhouse config provides the contact
name and email so callers should pass them through.
The claim filing indicator (SBR09) is read from the per-payer
config (``PayerConfig837.sbr09_default``); callers should pass it
in. If not passed, SBR09 is left empty (which causes the
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
its check degraded but not a hard error).
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
emitted from the canonical ``ClaimOutput`` fields, so post-parse emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output. edits propagate to the output.
@@ -568,13 +481,11 @@ def serialize_837(
), ),
] ]
segments.extend(_build_submitter_block( segments.extend(_build_submitter_block(
sender_id, submitter_name, sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
submitter_contact_email_qual,
)) ))
segments.extend(_build_receiver_block(receiver_id, receiver_name)) segments.extend(_build_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider)) segments.extend(_build_billing_provider_block(claim.billing_provider))
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code)) segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
segments.extend(_build_payer_block(claim.payer)) segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments. # Claim-level editable segments.
@@ -586,10 +497,7 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses)) segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R). # Service lines (LX / SV1 / DTP*472 / REF*6R).
segments.extend(_build_service_lines_block( segments.extend(_build_service_lines_block(claim.service_lines))
claim.service_lines,
has_diagnoses=bool(claim.diagnoses),
))
# SE segment count includes ST (line 3, 1-based) through SE itself # SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive. # — i.e. the entire ST..SE block inclusive.
@@ -606,23 +514,15 @@ def serialize_837_for_resubmit(
claim: ClaimOutput, claim: ClaimOutput,
*, *,
interchange_index: int, interchange_index: int,
**kwargs,
) -> str: ) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique """Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position. interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``. Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``. Group number = ``str(interchange_index)``.
All other keyword arguments (sender_id, receiver_id, submitter_*
and receiver_* contact info, claim_filing_indicator_code) are
forwarded to :func:`serialize_837` unchanged so callers like the
export and SFTP-submit endpoints can pass through clearhouse +
payer config without copying the signature.
""" """
return serialize_837( return serialize_837(
claim, claim,
interchange_control_number=f"{interchange_index:09d}", interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index), group_control_number=str(interchange_index),
**kwargs,
) )
+13 -227
View File
@@ -17,7 +17,7 @@ Match algorithm:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Iterable, Optional, Protocol from typing import Iterable, Optional, Protocol
@@ -50,18 +50,6 @@ class Match:
remittance: _RemitLike remittance: _RemitLike
strategy: str strategy: str
is_reversal: bool is_reversal: bool
# SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on.
# Populated for ``score-auto`` matches; empty for ``pcn-exact`` /
# ``manual`` (PCN-exact only uses the PCN key by definition; manual
# is operator-driven). Floats into the ``auto_matched_835``
# ActivityEvent payload so the audit trail records *why* the link
# fired. Transient — the ORM Match row does not persist these
# (spec keeps the audit trail in ActivityEvent.payload_json only).
keys_matched: set[str] = field(default_factory=set)
# SP31: how many candidates were in the ±30-day window pool when
# the score-auto match fired. Useful when an operator is reviewing
# a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky.
candidate_count: int = 0
def match( def match(
@@ -96,7 +84,7 @@ def match(
matches.append(Match( matches.append(Match(
claim=chosen, claim=chosen,
remittance=r, remittance=r,
strategy="pcn-exact", strategy="auto",
is_reversal=r.is_reversal, is_reversal=r.is_reversal,
)) ))
used_claim_ids.add(chosen.id) used_claim_ids.add(chosen.id)
@@ -129,163 +117,6 @@ def _pick_claim(
return None return None
# --- SP31: content-keys fallback matcher -----------------------------------
CHARGE_TOLERANCE = Decimal("0.01")
KEYS_REQUIRED = 2
def _content_keys_match(remit, claim) -> set[str]:
"""Return the set of content-keys that agree between ``remit`` and ``claim``.
Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns
the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED``
to decide whether to auto-link. Returning the matched keys (rather than a
bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3)
produced the auto-link the audit trail for the SP31 content-keys rule.
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
NPI counts as "not matched" when the remit's NPI is empty.
Uses duck-typing via ``getattr`` so the helper works against both:
- shim / dataclass test objects (planned names: total_charge_amount,
total_charge, rendering_provider_npi), and
- real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
Claim.provider_npi, Claim.rendering_provider_npi,
Remittance.rendering_provider_npi).
Production note (SP32): the typed ``rendering_provider_npi`` column is
the primary read for both sides (Claim = NM1*82, Remit = NM1*1P). Legacy
rows pre-0019 still carry the value in ``raw_json``; the read order is:
- NPI: typed column raw_json (``service_provider_npi`` for remit,
``rendering_provider_npi`` for claim) claim ``provider_npi``
(billing fallback for legacy 837p rows without NM1*82 extraction).
- Charge: planned attribute name real ORM column raw_json.
The ``raw_json`` fallback is what makes this helper safe against a
raw ``Remittance`` ORM instance from ``reconcile.run()``.
"""
matched: set[str] = set()
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
(getattr(claim, "patient_control_number", "") or "").strip():
matched.add("pcn")
# Charge: prefer the planned attribute names, fall back to real ORM columns,
# then to raw_json (where the 835 parser stores CLP03). Explicit ``is None``
# checks avoid the ``Decimal("0")`` truthiness footgun — ``Decimal("0")``
# is truthy in a boolean context but ``or`` would still let it through;
# the more important property is that we don't accidentally replace a real
# value with a default.
_remit_charge = getattr(remit, "total_charge_amount", None)
if _remit_charge is None:
_remit_charge = getattr(remit, "total_charge", None)
if _remit_charge is None:
_remit_charge = (getattr(remit, "raw_json", None) or {}).get("total_charge_amount")
_claim_charge = getattr(claim, "total_charge", None)
if _claim_charge is None:
_claim_charge = getattr(claim, "charge_amount", None)
if _claim_charge is None:
_claim_charge = (getattr(claim, "raw_json", None) or {}).get("total_charge_amount")
if _remit_charge is not None and _claim_charge is not None and \
abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
matched.add("charge")
# NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
# Remit reads rendering_provider_npi (single value, D4); claim reads
# rendering_provider_npi first, then falls back to provider_npi (billing)
# for legacy rows where the 837p parser hadn't yet extracted NM1*82.
remit_npi = (
(getattr(remit, "rendering_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
claim_npi = (
(getattr(claim, "rendering_provider_npi", "") or "")
or (getattr(claim, "provider_npi", "") or "")
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
matched.add("npi")
return matched
# --- SP31: DB-side fallback candidate matcher -------------------------------
SCORE_FALLBACK_WINDOW_DAYS = 30
def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]:
"""Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match.
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
- claim.matched_remittance_id IS NULL (not yet linked)
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED,
REVERSED, RECONCILED)
- claim.service_date_from within window of remit.service_date
Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one
candidate passes the 2-of-3 rule (via :func:`_content_keys_match`),
otherwise ``None``. ``keys_matched`` is the set returned by the helper
(``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) the
ActivityEvent payload records it as the audit trail for *why* the
auto-link fired. ``candidate_count`` is the size of the window pool
(independent of how many matched) so the operator can see whether
the match was 1-of-N or 1-of-1.
Ambiguous matches (2+ candidates) also return ``None`` they land
in the Inbox Unlinked lane for manual review.
Note on payer_id: ``Remittance`` has no ``payer_id`` column
(``Claim.payer_id`` does, and is the source of truth on the claim
side; the 835 parser stores payer_id in ``Remittance.raw_json``).
Filtering by payer would require either a raw_json read or a model
column add out of scope for SP31 Task 3. The PCN itself is
expected to be unique within a payer's submission, so cross-payer
collisions are unlikely. Revisit if production shows otherwise.
"""
from sqlalchemy import select, and_
from datetime import timedelta
from cyclone.db import Claim, ClaimState
TERMINAL = {
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
ClaimState.REVERSED, ClaimState.RECONCILED,
}
if remit.service_date is None:
return None # need a date for the window query
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
candidates = list(session.execute(
select(Claim).where(
and_(
Claim.matched_remittance_id.is_(None),
Claim.service_date_from >= window_lo,
Claim.service_date_from <= window_hi,
Claim.state.notin_([s.value for s in TERMINAL]),
)
)
).scalars().all())
matched_pairs = [
(c.id, _content_keys_match(remit, c))
for c in candidates
]
matched_pairs = [
(cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED
]
if len(matched_pairs) == 1:
cid, keys_matched = matched_pairs[0]
return (cid, keys_matched, len(candidates))
return None # 0 matches OR 2+ (ambiguous)
@dataclass @dataclass
class ApplyIntent: class ApplyIntent:
"""Result of applying a match. The caller persists this. """Result of applying a match. The caller persists this.
@@ -393,22 +224,17 @@ class ReconcileResult:
def run(session, batch_id: str) -> ReconcileResult: def run(session, batch_id: str) -> ReconcileResult:
"""Orchestrate reconciliation for an 835 batch. """Orchestrate reconciliation for an 835 batch.
Expects Batch + Remittance + CasAdjustment rows already added to Expects Batch + Remittance + CasAdjustment rows already persisted
``session`` (not yet committed). SP27 Task 10 calls this from (this is called from store.add() AFTER commit). Loads the new
inside ``CycloneStore.add``'s ingest session, BEFORE remittances + all currently-unmatched Claims, calls match(), then
``s.commit()`` so the whole 835 ingest (rows + reconcile) is applies each match's intent inside the caller's session.
a single transaction. If anything here raises, the ingest rolls
back; no half-reconciled batch ever lands.
Loads the new remittances + all currently-unmatched Claims, calls
match(), then applies each match's intent inside the caller's session.
Returns counts. Does NOT commit; caller controls transaction. Returns counts. Does NOT commit; caller controls transaction.
Raises on failure (caller catches and writes activity event). Raises on failure (caller catches and writes activity event).
""" """
from sqlalchemy import select from sqlalchemy import select
from cyclone.db import ( from cyclone.db import (
Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent, Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
ClaimState, ClaimState,
) )
@@ -426,34 +252,6 @@ def run(session, batch_id: str) -> ReconcileResult:
matches = match(unmatched_claims, new_remits) matches = match(unmatched_claims, new_remits)
# SP31: content-keys fallback for remits that PCN-exact couldn't pair.
# Operates on the still-unmatched remits so we don't double-match.
matched_remit_ids = {m.remittance.id for m in matches}
used_claim_ids = {m.claim.id for m in matches}
for remit in new_remits:
if remit.id in matched_remit_ids:
continue
if getattr(remit, "claim_id", None) is not None:
continue # already linked
fallback_result = _score_fallback_candidates(session, remit)
if fallback_result is None:
continue
matched_claim_id, keys_matched, candidate_count = fallback_result
# Find the claim object in the unmatched_claims list (it was loaded).
target_claim = next(
(c for c in unmatched_claims if c.id == matched_claim_id),
None,
)
if target_claim is None or target_claim.id in used_claim_ids:
continue
matches.append(Match(
claim=target_claim, remittance=remit,
strategy="score-auto", is_reversal=remit.is_reversal,
keys_matched=keys_matched,
candidate_count=candidate_count,
))
used_claim_ids.add(target_claim.id)
applied = 0 applied = 0
skipped = 0 skipped = 0
for m in matches: for m in matches:
@@ -477,7 +275,7 @@ def run(session, batch_id: str) -> ReconcileResult:
skipped += 1 skipped += 1
continue continue
session.add(MatchORM( session.add(Match(
claim_id=m.claim.id, remittance_id=m.remittance.id, claim_id=m.claim.id, remittance_id=m.remittance.id,
strategy=m.strategy, strategy=m.strategy,
matched_at=datetime.now(timezone.utc), matched_at=datetime.now(timezone.utc),
@@ -489,31 +287,19 @@ def run(session, batch_id: str) -> ReconcileResult:
else: else:
m.claim.state = ClaimState.REVERSED m.claim.state = ClaimState.REVERSED
m.claim.matched_remittance_id = m.remittance.id m.claim.matched_remittance_id = m.remittance.id
# Migration 0014: composite back-reference to remittances — both
# sides needed for get_claim_detail to JOIN back to remittances.
m.claim.matched_remittance_batch_id = m.remittance.batch_id
# Symmetric FK: also set Remittance.claim_id so list_unmatched (which # Symmetric FK: also set Remittance.claim_id so list_unmatched (which
# filters on Remittance.claim_id IS NULL) correctly drops auto-matched # filters on Remittance.claim_id IS NULL) correctly drops auto-matched
# remits from the orphan bucket. Manual matches do this too. # remits from the orphan bucket. Manual matches do this too.
m.remittance.claim_id = m.claim.id m.remittance.claim_id = m.claim.id
session.add(ActivityEvent( session.add(ActivityEvent(
ts=datetime.now(timezone.utc), ts=datetime.now(timezone.utc), kind=intent.activity_kind,
kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
batch_id=batch_id, claim_id=m.claim.id, batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id, remittance_id=m.remittance.id,
payload_json=( payload_json={"new_state": m.claim.state.value},
{
"new_state": m.claim.state.value, "strategy": m.strategy,
# SP31 spec D8: the auto_matched_835 payload records
# which content-keys the 2-of-3 (or 3-of-3) rule
# agreed on, plus how many candidates were in the
# window pool. ``sorted()`` converts the set to a
# JSON-serializable list and pins a deterministic
# order for tests + audit reads.
"keys_matched": sorted(m.keys_matched),
"candidate_count": m.candidate_count,
}
if m.strategy == "score-auto"
else {"new_state": m.claim.state.value}
),
)) ))
applied += 1 applied += 1
+227 -243
View File
@@ -55,10 +55,7 @@ from cyclone.audit_log import AuditEvent, append_event
from cyclone.clearhouse import InboundFile, SftpClient from cyclone.clearhouse import InboundFile, SftpClient
from cyclone.db import ProcessedInboundFile from cyclone.db import ProcessedInboundFile
from cyclone.edi.filenames import parse_inbound_filename from cyclone.edi.filenames import parse_inbound_filename
from cyclone.handlers import handle_277ca as _handle_277ca from cyclone.inbox_state import apply_999_rejections
from cyclone.handlers import handle_835 as _handle_835
from cyclone.handlers import handle_999 as _handle_999
from cyclone.handlers import handle_ta1 as _handle_ta1
from cyclone.inbox_state_277ca import apply_277ca_rejections from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.providers import SftpBlock from cyclone.providers import SftpBlock
@@ -72,11 +69,6 @@ STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending" STATUS_PENDING = "pending"
# How long reconfigure_scheduler waits for the in-flight tick to
# drain before cancelling the old task. Matches Scheduler.stop().
_DRAIN_TIMEOUT_SECONDS = 30
# File types we know how to route. The HCPF set is broader (270/271/ # File types we know how to route. The HCPF set is broader (270/271/
# 276/277/278/820/834/ENCR) but Cyclone's parser only covers the # 276/277/278/820/834/ENCR) but Cyclone's parser only covers the
# four below. Files with unknown types are recorded as ``skipped`` # four below. Files with unknown types are recorded as ``skipped``
@@ -95,12 +87,6 @@ class TickResult:
files_skipped: int = 0 files_skipped: int = 0
files_errored: int = 0 files_errored: int = 0
errors: list[str] = field(default_factory=list) errors: list[str] = field(default_factory=list)
# SP27 Task 9: ``sftp_failed`` is the discriminator that separates
# SFTP-side failures (the listing step) from per-file processing
# errors. ``tick()`` keys off this flag — NOT ``result.errors`` —
# so a single malformed 999 cannot flip the operator's destructive
# pill. Set in ``_tick_impl``'s listing-error branches only.
sftp_failed: bool = False
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
@@ -113,22 +99,12 @@ class TickResult:
"files_skipped": self.files_skipped, "files_skipped": self.files_skipped,
"files_errored": self.files_errored, "files_errored": self.files_errored,
"errors": list(self.errors), "errors": list(self.errors),
"sftp_failed": self.sftp_failed,
} }
@dataclass @dataclass
class SchedulerStatus: class SchedulerStatus:
"""Snapshot of the scheduler's runtime state. """Snapshot of the scheduler's runtime state."""
SP27 Task 9 added the four SFTP-error fields
(``consecutive_failures``, ``last_error``, ``last_error_at``,
``last_sftp_attempt_at``) so the operator's UI can tell "all
quiet on the MFT front" from "we've been unable to reach the
MFT server for 3 hours". The previous 06/25 incident went
unnoticed because the status dict had no signal for a hung
SFTP poll.
"""
running: bool running: bool
poll_interval_seconds: int poll_interval_seconds: int
@@ -139,13 +115,6 @@ class SchedulerStatus:
total_skipped: int total_skipped: int
total_errored: int total_errored: int
last_tick: Optional[TickResult] = None last_tick: Optional[TickResult] = None
# SP27 Task 9: SFTP-error surfacing. The UI flips to a destructive
# pill when ``consecutive_failures >= 3`` (``CYCLONE_SCHEDULER_FAIL_THRESHOLD``
# in the operator pill config; not enforced server-side).
consecutive_failures: int = 0
last_error: Optional[str] = None
last_error_at: Optional[datetime] = None
last_sftp_attempt_at: Optional[datetime] = None
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return { return {
@@ -160,15 +129,6 @@ class SchedulerStatus:
"total_skipped": self.total_skipped, "total_skipped": self.total_skipped,
"total_errored": self.total_errored, "total_errored": self.total_errored,
"last_tick": self.last_tick.as_dict() if self.last_tick else None, "last_tick": self.last_tick.as_dict() if self.last_tick else None,
"consecutive_failures": self.consecutive_failures,
"last_error": self.last_error,
"last_error_at": (
self.last_error_at.isoformat() if self.last_error_at else None
),
"last_sftp_attempt_at": (
self.last_sftp_attempt_at.isoformat()
if self.last_sftp_attempt_at else None
),
} }
@@ -176,17 +136,179 @@ class SchedulerStatus:
# Per-file-type handlers. Each returns (parser_name, claim_count) and # Per-file-type handlers. Each returns (parser_name, claim_count) and
# persists its own DB rows. The scheduler records the outcome. # persists its own DB rows. The scheduler records the outcome.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#
# 999 (Task 2), TA1 (Task 3), 277CA (Task 4), and 835 (Task 5) now
# live in ``cyclone.handlers``. The HANDLERS dict literal below
# references the alias imports so the wiring stays identical.
# The 277CA handler has moved to ``cyclone.handlers.handle_277ca`` def _handle_999(text: str, source_file: str) -> tuple[str, int]:
# (SP27 Task 4); the inline def was deleted. The HANDLERS dict literal """Parse a 999, apply rejections, persist ack row. Returns (parser, count)."""
# below still references ``_handle_277ca`` because the import-alias from cyclone.parsers.parse_999 import parse_999_text
# line at the top of this module binds that name to the new function. from cyclone.parsers.exceptions import CycloneParseError
# Only the 835 handler stays inline (Task 5).
try:
result = parse_999_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"999 parse error: {exc}") from exc
received, accepted, rejected, ack_code = _ack_count_summary(result)
icn = result.envelope.control_number
synthetic_id = _ack_synthetic_source_batch_id(icn)
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup,
)
if rejection_result.matched:
for cid in rejection_result.matched:
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id},
actor="999-parser-scheduler",
))
row = cycl_store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
)
session.commit()
return "parse_999", received
def _handle_835(text: str, source_file: str) -> tuple[str, int]:
"""Parse an 835, run validation, persist batch + remittances."""
import uuid
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.payers import PAYER_FACTORIES_835
from cyclone.store import BatchRecord
config = PAYER_FACTORIES_835["co_medicaid_835"]()
try:
result = parse_835(text, config, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"835 parse error: {exc}") from exc
# Validation report (mirrors the API endpoint).
report = validate_835(result, config)
n = len(result.claims)
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, [
c.payer_claim_control_number for c in result.claims
]
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=source_file,
parsed_at=datetime.now(timezone.utc),
result=result,
)
cycl_store.add(rec)
return "parse_835", len(result.claims)
def _handle_277ca(text: str, source_file: str) -> tuple[str, int]:
"""Parse a 277CA, persist ack + stamp payer-rejected claims."""
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.parsers.exceptions import CycloneParseError
try:
result = parse_277ca_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"277CA parse error: {exc}") from exc
icn = result.envelope.control_number
synthetic_id = _277ca_synthetic_source_batch_id(icn)
accepted = sum(
1 for s in result.claim_statuses if s.classification == "accepted"
)
paid = sum(
1 for s in result.claim_statuses if s.classification == "paid"
)
rejected = sum(
1 for s in result.claim_statuses if s.classification == "rejected"
)
pended = sum(
1 for s in result.claim_statuses if s.classification == "pended"
)
with db.SessionLocal()() as session:
row = cycl_store.add_277ca_ack(
source_batch_id=synthetic_id,
control_number=icn,
accepted_count=accepted,
rejected_count=rejected,
paid_count=paid,
pended_count=pended,
raw_json=json.loads(result.model_dump_json()),
)
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
apply_result = apply_277ca_rejections(
session, result, claim_lookup=_lookup, two77ca_id=row.id,
)
if apply_result.matched:
for cid in apply_result.matched:
append_event(session, AuditEvent(
event_type="claim.payer_rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
actor="277ca-parser-scheduler",
))
session.commit()
return "parse_277ca", len(result.claim_statuses)
def _handle_ta1(text: str, source_file: str) -> tuple[str, int]:
"""Parse a TA1, persist the interchange ack row."""
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.parsers.exceptions import CycloneParseError
try:
result = parse_ta1_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"TA1 parse error: {exc}") from exc
with db.SessionLocal()() as session:
cycl_store.add_ta1_ack(
source_batch_id=result.source_batch_id,
control_number=result.ta1.control_number,
interchange_date=result.ta1.interchange_date,
interchange_time=result.ta1.interchange_time,
ack_code=result.ta1.ack_code,
note_code=result.ta1.note_code,
ack_generated_date=result.ta1.ack_generated_date,
sender_id=result.envelope.sender_id,
receiver_id=result.envelope.receiver_id,
raw_json=json.loads(result.model_dump_json()),
)
session.commit()
return "parse_ta1", 1
# Map file_type → handler. Mirrors ROUTED_FILE_TYPES. # Map file_type → handler. Mirrors ROUTED_FILE_TYPES.
@@ -201,15 +323,43 @@ HANDLERS: dict[str, Callable[[str, str], tuple[str, int]]] = {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Light copies of helpers the API endpoints use, so the scheduler can # Light copies of helpers the API endpoints use, so the scheduler can
# Run without depending on the FastAPI module. # run without depending on the FastAPI module.
# # ---------------------------------------------------------------------------
# Note: ``_277ca_synthetic_source_batch_id`` lived here as a
# scheduler-local duplicate of
# ``cyclone.handlers._ack_id.two77ca_synthetic_source_batch_id`` until def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
# SP27 Task 4 landed; the inline def has been deleted because """Return (received, accepted, rejected, ack_code) for a 999.
# ``handle_277ca`` now imports the canonical copy. The historical
# helper was the only remaining inline def in this section — the Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives
# surviving inline handler is ``_handle_835`` (lifts in Task 5). here so the scheduler can run without importing the API module.
"""
if result.functional_group_acks:
fg = result.functional_group_acks[0]
return (
fg.received_count, fg.accepted_count,
fg.rejected_count, fg.ack_code,
)
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
rejected = received - accepted
if rejected == 0:
code = "A"
elif accepted == 0:
code = "R"
else:
code = "P"
return (received, accepted, rejected, code)
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Synthetic batches.id for a received 999 with no source batch."""
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Synthetic batches.id for a received 277CA with no source batch."""
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -263,40 +413,6 @@ class Scheduler:
# ticks stack up; the next tick fires only after the previous # ticks stack up; the next tick fires only after the previous
# one finishes). # one finishes).
self._tick_in_progress = False self._tick_in_progress = False
# SP27 Task 9: SFTP-error surfacing. ``_consecutive_failures``
# counts back-to-back tick failures (cleared on the next
# successful tick). The other three fields are the most
# recent failure for the operator's pill — preserved across
# successes so the audit trail doesn't disappear the moment
# the MFT server recovers.
self._consecutive_failures = 0
self._last_error: Optional[str] = None
self._last_error_at: Optional[datetime] = None
self._last_sftp_attempt_at: Optional[datetime] = None
def _record_sftp_outcome(
self, *, success: bool, error: Optional[str] = None,
) -> None:
"""Update the SFTP-error state after a tick.
``success=True`` resets the consecutive-failure counter; the
last-error fields are preserved as an audit trail (the
operator's UI can still surface "last failure: 2 hours ago"
after a recovery).
``success=False`` bumps the counter and records the error
message + timestamp. ``last_sftp_attempt_at`` is bumped in
both cases so the operator can tell when the scheduler last
*tried* to reach the MFT (not just when it last failed).
"""
now = datetime.now(timezone.utc)
self._last_sftp_attempt_at = now
if success:
self._consecutive_failures = 0
return
self._consecutive_failures += 1
self._last_error = error
self._last_error_at = now
# ---- Public API ------------------------------------------------------- # ---- Public API -------------------------------------------------------
@@ -344,10 +460,6 @@ class Scheduler:
total_skipped=self._total_skipped, total_skipped=self._total_skipped,
total_errored=self._total_errored, total_errored=self._total_errored,
last_tick=self._last_tick, last_tick=self._last_tick,
consecutive_failures=self._consecutive_failures,
last_error=self._last_error,
last_error_at=self._last_error_at,
last_sftp_attempt_at=self._last_sftp_attempt_at,
) )
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -361,13 +473,6 @@ class Scheduler:
SFTP server from a stampede when the operator hits SFTP server from a stampede when the operator hits
``/api/admin/scheduler/tick`` while a scheduled tick is ``/api/admin/scheduler/tick`` while a scheduled tick is
already running. already running.
SP27 Task 9: the tick outcome is also recorded on the
scheduler's SFTP-error state via
:meth:`_record_sftp_outcome`. The discriminator is
``TickResult.sftp_failed`` (set by ``_tick_impl``'s
listing-error branches only), not ``TickResult.errors``
which is also populated by per-file processing errors.
""" """
while self._tick_in_progress: while self._tick_in_progress:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
@@ -380,63 +485,6 @@ class Scheduler:
self._total_processed += result.files_processed self._total_processed += result.files_processed
self._total_skipped += result.files_skipped self._total_skipped += result.files_skipped
self._total_errored += result.files_errored self._total_errored += result.files_errored
# SFTP-error surfacing: discriminator is ``result.sftp_failed``,
# NOT ``result.errors``. ``sftp_failed`` is set by
# ``_tick_impl``'s listing-error branches (timeout, connect
# refused). Per-file processing errors append to
# ``result.errors`` but do NOT set ``sftp_failed`` — a
# single malformed 999 in a 5-file batch must not flip
# the operator's pill to destructive.
if result.sftp_failed:
self._record_sftp_outcome(
success=False,
error="; ".join(result.errors),
)
else:
self._record_sftp_outcome(success=True)
return result
finally:
self._tick_in_progress = False
async def process_inbound_files(
self, files: list[InboundFile],
) -> TickResult:
"""Run the per-file processing pipeline over a pre-fetched list.
Unlike :meth:`tick`, this does **not** call SFTP the caller
is expected to have already downloaded (or arranged the local
copy of) each ``InboundFile.local_path``. Used by the
``/api/admin/scheduler/pull-inbound`` admin endpoint and the
``cyclone pull-inbound`` CLI command to process a date-filtered
subset of the inbound MFT path without paying the cost of a
full poll.
Honors ``_stop_event`` between files. Concurrent calls are
coalesced the same way :meth:`tick` does, so a slow handler
can't be stampeded by a parallel admin invocation.
SP27 Task 9: deliberately does NOT update the SFTP-error
state (no listing, no SFTP). The consecutive-failure counter
is the signal for scheduled SFTP polling; operator-initiated
batches shouldn't be able to flip it.
"""
while self._tick_in_progress:
await asyncio.sleep(0.05)
self._tick_in_progress = True
try:
started = datetime.now(timezone.utc)
result = TickResult(started_at=started, files_seen=len(files))
for f in files:
if self._stop_event.is_set():
break
await self._handle_one(f, result)
result.finished_at = datetime.now(timezone.utc)
self._last_tick = result
self._last_poll_at = result.finished_at
self._poll_count += 1
self._total_processed += result.files_processed
self._total_skipped += result.files_skipped
self._total_errored += result.files_errored
return result return result
finally: finally:
self._tick_in_progress = False self._tick_in_progress = False
@@ -469,26 +517,11 @@ class Scheduler:
started = datetime.now(timezone.utc) started = datetime.now(timezone.utc)
result = TickResult(started_at=started) result = TickResult(started_at=started)
# SP27 Task 8: use the async-wrapped SFTP client so a hung
# ``listdir_attr`` (the 06/25 silent-hang failure mode) is
# bounded by ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` instead of
# waiting on paramiko forever. ``asyncio.TimeoutError`` is
# handled explicitly so the tick surfaces a clear error in
# ``Scheduler.status()`` (Task 9) rather than a generic
# ``Exception`` catch-all.
client = self._sftp_client_factory(self._sftp_block)
try: try:
files = await client.async_list_inbound() files = await asyncio.to_thread(self._list_inbound)
except asyncio.TimeoutError:
log.exception("SFTP list_inbound timed out")
result.errors.append("list_inbound: timeout")
result.sftp_failed = True
result.finished_at = datetime.now(timezone.utc)
return result
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.exception("SFTP list_inbound failed") log.exception("SFTP list_inbound failed")
result.errors.append(f"list_inbound: {exc}") result.errors.append(f"list_inbound: {exc}")
result.sftp_failed = True
result.finished_at = datetime.now(timezone.utc) result.finished_at = datetime.now(timezone.utc)
return result return result
@@ -501,6 +534,11 @@ class Scheduler:
result.finished_at = datetime.now(timezone.utc) result.finished_at = datetime.now(timezone.utc)
return result return result
def _list_inbound(self) -> list[InboundFile]:
"""Return files in the inbound MFT path. Runs on a thread."""
client = self._sftp_client_factory(self._sftp_block)
return client.list_inbound()
async def _handle_one(self, f: InboundFile, result: TickResult) -> None: async def _handle_one(self, f: InboundFile, result: TickResult) -> None:
"""Process one inbound file: skip-if-seen, classify, parse, record.""" """Process one inbound file: skip-if-seen, classify, parse, record."""
if await self._already_processed(f.name): if await self._already_processed(f.name):
@@ -607,21 +645,20 @@ class Scheduler:
def _download_and_parse( def _download_and_parse(
self, f: InboundFile, file_type: str, self, f: InboundFile, file_type: str,
) -> tuple[Path, str, int]: ) -> tuple[Path, str, int]:
"""Run the right handler on one inbound file. Returns (path, parser, count). """Download from MFT, run the right handler. Returns (path, parser, count).
Both stub and real modes read from ``f.local_path`` the Stub mode: ``f.local_path`` already points at the staged file
inbound file is already on disk: (set by ``SftpClient._list_inbound_stub``). Real mode: the
* Stub mode: ``_list_inbound_stub`` points ``local_path`` at remote name is ``f.name`` and we round-trip through paramiko.
the operator-dropped staging file.
* Real mode: ``_list_inbound_paramiko`` downloads each
``listdir_attr`` entry into the local cache as part of the
listing pass. Re-reading from the MFT would require
``SftpClient.read_file`` with a full remote path, which the
scheduler was passing just ``f.name`` for (i.e. a bare
filename at the SFTP root, not the inbound dir). Use the
cached bytes instead.
""" """
if self._sftp_block.stub:
# In stub mode the InboundFile already has a local_path;
# reading the staged bytes directly avoids the stub's
# remote-path semantics (which expect a full inbound path).
content = f.local_path.read_bytes() content = f.local_path.read_bytes()
else:
client = self._sftp_client_factory(self._sftp_block)
content = client.read_file(f.name)
text = content.decode("utf-8") text = content.decode("utf-8")
handler = HANDLERS[file_type] handler = HANDLERS[file_type]
parser_used, claim_count = handler(text, f.name) parser_used, claim_count = handler(text, f.name)
@@ -681,56 +718,3 @@ def reset_scheduler_for_tests() -> None:
"""Clear the module-level scheduler. Test-only.""" """Clear the module-level scheduler. Test-only."""
global _scheduler global _scheduler
_scheduler = None _scheduler = None
async def reconfigure_scheduler(
sftp_block: SftpBlock,
*,
poll_interval_seconds: int = 60,
sftp_block_name: str = "default",
) -> Scheduler:
"""Replace the singleton scheduler; restart if it was running. SP25.
Called from ``PATCH /api/clearhouse`` so the next scheduler tick
uses the new SftpBlock without a process restart.
Lifecycle:
* If the previous scheduler is running, ``stop()`` it (waits
up to ``_DRAIN_TIMEOUT_SECONDS`` for the current tick to
finish matches the existing ``Scheduler.stop()`` timeout).
* Replace the module-level ``_scheduler`` singleton with a
fresh ``Scheduler`` against the new block.
* If the previous one was running, ``start()`` the new one.
Threading: same constraint as ``configure_scheduler`` must be
called from the FastAPI event loop, not a worker thread.
"""
global _scheduler
was_running = False
if _scheduler is not None:
was_running = _scheduler.is_running()
if was_running:
# Drain the in-flight tick. Scheduler.stop() already
# applies the _DRAIN_TIMEOUT_SECONDS timeout internally;
# we just await it.
await _scheduler.stop()
poll = int(
os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
)
_scheduler = Scheduler(
sftp_block,
poll_interval_seconds=poll,
sftp_block_name=sftp_block_name,
)
if was_running:
await _scheduler.start()
log.info(
"Scheduler reconfigured",
extra={
"was_running": was_running,
"sftp_block": sftp_block_name,
"host": sftp_block.host,
"stub": sftp_block.stub,
},
)
return _scheduler
+8 -87
View File
@@ -14,36 +14,11 @@ Setup (one-time, by the operator):
Verification: Verification:
security find-generic-password -s cyclone -a sftp.gainwell.password -w security find-generic-password -s cyclone -a sftp.gainwell.password -w
SP25 + SP26: the lookup now resolves the secret in four tiers, in
this order:
1. ``<env_name>_FILE`` env var set highest priority. Reads the
file at that path, strips whitespace, returns the contents.
This is the standard Docker-secrets pattern: mount a file at
``/run/secrets/<name>`` and point the env var at it. An
operator who explicitly sets ``_FILE`` is making a positive
statement about where the secret lives, so a missing file
surfaces as a ``RuntimeError`` rather than silently falling
through. Empty string is treated as unset.
2. Plain env var named exactly ``<env_name>`` second priority.
Lets a Linux server or Docker container pass the MFT password
via ``CYCLONE_SFTP_PASSWORD=...`` without touching the Keychain
or a file mount. Trailing/leading whitespace (including the
``\\n`` that .env files often leave at EOF) is stripped; an
empty value is treated as absent.
3. macOS Keychain via ``keyring`` third priority. Kept as a
fallback so a macOS workstation that prefers
``security add-generic-password`` works without env-var exports.
4. ``None`` caller decides what to do (``SftpClient._connect``
raises a precise ``RuntimeError`` on real-mode auth).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from pathlib import Path
from typing import Optional from typing import Optional
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -61,65 +36,21 @@ except ImportError:
_HAS_KEYRING = False _HAS_KEYRING = False
def _env_var_for(name: str) -> str:
"""Resolve the operator-facing env-var name for a secret.
Returns the entry from ``_ENV_NAME_FOR`` if present, otherwise
returns ``name`` verbatim.
"""
return _ENV_NAME_FOR.get(name, name)
def get_secret(name: str) -> Optional[str]: def get_secret(name: str) -> Optional[str]:
"""Fetch a secret by name. Four-tier lookup: _FILE, env var, Keychain, None. """Fetch a secret from macOS Keychain by name.
Args: Args:
name: The secret name. Matches the Keychain account name name: The Keychain account (e.g. "sftp.gainwell.password").
(e.g. ``"sftp.gainwell.password"``). The operator-facing
env-var form is mapped via the ``_ENV_NAME_FOR`` table at
the bottom of this module.
Returns: Returns:
The secret string (stripped), or ``None`` if no tier produced The secret string, or None if the entry is missing or keyring
a value. is not installed.
Raises:
RuntimeError: if ``<env_name>_FILE`` is set but the file at
that path is missing or unreadable. The operator made a
positive statement about where the secret lives; silent
fall-through would mask a misconfiguration.
""" """
env_name = _env_var_for(name) if not _HAS_KEYRING:
file_env = env_name + "_FILE" log.warning("keyring not installed; get_secret(%r) returning None", name)
file_path_raw = os.environ.get(file_env) return None
if file_path_raw:
# An empty string is treated as unset (matches the SP25 rule
# for plain env vars).
stripped_path = file_path_raw.strip()
if stripped_path:
try: try:
value = Path(stripped_path).read_text().strip() return keyring.get_password(SERVICE_NAME, name)
except OSError as exc:
raise RuntimeError(
f"failed to read {file_env}={stripped_path}: {exc}"
) from exc
if value:
log.debug("Secret %r resolved from file %r", name, stripped_path)
return value
raw = os.environ.get(env_name)
if raw is not None:
stripped = raw.strip()
if stripped:
log.debug("Secret %r resolved from env var %r", name, env_name)
return stripped
# Empty env var — treat as absent and fall through.
if _HAS_KEYRING:
try:
value = keyring.get_password(SERVICE_NAME, name)
if value is not None:
return value
except Exception as exc: # noqa: BLE001 (Keychain can raise anything) except Exception as exc: # noqa: BLE001 (Keychain can raise anything)
log.warning("Keychain get_secret(%r) failed: %s", name, exc) log.warning("Keychain get_secret(%r) failed: %s", name, exc)
return None return None
@@ -146,13 +77,3 @@ def has_keyring() -> bool:
"""True if the ``keyring`` library is importable (regardless of whether """True if the ``keyring`` library is importable (regardless of whether
the Keychain entry actually exists).""" the Keychain entry actually exists)."""
return _HAS_KEYRING return _HAS_KEYRING
# Mapping from Keychain account name (used in ``SftpBlock.auth``) to
# the operator-facing env var. Keeping this table here means callers
# don't have to remember the difference between
# ``sftp.gainwell.password`` (Keychain account) and
# ``CYCLONE_SFTP_PASSWORD`` (env var).
_ENV_NAME_FOR: dict[str, str] = {
"sftp.gainwell.password": "CYCLONE_SFTP_PASSWORD",
}
-439
View File
@@ -1,439 +0,0 @@
"""CLI subcommand: ``python -m cyclone seed``.
Populates the local DB with a deterministic batch of sample claims
plus matching activity events, so the Dashboard / Claims / Activity
Log pages have something to render in a fresh dev environment.
This is a dev-only convenience the production DB never sees this
command (no ``[env: dev]`` gate, but it's never wired into any
container image). It writes rows with a recognizable batch id prefix
(``SEED-``) so ``--reset`` can clean them up without touching real
ingested data.
Why the data shape is hand-rolled here
--------------------------------------
The ``Claim`` ORM table stores its billing_provider / payer /
subscriber / service_lines payloads in a ``raw_json`` blob that the
read path (``store.to_ui_claim_from_orm``) parses back into the UI
shape. Mirroring the 837 parser's structure keeps the UI rendering
identical to a real ingestion wire format parity, not shortcuts.
Activity rows are similar: ``payload_json`` carries the message
string the Dashboard's activity card shows.
Subcommands
-----------
``python -m cyclone seed`` insert the default batch.
``python -m cyclone seed --count N`` insert N claims (default 96).
``python -m cyclone seed --reset`` wipe previously-seeded rows
first, then insert a fresh batch.
``python -m cyclone seed --status`` print row counts without
inserting anything.
Exit codes
----------
* 0 success (or already seeded and not asked to reset).
* 1 DB error during insert.
* 2 usage error (bad flag value).
The command is idempotent: re-running without ``--reset`` is a no-op
once a seed batch exists. This keeps ``cyclone``-driven boot scripts
safe to re-run.
"""
from __future__ import annotations
import json
import random
import sys
from datetime import datetime, timedelta, timezone
import click
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance, SessionLocal
SEED_BATCH_PREFIX = "SEED-"
SEED_CLAIM_PREFIX = "CLM-S"
SEED_REMIT_PREFIX = "REM-S"
SEED_DEFAULT_COUNT = 96
SEED_ACTIVITY_LIMIT = 28
# Mirror src/data/sampleData.ts on the frontend so the Dashboard looks
# the same in dev mode as it did with the in-memory fixtures.
SAMPLE_PROVIDERS = [
{
"npi": "1730187395",
"name": "Cedar Park Family Medicine",
"tax_id": "47-3829104",
"address": "1401 Medical Pkwy",
"city": "Cedar Park",
"state": "TX",
"zip": "78613",
"phone": "(512) 555-0142",
},
{
"npi": "1528471902",
"name": "Lakeside Orthopedics",
"tax_id": "83-1172654",
"address": "900 W Lake Dr",
"city": "Austin",
"state": "TX",
"zip": "78746",
"phone": "(512) 555-0188",
},
{
"npi": "1982036471",
"name": "Hill Country Pediatrics",
"tax_id": "74-5520183",
"address": "205 State Hwy 27",
"city": "Marble Falls",
"state": "TX",
"zip": "78654",
"phone": "(830) 555-0117",
},
]
SAMPLE_PAYERS = [
"Blue Cross Blue Shield",
"United Healthcare",
"Aetna",
"Cigna",
"Humana",
"Medicare",
"Medicaid TX",
]
SAMPLE_CPTS = ["99213", "99214", "99203", "93000", "85025", "80053", "73721", "20610"]
SAMPLE_FIRST_NAMES = [
"Avery", "Jordan", "Riley", "Casey", "Morgan",
"Quinn", "Reese", "Sasha", "Drew", "Hayden",
]
SAMPLE_LAST_NAMES = [
"Nguyen", "Patel", "Garcia", "Cohen", "Okafor",
"Martinez", "Hwang", "Brooks", "Singh", "Tanaka",
]
# Frontend uses "accepted"/"pending" which the backend ClaimState
# enum doesn't carry. Map them to the closest real states so the
# Dashboard's filters (e.g. "status === 'submitted' || 'pending'")
# still find a match for in-flight work.
STATUS_WEIGHTS: list[tuple[ClaimState, int]] = [
(ClaimState.SUBMITTED, 1),
(ClaimState.RECEIVED, 1),
(ClaimState.DENIED, 1),
(ClaimState.PAID, 3),
(ClaimState.PAID, 3),
(ClaimState.PARTIAL, 1),
]
DENIAL_REASONS = [
"CO-97: Service included in another service",
"CO-16: Claim lacks information",
"CO-50: Non-covered service",
"PR-1: Deductible amount",
]
def _now_utc() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _pick(rng: random.Random, items):
return items[rng.randint(0, len(items) - 1)]
def _build_seed_rows(
count: int, *, seed: int = 42,
) -> tuple[Batch, list[Claim], list[ActivityEvent], list[Remittance]]:
"""Build a deterministic Batch + N Claims + matching activity events.
PAID and PARTIAL claims get a paired ``Remittance`` row (with a
realistic ``total_paid`` derived from the billed amount) so the
Dashboard's "Received" KPI lights up; the wire shape mirrors what
``CycloneStore.iter_claims`` expects to find via
``Claim.matched_remittance_id`` ``Remittance.total_paid``.
The seed is fixed so re-running produces identical data keeps
screenshots and dev environments stable.
"""
rng = random.Random(seed)
now = _now_utc()
parsed_at = now - timedelta(minutes=5)
batch = Batch(
id=f"{SEED_BATCH_PREFIX}{now.strftime('%Y%m%d-%H%M%S')}",
kind="837",
input_filename="seed/sample-837.edi",
parsed_at=parsed_at,
totals_json={"claim_count": count},
validation_json={"errors": [], "warnings": []},
raw_result_json={},
)
claims: list[Claim] = []
activity: list[ActivityEvent] = []
remittances: list[Remittance] = []
seq = 10428 # Match the frontend fixture's id range
for i in range(count):
provider = _pick(rng, SAMPLE_PROVIDERS)
payer = _pick(rng, SAMPLE_PAYERS)
cpt = _pick(rng, SAMPLE_CPTS)
first = _pick(rng, SAMPLE_FIRST_NAMES)
last = _pick(rng, SAMPLE_LAST_NAMES)
state, _ = _pick(rng, STATUS_WEIGHTS)
days_back = rng.randint(0, 200)
submitted = now - timedelta(days=days_back, hours=rng.randint(0, 23))
billed = 80 + rng.randint(0, 1400)
if state == ClaimState.PAID:
received = int(billed * (0.6 + rng.random() * 0.4))
elif state == ClaimState.PARTIAL:
received = int(billed * (0.2 + rng.random() * 0.3))
elif state == ClaimState.DENIED:
received = 0
else:
received = 0
claim_id = f"{SEED_CLAIM_PREFIX}{(seq + i):05d}"
denial_reason = _pick(rng, DENIAL_REASONS) if state == ClaimState.DENIED else None
# Build a paired Remittance for any claim with received > 0. Status
# code 1 = "Primary payer forward" — the 835 CAS code that the
# remittance mapper turns into "received". Mirror the 835 parser's
# raw_json shape (a stripped provider/payer/service_lines block)
# so downstream debug views still render something useful.
matched_remit_id: str | None = None
if received > 0:
matched_remit_id = f"{SEED_REMIT_PREFIX}{(seq + i):05d}"
remittances.append(Remittance(
id=matched_remit_id,
batch_id=batch.id,
payer_claim_control_number=f"PCN-{(seq + i):05d}",
claim_id=claim_id,
status_code="1",
status_label="Primary payer forward",
total_charge=float(billed),
total_paid=float(received),
adjustment_amount=float(billed - received),
received_at=submitted + timedelta(days=rng.randint(2, 14)),
raw_json={
"payer": {"name": payer},
"provider": {
"npi": provider["npi"],
"name": provider["name"],
},
"service_lines": [
{
"procedure": {"code": cpt},
"charge_amount": float(billed),
"paid_amount": float(received),
}
],
},
))
raw_json = {
"billing_provider": {
"npi": provider["npi"],
"name": provider["name"],
"tax_id": provider["tax_id"],
# 837 parser produces ``address`` as a structured dict so
# the claim-detail drawer can render line1/line2/city/state/zip.
# A flat string here crashes ``_address_to_ui`` with
# ``AttributeError: 'str' object has no attribute 'get'``.
"address": {
"line1": provider["address"],
"line2": None,
"city": provider["city"],
"state": provider["state"],
"zip": provider["zip"],
},
"phone": provider["phone"],
},
"payer": {"name": payer},
"subscriber": {"first_name": first, "last_name": last},
"service_lines": [
{
"procedure": {"code": cpt},
"charge_amount": float(billed),
"service_date": submitted.date().isoformat(),
}
],
}
claims.append(Claim(
id=claim_id,
batch_id=batch.id,
patient_control_number=claim_id.replace(SEED_CLAIM_PREFIX, "PCN-"),
service_date_from=submitted.date(),
service_date_to=submitted.date(),
charge_amount=billed,
provider_npi=provider["npi"],
payer_id=None,
state=state,
state_changed_at=submitted,
rejection_reason=denial_reason,
resubmit_count=0,
matched_remittance_id=matched_remit_id,
raw_json=raw_json,
))
# First SEED_ACTIVITY_LIMIT claims get an activity event. Mirrors
# the frontend buildActivity() shape (claim_paid / claim_denied /
# claim_accepted / claim_submitted) but maps frontend statuses
# onto the backend's wire enum.
if i < SEED_ACTIVITY_LIMIT:
if state == ClaimState.PAID:
kind = "claim_paid"
verb = "Paid"
elif state == ClaimState.DENIED:
kind = "claim_denied"
verb = "Denied"
elif state in (ClaimState.RECEIVED, ClaimState.PARTIAL):
kind = "claim_accepted"
verb = "Accepted"
else:
kind = "claim_submitted"
verb = "Submitted"
payload = {
"message": f"{verb} {claim_id} · {first} {last}",
"npi": provider["npi"],
"amount": float(billed),
}
activity.append(ActivityEvent(
ts=submitted,
kind=kind,
batch_id=batch.id,
claim_id=claim_id,
remittance_id=None,
payload_json=payload,
))
return batch, claims, activity, remittances
def _existing_seed_batch_ids(s) -> list[str]:
rows = s.query(Batch.id).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).all()
return [r[0] for r in rows]
def _delete_seed_rows(s) -> int:
"""Delete every row that was inserted by the seed.
The seed writes rows whose ids begin with ``SEED-`` (batches),
``CLM-S`` (claims), or ``REM-S`` (remittances). Activity events
are joined to seeded batches when the batch is still around, but
we also clean up orphan activity rows (no FK from
``activity_events.claim_id`` to ``claims.id``) by ``claim_id``
prefix. Returns the number of batch rows deleted.
"""
# Activity first — its rows reference both claims and batches, so
# delete the events tied to seed claims first, then any that were
# only tied to a now-orphaned seed batch.
activity = s.query(ActivityEvent).filter(
ActivityEvent.claim_id.like(f"{SEED_CLAIM_PREFIX}%")
).delete(synchronize_session=False)
activity2 = s.query(ActivityEvent).filter(
ActivityEvent.batch_id.like(f"{SEED_BATCH_PREFIX}%")
).delete(synchronize_session=False)
# Remittances (FK to claims; cascade may not fire without FK pragma,
# so we delete them explicitly to clear the symmetric FK first).
remits = s.query(Remittance).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).delete(synchronize_session=False)
claims = s.query(Claim).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).delete(synchronize_session=False)
batches = s.query(Batch).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).delete(synchronize_session=False)
s.commit()
click.echo(f" Removed {batches} seeded batch(es), {claims} claim(s), "
f"{remits} remittance(s), {activity + activity2} activity event(s).")
return batches
def _insert(
batch: Batch,
claims: list[Claim],
activity: list[ActivityEvent],
remittances: list[Remittance],
) -> None:
with SessionLocal()() as s:
s.add(batch)
s.add_all(claims)
s.add_all(activity)
s.add_all(remittances)
s.commit()
def _print_status(s) -> None:
from sqlalchemy import func
seed_batches = s.query(func.count(Batch.id)).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).scalar() or 0
seed_claims = s.query(func.count(Claim.id)).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).scalar() or 0
seed_remits = s.query(func.count(Remittance.id)).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).scalar() or 0
total_claims = s.query(func.count(Claim.id)).scalar() or 0
total_remits = s.query(func.count(Remittance.id)).scalar() or 0
total_activity = s.query(func.count(ActivityEvent.id)).scalar() or 0
click.echo(f" Seed batches: {seed_batches}")
click.echo(f" Seed claims: {seed_claims}")
click.echo(f" Seed remits: {seed_remits}")
click.echo(f" Total claims: {total_claims}")
click.echo(f" Total remits: {total_remits}")
click.echo(f" Total activity: {total_activity}")
@click.command("seed")
@click.option(
"--count",
default=SEED_DEFAULT_COUNT,
show_default=True,
type=click.IntRange(min=1, max=10_000),
help="Number of claims to insert in the new batch.",
)
@click.option(
"--reset",
is_flag=True,
help="Delete any existing seeded batch (and its claims/activity) before inserting.",
)
@click.option(
"--status",
"show_status",
is_flag=True,
help="Print current seeded row counts and exit.",
)
def seed_cli(count: int, reset: bool, show_status: bool) -> None:
"""Populate the local DB with a deterministic batch of sample claims."""
with SessionLocal()() as s:
if show_status:
_print_status(s)
return
existing = _existing_seed_batch_ids(s)
if existing and not reset:
click.echo(
f"Seed already present (batch ids: {', '.join(existing)}). "
f"Re-run with --reset to replace, or --status to inspect.",
err=True,
)
sys.exit(0)
if reset and existing:
deleted = _delete_seed_rows(s)
click.echo(f" Removed {deleted} seeded batch(es).")
try:
batch, claims, activity, remittances = _build_seed_rows(count)
except Exception as exc:
click.echo(f"Failed to build seed rows: {exc}", err=True)
sys.exit(1)
try:
_insert(batch, claims, activity, remittances)
except Exception as exc:
click.echo(f"Failed to insert seed rows: {exc}", err=True)
sys.exit(1)
click.echo(f" Inserted batch {batch.id} with {len(claims)} claims, "
f"{len(remittances)} remittances, {len(activity)} activity events.")
_print_status(s)
File diff suppressed because it is too large Load Diff
-377
View File
@@ -1,377 +0,0 @@
"""SQLAlchemy-backed batch store for parsed X12 files.
The package exposes a single ``CycloneStore`` class and a module-level
singleton (``store``). All persistence flows through SQLAlchemy
sessions via ``db.SessionLocal()()`` (the double-paren function-style
accessor).
This ``__init__.py`` is the facade it re-exports every public name
plus the 3 private helpers imported by tests (``_claim_status_from_validation``,
``_persist_835_remit``, ``_remittance_835_row``) from the sibling modules:
exceptions AlreadyMatchedError, NotMatchedError, InvalidStateError
records BatchKind, BatchRecord, BatchRecord837, BatchRecord835
orm_builders ORM row builders + the 3 private-helper re-exports
ui UI serializers (to_ui_*)
write add_record + private event/reconcile helpers
batches Batch reads + _BatchesShim (test-cleanup shim)
claim_detail Single-claim reads + matched-pair drift audit
kpis Dashboard aggregation
acks 999 / TA1 / 277CA ACK persistence
backups Backup-pending marker inserts
inbox Manual match/unmatch + lane listing
providers Provider/payer/clearhouse config
The ``CycloneStore`` class below keeps its current method signatures as
thin delegations for back-compat with existing callers and tests.
Public API (preserved verbatim):
CycloneStore, store, BatchRecord, BatchRecord837, BatchRecord835,
BatchKind, AlreadyMatchedError, NotMatchedError, InvalidStateError,
utcnow, dashboard_kpis, check_matched_pair_drift
Backward-compat shims for tests:
``_lock`` a threading.RLock used by 7 test files for cleanup.
``_batches.clear()`` wipes all rows from the DB tables; the
``_BatchesShim`` class lives in ``batches.py``.
"""
from __future__ import annotations
import threading
from datetime import datetime, timezone
def utcnow() -> datetime:
"""tz-aware UTC `datetime` (replaces the old `utcnow_iso` string helper)."""
return datetime.now(timezone.utc)
from . import write
from .batches import (
_BatchesShim,
_row_to_record,
all_batches,
get_batch,
get_record,
list_batches,
load_two_for_diff,
)
from .claim_detail import (
check_matched_pair_drift,
count_claims,
count_remittances,
distinct_providers,
get_claim_detail,
get_remittance,
iter_claims,
iter_remittances,
recent_activity,
summarize_remittances,
)
from .acks import (
add_277ca_ack,
add_999_ack,
add_ta1_ack,
get_277ca_ack,
get_ack,
get_ta1_ack,
list_277ca_acks,
list_acks,
list_ta1_acks,
)
from .claim_acks import (
add_claim_ack as _add_claim_ack,
batch_envelope_index as _batch_envelope_index,
find_ack_orphans as _find_ack_orphans,
list_acks_for_claim as _list_acks_for_claim,
list_claims_for_ack as _list_claims_for_ack,
remove_claim_ack as _remove_claim_ack,
)
from .backups import add_backup_pending
from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
from .inbox import list_unmatched, manual_match, manual_unmatch
from .kpis import dashboard_kpis
from .orm_builders import (
_claim_status_from_validation,
_persist_835_remit,
_remittance_835_row,
)
from .providers import (
ensure_clearhouse_seeded,
get_clearhouse,
get_payer_config,
get_provider,
list_payers,
list_providers,
update_clearhouse,
upsert_provider,
)
from .records import BatchKind, BatchRecord, BatchRecord837, BatchRecord835
from .ui import (
to_ui_ack,
to_ui_claim_ack,
to_ui_claim_detail,
to_ui_claim_from_orm,
to_ui_provider,
to_ui_remittance_from_orm,
to_ui_remittance_with_adjustments,
to_ui_ta1_ack,
to_ui_two77ca_ack,
)
# ---------------------------------------------------------------------------
# UI mappers: ORM rows → simpler UI types.
# ---------------------------------------------------------------------------
# Backward-compat shim: tests called ``_batches.clear()`` on the in-memory
# store. The DB-backed store doesn't have an in-memory list, so we expose
# a tiny shim object whose ``.clear()`` wipes the DB. The class itself
# lives in ``batches.py`` (imported above as ``_BatchesShim``); the
# ``CycloneStore.__init__`` below instantiates that imported class.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# CycloneStore: the SQLAlchemy-backed facade.
# ---------------------------------------------------------------------------
class CycloneStore:
"""SQLAlchemy-backed facade over the parsed X12 store.
Each public method opens a short-lived session via
``db.SessionLocal()()`` so callers don't have to manage session
lifecycles. Concurrency is handled by the SQLAlchemy engine; the
``_lock`` attribute is a no-op ``RLock`` retained for backward
compatibility with code that wrapped cleanup in a lock context.
"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._batches = _BatchesShim()
# -- write path -----------------------------------------------------
def add(self, record: BatchRecord, *, event_bus=None):
"""Persist a parsed batch (837P or 835)."""
return write.add_record(record, event_bus=event_bus)
def _publish_events_sync(self, event_bus, record, claim_ids, remit_ids):
return write.publish_events_sync(event_bus, record, claim_ids, remit_ids)
@staticmethod
def _sync_publish(event_bus, kind: str, payload: dict) -> None:
return write._sync_publish(event_bus, kind, payload)
# -- read path ------------------------------------------------------
def get_batch(self, batch_id: str) -> dict | None:
return get_batch(batch_id)
def get(self, batch_id: str) -> BatchRecord | None:
return get_record(batch_id)
def list(self, *, limit: int = 100) -> list[BatchRecord]:
return list_batches(limit=limit)
def get_remittance(self, remittance_id: str) -> dict | None:
return get_remittance(remittance_id)
def get_claim_detail(self, claim_id: str) -> dict | None:
return get_claim_detail(claim_id)
def all(self) -> list[BatchRecord]:
return all_batches()
def load_two_for_diff(
self,
a_id: str,
b_id: str,
) -> tuple[BatchRecord, BatchRecord]:
return load_two_for_diff(a_id, b_id)
def iter_claims(self, **kwargs):
return iter_claims(**kwargs)
def iter_remittances(self, **kwargs):
return iter_remittances(**kwargs)
# -- count helpers (SP27 Task 13b) ---------------------------------
#
# The list endpoints (``/api/claims`` and ``/api/remittances``)
# previously computed ``total`` by calling ``iter_*`` with default
# ``limit=100`` and taking ``len(...)``. With 60k+ claims and 800+
# remits in production, the reported total silently capped at 100
# even when the UI rendered a "100" KPI tile and a 100-row table —
# the page looked complete but the population was 600× larger. These
# helpers reuse the iter's filter pipeline with an effectively
# unbounded ``limit`` so the count reflects the true DB population,
# not a 100-row sample. Mirrors Dashboard's "server-aggregated
# counts" fix from commit 59c3275.
def count_claims(
self,
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> int:
"""Count claims that would be returned by ``iter_claims``."""
return count_claims(
batch_id=batch_id, status=status, provider_npi=provider_npi,
payer=payer, date_from=date_from, date_to=date_to,
)
def count_remittances(
self,
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> int:
"""Count remittances that would be returned by ``iter_remittances``."""
return count_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
)
def summarize_remittances(
self,
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict:
"""Return ``{count, total_paid, total_adjustments}`` summed over
the remittance population that ``iter_remittances`` would return.
"""
return summarize_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
)
def distinct_providers(self) -> list[dict]:
return distinct_providers()
def recent_activity(self, *, limit: int = 200) -> list[dict]:
return recent_activity(limit=limit)
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
def add_ack(self, **kwargs):
return add_999_ack(**kwargs)
def list_acks(self):
return list_acks()
def get_ack(self, ack_id):
return get_ack(ack_id)
# -- TA1 (Interchange Acknowledgment) -------------------------------
def add_ta1_ack(self, **kwargs):
return add_ta1_ack(**kwargs)
def list_ta1_acks(self):
return list_ta1_acks()
def get_ta1_ack(self, ack_id):
return get_ta1_ack(ack_id)
# -- 277CA (SP10) --------------------------------------------------
def add_277ca_ack(self, **kwargs):
return add_277ca_ack(**kwargs)
def list_277ca_acks(self):
return list_277ca_acks()
def get_277ca_ack(self, ack_id):
return get_277ca_ack(ack_id)
# -- SP28: claim↔ack auto-link join table --------------------------
def add_claim_ack(self, **kwargs):
"""Persist one claim_acks link row.
Mirrors the publish-from-store contract used by the ACK paths:
when ``event_bus`` is passed, the matching ``claim_ack_written``
event fires after commit so live-tail subscribers on both the
claim and the ack side see the row immediately.
"""
return _add_claim_ack(**kwargs)
def list_acks_for_claim(self, claim_id):
"""Return every ClaimAck row for one claim (newest first)."""
return _list_acks_for_claim(claim_id)
def list_claims_for_ack(self, kind, ack_id):
"""Return every ClaimAck row for one ack."""
return _list_claims_for_ack(kind, ack_id)
def find_ack_orphans(self, kind):
"""Return acks with no resolvable link (Inbox ack-orphans lane)."""
return _find_ack_orphans(kind)
def remove_claim_ack(self, link_id, *, event_bus=None):
"""Unlink one row. Publishes ``claim_ack_dropped`` on the bus."""
return _remove_claim_ack(link_id, event_bus=event_bus)
def batch_envelope_index(self):
"""Return a {envelope.control_number: batch.id} map for D10 Pass 1."""
return _batch_envelope_index()
# -- SP17: encrypted DB backups -------------------------------------
def add_backup_pending(self, *, filename: str, backup_dir: str):
return add_backup_pending(filename=filename, backup_dir=backup_dir)
# -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self, *, kind="both"):
return list_unmatched(kind=kind)
def manual_match(self, claim_id, remit_id):
return manual_match(claim_id, remit_id)
def manual_unmatch(self, claim_id):
return manual_unmatch(claim_id)
# ------------------------------------------------------------------
# SP9: providers / payers / payer_configs / clearhouse
# ------------------------------------------------------------------
def list_providers(self, *, is_active=True):
return list_providers(is_active=is_active)
def get_provider(self, npi):
return get_provider(npi)
def upsert_provider(self, provider):
return upsert_provider(provider)
def list_payers(self, *, is_active=True):
return list_payers(is_active=is_active)
def get_payer_config(self, payer_id, transaction_type):
return get_payer_config(payer_id, transaction_type)
def get_clearhouse(self):
return get_clearhouse()
def update_clearhouse(self, block):
return update_clearhouse(block)
def ensure_clearhouse_seeded(self):
return ensure_clearhouse_seeded()
# Module-level singleton — same import path the old InMemoryStore used.
store = CycloneStore()
-242
View File
@@ -1,242 +0,0 @@
"""ACK persistence — 999 / TA1 / 277CA acknowledgements.
Each ACK type has its own ORM row (Ack / Ta1Ack / Two77caAck). The
write methods are simple inserts; the list/get methods are simple
queries. Fail-soft: persistence errors are logged but do not block
parsing.
SP25: every write path publishes one event on the EventBus so the
Acks page live-tail can subscribe mirrors the existing
``claim_written`` / ``remittance_written`` / ``activity_recorded``
publish pattern (see ``cyclone.store.write``). Publish is best-effort:
a failing subscriber MUST NOT roll back the persisted row.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import TYPE_CHECKING
from cyclone import db
from cyclone.db import Ack
from . import utcnow
from .ui import to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
from .write import _sync_publish
if TYPE_CHECKING:
from cyclone.pubsub import EventBus
log = logging.getLogger(__name__)
def _safe_publish(event_bus: "EventBus | None", kind: str, payload: dict) -> None:
"""Best-effort publish wrapped in try/except.
Mirrors ``CycloneStore._publish_events_sync`` never raises, never
rolls back the persisted row. Falls back to skipping silently when
the bus doesn't implement the private ``_sync_publish`` interface
(e.g. test stubs).
"""
if event_bus is None:
return
try:
_sync_publish(event_bus, kind, payload)
except Exception: # noqa: BLE001
log.exception("acks store: %s publish failed", kind)
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
def add_999_ack(
*,
source_batch_id: str,
accepted_count: int,
rejected_count: int,
received_count: int,
ack_code: str,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Ack:
"""Persist a 999 ACK row and return it.
``source_batch_id`` must reference an existing ``batches.id``.
For received 999s with no source batch the caller should pass a
synthetic id (e.g. ``"999-<interchange_control_number>"``)
see ``/api/parse-999`` for that policy.
``raw_json`` is the full ``ParseResult999`` model dump; the
detail endpoint surfaces it without re-parsing the original
X12 text.
SP25: when ``event_bus`` is provided, publishes one
``ack_received`` event (with the full ``to_ui_ack`` row shape)
after the row commits so the Acks page live-tail sees new 999s
the moment they land.
"""
with db.SessionLocal()() as s:
row = Ack(
source_batch_id=source_batch_id,
accepted_count=accepted_count,
rejected_count=rejected_count,
received_count=received_count,
ack_code=ack_code,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
with db.SessionLocal()() as s:
payload = to_ui_ack(s.get(Ack, row_id))
_safe_publish(event_bus, "ack_received", payload)
return row
def list_acks() -> list[db.Ack]:
"""Return every 999 ACK row, newest first (auto-increment id desc)."""
with db.SessionLocal()() as s:
return (
s.query(Ack)
.order_by(Ack.id.desc())
.all()
)
def get_ack( ack_id: int) -> db.Ack | None:
"""Return a single ACK row by id, or ``None`` if not found."""
with db.SessionLocal()() as s:
return s.get(Ack, ack_id)
# -- TA1 (Interchange Acknowledgment) -------------------------------
def add_ta1_ack(
*,
source_batch_id: str,
control_number: str,
interchange_date: date | None,
interchange_time: str | None,
ack_code: str,
note_code: str | None,
ack_generated_date: date | None,
sender_id: str,
receiver_id: str,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Ta1Ack:
"""Persist a TA1 (Interchange Acknowledgment) row and return it.
Mirrors :meth:`add_999_ack` for the lower-level envelope ack. The
flat columns are promoted out of ``raw_json`` so the list
endpoint can sort/filter without a JSON parse; the full
``ParseResultTa1`` stays in ``raw_json`` for the detail endpoint.
SP25: when ``event_bus`` is provided, publishes one
``ta1_ack_received`` event after the row commits.
"""
with db.SessionLocal()() as s:
row = db.Ta1Ack(
source_batch_id=source_batch_id,
control_number=control_number,
interchange_date=interchange_date,
interchange_time=interchange_time,
ack_code=ack_code,
note_code=note_code,
ack_generated_date=ack_generated_date,
sender_id=sender_id,
receiver_id=receiver_id,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
with db.SessionLocal()() as s:
payload = to_ui_ta1_ack(s.get(db.Ta1Ack, row_id))
_safe_publish(event_bus, "ta1_ack_received", payload)
return row
def list_ta1_acks() -> list[db.Ta1Ack]:
"""Return every TA1 ACK row, newest first (auto-increment id desc).
Mirrors :meth:`list_acks` the API endpoint slices to its own
``limit`` so the ``total`` field reflects the full row count.
"""
with db.SessionLocal()() as s:
return (
s.query(db.Ta1Ack)
.order_by(db.Ta1Ack.id.desc())
.all()
)
def get_ta1_ack( ack_id: int) -> db.Ta1Ack | None:
"""Return a single TA1 ACK row by id, or ``None`` if not found."""
with db.SessionLocal()() as s:
return s.get(db.Ta1Ack, ack_id)
# -- 277CA (SP10) --------------------------------------------------
def add_277ca_ack(
*,
source_batch_id: str,
control_number: str,
accepted_count: int,
rejected_count: int,
paid_count: int,
pended_count: int,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Two77caAck:
"""Persist a 277CA (Claim Acknowledgment) row and return it.
Mirrors :meth:`add_999_ack` but for the claim-level ack. The
per-claim status detail stays in ``raw_json``; only the four
counts are promoted so the list endpoint stays fast.
SP25: when ``event_bus`` is provided, publishes one
``two77ca_ack_received`` event after the row commits.
"""
with db.SessionLocal()() as s:
row = db.Two77caAck(
source_batch_id=source_batch_id,
control_number=control_number,
accepted_count=accepted_count,
rejected_count=rejected_count,
paid_count=paid_count,
pended_count=pended_count,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
with db.SessionLocal()() as s:
payload = to_ui_two77ca_ack(s.get(db.Two77caAck, row_id))
_safe_publish(event_bus, "two77ca_ack_received", payload)
return row
def list_277ca_acks() -> list[db.Two77caAck]:
"""Return every 277CA ACK row, newest first (auto-increment id desc)."""
with db.SessionLocal()() as s:
return (
s.query(db.Two77caAck)
.order_by(db.Two77caAck.id.desc())
.all()
)
def get_277ca_ack( ack_id: int) -> db.Two77caAck | None:
"""Return a single 277CA ACK row by id, or ``None`` if not found."""
with db.SessionLocal()() as s:
return s.get(db.Two77caAck, ack_id)
-340
View File
@@ -1,340 +0,0 @@
"""SP32 Task 6: backfill rendering/service-provider NPIs from on-disk files.
The T4 writers populate ``Claim.rendering_provider_npi`` (from NM1*82 in
837P Loop 2420A) and ``Remittance.rendering_provider_npi`` (from the
NM1*1P service-provider segment in 835 Loop 2100). Rows ingested before
T4 was wired (or ingested via a path that bypasses the writer e.g. an
ad-hoc ``store.add`` from a notebook) still have a NULL column.
This module re-parses on-disk X12 files and patches up those columns on
matching rows. It is **idempotent**: rows whose
``rendering_provider_npi`` is already non-NULL are left untouched, and
re-running the same file twice is a clean no-op.
Public surface
--------------
* :func:`backfill_rendering_provider_npi` entry point used by the CLI.
* :class:`BackfillSummary` counts dataclass echoed back as a one-line
summary by the CLI.
Reconcile is run once at the end so the new typed NPI arm (T5) can fire
retroactively across the open claim/remit pairs the backfill touched.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
from sqlalchemy import select
from cyclone import db
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_837 import parse as parse_837
from cyclone.parsers.payer import PayerConfig, PayerConfig835
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 837P fixture uses NM1*82 → rendering_provider_npi.
# 835 fixture uses NM1*1P → service_provider_npi (mapped onto the same
# Remittance.rendering_provider_npi column by T4 _remittance_835_row).
# ---------------------------------------------------------------------------
@dataclass
class BackfillSummary:
"""Counts emitted by ``backfill_rendering_provider_npi``.
The CLI echoes a one-line summary (`claims_updated=N remits_updated=N
`) at the end of the run.
"""
claims_updated: int = 0
remits_updated: int = 0
files_processed: int = 0
files_skipped: int = 0
# ---------------------------------------------------------------------------
# Parser wrappers — keep exceptions local so one bad file can't abort
# the whole backfill run.
# ---------------------------------------------------------------------------
def _parse_837_file(path: Path) -> tuple[list, Path | None]:
"""Re-parse a single 837P file. Returns ``(claims, broken_alias)``.
On success, ``claims`` is the list of parsed ``ClaimOutput`` rows
and ``broken_alias`` is None. On any failure, ``claims`` is empty
and ``broken_alias`` is the same ``path`` so the caller can log
which file was skipped.
"""
try:
text = path.read_text()
except OSError as exc:
log.warning("backfill: cannot read %s: %s", path, exc)
return [], path
try:
result = parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
except Exception as exc: # noqa: BLE001 — failure isolated per-file
log.warning("backfill: 837 parse failed for %s: %s", path, exc)
return [], path
return list(result.claims), None
def _parse_835_file(path: Path) -> tuple[list, Path | None]:
"""Re-parse a single 835 file. Returns ``(claims, broken_alias)``.
``claims`` is the list of parsed ``ClaimPayment`` rows.
"""
try:
text = path.read_text()
except OSError as exc:
log.warning("backfill: cannot read %s: %s", path, exc)
return [], path
try:
result = parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
except Exception as exc: # noqa: BLE001
log.warning("backfill: 835 parse failed for %s: %s", path, exc)
return [], path
return list(result.claims), None
# ---------------------------------------------------------------------------
# DB-patching helpers — keep the per-row update logic in one place.
# ---------------------------------------------------------------------------
def _patch_claim_rendering_npi(parsed_claims: list) -> int:
"""Update ``Claim.rendering_provider_npi`` for any parsed 837 claim.
Only rows whose column is currently NULL are touched. Returns the
count of rows updated.
"""
from cyclone.db import Claim, SessionLocal
updated = 0
with SessionLocal()() as session:
for parsed in parsed_claims:
npi = getattr(parsed, "rendering_provider_npi", None)
if not npi:
continue
row = session.get(Claim, parsed.claim_id)
if row is None:
continue
if row.rendering_provider_npi is not None:
# Already populated (e.g. by a prior backfill run, or by
# the T4 writer if the claim was ingested afterwards).
continue
row.rendering_provider_npi = npi
updated += 1
if updated:
session.commit()
return updated
def _patch_remit_rendering_npi(parsed_claims: list) -> int:
"""Update ``Remittance.rendering_provider_npi`` for any parsed 835 claim.
The 835 NM1*1P segment maps onto ``ClaimPayment.service_provider_npi``
(which the T4 writer copies into ``Remittance.rendering_provider_npi``).
We match on ``payer_claim_control_number``; only NULL columns are
touched. Returns the count of rows updated.
"""
from cyclone.db import Remittance, SessionLocal
updated = 0
with SessionLocal()() as session:
for parsed in parsed_claims:
npi = getattr(parsed, "service_provider_npi", None)
if not npi:
continue
target_pcn = getattr(parsed, "payer_claim_control_number", None)
if not target_pcn:
continue
row = session.execute(
select(Remittance).where(
Remittance.payer_claim_control_number == target_pcn,
Remittance.rendering_provider_npi.is_(None),
)
).scalars().first()
if row is None:
continue
row.rendering_provider_npi = npi
updated += 1
if updated:
session.commit()
return updated
# ---------------------------------------------------------------------------
# Reconcile pass — run once across every 835 batch so the T5 scoring arm
# can fire retroactively on pairs the backfill touched.
# ---------------------------------------------------------------------------
def _run_reconcile_sweep() -> int:
"""Run :func:`cyclone.reconcile.run` over every 835 batch. Returns count.
Failures are isolated per-batch so one bad batch can't abort the
sweep the goal is "best-effort retroactive reconcile".
"""
from sqlalchemy import select as _select
from cyclone import reconcile as _reconcile
from cyclone.db import Batch, SessionLocal
completed = 0
with SessionLocal()() as session:
batch_ids = [
row[0] for row in session.execute(
_select(Batch.id).where(Batch.kind == "835")
).all()
]
# Each batch gets its own session — ``reconcile.run`` does not commit,
# so an exception in one batch must not orphan a half-flushed
# transaction.
for bid in batch_ids:
try:
with SessionLocal()() as s:
_reconcile.run(s, bid)
s.commit()
completed += 1
except Exception as exc: # noqa: BLE001
log.warning("backfill: reconcile failed for batch %s: %s", bid, exc)
return completed
# ---------------------------------------------------------------------------
# Public entry point.
# ---------------------------------------------------------------------------
# Supported ``--type`` flag values; also ``None`` means auto-detect.
TransactionType = str | None
def backfill_rendering_provider_npi(
*,
files: Iterable[Path] | None = None,
input_dir: Path | None = None,
transaction_type: TransactionType = None,
) -> BackfillSummary:
"""Re-parse on-disk 837p + 835 files and populate the typed NPI columns.
Args:
files: explicit list of file paths to re-parse.
input_dir: directory to scan for ``*.txt`` / ``*.edi`` files.
Files are scanned one level deep.
transaction_type: ``"837p"`` or ``"835"``. ``None`` auto-detects
by attempting the 837p parser first and falling back to
the 835 parser.
Returns:
:class:`BackfillSummary` with populated counts.
Idempotent: only writes columns that are currently NULL; re-running
on the same files is a clean no-op. After patching, runs
:func:`cyclone.reconcile.run` over every 835 batch so the T5
scoring arm can re-fire on the touched pairs.
"""
summary = BackfillSummary()
# 1. Resolve the candidate file set.
candidates: list[Path] = []
if files:
candidates.extend(Path(f) for f in files)
if input_dir is not None:
for ext in ("*.txt", "*.edi", "*.835", "*.837", "*.x12"):
candidates.extend(input_dir.glob(ext))
candidates = [p for p in candidates if p.is_file()]
# 2. Re-parse each file and patch matching rows.
for path in candidates:
kind = transaction_type or _sniff_kind(path)
if kind == "837p":
parsed_claims, broken = _parse_837_file(path)
if broken is not None:
summary.files_skipped += 1
continue
summary.files_processed += 1
summary.claims_updated += _patch_claim_rendering_npi(parsed_claims)
elif kind == "835":
parsed_claims, broken = _parse_835_file(path)
if broken is not None:
summary.files_skipped += 1
continue
summary.files_processed += 1
summary.remits_updated += _patch_remit_rendering_npi(parsed_claims)
else:
# Auto-detect tried both parsers and both failed → skip.
summary.files_skipped += 1
# 3. Reconcile sweep — let the T5 NPI arm fire retroactively.
if summary.claims_updated or summary.remits_updated:
try:
_run_reconcile_sweep()
except Exception: # noqa: BLE001 — sweep is best-effort
log.exception("backfill: reconcile sweep failed")
return summary
def _sniff_kind(path: Path) -> TransactionType:
"""Best-effort transaction-type sniff (content + filename).
Returns ``"837p"``, ``"835"``, or ``None`` if both parsers fail.
Used only when the caller didn't pin ``transaction_type`` explicitly
via the CLI.
"""
try:
text = path.read_text()
except OSError:
return None
# Cheap filename hint.
name = path.name.lower()
if name.endswith(".835") or "835" in name:
return _try_both(path, text, prefer="835")
if name.endswith(".837") or "837" in name or "837p" in name:
return _try_both(path, text, prefer="837p")
# Content: ISA + ST. 837 starts with "ST*837", 835 starts with "ST*835".
if "ST*837*" in text:
return "837p"
if "ST*835*" in text:
return "835"
# Fallback: try 837p parser, then 835.
return _try_both(path, text, prefer="837p")
def _try_both(path: Path, text: str, *, prefer: str) -> TransactionType:
"""Try the preferred parser first, then the other; return first winner."""
if prefer == "837p":
try:
parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
return "837p"
except Exception:
try:
parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
return "835"
except Exception:
return None
try:
parse_835(text, PayerConfig835.co_medicaid_835(), input_file=str(path))
return "835"
except Exception:
try:
parse_837(text, PayerConfig.co_medicaid(), input_file=str(path))
return "837p"
except Exception:
return None
__all__ = [
"BackfillSummary",
"backfill_rendering_provider_npi",
]
-39
View File
@@ -1,39 +0,0 @@
"""Backup-pending marker inserts — SP17 encrypted DB backups.
``add_backup_pending()`` inserts a ``pending`` row for a backup that is
about to start; the BackupService updates ``status`` / ``size_bytes``
/ ``db_fingerprint`` / ``table_count`` / ``completed_at`` after the
encrypted blob lands on disk.
"""
from cyclone import db
from . import utcnow
# -- SP17: encrypted DB backups -------------------------------------
def add_backup_pending(*, filename: str, backup_dir: str) -> db.DbBackup:
"""Insert a ``pending`` row for a backup that is about to start.
The BackupService fills in ``status`` / ``size_bytes`` /
``db_fingerprint`` / ``table_count`` / ``completed_at`` after
the encrypted blob lands on disk.
"""
with db.SessionLocal()() as s:
row = db.DbBackup(
filename=filename,
backup_dir=backup_dir,
size_bytes=0,
db_fingerprint=None,
table_count=0,
created_at=utcnow(),
completed_at=None,
status="pending",
error_message=None,
)
s.add(row)
s.commit()
s.refresh(row)
return row
-155
View File
@@ -1,155 +0,0 @@
"""Batch read APIs and the legacy _BatchesShim.
The ``_BatchesShim`` class is retained for back-compat with the
``with store._lock: store._batches.clear()`` test-cleanup idiom used
in 7 test files. Its ``clear()`` method wipes all rows from the DB
tables so tests that depended on a fresh in-memory list per-test get
a fresh DB state per-test.
"""
from __future__ import annotations
from datetime import timezone
from cyclone import db
from cyclone.db import (
ActivityEvent,
Batch,
CasAdjustment,
Claim,
Match,
Remittance,
)
from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835
from .records import BatchRecord, BatchRecord837, BatchRecord835
class _BatchesShim:
"""Drop-in replacement for the old in-memory ``_batches`` list.
``clear()`` removes every row from the DB tables in FK-safe order.
Other list operations are not implemented because the only call site
is the ``clear()`` inside the test fixtures (``test_api_gets.py`` and
``test_api_parse_persists.py``).
"""
def clear(self) -> None: # type: ignore[no-untyped-def]
with db.SessionLocal()() as s:
s.query(ActivityEvent).delete()
s.query(Match).delete()
s.query(CasAdjustment).delete()
s.query(Remittance).delete()
s.query(Claim).delete()
s.query(Batch).delete()
s.commit()
def _row_to_record(row: Batch) -> BatchRecord:
"""Rehydrate a ``BatchRecord`` (837 or 835) from a Batch ORM row.
The full ``ParseResult`` / ``ParseResult835`` lives in
``raw_result_json`` (stashed at insert time). Re-parsing JSON
here means callers get the same typed Pydantic object the old
in-memory store handed out, so api.py and tests that do
``rec.result.claims`` keep working unchanged.
SQLite drops tz info on round-trip even though the column type
is ``DateTime(timezone=True)``. We re-attach UTC so the
``BatchRecord`` validator (``parsed_at must be tz-aware``)
passes.
"""
if row.kind == "835":
result_cls = ParseResult835
else:
result_cls = ParseResult
payload = row.raw_result_json or {}
result = result_cls.model_validate(payload)
parsed_at = row.parsed_at
if parsed_at is not None and parsed_at.tzinfo is None:
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
record_cls = BatchRecord835 if row.kind == "835" else BatchRecord837
return record_cls(
id=row.id,
kind=row.kind,
input_filename=row.input_filename,
parsed_at=parsed_at,
result=result,
)
def get_batch(batch_id: str) -> dict | None:
"""Return a summary dict for ``batch_id`` or ``None`` if missing.
The dict shape matches what ``/api/batches/{id}`` callers need:
``id``, ``kind``, ``input_filename``, ``parsed_at``, and the
full ``result`` (raw_result_json) as a dict.
"""
with db.SessionLocal()() as s:
row = s.get(Batch, batch_id)
if row is None:
return None
return {
"id": row.id,
"kind": row.kind,
"input_filename": row.input_filename,
"parsed_at": row.parsed_at,
"result": row.raw_result_json,
}
def get_record(batch_id: str) -> BatchRecord | None:
"""Return the ``BatchRecord`` for ``batch_id`` or ``None``.
Preserves the in-memory store contract: callers get a Pydantic
``BatchRecord`` (subclass ``BatchRecord837`` / ``BatchRecord835``)
with ``.id``, ``.kind``, ``.input_filename``, ``.parsed_at``,
and ``.result`` (typed ``ParseResult`` / ``ParseResult835``).
"""
with db.SessionLocal()() as s:
row = s.get(Batch, batch_id)
if row is None:
return None
return _row_to_record(row)
def list_batches(*, limit: int = 100) -> list[BatchRecord]:
"""Return up to ``limit`` ``BatchRecord``s, newest first."""
with db.SessionLocal()() as s:
rows = (
s.query(Batch)
.order_by(Batch.parsed_at.desc())
.limit(limit)
.all()
)
return [_row_to_record(r) for r in rows]
def all_batches() -> list[BatchRecord]:
"""Return every ``BatchRecord``, oldest first (no pagination)."""
with db.SessionLocal()() as s:
rows = s.query(Batch).order_by(Batch.parsed_at.asc()).all()
return [_row_to_record(r) for r in rows]
def load_two_for_diff(a_id: str, b_id: str) -> tuple[BatchRecord, BatchRecord]:
"""Load two batches by id for the side-by-side diff view.
Returns ``(a, b)`` as ``BatchRecord`` objects. Raises
:class:`LookupError` when either id is missing the API layer
catches it and maps it to ``404 Not Found`` (matching the
``GET /api/batches/{id}`` contract). The two loads happen in
independent sessions so a transient failure on one side can't
poison the other.
Used exclusively by :mod:`cyclone.batch_diff` via the
``/api/batch-diff`` endpoint.
"""
a = get_record(a_id)
if a is None:
raise LookupError(f"batch {a_id} not found")
b = get_record(b_id)
if b is None:
raise LookupError(f"batch {b_id} not found")
return a, b
-360
View File
@@ -1,360 +0,0 @@
"""SP28: claim_acks persistence + batch envelope index (D10).
Five methods on top of the new ``ClaimAck`` ORM table:
* ``add_claim_ack`` insert one link row (manual or auto) and
publish ``claim_ack_written`` on the bus.
* ``list_acks_for_claim`` every link row for one claim (per-claim
only; TA1 batch-level rows are filtered out by the API layer).
* ``list_claims_for_ack`` every link row for one ack.
* ``find_ack_orphans`` acks with no resolvable claim (Inbox
ack-orphans lane).
* ``remove_claim_ack`` unlink. Publishes ``claim_ack_dropped``.
* ``batch_envelope_index`` D10 in-memory map of
``Batch.envelope.control_number batch.id`` (cheap to rebuild;
re-built once per ingest).
Each mutating method opens its own ``db.SessionLocal()()`` session
so callers don't have to manage session lifecycles. Publishes are
best-effort (wrapped in :func:`_safe_publish`) so a failing bus
subscriber cannot roll back the persisted row.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from cyclone import db
from cyclone.db import (
Ack,
Batch,
Claim,
ClaimAck,
Ta1Ack,
Two77caAck,
)
from . import utcnow
from .ui import to_ui_claim_ack
from .write import _sync_publish
if TYPE_CHECKING:
from cyclone.pubsub import EventBus
log = logging.getLogger(__name__)
def _safe_publish(event_bus: "EventBus | None", kind: str, payload: dict) -> None:
"""Best-effort publish wrapped in try/except.
Mirrors ``cyclone.store.acks._safe_publish`` never raises,
never rolls back the persisted row.
"""
if event_bus is None:
return
try:
_sync_publish(event_bus, kind, payload)
except Exception: # noqa: BLE001
log.exception("claim_acks store: %s publish failed", kind)
# ---------------------------------------------------------------------------
# D10 batch envelope index
# ---------------------------------------------------------------------------
def batch_envelope_index() -> dict[str, str]:
"""Build a ``{envelope.control_number: batch.id}`` map.
D10 primary join key (spec §D10). Built once per ingest from
every Batch row whose ``raw_result_json`` carries an envelope
cost is O(N batches), currently ~16, so trivial. Kept as a
plain dict so callers can ``index.get(scn)`` to resolve.
"""
out: dict[str, str] = {}
with db.SessionLocal()() as s:
rows = (
s.query(Batch.id, Batch.raw_result_json)
.filter(Batch.kind == "837p")
.all()
)
for bid, raw in rows:
env = (raw or {}).get("envelope") or {}
ctrl = env.get("control_number")
if isinstance(ctrl, str) and ctrl:
out[ctrl] = bid
return out
# ---------------------------------------------------------------------------
# Mutators
# ---------------------------------------------------------------------------
def add_claim_ack(
*,
claim_id: str | None,
batch_id: str | None,
ack_id: int,
ack_kind: str,
ak2_index: int | None = None,
set_control_number: str | None = None,
set_accept_reject_code: str | None = None,
linked_by: str = "auto",
event_bus: "EventBus | None" = None,
now: datetime | None = None,
) -> ClaimAck:
"""Persist one link row and return it.
The DB-level unique index
``ux_claim_acks_dedup(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL`` enforces
idempotency for the per-AK2 case. The 277CA (ak2_index NULL)
and TA1 (claim_id NULL) paths are deduplicated by application
code see :func:`cyclone.claim_acks._link_exists`.
When ``event_bus`` is provided, publishes one ``claim_ack_written``
event (with the full :func:`cyclone.store.ui.to_ui_claim_ack`
payload) so the live-tail subscribers on both
``/api/claims/{id}/acks/stream`` and
``/api/acks/{kind}/{id}/claims/stream`` see new rows the moment
they land.
"""
if ack_kind not in ("999", "277ca", "ta1"):
raise ValueError(f"add_claim_ack: unknown ack_kind={ack_kind!r}")
if linked_by not in ("auto", "manual"):
raise ValueError(f"add_claim_ack: linked_by={linked_by!r}")
if claim_id is None and batch_id is None:
raise ValueError(
"add_claim_ack: at least one of claim_id/batch_id must be set"
)
ts = now or utcnow()
with db.SessionLocal()() as s:
row = ClaimAck(
claim_id=claim_id,
batch_id=batch_id,
ack_id=ack_id,
ack_kind=ack_kind,
ak2_index=ak2_index,
set_control_number=set_control_number,
set_accept_reject_code=set_accept_reject_code,
linked_at=ts,
linked_by=linked_by,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
with db.SessionLocal()() as s:
payload = to_ui_claim_ack(s.get(ClaimAck, row_id))
_safe_publish(event_bus, "claim_ack_written", payload)
return row
def remove_claim_ack(
link_id: int,
*,
event_bus: "EventBus | None" = None,
) -> bool:
"""Unlink. Returns ``True`` when a row was deleted.
Publishes ``claim_ack_dropped`` with ``{"id", "claim_id"}`` so
the live-tail subscribers can remove the link from their local
store. Does NOT touch ``Claim.state`` the unlink is purely
about the link row, per spec §D6.
"""
with db.SessionLocal()() as s:
row = s.get(ClaimAck, link_id)
if row is None:
return False
payload = {
"id": row.id,
"claim_id": row.claim_id,
"ack_id": row.ack_id,
"ack_kind": row.ack_kind,
}
s.delete(row)
s.commit()
if event_bus is not None:
_safe_publish(event_bus, "claim_ack_dropped", payload)
return True
# ---------------------------------------------------------------------------
# Readers
# ---------------------------------------------------------------------------
def list_acks_for_claim(claim_id: str) -> list[ClaimAck]:
"""Return every ClaimAck row for one claim (newest first)."""
with db.SessionLocal()() as s:
return (
s.query(ClaimAck)
.filter(ClaimAck.claim_id == claim_id)
.order_by(ClaimAck.id.desc())
.all()
)
def list_claims_for_ack(kind: str, ack_id: int) -> list[ClaimAck]:
"""Return every ClaimAck row for one ack (any kind).
For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
For TA1: returns 0..1 row (envelope-level, populated batch_id).
"""
if kind not in ("999", "277ca", "ta1"):
raise ValueError(f"list_claims_for_ack: unknown kind={kind!r}")
with db.SessionLocal()() as s:
return (
s.query(ClaimAck)
.filter(
ClaimAck.ack_kind == kind,
ClaimAck.ack_id == ack_id,
)
.order_by(ClaimAck.id.asc())
.all()
)
# ---------------------------------------------------------------------------
# Orphan detection (Inbox "Ack orphans" lane — spec §D7)
# ---------------------------------------------------------------------------
def find_ack_orphans(kind: str) -> list[dict]:
"""Return acks with no resolvable Claim row of their own kind.
Used by the Inbox ack-orphans lane for the operator's manual
reconciliation flow. The downstream ``/api/inbox/ack-orphans``
endpoint calls this and returns the rendered shape.
"Orphan" means: for 999 / 277CA, ``ack_kind=kind AND ack_id IN
(acks_with_no_claim_acks_link)``. For TA1, "orphan" means the
TA1 row exists but no Batch with matching sender/receiver was
resolved (so no link row was created).
Output dict shape (one row per orphan ack, rendered by
:func:`to_ui_claim_ack`-style serialization):
* ``kind`` "999" / "277ca" / "ta1"
* ``ack_id`` the ack row's id
* ``control_number`` for 999/277CA, envelope.control_number;
for TA1, ta1.control_number
* ``set_control_numbers`` empty list when no claims match
* ``raw_summary`` flat copy of the ack's UI shape for the
lane-header counts
"""
if kind not in ("999", "277ca", "ta1"):
raise ValueError(f"find_ack_orphans: unknown kind={kind!r}")
out: list[dict] = []
if kind == "999":
ack_table = Ack
ctrl_attr = None
elif kind == "277ca":
ack_table = Two77caAck
ctrl_attr = "control_number"
else:
ack_table = Ta1Ack
ctrl_attr = "control_number"
with db.SessionLocal()() as s:
# Every ack row of the given kind, with a LEFT JOIN against
# any claim_acks link; orphan when NO link was created.
if kind == "999":
# For 999, the "ack has no link" means no ClaimAck row
# was emitted at all (the auto-linker emits one per AK2
# even when the AK2 is rejected, so 999 with at least
# one AK2 that resolved to a claim is never an orphan).
# We treat a 999 as orphan when it has zero ClaimAck
# rows tied to its id.
all_acks = s.query(Ack).order_by(Ack.id.desc()).all()
for ack_row in all_acks:
count = (
s.query(ClaimAck)
.filter(ClaimAck.ack_kind == "999",
ClaimAck.ack_id == ack_row.id)
.count()
)
if count == 0:
out.append({
"kind": "999",
"ack_id": ack_row.id,
"control_number": _ack_control_number(ack_row, "999"),
"parsed_at": (
ack_row.parsed_at.isoformat().replace(
"+00:00", "Z"
) if ack_row.parsed_at else None
),
})
elif kind == "277ca":
all_acks = s.query(Two77caAck).order_by(Two77caAck.id.desc()).all()
for ack_row in all_acks:
count = (
s.query(ClaimAck)
.filter(ClaimAck.ack_kind == "277ca",
ClaimAck.ack_id == ack_row.id)
.count()
)
if count == 0:
out.append({
"kind": "277ca",
"ack_id": ack_row.id,
"control_number": ack_row.control_number or "",
"parsed_at": (
ack_row.parsed_at.isoformat().replace(
"+00:00", "Z"
) if ack_row.parsed_at else None
),
})
else:
all_acks = s.query(Ta1Ack).order_by(Ta1Ack.id.desc()).all()
for ack_row in all_acks:
count = (
s.query(ClaimAck)
.filter(ClaimAck.ack_kind == "ta1",
ClaimAck.ack_id == ack_row.id)
.count()
)
if count == 0:
out.append({
"kind": "ta1",
"ack_id": ack_row.id,
"control_number": ack_row.control_number or "",
"parsed_at": (
ack_row.parsed_at.isoformat().replace(
"+00:00", "Z"
) if ack_row.parsed_at else None
),
})
return out
def _ack_control_number(ack_row: Ack, kind: str) -> str:
"""Best-effort control-number lookup for a 999 ack row.
The 999 ORM row doesn't carry the envelope's control_number in
a dedicated column; we re-derive it from ``raw_json`` (the same
source :func:`cyclone.store.ui.to_ui_ack` uses for the patient
control number).
"""
raw = ack_row.raw_json or {}
env = raw.get("envelope") or {}
return env.get("control_number") or ""
__all__ = [
"add_claim_ack",
"batch_envelope_index",
"find_ack_orphans",
"list_acks_for_claim",
"list_claims_for_ack",
"remove_claim_ack",
]
-710
View File
@@ -1,710 +0,0 @@
"""Claim- and remittance-detail read APIs.
Includes the only large non-write query in the store:
``get_claim_detail`` which joins Claim + CasAdjustment + ActivityEvent
history for the right-drawer UI.
Also hosts ``check_matched_pair_drift()`` the SP27 startup invariant
audit that returns the count of ClaimRemit pairs where
``matched_remittance_id`` doesn't agree with the FK in ``remittances.claim_id``.
It lives here (not in its own module) because it reads the same pair of
tables as ``get_claim_detail``'s matched-remittance summary.
"""
from __future__ import annotations
from datetime import timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import (
ActivityEvent,
CasAdjustment,
Claim,
ClaimState,
Remittance,
)
from .ui import (
CLAIM_DETAIL_HISTORY_LIMIT,
_date_in_bounds,
_iso_z,
_svc_to_wire_dict,
to_ui_claim_detail,
to_ui_claim_from_orm,
to_ui_provider,
to_ui_remittance_from_orm,
to_ui_remittance_with_adjustments,
)
def get_remittance(remittance_id: str) -> dict | None:
"""Return a UI-shaped remittance dict with ``adjustments`` array.
Joins the persisted ``CasAdjustment`` rows for ``remittance_id``
and labels each via :mod:`cyclone.parsers.cas_codes`. Returns
``None`` when the remittance is not found so the API layer can
map that to a 404.
SP7: also returns the per-line SVC composites
(``serviceLinePayments``) and the CLP-level (claim-level) CAS
bucket (``claimLevelAdjustments``) so the remit drawer can show
per-line payments + adjustments without a second fetch.
"""
with db.SessionLocal()() as s:
row = s.get(Remittance, remittance_id)
if row is None:
return None
cas_rows = (
s.query(CasAdjustment)
.filter(CasAdjustment.remittance_id == remittance_id)
.all()
)
parsed_at = (
row.batch.parsed_at if row.batch is not None else row.received_at
)
if parsed_at is not None and parsed_at.tzinfo is None:
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
body = to_ui_remittance_with_adjustments(
row,
batch_id=row.batch_id,
parsed_at=parsed_at,
cas_rows=cas_rows,
)
# SP7: per-line SVC composites + claim-level CAS bucket.
from cyclone.db import ServiceLinePayment as SLP
slps = (
s.query(SLP)
.filter(SLP.remittance_id == remittance_id)
.order_by(SLP.line_number)
.all()
)
body["serviceLinePayments"] = [
_svc_to_wire_dict(svc) for svc in slps
]
body["claimLevelAdjustments"] = [
{
"id": c.id,
"group_code": c.group_code,
"reason_code": c.reason_code,
"amount": str(Decimal(str(c.amount))),
"quantity": (
str(Decimal(str(c.quantity)))
if c.quantity is not None
else None
),
}
for c in cas_rows
if c.service_line_payment_id is None
]
return body
def get_claim_detail(claim_id: str) -> dict | None:
"""Return the SP4 detail-drawer shape for one claim, or ``None``.
Drives ``GET /api/claims/{claim_id}``. Returns the spec-shaped
dict from :func:`to_ui_claim_detail` (header + state + parties +
validation + service lines + diagnoses + raw segments) stitched
with the claim's recent activity history and, if paired, a
matched-remittance summary.
Returns ``None`` when ``claim_id`` is not in the DB so the API
layer can map that to a 404 the URL-driven drawer
distinguishes "claim doesn't exist" from "fetch failed" (the
spec §3.4 calls for a distinct 404 state in the drawer).
The history is capped at :data:`CLAIM_DETAIL_HISTORY_LIMIT`
(50, per the spec) and ordered ``ts DESC`` so the most recent
event is first. The status string in ``matchedRemittance``
follows the same ``reconciled``/``received`` mapping used by
:func:`to_ui_remittance_from_orm`.
"""
# Lazy import — same pattern used throughout this module to
# avoid a circular store ↔ db import on cold start.
from cyclone import db as _db
with _db.SessionLocal()() as s:
row = s.get(Claim, claim_id)
if row is None:
return None
history_rows = (
s.query(ActivityEvent)
.filter(ActivityEvent.claim_id == claim_id)
.order_by(ActivityEvent.ts.desc())
.limit(CLAIM_DETAIL_HISTORY_LIMIT)
.all()
)
# Claim.batch_id is FK NOT NULL with ON DELETE CASCADE, so
# ``row.batch`` is always populated in normal flow. Re-attach
# UTC only when SQLite drops the tzinfo on read.
parsed_at = row.batch.parsed_at
if parsed_at.tzinfo is None:
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
detail = to_ui_claim_detail(
row,
batch_id=row.batch_id,
parsed_at=parsed_at,
)
detail["stateHistory"] = [
{
"kind": ev.kind,
# SQLite drops tzinfo on read; rows are stored UTC
# at write time (see ``add`` / ``manual_match``),
# so re-attach UTC if needed to keep the spec
# contract that ``ts`` ends in Z.
"ts": _iso_z(ev.ts),
"batchId": ev.batch_id,
"remittanceId": ev.remittance_id,
}
for ev in history_rows
]
if row.matched_remittance_id is not None:
remit = s.get(Remittance, row.matched_remittance_id)
if remit is not None:
status = (
"reconciled"
if remit.status_code in ("21", "22")
else "received"
)
detail["matchedRemittance"] = {
"id": remit.id,
"totalPaid": float(remit.total_paid or 0),
"status": status,
"receivedAt": _iso_z(remit.received_at),
}
# If the remittance was deleted out from under the FK
# (the FK is ``ON DELETE SET NULL`` so the column is
# already cleared in normal flow), the matched_remittance_id
# would be None here and we wouldn't enter this branch.
# If the FK is non-null but the row is gone (e.g. tests
# that bypass the cascade), fall through with the
# default ``None`` — the UI shows "no match" rather
# than crashing.
# SP7 §5.2: slim per-line projection so the ServiceLinesTable
# can show Paid + Adjustments columns without a second fetch.
# The 837 side is keyed by ``claim_service_line_number`` (the
# 1-based line number from raw_json) since 837 service lines
# are not a separate ORM table.
from cyclone.db import (
LineReconciliation, ServiceLinePayment, CasAdjustment,
)
slim_lrs = list(
s.query(LineReconciliation)
.filter(LineReconciliation.claim_id == claim_id)
.all()
)
svc_ids_for_cas = [
lr.service_line_payment_id
for lr in slim_lrs
if lr.service_line_payment_id is not None
]
cas_sums_by_svc: dict = {}
svc_by_id_slim: dict = {}
if svc_ids_for_cas:
cas_rows = (
s.query(CasAdjustment.service_line_payment_id, CasAdjustment.amount)
.filter(CasAdjustment.service_line_payment_id.in_(svc_ids_for_cas))
.all()
)
from collections import defaultdict
agg = defaultdict(lambda: Decimal("0"))
for svc_id, amount in cas_rows:
agg[svc_id] += Decimal(str(amount))
cas_sums_by_svc = {k: str(v) for k, v in agg.items()}
for svc in (
s.query(ServiceLinePayment)
.filter(ServiceLinePayment.id.in_(svc_ids_for_cas))
.all()
):
svc_by_id_slim[svc.id] = svc
slim_by_num: dict = {
lr.claim_service_line_number: lr
for lr in slim_lrs
if lr.claim_service_line_number is not None
}
line_reconciliation_slim: list = []
for sl in detail["serviceLines"]:
ln = sl.get("lineNumber")
lr = slim_by_num.get(ln)
if lr is None:
line_reconciliation_slim.append({
"lineNumber": ln,
"status": "unmatched_837_only",
"paid": None,
"adjustmentsSum": None,
})
continue
svc = (
svc_by_id_slim.get(lr.service_line_payment_id)
if lr.service_line_payment_id
else None
)
line_reconciliation_slim.append({
"lineNumber": ln,
"status": lr.status,
"paid": str(Decimal(str(svc.payment))) if svc else None,
"adjustmentsSum": (
cas_sums_by_svc.get(lr.service_line_payment_id)
if lr.service_line_payment_id
else None
),
})
detail["lineReconciliation"] = line_reconciliation_slim
return detail
def iter_claims(
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Return UI-shaped claim dicts from the DB.
Filters mirror the in-memory version. The ``payer`` filter is
a case-insensitive substring on the payer's ``name``, recovered
from each claim's ``raw_json`` payload (the DB stores it there
because ``Claim`` itself only carries ``payer_id``).
"""
with db.SessionLocal()() as s:
q = s.query(Claim)
if batch_id is not None:
q = q.filter(Claim.batch_id == batch_id)
if status is not None:
q = q.filter(Claim.state == ClaimState(status))
if provider_npi is not None:
q = q.filter(Claim.provider_npi == provider_npi)
rows = q.all()
# Bulk-load matched-remittance totals so the UI's "Received"
# KPI + per-claim received_amount reflect real paid amounts
# rather than always-0. One SQL roundtrip for the whole page
# rather than per-claim lookups.
matched_ids = [
r.matched_remittance_id
for r in rows
if r.matched_remittance_id
]
received_by_remit: dict[str, float] = {}
if matched_ids:
for rid, total_paid in (
s.query(Remittance.id, Remittance.total_paid)
.filter(Remittance.id.in_(matched_ids))
.all()
):
received_by_remit[rid] = float(total_paid or 0)
out: list[dict] = []
for r in rows:
raw = r.raw_json or {}
bp = raw.get("billing_provider", {})
payer_obj = raw.get("payer", {})
sub = raw.get("subscriber", {})
claim_hdr = raw.get("claim", {})
service_lines = raw.get("service_lines", [])
parsed_at_iso = (
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
if r.batch is not None
else ""
)
cpt = (
service_lines[0].get("procedure", {}).get("code", "")
if service_lines
else ""
)
out.append({
"id": r.id,
"patientName": (
f"{sub.get('first_name', '')} "
f"{sub.get('last_name', '')}".strip()
),
"providerNpi": bp.get("npi") or r.provider_npi or "",
"payerName": payer_obj.get("name") or "",
"cptCode": cpt,
"billedAmount": float(r.charge_amount or 0),
"receivedAmount": received_by_remit.get(
r.matched_remittance_id, 0.0
),
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
"denialReason": None,
"submissionDate": parsed_at_iso,
"batchId": r.batch_id,
"parsedAt": parsed_at_iso,
# Keep these so we can sort on them in-memory below.
"_sort_billedAmount": float(r.charge_amount or 0),
"_sort_submissionDate": parsed_at_iso,
})
if payer is not None:
needle = payer.casefold()
out = [
c for c in out
if needle in (c.get("payerName") or "").casefold()
]
out = [
c for c in out
if _date_in_bounds(c, "submissionDate", date_from, date_to)
]
if sort is not None:
out.sort(
key=lambda c: c.get(f"_sort_{sort}", 0) or 0,
reverse=(order == "desc"),
)
# Drop the private sort keys before returning.
for c in out:
c.pop("_sort_billedAmount", None)
c.pop("_sort_submissionDate", None)
return out[offset:offset + limit]
def iter_remittances(
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
"""Return UI-shaped remittance dicts from the DB."""
with db.SessionLocal()() as s:
q = s.query(Remittance)
if batch_id is not None:
q = q.filter(Remittance.batch_id == batch_id)
if claim_id is not None:
q = q.filter(Remittance.claim_id == claim_id)
rows = q.all()
# Bulk-fetch all CAS rows for these remittances in one query
# (SP3 P2 follow-up — fixes the list-view's empty adjustments
# expansion). N+1-free.
cas_by_remit: dict[str, list] = {}
if rows:
from cyclone.parsers.cas_codes import reason_label
cas_rows = (
s.query(CasAdjustment)
.filter(CasAdjustment.remittance_id.in_([r.id for r in rows]))
.all()
)
for c in cas_rows:
cas_by_remit.setdefault(c.remittance_id, []).append(c)
out: list[dict] = []
for r in rows:
raw = r.raw_json or {}
parsed_at_iso = (
r.batch.parsed_at.isoformat().replace("+00:00", "Z")
if r.batch is not None
else r.received_at.isoformat().replace("+00:00", "Z")
)
payer_name = ""
if r.batch is not None and r.batch.raw_result_json:
payer_name = (
r.batch.raw_result_json.get("payer", {}).get("name", "")
)
adjustments = [
{
"group": c.group_code,
"reason": c.reason_code,
"label": reason_label(c.group_code, c.reason_code),
"amount": float(c.amount),
"quantity": float(c.quantity) if c.quantity is not None else None,
}
for c in cas_by_remit.get(r.id, [])
]
out.append({
"id": r.id,
"claimId": r.claim_id or "",
"payerName": payer_name,
"paidAmount": float(r.total_paid or 0),
"adjustmentAmount": float(r.adjustment_amount or 0),
"status": (
"reconciled" if r.status_code in ("21", "22")
else "received"
),
"denialReason": None,
"validationWarnings": [],
"receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
"batchId": r.batch_id,
"parsedAt": parsed_at_iso,
"adjustments": adjustments,
"_sort_receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
})
if payer is not None:
out = [r for r in out if r.get("payerName") == payer]
out = [
r for r in out
if _date_in_bounds(r, "receivedDate", date_from, date_to)
]
if sort is not None:
out.sort(
key=lambda r: r.get(f"_sort_{sort}", 0) or 0,
reverse=(order == "desc"),
)
for r in out:
r.pop("_sort_receivedDate", None)
return out[offset:offset + limit]
def distinct_providers() -> list[dict]:
"""Group claims by NPI and return one row per provider."""
with db.SessionLocal()() as s:
rows = s.query(Claim).all()
by_npi: dict[str, dict] = {}
for r in rows:
npi = r.provider_npi or ""
if npi not in by_npi:
raw = r.raw_json or {}
bp = raw.get("billing_provider", {})
by_npi[npi] = to_ui_provider(
npi=npi,
name=bp.get("name") or "",
tax_id=bp.get("tax_id"),
address=None,
city=None,
state=None,
zip=None,
phone=None,
claim_count=0,
outstanding_ar=0.0,
)
by_npi[npi]["claimCount"] += 1
return list(by_npi.values())
def recent_activity(*, limit: int = 200) -> list[dict]:
"""Return recent activity events from the DB, newest first.
SP21 Task 2.5: each row also carries ``claimId`` and
``remittanceId`` (read from the ORM columns) so the Dashboard's
Recent-activity card can route clicks to the right entity
drawer via ``src/lib/event-routing.ts``. Both are nullable
strings; the wire shape uses camelCase keys to match the
existing ``npi`` / ``amount`` fields and the frontend
``Activity`` interface.
"""
with db.SessionLocal()() as s:
rows = (
s.query(ActivityEvent)
.order_by(ActivityEvent.ts.desc())
.limit(limit)
.all()
)
return [
{
"id": f"ae-{r.id}",
"kind": r.kind,
"message": (r.payload_json or {}).get("message", ""),
"timestamp": r.ts.isoformat().replace("+00:00", "Z"),
"npi": (r.payload_json or {}).get("npi"),
"amount": (r.payload_json or {}).get("amount"),
"claimId": r.claim_id,
"remittanceId": r.remittance_id,
}
for r in rows
]
def check_matched_pair_drift() -> int:
"""Audit the ``Claim.matched_remittance_id`` ↔ ``Remittance.claim_id``
FK pair at startup. Non-blocking (SP27 Task 11).
The matched pair is a denormalized FK pair maintained transactionally
by ``manual_match``, ``manual_unmatch``, and ``reconcile.run``. A
pre-existing mismatch (e.g. a row written before a state migration
that added one column but not the other) would otherwise stay
invisible until the next operator pair attempt fails confusingly.
This check logs the count + up to N examples so operators can
investigate without booting the system.
Returns the number of drifted rows (0 means clean). Does not
raise; bootstrap continues even if drift is detected.
Count semantics: this returns *drifted rows*, not *drifted pairs*.
A single broken pair (``Claim.matched_remittance_id = X`` AND
``Remittance.claim_id = Y != nil`` with neither pointing back)
can produce TWO drifted rows one in case A (the claim) and
one in case B (the remit). In practice drift is almost always
asymmetric (one side NULL), so count == count(pairs); for the
fully-symmetric minority, divide by ~2 when alerting. Real drift
should be fixed by repairing the writer path, not by counting.
Cases:
A. Claim ``matched_remittance_id = X`` but the paired remit X's
``claim_id`` is either NULL or doesn't point back to the claim.
B. Remit ``claim_id = A`` but the paired claim A's
``matched_remittance_id`` is either NULL or doesn't point back.
"""
import logging
from sqlalchemy import select
log = logging.getLogger(__name__)
with db.SessionLocal()() as s:
# Case A: claim says X is paired, but X.claim_id doesn't point back.
case_a = list(
s.execute(
select(
Claim.id.label("claim_id"),
Claim.matched_remittance_id.label("claimed_remit_id"),
Remittance.claim_id.label("remit_points_to"),
)
.outerjoin(
Remittance,
Remittance.id == Claim.matched_remittance_id,
)
.where(Claim.matched_remittance_id.is_not(None))
.where(
(Remittance.claim_id.is_(None))
| (Remittance.claim_id != Claim.id)
)
).all()
)
# Case B: remit says A is paired, but A.matched_remittance_id
# doesn't point back.
case_b = list(
s.execute(
select(
Remittance.id.label("remit_id"),
Remittance.claim_id.label("claimed_claim_id"),
Claim.matched_remittance_id.label("claim_points_to"),
)
.outerjoin(
Claim,
Claim.id == Remittance.claim_id,
)
.where(Remittance.claim_id.is_not(None))
.where(
(Claim.matched_remittance_id.is_(None))
| (Claim.matched_remittance_id != Remittance.id)
)
).all()
)
total = len(case_a) + len(case_b)
if total == 0:
log.info("matched-pair drift check: 0 mismatches (clean)")
return 0
log.warning(
"matched-pair drift check: %d mismatched pair(s) (showing up to 5 "
"of each). Investigate via SELECT against claim / remittance; "
"manual re-pair via /api/claims/{id}/manual-match will repair.",
total,
)
for r in case_a[:5]:
log.warning(
" case A: claim %s -> remit %s, but remit.claim_id=%r",
r.claim_id,
r.claimed_remit_id,
r.remit_points_to,
)
for r in case_b[:5]:
log.warning(
" case B: remit %s -> claim %s, but claim.matched_remittance_id=%r",
r.remit_id,
r.claimed_claim_id,
r.claim_points_to,
)
return total
# ---------------------------------------------------------------------------
# Aggregate counters (SP25 / SP27): full-population counts and sums that
# the /api/* endpoints expose so page-local reductions (25 rows + live-tail
# delta) can never silently understate the true population.
# ---------------------------------------------------------------------------
_ITER_UNBOUNDED = 2**31 - 1
def count_claims(
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
payer: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> int:
"""Count claims that would be returned by ``iter_claims``.
Same filter parameters as ``iter_claims`` (excluding
``sort``/``order``/``limit``/``offset``, which don't affect cardinality).
"""
rows = iter_claims(
batch_id=batch_id, status=status, provider_npi=provider_npi,
payer=payer, date_from=date_from, date_to=date_to,
sort=None, order="desc", limit=_ITER_UNBOUNDED, offset=0,
)
return len(rows)
def count_remittances(
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> int:
"""Count remittances that would be returned by ``iter_remittances``.
Same filter parameters as ``iter_remittances`` (excluding
``sort``/``order``/``limit``/``offset``).
"""
rows = iter_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
sort=None, order="desc", limit=_ITER_UNBOUNDED, offset=0,
)
return len(rows)
def summarize_remittances(
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> dict:
"""Return ``{count, total_paid, total_adjustments}`` summed over the
remittance population that ``iter_remittances`` would return under
the same filters. Backs ``GET /api/remittances/summary`` the
Remittances page's KPI tiles (added in SP27).
"""
rows = iter_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
sort=None, order="desc", limit=_ITER_UNBOUNDED, offset=0,
)
total_paid = 0.0
total_adjustments = 0.0
for r in rows:
total_paid += float(r.get("paidAmount") or 0)
total_adjustments += float(r.get("adjustmentAmount") or 0)
return {
"count": len(rows),
"total_paid": total_paid,
"total_adjustments": total_adjustments,
}
-41
View File
@@ -1,41 +0,0 @@
"""Exception types raised by CycloneStore and its domain modules.
These are part of the public API callers (API endpoints) catch them
and translate to HTTP 409 Conflict responses.
"""
class AlreadyMatchedError(Exception):
"""Raised by ``CycloneStore.manual_match`` when the claim is already paired.
The claim's ``matched_remittance_id`` is set, so any new pairing would
clobber an existing match. Callers (the T15 API endpoint) should surface
this as a 409 Conflict.
"""
class NotMatchedError(Exception):
"""Raised by ``CycloneStore.manual_unmatch`` when the claim has no match.
Mirrors ``AlreadyMatchedError`` for the unpair operation. Same HTTP
treatment: 409 Conflict at the API layer.
"""
class InvalidStateError(Exception):
"""Raised when an apply_* pure fn returns a skipped ApplyIntent.
``reconcile.apply_payment`` / ``apply_reversal`` may return
``skipped=True`` (e.g. claim already in a terminal state, or reversal
on a non-paid claim). The store surfaces that as ``InvalidStateError``
rather than silently pairing. The T15 API endpoint maps this to a
409 Conflict and echoes ``current_state`` and ``activity_kind`` so
the UI can render a precise message.
"""
def __init__(self, current_state: str, activity_kind: str = "invalid_state"):
self.current_state = current_state
self.activity_kind = activity_kind
super().__init__(
f"invalid state {current_state} for apply (kind={activity_kind})"
)
-315
View File
@@ -1,315 +0,0 @@
"""Inbox lane state and manual match/unmatch operations.
``list_unmatched`` returns the 5-lane inbox view (unmatched claims +
unmatched remits). ``manual_match`` and ``manual_unmatch`` invoke the
reconcile pure functions and persist the resulting Match row (or
remove it). Invalid state transitions surface as ``InvalidStateError``.
"""
from cyclone import db
from cyclone.db import ActivityEvent, Claim, ClaimState, Match, Remittance
from .exceptions import AlreadyMatchedError, InvalidStateError, NotMatchedError
from . import utcnow
from .ui import to_ui_claim_from_orm, to_ui_remittance_from_orm
# -- manual reconciliation (T12) -----------------------------------
def list_unmatched(*, kind: str = "both") -> dict:
"""Return unmatched claims and/or remittances.
An unmatched claim is one with ``matched_remittance_id IS NULL``
either auto-match never paired it, or it was unpaired by
``manual_unmatch``. An unmatched remittance is one with
``claim_id IS NULL`` symmetric FK on the remittance side; we
update this in ``manual_match`` so the filter reflects the pair.
Note: T10's ``reconcile.run`` only writes the claim-side FK
(``Claim.matched_remittance_id``) when auto-pairing. Auto-matched
remittances therefore still show as "unmatched" here until the
pair is touched (re-ingest, manual unmatch + rematch). That gap
is intentional for T12 fixing it requires modifying T10.
``kind`` selects which side(s) to return:
- "claims": only claims
- "remittances": only remittances
- "both": both (default)
Returns ``{"claims": [...], "remittances": [...]}`` with the
unused side always an empty list (never absent) so callers can
unconditionally index.
"""
if kind not in ("claims", "remittances", "both"):
raise ValueError(
f"list_unmatched: unknown kind={kind!r} "
"(expected 'claims', 'remittances', or 'both')"
)
result: dict = {"claims": [], "remittances": []}
with db.SessionLocal()() as s:
if kind in ("claims", "both"):
rows = (
s.query(Claim)
.filter(Claim.matched_remittance_id.is_(None))
.order_by(Claim.id.asc())
.all()
)
for r in rows:
parsed_at = (
r.batch.parsed_at
if r.batch is not None
else r.service_date_from or utcnow()
)
result["claims"].append(
to_ui_claim_from_orm(
r, batch_id=r.batch_id, parsed_at=parsed_at,
# list_unmatched filters matched_remittance_id IS NULL,
# so every row has no remittance yet.
received_total=0.0,
)
)
if kind in ("remittances", "both"):
rows = (
s.query(Remittance)
.filter(Remittance.claim_id.is_(None))
.order_by(Remittance.id.asc())
.all()
)
for r in rows:
parsed_at = (
r.batch.parsed_at
if r.batch is not None
else r.received_at
)
result["remittances"].append(
to_ui_remittance_from_orm(
r, batch_id=r.batch_id, parsed_at=parsed_at,
)
)
return result
def manual_match(claim_id: str, remit_id: str) -> dict:
"""Pair a claim with a remittance manually (operator override).
Steps:
1. Load the claim; raise ``AlreadyMatchedError`` if it already
has a match (we never silently overwrite an existing pair).
2. Load the remittance; raise ``LookupError`` if missing.
3. Compute the new claim state via ``reconcile.apply_payment``
(or ``apply_reversal`` for status codes 21/22).
4. Insert a ``Match`` row with ``strategy="manual"``.
5. Update the claim (``state``, ``matched_remittance_id``) AND
the remittance (``claim_id``) so the symmetric FK reflects
the pair required for ``list_unmatched`` to drop them.
6. Record an ``ActivityEvent(kind="manual_match", ...)``.
7. Commit; return ``{"claim": <ui>, "match": <ui>}``.
``reconcile.apply_payment`` may return a noop (claim in terminal
state); we surface that as ``InvalidStateError`` rather than
silently pairing, because the operator clearly intended a state
change. The T15 API endpoint maps this to a 409 Conflict.
"""
from cyclone import reconcile as _reconcile
with db.SessionLocal()() as s:
claim = s.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
if claim.matched_remittance_id is not None:
raise AlreadyMatchedError(
f"claim {claim_id} already matched to "
f"{claim.matched_remittance_id}"
)
remit = s.get(Remittance, remit_id)
if remit is None:
raise LookupError(f"remittance {remit_id} not found")
prior_state = claim.state
if remit.is_reversal:
intent = _reconcile.apply_reversal(claim, remit)
else:
intent = _reconcile.apply_payment(
claim, remit,
charge=claim.charge_amount,
paid=remit.total_paid,
status_code=remit.status_code,
)
if intent.skipped or intent.new_state is None:
current = (
claim.state.value
if hasattr(claim.state, "value")
else str(claim.state)
)
raise InvalidStateError(
current_state=current,
activity_kind=intent.activity_kind,
)
new_state = intent.new_state
now = utcnow()
s.add(Match(
claim_id=claim_id,
remittance_id=remit_id,
strategy="manual",
matched_at=now,
prior_claim_state=prior_state,
is_reversal=remit.is_reversal,
))
claim.state = new_state
claim.matched_remittance_id = remit_id
# Symmetric FK update — see list_unmatched docstring for why
# we don't rely on T10 to do this.
remit.claim_id = claim_id
# SP7: line-level reconciliation + claim-level CAS aggregate.
# Skipped for reversals — they don't have SV1↔SVC line pairs.
if not remit.is_reversal:
_reconcile._reconcile_pair(s, claim, remit)
s.add(ActivityEvent(
ts=now,
kind="manual_match",
batch_id=remit.batch_id,
claim_id=claim_id,
remittance_id=remit_id,
payload_json={
"strategy": "manual",
"new_state": new_state.value,
"prior_state": prior_state.value,
"is_reversal": remit.is_reversal,
},
))
s.commit()
parsed_at = (
claim.batch.parsed_at
if claim.batch is not None
else now
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
received_total=float(remit.total_paid or 0),
)
matched_at_iso = now.isoformat().replace("+00:00", "Z")
return {
"claim": claim_dict,
"match": {
"strategy": "manual",
"claimId": claim_id,
"remittanceId": remit_id,
"matchedAt": matched_at_iso,
"isReversal": remit.is_reversal,
"priorState": prior_state.value,
"newState": new_state.value,
},
}
def manual_unmatch(claim_id: str) -> dict:
"""Unpair a previously matched claim and restore its prior state.
Reverses ``manual_match`` (and auto-match as a side-effect of
clearing the FK). Strategy:
1. Load the claim; raise ``NotMatchedError`` if it isn't
currently matched.
2. Delete every ``Match`` row for the claim (there may be more
than one reversals create a 2nd row, see T10 spec).
3. Restore ``claim.state`` from the latest Match's
``prior_claim_state``; fall back to ``SUBMITTED`` when
``prior_claim_state`` is NULL (auto-match doesn't set it for
non-reversal payments see reconcile.run line ~278).
4. Clear ``claim.matched_remittance_id`` and the symmetric
``remit.claim_id`` so ``list_unmatched`` surfaces the pair
again.
5. Record ``ActivityEvent(kind="manual_unmatch", ...)`` and
commit.
6. Return ``{"claim": <ui>, "deletedMatches": <count>}``.
"""
with db.SessionLocal()() as s:
claim = s.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
if claim.matched_remittance_id is None:
raise NotMatchedError(
f"claim {claim_id} has no active match"
)
matches = (
s.query(Match)
.filter(Match.claim_id == claim_id)
.order_by(Match.matched_at.desc())
.all()
)
if not matches:
# Defensive: matched_remittance_id was set but no Match
# rows exist. Shouldn't happen, but if it does, fall back
# to clearing the FK and starting fresh.
latest = None
paired_remit = None
restored_state = ClaimState.SUBMITTED
else:
latest = matches[0]
paired_remit = s.get(Remittance, latest.remittance_id)
restored_state = (
latest.prior_claim_state
if latest.prior_claim_state is not None
else ClaimState.SUBMITTED
)
deleted_count = len(matches)
for m in matches:
s.delete(m)
claim.state = restored_state
claim.matched_remittance_id = None
# Clear the symmetric FK on the remittance so list_unmatched
# surfaces the pair again. The remittance may have been
# deleted between the match and this call — guard with a
# None check so we don't blow up on a stale FK.
if paired_remit is not None:
paired_remit.claim_id = None
now = utcnow()
s.add(ActivityEvent(
ts=now,
kind="manual_unmatch",
claim_id=claim_id,
payload_json={
"restored_state": restored_state.value,
"deleted_matches": deleted_count,
},
))
s.commit()
parsed_at = (
claim.batch.parsed_at
if claim.batch is not None
else now
)
# ``paired_remit`` is the matched remittance we cleared in
# the unmatch; use its ``total_paid`` for the response shape
# so the UI sees what was paid before the unpair. May be
# ``None`` if the remittance was deleted since the match —
# default to 0.0 in that case.
received_total = (
float(paired_remit.total_paid or 0)
if paired_remit is not None
else 0.0
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
received_total=received_total,
)
return {
"claim": claim_dict,
"deletedMatches": deleted_count,
}
# ------------------------------------------------------------------
-307
View File
@@ -1,307 +0,0 @@
"""Dashboard KPI aggregation — cross-table reads consumed by /api/dashboard/kpis.
``dashboard_kpis()`` returns a single dict that the React Dashboard page
consumes as the source of truth for the 4 KPI tiles + the recent-activity
panel. It reads from claims + remittances + batches in a single pass.
``_claim_state_str`` is a small mapping helper that translates ORM
status enum values to UI-friendly strings. It's private to this module.
"""
from datetime import datetime, timezone
from cyclone import db
from cyclone.db import Claim, Remittance
# ---------------------------------------------------------------------------
# SP27 Task 13: Dashboard aggregate KPIs.
#
# The Dashboard's "Billed / Received / Denial rate / Pending AR" tiles are
# computed from the *whole* claim population, not a sample. With 60k+ claims
# in production, fetching ``/api/claims?limit=100`` and reducing in JS would
# silently produce wrong numbers (denial rate sampled, billed summed from
# 100 rows). This module-level function does the aggregation server-side in
# a single session and returns a small structured payload the Dashboard
# can render directly.
#
# Performance: one ``SELECT * FROM claims`` (no pagination) + one
# ``SELECT id, total_paid FROM remittances WHERE id IN (...)`` for matched
# remits. SQLite returns 60k claim rows in ~30ms on the development
# machine; the Python reduce is microseconds. If the dataset grows past
# ~500k claims we'd want a SQL-side ``GROUP BY month`` instead — but at
# that volume the dashboard should probably be backed by a materialized
# view, not a live query.
# ---------------------------------------------------------------------------
def _claim_state_str(claim: Claim) -> str:
"""Stringify a Claim's ``state`` regardless of enum vs raw str storage."""
st = claim.state
return st.value if hasattr(st, "value") else str(st)
# Claim states counted toward the Dashboard's "pending" tile. SUBMITTED
# is the initial post-parse state; REJECTED is the 999 envelope-level
# rejection that means the payer never saw the claim. Both are
# "outstanding adjudication" from the operator's POV; ``denied`` is
# payer adjudication and lives in its own tile.
_DASHBOARD_PENDING_STATES: frozenset[str] = frozenset({"submitted", "rejected"})
def dashboard_kpis(
*,
months: int = 6,
top_n_providers: int = 4,
top_n_denials: int = 5,
) -> dict:
"""Compute Dashboard KPIs over the entire claim/remittance population.
Parameters
----------
months
Number of trailing calendar months to include in the ``monthly``
sparkline series (default 6 matches the existing frontend
``MONTHS_BACK`` constant).
top_n_providers
How many providers to include in the ``topProviders`` array
(default 4 matches the existing Dashboard layout).
top_n_denials
How many most-recent denied claims to include in the
``topDenials`` array (default 5).
Returns
-------
dict with keys:
- ``totals``: aggregate counts + dollar sums + rates for the whole DB.
- ``monthly``: list of ``{month, label, count, billed, received,
denied, denialRate, ar}`` dicts, oldest-first, length = ``months``.
``ar`` is the running outstanding accounts-receivable (billed -
received) carried forward across months, clamped at zero.
- ``topProviders``: list of ``{npi, label, claimCount, billed,
denied}`` dicts, sorted by claimCount desc.
- ``topDenials``: list of ``{id, patientName, billedAmount,
denialReason, submissionDate}`` dicts, sorted by submissionDate
desc, capped at ``top_n_denials``.
Notes
-----
- Empty DB returns zero-filled aggregates, an empty ``monthly`` array
(one entry per requested month), an empty ``topProviders`` array,
and an empty ``topDenials`` array.
- ``received`` for a claim is sourced from the matched Remittance's
``total_paid`` column. Claims without a matched remittance
contribute 0 to the received sum (and thus inflate the
outstanding-AR figure, which is the correct operator-visible
semantic "money we haven't been told was paid yet").
"""
# Pre-build the trailing-N-months skeleton so the response always has
# exactly ``months`` entries even when the DB is empty or sparse.
now = datetime.now(timezone.utc)
skeleton: list[dict] = []
for i in range(months - 1, -1, -1):
d = now.replace(day=1)
# Walk backwards N months without ``relativedelta``.
for _ in range(i):
prev_month = d.month - 1
if prev_month == 0:
d = d.replace(year=d.year - 1, month=12)
else:
d = d.replace(month=prev_month)
skeleton.append({
"month": f"{d.year:04d}-{d.month:02d}",
"label": d.strftime("%b"),
"count": 0,
"billed": 0.0,
"received": 0.0,
"denied": 0,
"ar": 0.0,
})
skeleton_index = {entry["month"]: entry for entry in skeleton}
with db.SessionLocal()() as s:
# ``Claim.batch`` is a lazy ``relationship`` (default
# ``lazy="select"``); without ``selectinload`` each access to
# ``r.batch`` in the reduce loop below issues a fresh
# ``SELECT ... FROM batches WHERE id=?``. ``selectinload``
# pulls every distinct batch in one round-trip instead of N+1
# — critical for the 60k-claim dataset.
from sqlalchemy.orm import selectinload
claims: list[Claim] = (
s.query(Claim)
.options(selectinload(Claim.batch))
.all()
)
# Bulk-load matched-remit total_paid so a 60k-claim DB doesn't
# produce a 60k-query N+1.
matched_ids = [
r.matched_remittance_id
for r in claims
if r.matched_remittance_id
]
received_by_remit: dict[str, float] = {}
if matched_ids:
for rid, total_paid in (
s.query(Remittance.id, Remittance.total_paid)
.filter(Remittance.id.in_(matched_ids))
.all()
):
received_by_remit[rid] = float(total_paid or 0)
# Per-provider accumulator for the topProviders leaderboard.
provider_counts: dict[str, int] = {}
provider_billed: dict[str, float] = {}
provider_denied: dict[str, int] = {}
# Per-month accumulator + totals in a single pass.
total_count = 0
total_billed = 0.0
total_received = 0.0
denied_count = 0
pending_count = 0
# Collect denied candidates as we walk so we don't issue a
# second pass for the topDenials array.
denied_candidates: list[dict] = []
for r in claims:
billed = float(r.charge_amount or 0)
received = received_by_remit.get(r.matched_remittance_id, 0.0)
state_str = _claim_state_str(r)
total_count += 1
total_billed += billed
total_received += received
if state_str == "denied":
denied_count += 1
# Drop denied claims with no ``submissionDate`` — they'd
# sort first under reverse-lex (empty string < ISO) and
# render as "Invalid Date" in the Dashboard. A denial
# without a batch is exceptional and operator-irrelevant
# for the "recent denials" widget.
if r.batch is None or r.batch.parsed_at is None:
pass
else:
raw = r.raw_json or {}
sub = raw.get("subscriber", {})
denied_candidates.append({
"id": r.id,
"patientName": (
f"{sub.get('first_name', '')} "
f"{sub.get('last_name', '')}".strip()
),
"billedAmount": billed,
"denialReason": r.rejection_reason,
"submissionDate": (
r.batch.parsed_at
.isoformat()
.replace("+00:00", "Z")
),
})
if state_str in _DASHBOARD_PENDING_STATES:
pending_count += 1
# Monthly bin. ``submissionDate`` lives on the parent Batch
# (all claims in a batch share a parsed_at). Use UTC year-month
# to match the skeleton.
if r.batch is not None and r.batch.parsed_at is not None:
pa = r.batch.parsed_at
key = f"{pa.year:04d}-{pa.month:02d}"
bucket = skeleton_index.get(key)
if bucket is not None:
bucket["count"] += 1
bucket["billed"] += billed
bucket["received"] += received
if state_str == "denied":
bucket["denied"] += 1
# Provider bin (keyed on NPI).
npi = r.provider_npi or ""
if npi:
provider_counts[npi] = provider_counts.get(npi, 0) + 1
provider_billed[npi] = provider_billed.get(npi, 0.0) + billed
if state_str == "denied":
provider_denied[npi] = provider_denied.get(npi, 0) + 1
# Compute denial rate per month + running AR.
running_ar = 0.0
for entry in skeleton:
if entry["count"] > 0:
entry["denialRate"] = (entry["denied"] / entry["count"]) * 100.0
else:
entry["denialRate"] = 0.0
running_ar = max(0.0, running_ar + entry["billed"] - entry["received"])
entry["ar"] = running_ar
# Resolve top provider labels from the Provider table in one
# round-trip. NPIs without a Provider row still appear in the
# leaderboard with an empty label so operators can see
# "unknown-NPI" claims are coming from somewhere.
# Local import keeps the module-level ``cyclone.providers.Provider``
# Pydantic DTO and the SQLAlchemy ``cyclone.db.Provider`` ORM
# separate (same pattern as ``list_providers`` / ``upsert_provider``).
provider_labels: dict[str, str] = {}
if provider_counts:
from cyclone.db import Provider as ProviderORM
for npi, label in (
s.query(ProviderORM.npi, ProviderORM.label).filter(
ProviderORM.npi.in_(provider_counts.keys())
).all()
):
provider_labels[npi] = label or ""
top_providers = sorted(
provider_counts.items(),
key=lambda kv: kv[1],
reverse=True,
)[: max(0, top_n_providers)]
top_providers_out = [
{
"npi": npi,
"label": provider_labels.get(npi, ""),
"claimCount": count,
"billed": round(provider_billed.get(npi, 0.0), 2),
"denied": provider_denied.get(npi, 0),
}
for npi, count in top_providers
]
# Top denials = most recently submitted denied claims, capped at
# ``top_n_denials``. submissionDate is ISO-8601 so lex sort ==
# chronological sort when timestamps share a tz.
denied_candidates.sort(key=lambda d: d["submissionDate"], reverse=True)
top_denials = denied_candidates[: max(0, top_n_denials)]
total_denial_rate = (
(denied_count / total_count) * 100.0 if total_count > 0 else 0.0
)
return {
"totals": {
"count": total_count,
"billed": round(total_billed, 2),
"received": round(total_received, 2),
"outstandingAr": round(max(0.0, total_billed - total_received), 2),
"denied": denied_count,
"denialRate": round(total_denial_rate, 4),
"pending": pending_count,
},
"monthly": [
{
"month": e["month"],
"label": e["label"],
"count": e["count"],
"billed": round(e["billed"], 2),
"received": round(e["received"], 2),
"denied": e["denied"],
"denialRate": round(e["denialRate"], 4),
"ar": round(e["ar"], 2),
}
for e in skeleton
],
"topProviders": top_providers_out,
"topDenials": top_denials,
}
-180
View File
@@ -1,180 +0,0 @@
"""ORM row builders — convert parser output into SQLAlchemy ORM rows.
Each function returns an unsaved ORM object (or inserts related rows
within an existing session). They never commit or close the session
the caller (``write.add_record``, ``acks.add_ack``, etc.) owns the
transaction boundary.
"""
from __future__ import annotations
import json
from decimal import Decimal
from cyclone import db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.models_835 import ClaimPayment
from . import utcnow
def _service_dates_from_claim(claim: ClaimOutput) -> tuple[date | None, date | None]:
"""Extract (service_date_from, service_date_to) from a ClaimOutput.
The 837P model has ``service_lines[*].service_date`` (one per SV1).
We use the earliest as ``from`` and the latest as ``to``; if there
are no service lines, both are ``None``.
"""
dates: list[date] = []
for sl in claim.service_lines:
if sl.service_date is not None:
dates.append(sl.service_date)
if not dates:
return None, None
return min(dates), max(dates)
def _claim_837_row(claim: ClaimOutput, batch_id: str) -> Claim:
"""Build a Claim ORM row from a ClaimOutput. NOT yet persisted."""
d_from, d_to = _service_dates_from_claim(claim)
return Claim(
id=claim.claim_id,
batch_id=batch_id,
# SP27 Task 17: Claim.patient_control_number must hold the CLM01
# claim_submittr's_identifier the 837 sent — that's the value the
# 835 echoes in CLP01, which the reconcile matcher joins on
# (reconcile.py:by_pcn), and what 999 / 277CA ACK lookups also
# use to cross-reference the original claim. Storing
# subscriber.member_id here (the 2010BA NM109) silently broke
# every auto-match in production.
patient_control_number=claim.claim_id or "",
service_date_from=d_from,
service_date_to=d_to,
charge_amount=Decimal(claim.claim.total_charge or 0),
provider_npi=claim.billing_provider.npi,
# SP32: wire NM1*82 (Loop 2420A) rendering provider NPI from
# ClaimOutput (T3 parser extraction) into the typed ORM column
# (T1 migration 0019). Falls back to None if absent.
rendering_provider_npi=claim.rendering_provider_npi,
payer_id=claim.payer.id,
state=ClaimState.SUBMITTED,
raw_json=json.loads(claim.model_dump_json()),
)
def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
"""Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
received_at = utcnow()
# Adjustment amount: sum the CAS rows for the first service line.
# NOTE: This is a best-effort placeholder used until the reconciliation
# pass (T10) overwrites it from the persisted CasAdjustment rows. The
# authoritative value comes from `reconcile.run()`, which sums
# ``CasAdjustment.amount`` per ``remittance_id`` and writes the result
# back to ``Remittance.adjustment_amount``. We keep this stub so the
# row has a sane value if reconciliation is disabled or fails.
adjustment = Decimal("0")
if cp.service_payments:
sp = cp.service_payments[0]
for adj in sp.adjustments:
adjustment += adj.amount
# Use the first service line's service_date as the remit service_date.
service_date: date | None = None
if cp.service_payments and cp.service_payments[0].service_date is not None:
service_date = cp.service_payments[0].service_date
return Remittance(
id=cp.payer_claim_control_number,
batch_id=batch_id,
payer_claim_control_number=cp.payer_claim_control_number,
claim_id=None,
status_code=cp.status_code,
status_label=cp.status_label,
total_charge=Decimal(cp.total_charge or 0),
total_paid=Decimal(cp.total_paid or 0),
patient_responsibility=cp.patient_responsibility,
adjustment_amount=adjustment,
# SP32: wire NM1*1P (Loop 2100) service provider NPI from
# ClaimPayment (T2 parser extraction) into the typed ORM column
# (T1 migration 0019). Falls back to None if absent.
rendering_provider_npi=cp.service_provider_npi,
received_at=received_at,
service_date=service_date,
is_reversal=cp.status_code in ("21", "22"),
raw_json=json.loads(cp.model_dump_json()),
)
def _persist_835_remit(session, cp: "ClaimPayment", remittance_id: str) -> None:
"""SP7: persist ServiceLinePayment + CAS rows for one CLP composite.
For each 835 SVC composite in ``cp.service_payments``:
- insert a ServiceLinePayment row (line_number, procedure, modifiers,
charge, payment, units, service_date).
- flush to populate slp.id.
- insert each per-SVC CAS adjustment with ``service_line_payment_id``
set to slp.id.
For CLP-level CAS adjustments (``cp.claim_adjustments``, a future
extension; not produced by today's 835 parser but allowed by the spec):
- insert CAS rows with ``service_line_payment_id IS NULL``.
The caller controls the transaction; this function does not commit.
The 835 ingest site calls this after ``_remittance_835_row`` is
flushed so the FK target is populated.
"""
import json as _json
from cyclone.db import ServiceLinePayment, CasAdjustment
for svc in cp.service_payments:
slp = ServiceLinePayment(
remittance_id=remittance_id,
line_number=svc.line_number,
procedure_qualifier=svc.procedure_qualifier,
procedure_code=svc.procedure_code,
modifiers_json=_json.dumps(svc.modifiers or []),
charge=Decimal(str(svc.charge)),
payment=Decimal(str(svc.payment)),
units=Decimal(str(svc.units)) if svc.units is not None else None,
unit_type=svc.unit_type,
service_date=svc.service_date,
ref_benefit_plan=svc.ref_benefit_plan,
)
session.add(slp)
session.flush() # populate slp.id for the FK below
for adj in svc.adjustments:
quantity = getattr(adj, "quantity", None)
session.add(CasAdjustment(
remittance_id=remittance_id,
group_code=adj.group_code,
reason_code=adj.reason_code,
amount=Decimal(str(adj.amount)),
quantity=Decimal(str(quantity)) if quantity is not None else None,
service_line_payment_id=slp.id,
))
# CLP-level CAS (no SVC composite to attach to). Today's parser does
# not produce these; the branch is forward-compatible.
for adj in getattr(cp, "claim_adjustments", []) or []:
quantity = getattr(adj, "quantity", None)
session.add(CasAdjustment(
remittance_id=remittance_id,
group_code=adj.group_code,
reason_code=adj.reason_code,
amount=Decimal(str(adj.amount)),
quantity=Decimal(str(quantity)) if quantity is not None else None,
service_line_payment_id=None,
))
def _claim_status_from_validation(claim: ClaimOutput) -> str:
"""Re-implement the in-memory status rules (sub-project 1 §6.2)."""
v = claim.validation
if not v.passed:
has_r050 = any(e.rule == "R050_diagnosis_present" for e in v.errors)
return "draft" if has_r050 else "denied"
if claim.claim.frequency_code == "1":
return "submitted"
if v.warnings:
return "pending"
return "draft"
-307
View File
@@ -1,307 +0,0 @@
"""Provider, payer, and clearhouse configuration reads and upserts.
The ``providers`` and ``payers`` modules in ``cyclone`` provide the
ORM-row DTOs; this module is the read/upsert surface over them.
``ensure_clearhouse_seeded`` is called at startup to insert the
default Clearhouse row if missing.
"""
import json
from datetime import datetime, timezone
from cyclone import db
from cyclone.providers import Clearhouse, Payer, Provider
from . import utcnow
# ------------------------------------------------------------------
# SP9: providers / payers / payer_configs / clearhouse
# ------------------------------------------------------------------
def list_providers(*, is_active: bool | None = True) -> list[Provider]:
"""List providers. ``is_active=None`` returns all."""
from cyclone.db import Provider as ProviderORM
from cyclone.providers import Provider
with db.SessionLocal()() as s:
q = s.query(ProviderORM)
if is_active is not None:
q = q.filter(ProviderORM.is_active == (1 if is_active else 0))
rows = q.order_by(ProviderORM.label).all()
return [Provider.model_validate(_provider_orm_to_dict(r)) for r in rows]
def get_provider(npi: str) -> Provider | None:
from cyclone.db import Provider as ProviderORM
from cyclone.providers import Provider
with db.SessionLocal()() as s:
row = s.get(ProviderORM, npi)
return Provider.model_validate(_provider_orm_to_dict(row)) if row else None
def upsert_provider(provider: Provider) -> Provider:
from cyclone.db import Provider as ProviderORM
with db.SessionLocal()() as s:
row = s.get(ProviderORM, provider.npi)
now = utcnow().isoformat()
if row is None:
row = ProviderORM(
npi=provider.npi, label=provider.label,
legal_name=provider.legal_name, tax_id=provider.tax_id,
taxonomy_code=provider.taxonomy_code,
address_line1=provider.address_line1,
address_line2=provider.address_line2,
city=provider.city, state=provider.state, zip=provider.zip,
is_active=1 if provider.is_active else 0,
created_at=provider.created_at.isoformat(),
updated_at=now,
)
s.add(row)
else:
row.label = provider.label
row.legal_name = provider.legal_name
row.tax_id = provider.tax_id
row.taxonomy_code = provider.taxonomy_code
row.address_line1 = provider.address_line1
row.address_line2 = provider.address_line2
row.city = provider.city
row.state = provider.state
row.zip = provider.zip
row.is_active = 1 if provider.is_active else 0
row.updated_at = now
s.commit()
return get_provider(provider.npi) # type: ignore[return-value]
def list_payers(*, is_active: bool | None = True) -> list[Payer]:
from cyclone.db import Payer as PayerORM
from cyclone.providers import Payer
with db.SessionLocal()() as s:
q = s.query(PayerORM)
if is_active is not None:
q = q.filter(PayerORM.is_active == (1 if is_active else 0))
rows = q.order_by(PayerORM.payer_id).all()
return [Payer.model_validate(_payer_orm_to_dict(r)) for r in rows]
def get_payer_config(payer_id: str, transaction_type: str) -> dict | None:
from cyclone.db import PayerConfigORM
with db.SessionLocal()() as s:
row = s.get(PayerConfigORM, (payer_id, transaction_type))
return dict(row.config_json) if row else None
def get_clearhouse() -> Clearhouse | None:
from cyclone.db import ClearhouseORM
from cyclone.providers import Clearhouse
with db.SessionLocal()() as s:
row = s.get(ClearhouseORM, 1)
if row is None:
return None
return Clearhouse.model_validate({
"id": 1,
"name": row.name,
"tpid": row.tpid,
"submitter_name": row.submitter_name,
"submitter_id_qual": row.submitter_id_qual,
"submitter_contact_name": row.submitter_contact_name,
"submitter_contact_email": row.submitter_contact_email,
"filename_block": dict(row.filename_block_json),
"sftp_block": dict(row.sftp_block_json),
"updated_at": row.updated_at,
})
def update_clearhouse(block: Clearhouse) -> Clearhouse:
"""Replace the singleton clearhouse row. SP25.
Used by ``PATCH /api/clearhouse`` to flip ``sftp_block.stub``
and adjust host/port/paths without touching SQLite directly.
Raises ``LookupError`` if the singleton row is missing the
caller is expected to run the lifespan seed first.
"""
from cyclone.db import ClearhouseORM
with db.SessionLocal()() as s:
row = s.get(ClearhouseORM, 1)
if row is None:
raise LookupError(
"clearhouse singleton row missing; run the lifespan "
"seed (ensure_clearhouse_seeded) before PATCH /api/clearhouse"
)
row.name = block.name
row.tpid = block.tpid
row.submitter_name = block.submitter_name
row.submitter_id_qual = block.submitter_id_qual
row.submitter_contact_name = block.submitter_contact_name
row.submitter_contact_email = block.submitter_contact_email
row.filename_block_json = json.loads(
json.dumps(block.filename_block.model_dump())
)
row.sftp_block_json = json.loads(
json.dumps(block.sftp_block.model_dump())
)
row.updated_at = datetime.now(timezone.utc).isoformat()
s.commit()
return Clearhouse.model_validate({
"id": 1,
"name": row.name,
"tpid": row.tpid,
"submitter_name": row.submitter_name,
"submitter_id_qual": row.submitter_id_qual,
"submitter_contact_name": row.submitter_contact_name,
"submitter_contact_email": row.submitter_contact_email,
"filename_block": dict(row.filename_block_json),
"sftp_block": dict(row.sftp_block_json),
"updated_at": row.updated_at,
})
def ensure_clearhouse_seeded() -> None:
"""Insert the default clearhouse singleton + 3 providers + CO_TXIX payer
if they don't exist. Idempotent. Called from the API lifespan."""
from cyclone.db import ClearhouseORM, Payer as PayerORM, PayerConfigORM, Provider as ProviderORM
from cyclone.providers import Clearhouse
with db.SessionLocal()() as s:
if s.get(ClearhouseORM, 1) is None:
ch = Clearhouse(
id=1,
name="dzinesco",
tpid="11525703",
submitter_name="Dzinesco",
submitter_id_qual="46",
submitter_contact_name="Tyler Martinez",
submitter_contact_email="tyler@dzinesco.com",
filename_block={
"tz": "America/Denver",
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
sftp_block={
"host": "mft.gainwelltechnologies.com",
"port": 22,
"username": "colorado-fts\\coxix_prod_11525703",
"paths": {
"outbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
"inbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
},
"stub": True,
"staging_dir": "./var/sftp/staging",
"poll_seconds": 300,
"auth": {"method": "keychain", "secret_ref": "sftp.gainwell.password"},
},
updated_at=utcnow(),
)
s.add(ClearhouseORM(
id=1,
name=ch.name,
tpid=ch.tpid,
submitter_name=ch.submitter_name,
submitter_id_qual=ch.submitter_id_qual,
submitter_contact_name=ch.submitter_contact_name,
submitter_contact_email=ch.submitter_contact_email,
filename_block_json=ch.filename_block.model_dump(),
sftp_block_json=ch.sftp_block.model_dump(),
updated_at=ch.updated_at.isoformat(),
))
# Seed 3 providers (idempotent)
from cyclone.providers import Provider
now = utcnow().isoformat()
for npi, label in [
("1881068062", "Montrose"),
("1851446637", "Delta"),
("1467507269", "Salida"),
]:
if s.get(ProviderORM, npi) is None:
s.add(ProviderORM(
npi=npi,
label=label,
legal_name="TOC, Inc.",
tax_id="721587149",
taxonomy_code="251E00000X",
address_line1="1100 East Main St",
address_line2="Suite A",
city="Montrose",
state="CO",
zip="814014063",
is_active=1,
created_at=now,
updated_at=now,
))
# Seed CO_TXIX payer (idempotent)
if s.get(PayerORM, "CO_TXIX") is None:
s.add(PayerORM(
payer_id="CO_TXIX",
name="Colorado Medical Assistance Program",
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
receiver_id="COMEDASSISTPROG",
is_active=1,
created_at=now,
updated_at=now,
))
# 837P config block
s.add(PayerConfigORM(
payer_id="CO_TXIX",
transaction_type="837P",
config_json={
"submitter_name": "Dzinesco",
"submitter_contact_name": "Tyler Martinez",
"submitter_contact_email": "tyler@dzinesco.com",
"receiver_name": "COLORADO MEDICAL ASSISTANCE PROGRAM",
"receiver_id_qualifier": "46",
"receiver_id": "COMEDASSISTPROG",
"bht06_allowed": ["CH", "RP"],
"bht06_default": "CH",
"sbr09_default": "MC",
"sbr09_allowed": ["MC", "16", "MA", "MB", "ZZ"],
"payer_id_qualifier": "PI",
"payer_id": "CO_TXIX",
"pwk_supported": False,
"cas_2320_group_allowed": False,
"claim_type_codes": {"11": "Office", "12": "Home", "99": "Other"},
},
updated_at=now,
))
# 835 config block
s.add(PayerConfigORM(
payer_id="CO_TXIX",
transaction_type="835",
config_json={
"expected_payer_tax_ids": [
"81-1725341", "811725341", "84-0644739",
"840644739", "1811725341",
],
"expected_payer_health_plan_id": "7912900843",
"payer_name_pattern": "^CO_(TXIX|BHA)$",
},
updated_at=now,
))
s.commit()
def _provider_orm_to_dict(row) -> dict:
return {
"npi": row.npi,
"label": row.label,
"legal_name": row.legal_name,
"tax_id": row.tax_id,
"taxonomy_code": row.taxonomy_code,
"address_line1": row.address_line1,
"address_line2": row.address_line2,
"city": row.city,
"state": row.state,
"zip": row.zip,
"is_active": bool(row.is_active),
"created_at": row.created_at,
"updated_at": row.updated_at,
}
def _payer_orm_to_dict(row) -> dict:
return {
"payer_id": row.payer_id,
"name": row.name,
"receiver_name": row.receiver_name,
"receiver_id": row.receiver_id,
"is_active": bool(row.is_active),
"created_at": row.created_at,
"updated_at": row.updated_at,
}
-84
View File
@@ -1,84 +0,0 @@
"""Pydantic models for parsed-batch records.
``BatchRecord`` is the union type; ``BatchRecord837`` and ``BatchRecord835``
narrow ``result`` to the parser-specific output types. Construction of
``BatchRecord(kind="837p", ...)`` dispatches to ``BatchRecord837`` via
``__new__`` so isinstance checks downstream narrow ``result`` correctly.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, model_validator
from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835
BatchKind = Literal["837p", "835"]
# ---------------------------------------------------------------------------
# BatchRecord: value object preserved from sub-project 1.
# ---------------------------------------------------------------------------
class BatchRecord(BaseModel):
"""One parsed file, with a stable uuid4 id and the full ParseResult.
``result`` is a union: ``ParseResult`` for ``kind="837p"`` and
``ParseResult835`` for ``kind="835"``. The concrete subclasses
``BatchRecord837`` and ``BatchRecord835`` narrow those fields, so
callers that want type-checked access should use them and check
``isinstance`` rather than pattern-matching on ``kind``.
Constructing ``BatchRecord(kind="837p", ...)`` dispatches to
``BatchRecord837``; ``kind="835"`` dispatches to ``BatchRecord835``.
This lets the union-member narrowing work transparently.
"""
model_config = ConfigDict(extra="ignore")
id: str
kind: BatchKind
input_filename: str
parsed_at: datetime # tz-aware UTC
result: ParseResult | ParseResult835
def __new__(
cls, *args: Any, **kwargs: Any,
) -> BatchRecord837 | BatchRecord835 | BatchRecord:
# Dispatch base-class construction to the right concrete subclass
# so isinstance checks downstream narrow `result` correctly.
if cls is BatchRecord:
kind = kwargs.get("kind")
if kind is None and args and isinstance(args[0], dict):
kind = args[0].get("kind")
if kind == "837p":
return BatchRecord837(*args, **kwargs)
if kind == "835":
return BatchRecord835(*args, **kwargs)
return super().__new__(cls)
@model_validator(mode="after")
def _check_parsed_at_tz(self) -> BatchRecord:
if self.parsed_at.tzinfo is None:
raise ValueError(
"parsed_at must be tz-aware (use datetime.now(timezone.utc))"
)
return self
class BatchRecord837(BatchRecord):
"""A parsed 837P (professional claim) batch."""
kind: Literal["837p"] = "837p"
result: ParseResult
class BatchRecord835(BatchRecord):
"""A parsed 835 (remittance advice) batch."""
kind: Literal["835"] = "835"
result: ParseResult835
-683
View File
@@ -1,683 +0,0 @@
"""UI serialization helpers — convert ORM rows to API-shaped dicts.
These are the boundary between the persistence layer and the wire format
consumed by the React frontend. Keep them stable: any rename or
shape change ripples to every API consumer.
Also hosts ``utcnow()`` (the tz-aware UTC now) because it's a small
free function that several modules want and there's no better home.
SP25: ``to_ui_ack`` / ``to_ui_ta1_ack`` / ``to_ui_two77ca_ack`` were
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
/ ``api.py`` so the live-tail event payload matches the list endpoint
shape byte-for-byte. The seam between persistence and streaming
depends on the two halves staying in sync.
SP28 adds ``to_ui_claim_ack`` for the same reason the
``claim_ack_written`` event payload must match the
``GET /api/acks/{kind}/{id}/claims`` list shape byte-for-byte.
moved here from ``api_routers/acks.py`` / ``api_routers/ta1_acks.py``
/ ``api.py`` so the live-tail event payload can match the list endpoint
shape byte-for-byte. The seam between persistence and streaming
depends on the two halves staying in sync.
"""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import Claim, ClaimAck, Remittance
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.models_835 import ClaimPayment
from cyclone.parsers.payer import PayerConfig835
from .orm_builders import _claim_status_from_validation
def to_ui_claim(
claim: ClaimOutput,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map a 837P ClaimOutput to the UI's `Claim` shape (preserved)."""
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
return {
"id": claim.claim_id,
"patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(),
"providerNpi": claim.billing_provider.npi,
"payerName": claim.payer.name,
"cptCode": (
claim.service_lines[0].procedure.code
if claim.service_lines
else ""
),
"billedAmount": float(claim.claim.total_charge or 0.0),
"receivedAmount": 0.0,
"status": _claim_status_from_validation(claim),
"denialReason": None,
"submissionDate": parsed_iso,
"batchId": batch_id,
"parsedAt": parsed_iso,
}
def to_ui_remittance(
cp: ClaimPayment,
*,
batch_id: str,
parsed_at: datetime,
payer_config: PayerConfig835 | None = None,
payer_name: str = "",
) -> dict:
"""Map an 835 ClaimPayment to the UI's `Remittance` shape (preserved)."""
code = cp.status_code
if code in {"21", "22"}:
status = "reconciled"
else:
status = "received"
denial_reason: str | None = None
if code == "4" and cp.service_payments:
sp = cp.service_payments[0]
if sp.adjustments:
adj = sp.adjustments[0]
denial_reason = (
f"{adj.group_code}-{adj.reason_code}: ${float(adj.amount):.2f}"
)
cfg = payer_config if payer_config is not None else PayerConfig835.generic_835()
validation_warnings: list[str] = []
if code not in cfg.allowed_status_codes:
validation_warnings.append(
f"CLP02 code {code} not in payer allowlist"
)
# Aggregate adjustmentAmount across ALL service-line CAS rows, not just
# the first line. Mirrors the SUM the reconcile aggregator (T10)
# computes against persisted CasAdjustment rows; this inline version
# is the write-path equivalent (used when streaming 835 NDJSON
# responses before persistence finishes).
adjustment_total = Decimal("0")
for sp in cp.service_payments:
for adj in sp.adjustments:
adjustment_total += adj.amount
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
return {
"id": cp.payer_claim_control_number,
"claimId": cp.original_claim_id or "",
"payerName": payer_name,
"paidAmount": float(cp.total_paid or 0.0),
"adjustmentAmount": float(adjustment_total),
"status": status,
"denialReason": denial_reason,
"validationWarnings": validation_warnings,
"receivedDate": parsed_iso,
"batchId": batch_id,
"parsedAt": parsed_iso,
}
def to_ui_claim_from_orm(
row: Claim,
*,
batch_id: str,
parsed_at: datetime,
received_total: float = 0.0,
) -> dict:
"""Map an ORM ``Claim`` row to the UI's claim shape.
``to_ui_claim`` takes a Pydantic ``ClaimOutput`` (used on the write path
during 837 ingest). For read paths list_unmatched, manual_match return
values we already have the ORM row and the serialized fields it
carries in ``raw_json``. Reading from ``raw_json`` keeps the UI shape
in sync with the original 837 parse without re-deserializing to a
Pydantic model.
Adds two fields ``to_ui_claim`` doesn't emit: ``state`` (the
reconciliation state machine value) and ``matchedRemittanceId`` (the
FK to the paired remittance, or None). Both are required by the UI.
"""
raw = row.raw_json or {}
bp = raw.get("billing_provider", {})
payer_obj = raw.get("payer", {})
sub = raw.get("subscriber", {})
service_lines = raw.get("service_lines", [])
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
cpt = (
service_lines[0].get("procedure", {}).get("code", "")
if service_lines
else ""
)
state_value = (
row.state.value if hasattr(row.state, "value") else str(row.state)
)
return {
"id": row.id,
"state": state_value,
"billedAmount": float(row.charge_amount or 0),
"patientName": (
f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
),
"providerNpi": bp.get("npi") or row.provider_npi or "",
"payerName": payer_obj.get("name") or "",
"cptCode": cpt,
"submissionDate": parsed_iso,
"parsedAt": parsed_iso,
"status": state_value,
"matchedRemittanceId": row.matched_remittance_id,
"batchId": batch_id,
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
# but expects these on freshly-loaded rows from /api/claims too.
# ``received_total`` comes from the matched Remittance row when one
# exists; callers that don't pre-compute it (write path, unmatched
# list) get the default of 0.0 — which matches the unmapped state.
"receivedAmount": float(received_total),
"denialReason": None,
}
# Max number of ActivityEvent rows surfaced in the detail drawer's
# state history. The spec caps it at 50; a higher claim volume (manual
# match/unmatch thrash) just shows the 50 most recent. Exposed as a
# module constant so the endpoint layer can pass it through as a
# default if it ever supports a `?limit=N` query param.
CLAIM_DETAIL_HISTORY_LIMIT = 50
def _iso_z(value: datetime | None) -> str:
"""Format a tz-aware-or-naive UTC datetime as ISO-8601 with trailing Z.
The DB columns are declared ``DateTime(timezone=True)`` and rows are
stored UTC at write time, but SQLite drops the tzinfo on read
(returning a naive ``datetime``). Re-attach UTC for naive values
so the spec contract holds: every ISO datetime field ends in Z.
"""
if value is None:
return ""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat().replace("+00:00", "Z")
def to_ui_ack(row: db.Ack) -> dict:
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
Adds ``patient_control_number`` pulled from ``raw_json`` so the
operator can correlate each 999 back to the original claim batch.
SP25: this was previously ``api_routers/acks._ack_to_ui``. Moved
here so the live-tail event payload (``ack_received``) matches the
list-endpoint shape the seam between persistence and streaming
depends on byte-for-byte equality.
"""
body = {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": (
row.parsed_at.isoformat().replace("+00:00", "Z")
if row.parsed_at is not None
else ""
),
"patient_control_number": None,
}
raw = row.raw_json or {}
try:
set_responses = raw.get("set_responses") or []
if set_responses:
body["patient_control_number"] = set_responses[0].get("set_control_number")
except (AttributeError, TypeError):
pass
return body
def to_ui_ta1_ack(row: db.Ta1Ack) -> dict:
"""Map a ``Ta1Ack`` ORM row to the UI shape used by ``/api/ta1-acks``.
SP25: this was previously ``api_routers/ta1_acks._ta1_to_ui``.
Moved here so the live-tail event payload (``ta1_ack_received``)
matches the list-endpoint shape byte-for-byte.
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix)
preserved from the original implementation. SQLite strips tzinfo
on read, so the field comes back as a naive datetime; we deliberately
keep the original (non-``Z``) rendering rather than re-attaching
UTC, because changing the wire shape now would drift both halves
of the live-tail contract.
"""
return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def to_ui_two77ca_ack(row: db.Two77caAck) -> dict:
"""Map a ``Two77caAck`` ORM row to the UI shape used by ``/api/277ca-acks``.
SP25: this was previously ``api._277ca_to_ui`` inlined alongside
the 277CA list endpoint. Moved here so the live-tail event
payload (``two77ca_ack_received``) matches the list-endpoint
shape byte-for-byte.
Note: ``parsed_at`` uses naive ``isoformat()`` (no ``Z`` suffix)
same SQLite-tzinfo caveat as :func:`to_ui_ta1_ack`.
"""
return {
"id": row.id,
"control_number": row.control_number,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"paid_count": row.paid_count,
"pended_count": row.pended_count,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def _address_to_ui(addr: dict | None) -> dict:
"""Render a raw ``Address`` dict in the spec's parties address shape.
Returns an empty dict when the source is missing so the UI can
branch on the field's presence rather than the value. The spec
shape is ``{line1, line2|null, city, state, zip}``.
"""
if not addr:
return {}
return {
"line1": addr.get("line1") or "",
"line2": addr.get("line2"),
"city": addr.get("city") or "",
"state": addr.get("state") or "",
"zip": addr.get("zip") or "",
}
def _validation_issues_to_ui(issues: list[dict] | None) -> list[dict]:
"""Project ValidationIssue dicts onto the spec's per-issue shape.
Source includes ``segment_index`` (a parser debug aid) which the
spec doesn't surface; we drop it. The endpoint contract is
``{rule, severity, message}`` per issue.
"""
if not issues:
return []
return [
{
"rule": issue.get("rule", ""),
"severity": issue.get("severity", "error"),
"message": issue.get("message", ""),
}
for issue in issues
]
def to_ui_claim_detail(
row: Claim,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map an ORM ``Claim`` row to the SP4 detail-drawer UI shape.
A superset of :func:`to_ui_claim_from_orm`: same top-level identity
fields, plus the full parties / validation / service-lines /
diagnoses / raw-segments / service-date / state-label payload that
the drawer needs. ``matchedRemittance`` and ``stateHistory`` are
*not* filled in here they require extra queries and are stitched
in by :meth:`CycloneStore.get_claim_detail`.
The mapper is deliberately a pure function (no DB I/O) so the
endpoint layer can call it from a worker thread or swap the
history/remittance sources for tests without re-implementing the
body.
"""
raw = row.raw_json or {}
bp = raw.get("billing_provider", {}) or {}
payer_obj = raw.get("payer", {}) or {}
sub = raw.get("subscriber", {}) or {}
service_lines = raw.get("service_lines", []) or []
diagnoses = raw.get("diagnoses", []) or []
validation = raw.get("validation", {}) or {}
raw_segments = raw.get("raw_segments", []) or []
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
state_value = (
row.state.value if hasattr(row.state, "value") else str(row.state)
)
# Service dates come from the dedicated ORM columns (denormalized at
# ingest in _claim_837_row so the list views can sort/filter on
# them without a JSON parse). ``isoformat()`` on a ``date`` gives
# ``YYYY-MM-DD`` — the spec shape.
service_date_from_iso = (
row.service_date_from.isoformat() if row.service_date_from else None
)
service_date_to_iso = (
row.service_date_to.isoformat() if row.service_date_to else None
)
return {
# -- identity + state -----------------------------------------
"id": row.id,
"batchId": batch_id,
"state": state_value,
"stateLabel": state_value.capitalize(),
# -- money + dates --------------------------------------------
"billedAmount": float(row.charge_amount or 0),
"serviceDateFrom": service_date_from_iso,
"serviceDateTo": service_date_to_iso,
"submissionDate": parsed_iso,
"parsedAt": parsed_iso,
# -- patient / provider / payer -------------------------------
"patientName": (
f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
),
"providerNpi": bp.get("npi") or row.provider_npi or "",
"providerName": bp.get("name") or "",
"payerName": payer_obj.get("name") or "",
"payerId": payer_obj.get("id") or row.payer_id or "",
# -- diagnoses ------------------------------------------------
"diagnoses": [
{
"code": d.get("code", ""),
"qualifier": d.get("qualifier"),
}
for d in diagnoses
],
# -- service lines --------------------------------------------
# ``service_lines[i].procedure`` is a nested dict in the
# serialized raw_json; the spec flattens it into the line.
# ``charge`` and ``units`` are stored as Decimal via Pydantic
# and serialized to string — coerce defensively. ``modifiers``
# defaults to [] so the UI doesn't have to handle null.
"serviceLines": [
{
"lineNumber": sl.get("line_number"),
"procedureQualifier": (
sl.get("procedure", {}).get("qualifier", "") or ""
),
"procedureCode": (
sl.get("procedure", {}).get("code", "") or ""
),
"modifiers": list(
sl.get("procedure", {}).get("modifiers") or []
),
"charge": float(sl.get("charge") or 0),
"units": (
float(sl["units"])
if sl.get("units") is not None
else None
),
"unitType": sl.get("unit_type"),
"serviceDate": sl.get("service_date"),
}
for sl in service_lines
],
# -- parties --------------------------------------------------
"parties": {
"billingProvider": {
"name": bp.get("name") or "",
"npi": bp.get("npi") or "",
"taxId": bp.get("tax_id") or "",
"address": _address_to_ui(bp.get("address")),
},
"subscriber": {
"firstName": sub.get("first_name") or "",
"lastName": sub.get("last_name") or "",
"memberId": sub.get("member_id") or "",
"dob": sub.get("dob"),
"gender": sub.get("gender"),
},
"payer": {
"name": payer_obj.get("name") or "",
"id": payer_obj.get("id") or "",
},
},
# -- validation ----------------------------------------------
"validation": {
"passed": bool(validation.get("passed", True)),
"errors": _validation_issues_to_ui(validation.get("errors")),
"warnings": _validation_issues_to_ui(validation.get("warnings")),
},
# -- raw segments (debug aid) --------------------------------
"rawSegments": raw_segments,
# -- matched remittance (filled by get_claim_detail) ---------
"matchedRemittance": None,
# -- state history (filled by get_claim_detail) --------------
"stateHistory": [],
}
def to_ui_remittance_from_orm(
row: Remittance,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map an ORM ``Remittance`` row to the UI's remittance shape.
Same idea as ``to_ui_claim_from_orm``: read the PayerName from the
parent batch's ``raw_result_json`` (the ParseResult835 stashed at
insert time) since ``Remittance`` itself doesn't carry it.
"""
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
payer_name = ""
if row.batch is not None and row.batch.raw_result_json:
payer_obj = row.batch.raw_result_json.get("payer", {}) or {}
payer_name = payer_obj.get("name") or ""
status = (
"reconciled" if row.status_code in ("21", "22") else "received"
)
return {
"id": row.id,
"payerClaimControlNumber": row.payer_claim_control_number,
"claimId": row.claim_id or "",
"payerName": payer_name,
"paidAmount": float(row.total_paid or 0),
"adjustmentAmount": float(row.adjustment_amount or 0),
"status": status,
"denialReason": None,
"validationWarnings": [],
"receivedDate": row.received_at.isoformat().replace("+00:00", "Z"),
"batchId": batch_id,
"parsedAt": parsed_iso,
}
def to_ui_remittance_with_adjustments(
row: Remittance,
*,
batch_id: str,
parsed_at: datetime,
cas_rows: list["db.CasAdjustment"] | None = None,
) -> dict:
"""Same shape as :func:`to_ui_remittance_from_orm` plus an ``adjustments`` array.
Each persisted ``CasAdjustment`` row is rendered as
``{"group", "reason", "label", "amount", "quantity"}``. The ``label``
is resolved through :func:`cyclone.parsers.cas_codes.reason_label`
so the UI does not have to ship its own CARC dictionary.
``cas_rows`` is optional so callers that don't have the rows handy
can still get the base dict; in that case ``adjustments`` is ``[]``.
Pass ``cas_rows`` to avoid an extra round-trip; the endpoint at
``GET /api/remittances/{id}`` passes them in to keep this mapper a
pure function.
"""
base = to_ui_remittance_from_orm(
row, batch_id=batch_id, parsed_at=parsed_at,
)
if not cas_rows:
base["adjustments"] = []
return base
# Lazy import to avoid the circular store ↔ parsers import that
# happens on cold start; mirrors the same pattern used elsewhere
# in this module.
from cyclone.parsers.cas_codes import reason_label
base["adjustments"] = [
{
"group": c.group_code,
"reason": c.reason_code,
"label": reason_label(c.group_code, c.reason_code),
"amount": float(c.amount or 0),
"quantity": (
float(c.quantity) if c.quantity is not None else None
),
}
for c in cas_rows
]
return base
def _svc_to_wire_dict(svc) -> dict:
"""Project an ORM ``ServiceLinePayment`` to the wire format used by
the remit drawer's ``serviceLinePayments`` array.
Mirrors the shape produced by the line-reconciliation endpoint so
the UI can render the same components from either source.
"""
import json as _json
return {
"id": svc.id,
"line_number": svc.line_number,
"procedure_qualifier": svc.procedure_qualifier,
"procedure_code": svc.procedure_code,
"modifiers": _json.loads(svc.modifiers_json or "[]"),
"charge": str(Decimal(str(svc.charge))),
"payment": str(Decimal(str(svc.payment))),
"units": str(Decimal(str(svc.units))) if svc.units is not None else None,
"unit_type": svc.unit_type,
"service_date": svc.service_date.isoformat() if svc.service_date else None,
}
def to_ui_provider(
*,
npi: str,
name: str,
tax_id: str | None = None,
address: str | None = None,
city: str | None = None,
state: str | None = None,
zip: str | None = None,
phone: str | None = None,
claim_count: int = 0,
outstanding_ar: float = 0.0,
) -> dict:
return {
"npi": npi,
"name": name,
"taxId": tax_id or "",
"address": address or "",
"city": city or "",
"state": state or "",
"zip": zip or "",
"phone": phone or "",
"claimCount": claim_count,
"outstandingAr": float(outstanding_ar),
}
def to_activity_event(
*,
id: str,
kind: str,
message: str,
timestamp: datetime,
npi: str | None = None,
amount: float | None = None,
) -> dict:
return {
"id": id,
"kind": kind,
"message": message,
"timestamp": timestamp.isoformat().replace("+00:00", "Z"),
"npi": npi,
"amount": amount,
}
def _date_in_bounds(
item: dict,
field: str,
date_from: str | None,
date_to: str | None,
) -> bool:
"""True if ``item[field]`` falls within ``[date_from, date_to]``."""
val = item.get(field)
if val is None:
return date_from is None and date_to is None
date_part = val[:10]
if date_from is not None and date_part < date_from:
return False
if date_to is not None and date_part > date_to:
return False
return True
def to_ui_claim_ack(row: ClaimAck) -> dict:
"""SP28: map a ClaimAck ORM row to the API shape.
Mirrors the rest of the to_ui_* serializers. The wire shape is
identical between the matching list endpoint (``GET /api/acks/{kind}/
{id}/claims``) and the ``claim_ack_written`` pubsub event so the
live-tail subscribers can rehydrate the snapshot from the bus
without diverging from the API response.
``claim_state`` is queried via a session round-trip
(``SELECT state FROM claims WHERE id = :claim_id``) so the drawer
panel can render the colored ClaimStateBadge inline. For TA1 rows
with ``claim_id IS NULL`` (batch-level envelope link),
``claim_state`` is ``"n/a"``.
"""
claim_state: str = "n/a"
if row.claim_id:
with db.SessionLocal()() as lookup_s:
crow = lookup_s.get(Claim, row.claim_id)
if crow is not None:
claim_state = (
crow.state.value
if hasattr(crow.state, "value")
else str(crow.state)
)
linked_iso = (
row.linked_at.isoformat().replace("+00:00", "Z")
if row.linked_at is not None
else ""
)
return {
"id": row.id,
"claim_id": row.claim_id,
"batch_id": row.batch_id,
"ack_id": row.ack_id,
"ack_kind": row.ack_kind,
"ak2_index": row.ak2_index,
"set_control_number": row.set_control_number,
"set_accept_reject_code": row.set_accept_reject_code,
"linked_at": linked_iso,
"linked_by": row.linked_by,
"claim_state": claim_state,
}
-241
View File
@@ -1,241 +0,0 @@
"""Write path for parsed batches — insert, reconcile, publish.
The single entry point is ``add_record(record, *, event_bus=None)``,
which replaces the body of the previous ``CycloneStore.add`` method.
It owns its own SQLAlchemy session, runs idempotency checks, persists
the batch + child rows, then (for 835 batches) triggers reconciliation
and (if ``event_bus`` is provided) publishes live-tail events.
Reconciliation runs OUTSIDE the persistence session, fail-soft: errors
are logged but do not roll back the persisted batch.
"""
from __future__ import annotations
import json
from cyclone import db
from cyclone.db import (
ActivityEvent,
Batch,
Claim,
Remittance,
)
from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835
from .orm_builders import _claim_837_row, _persist_835_remit, _remittance_835_row
from .records import BatchRecord, BatchRecord837, BatchRecord835
from .ui import to_ui_claim_from_orm, to_ui_remittance_from_orm
def add_record(record: BatchRecord, *, event_bus=None) -> None:
"""Persist a parsed batch (837P or 835) to the DB.
For 837P batches: inserts the Batch row, one Claim row per
claim, and a ``claim_submitted`` ActivityEvent per claim.
For 835 batches: inserts the Batch row, one Remittance row per
ClaimPayment, and a ``remit_received`` ActivityEvent per
ClaimPayment. Reconciliation (auto-match + per-pair CAS
aggregate) runs IN THE SAME SESSION before commit (SP27
Task 10) a single ``s.commit()`` covers both ingest and
reconciliation. If reconcile raises, the whole 835 ingest
rolls back; the batch never appears half-reconciled with
placeholder ``adjustment_amount`` values.
Idempotency: ``Claim.id`` and ``Remittance.id`` are PRIMARY KEYS,
so a re-ingest of the same fixture (e.g. ``/api/parse-837`` called
twice with the same file) would otherwise raise
``IntegrityError``. We do a per-row ``session.get(...)`` check
before each insert; if the row already exists, we log a warning
and skip. The batch row itself is still inserted (each parse
has a fresh ``uuid4`` id from the API). O(n) per row, but
acceptable for the small fixture sizes production load is
one batch at a time via the API, not bulk inserts.
When ``event_bus`` is provided, publishes one ``claim_written``
or ``remittance_written`` event per newly-inserted row plus an
``activity_recorded`` event per activity row, after commit. The
publish calls are best-effort failures are logged but do not
roll back the persisted batch.
"""
from cyclone.pubsub import EventBus
import logging
log = logging.getLogger(__name__)
# Track rows we actually inserted so we can publish events for them.
inserted_claim_ids: list[str] = []
inserted_remit_ids: list[str] = []
with db.SessionLocal()() as s:
batch_row = Batch(
id=record.id,
kind=record.kind,
input_filename=record.input_filename,
parsed_at=record.parsed_at,
totals_json=None,
validation_json=None,
raw_result_json=json.loads(record.result.model_dump_json()),
)
s.add(batch_row)
if isinstance(record, BatchRecord837):
result: ParseResult = record.result
for claim in result.claims:
if s.get(Claim, claim.claim_id) is not None:
log.warning(
"add: claim %s already exists; skipping (batch=%s)",
claim.claim_id, record.id,
)
continue
s.add(_claim_837_row(claim, record.id))
s.add(ActivityEvent(
ts=record.parsed_at,
kind="claim_submitted",
batch_id=record.id,
claim_id=claim.claim_id,
payload_json={
"message": (
f"Claim {claim.claim_id} submitted · "
f"{claim.payer.name}"
),
"npi": claim.billing_provider.npi,
"amount": float(claim.claim.total_charge or 0.0),
},
))
inserted_claim_ids.append(claim.claim_id)
elif isinstance(record, BatchRecord835):
result835: ParseResult835 = record.result
payer_name = result835.payer.name
for cp in result835.claims:
if s.get(Remittance, cp.payer_claim_control_number) is not None:
log.warning(
"add: remittance %s already exists; skipping (batch=%s)",
cp.payer_claim_control_number, record.id,
)
continue
remit_row = _remittance_835_row(cp, record.id)
s.add(remit_row)
# Flush so remit_row.id (FK target of cas_adjustments) is
# populated. SQLAlchemy assigns the PK on flush; without
# this the CasAdjustment inserts below would reference an
# unset id and violate the FK.
s.flush()
# SP7: persist per-line ServiceLinePayment + linked
# SVC-level CAS rows + claim-level CAS bucket. Replaces
# the previous per-SVC CAS insert loop so the
# service_line_payment_id FK is set correctly.
_persist_835_remit(s, cp, remit_row.id)
s.add(ActivityEvent(
ts=record.parsed_at,
kind="remit_received",
batch_id=record.id,
remittance_id=cp.payer_claim_control_number,
payload_json={
"message": (
f"Remit {cp.payer_claim_control_number} received"
),
"payerName": payer_name,
"amount": float(cp.total_paid or 0.0),
},
))
inserted_remit_ids.append(cp.payer_claim_control_number)
else:
raise TypeError(
f"Unsupported BatchRecord subclass: {type(record).__name__}"
)
# SP27 Task 10: run reconcile INSIDE the same session, before
# commit. The placeholder ``adjustment_amount`` set by
# ``_remittance_835_row`` is overwritten by reconcile's
# ``_reconcile_pair`` SUM-of-CAS-aggregate pass before the
# rows are ever visible. If reconcile raises, the whole
# 835 ingest rolls back and the batch never lands.
if record.kind == "835":
from cyclone import reconcile as _reconcile
_reconcile.run(s, record.id)
s.commit()
# Publish live-tail events synchronously. EventBus.publish is async
# but its body is purely synchronous ``put_nowait`` enqueues; we
# bypass the async wrapper and call the internal enqueue directly
# so callers (sync FastAPI endpoints, sync test harnesses) don't
# need to await.
if event_bus is not None and (inserted_claim_ids or inserted_remit_ids):
publish_events_sync(
event_bus, record, inserted_claim_ids, inserted_remit_ids,
)
def publish_events_sync(
event_bus,
record: BatchRecord,
claim_ids: list[str],
remit_ids: list[str],
) -> None:
"""Build UI-shaped payloads for newly-inserted rows and publish.
Runs after commit so subscribers can immediately re-fetch from
the API and see consistent data. Each ``claim_written`` /
``remittance_written`` payload is identical to what the matching
list endpoint would return for that row.
This is sync because EventBus's enqueue path is sync; we don't
need a coroutine for ``put_nowait``.
"""
from cyclone.pubsub import EventBus
import logging
log = logging.getLogger(__name__)
try:
with db.SessionLocal()() as s:
for cid in claim_ids:
row = s.get(Claim, cid)
if row is None:
continue
ui = to_ui_claim_from_orm(
row, batch_id=row.batch_id or record.id,
parsed_at=record.parsed_at,
# Fresh ingest — no remittance has been paired yet,
# so ``Received`` is necessarily 0.
received_total=0.0,
)
_sync_publish(event_bus, "claim_written", ui)
for rid in remit_ids:
row = s.get(Remittance, rid)
if row is None:
continue
ui = to_ui_remittance_from_orm(
row, batch_id=row.batch_id or record.id,
parsed_at=record.parsed_at,
)
_sync_publish(event_bus, "remittance_written", ui)
# Activity events for this batch.
from sqlalchemy import select
activity_rows = s.execute(
select(ActivityEvent).where(ActivityEvent.batch_id == record.id)
).scalars().all()
for arow in activity_rows:
ui = {
"kind": arow.kind,
"ts": arow.ts.isoformat().replace("+00:00", "Z"),
"batchId": arow.batch_id,
"claimId": arow.claim_id,
"remittanceId": arow.remittance_id,
"payload": arow.payload_json,
}
_sync_publish(event_bus, "activity_recorded", ui)
except Exception:
log.exception("add: event publish failed for batch %s", record.id)
def _sync_publish(event_bus, kind: str, payload: dict) -> None:
"""Synchronous fan-out helper. Mirrors ``EventBus.publish`` but
bypasses the async wrapper so callers don't need an event loop.
"""
event = {**payload, "_kind": kind}
for queue in list(event_bus._subscribers.get(kind, ())):
event_bus._enqueue_or_drop_oldest(queue, event)
+3 -184
View File
@@ -14,9 +14,6 @@ test_api_parse_persists.py) working unchanged.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest import pytest
@@ -27,195 +24,17 @@ def _auto_init_db(tmp_path, monkeypatch):
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient`` Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
does not invoke the FastAPI lifespan handler unless used as a context does not invoke the FastAPI lifespan handler unless used as a context
manager. The bus is reset between tests so subscribers don't leak. manager. The bus is reset between tests so subscribers don't leak.
Auth gating is enabled at the router/endpoint level via the
``Depends(matrix_gate)`` wiring in ``cyclone.api``. The auth tests
(``test_auth_*``) explicitly flip ``AUTH_DISABLED = False`` and
authenticate via the public login route to exercise the real
auth path. Every other test the original test suite predates
auth gets ``AUTH_DISABLED = True`` here so the existing tests
keep working without each one having to login first.
""" """
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
from cyclone import db from cyclone import db
from cyclone.api import app
from cyclone.pubsub import EventBus from cyclone.pubsub import EventBus
from cyclone.auth import deps
db._reset_for_tests() db._reset_for_tests()
db.init_db() db.init_db()
# Re-resolve `app` each fixture invocation because some tests app.state.event_bus = EventBus()
# (test_cors_extra_origins_via_env) call ``importlib.reload`` on
# ``cyclone.api`` to mutate the CORS allow-list. If we cached
# the app reference at conftest module load, we'd be setting
# ``event_bus`` on a stale instance that no test client is
# actually using.
from cyclone import api as _api_mod
_api_mod.app.state.event_bus = EventBus()
deps.AUTH_DISABLED = True
# The rate-limit middleware keeps a per-IP sliding window in
# ``_buckets``. Without a reset between tests, later tests in a
# full-suite run get ``429 Too Many Requests`` once the testclient
# IP exhausts its 300 req/60s budget. Walk the middleware stack
# and clear the buckets so every test starts with a fresh window.
# Trigger the stack build with a cheap health probe (the only
# request exempt from the limiter — see RateLimitMiddleware.EXEMPT_PATHS).
_reset_rate_limit_buckets(_api_mod.app)
try: try:
yield yield
finally: finally:
deps.AUTH_DISABLED = False app.state.event_bus = None
_api_mod.app.state.event_bus = None
db._reset_for_tests() db._reset_for_tests()
def _reset_rate_limit_buckets(app) -> None:
"""Clear the rate-limit middleware's per-IP sliding-window buckets.
SP27 Task 13b follow-up: ``RateLimitMiddleware._buckets`` is shared
across all tests in a process (TestClient reuses the same ``app``
instance), so without a reset between tests the full suite trips
the limiter after ~300 requests and later tests get 429s.
The middleware stack is only built on the first request, so we
prime it with an exempt health probe before walking to the
RateLimit layer. If the stack ever stops being a single chain
of ``.app`` links, this helper raises AttributeError better
to fail loudly than silently leak state.
"""
from fastapi.testclient import TestClient
TestClient(app).get("/api/health")
cur = app.middleware_stack
while cur is not None:
if hasattr(cur, "_buckets"):
cur._buckets.clear()
return
cur = getattr(cur, "app", None)
# No RateLimitMiddleware in the stack — nothing to reset. Should
# not happen in this codebase (security.py registers it at boot)
# but we don't want a missing reset to crash unrelated tests.
# ---------------------------------------------------------------------------
# SP31: shared DB-session + Claim/Remit factory fixtures.
#
# `db_session` yields a fresh session per test against the per-test DB set
# up by the autouse `_auto_init_db` fixture above. The session rolls back at
# teardown so a test that mutates rows doesn't leak into siblings.
#
# `make_claim` / `make_remit` build ORM rows with a small ergonomic surface:
# the planned parameter names follow the content-keys helper (PCN, charge,
# rendering NPI), but the ORM attribute names differ (`charge_amount` on
# Claim, `total_charge` on Remittance, `provider_npi` on Claim — and no
# `payer_id` / `rendering_provider_npi` column on Remittance at all). The
# factories map the planned names onto the real ORM attributes and stash
# `rendering_provider_npi` as a transient attribute so
# ``reconcile._content_keys_match`` can still read it via ``getattr``.
# ---------------------------------------------------------------------------
@pytest.fixture
def db_session():
"""Yield a fresh SQLAlchemy session, rolling back at teardown."""
from cyclone import db as _db
session = _db.SessionLocal()()
try:
yield session
session.rollback()
finally:
session.close()
def _ensure_batch(session, batch_id: str = "test-batch") -> None:
"""Create the Batch row a Claim/Remittance FKs to (idempotent)."""
from cyclone.db import Batch
if session.get(Batch, batch_id) is None:
session.add(Batch(
id=batch_id, kind="837p", input_filename="test.txt",
parsed_at=datetime.now(timezone.utc),
))
session.flush()
@pytest.fixture
def make_claim(db_session):
"""Factory: build & flush a Claim with the planned content-keys params.
Planned params (match the SP31 spec / content-keys test surface):
patient_control_number, total_charge, rendering_provider_npi,
service_date_from, matched_remittance_id=None, state=None,
claim_id=None
`state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests
that omit it keep behaving. `rendering_provider_npi` is wired through
the real ``Claim.rendering_provider_npi`` column (SP32 migration 0019).
"""
def _make(
patient_control_number: str,
total_charge,
rendering_provider_npi: str,
service_date_from,
matched_remittance_id=None,
state=None,
claim_id: str | None = None,
):
from cyclone.db import Claim, ClaimState as _CS
_ensure_batch(db_session)
cid = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}"
c = Claim(
id=cid,
batch_id="test-batch",
patient_control_number=patient_control_number,
service_date_from=service_date_from,
charge_amount=total_charge,
state=state if state is not None else _CS.SUBMITTED,
matched_remittance_id=matched_remittance_id,
rendering_provider_npi=rendering_provider_npi,
)
db_session.add(c)
db_session.flush()
return c
return _make
@pytest.fixture
def make_remit(db_session):
"""Factory: build & flush a Remittance with the planned content-keys params.
Planned params:
payer_claim_control_number, total_charge_amount, rendering_provider_npi,
service_date, remit_id=None
`total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM
field). `rendering_provider_npi` is wired through the real
``Remittance.rendering_provider_npi`` column (SP32 migration 0019).
"""
def _make(
payer_claim_control_number: str,
total_charge_amount,
rendering_provider_npi: str,
service_date,
remit_id: str | None = None,
):
from cyclone.db import Remittance
_ensure_batch(db_session)
rid = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}"
r = Remittance(
id=rid,
batch_id="test-batch",
payer_claim_control_number=payer_claim_control_number,
status_code="1",
total_charge=total_charge_amount,
total_paid=Decimal("0"),
received_at=datetime.now(timezone.utc),
service_date=service_date,
is_reversal=False,
rendering_provider_npi=rendering_provider_npi,
)
db_session.add(r)
db_session.flush()
return r
return _make
+2 -2
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~ N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~ N4*Montrose*CO*814016072~
DMG*D8*19590223*F~ DMG*D8*19590223*F~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ NM1*PR*2*COHCPF*****PI*SKCO0~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
@@ -35,7 +35,7 @@ NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~ N3*1900 Kellie DR~
N4*Montrose*CO*814019524~ N4*Montrose*CO*814019524~
DMG*D8*19570727*F~ DMG*D8*19570727*F~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ NM1*PR*2*COHCPF*****PI*SKCO0~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~ CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~ REF*G1*3173~
HI*ABK:R69~ HI*ABK:R69~
@@ -1,61 +0,0 @@
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*260617*1937*^*00501*991102984*1*P*:~
GS*HC*11525703*COMEDASSISTPROG*20260617*193715*991102984*X*005010X222A1~
ST*837*991102984*005010X222A1~
BHT*0019*00*co-fixture-001*20260617*193715*CH~
NM1*41*2*Dzinesco*****46*11525703~
PER*IC*Tester*EM*tester@example.com~
NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~
HL*1**20*1~
PRV*BI*PXC*251E00000X~
NM1*85*2*TOC, Inc.*****XX*1881068062~
N3*1100 East Main St*Suite A~
N4*Montrose*CO*814014063~
REF*EI*721587149~
HL*2*1*22*0~
SBR*P*18*******MC~
NM1*IL*1*Balliache*Marianela****MI*P060946~
N3*1811 PAVILION DR APT 303~
N4*Montrose*CO*814016072~
DMG*D8*19590223*F~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c1d*85.40***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
LX*1~
SV1*HC:T1019:U2*42.70*UN*6.00***1~
DTP*472*D8*20260602~
REF*6R*t991102984v769804d~
LX*2~
SV1*HC:T1019:U2*42.70*UN*6.00***1~
DTP*472*D8*20260603~
REF*6R*t991102984v770058d~
NM1*82*2*RENDERING PROVIDER*****XX*1234567893~
HL*3*1*22*0~
SBR*P*18*******MC~
NM1*IL*1*Barella*Victoria****MI*H582447~
N3*1900 Kellie DR~
N4*Montrose*CO*814019524~
DMG*D8*19570727*F~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~
CLM*t991102984o1c2d*155.76***12:B:1*Y*A*Y*Y~
REF*G1*3173~
HI*ABK:R69~
LX*1~
SV1*HC:S5130:U2*38.94*UN*6.00***1~
DTP*472*D8*20260602~
REF*6R*t991102984v769805d~
LX*2~
SV1*HC:S5130:U2*38.94*UN*6.00***1~
DTP*472*D8*20260603~
REF*6R*t991102984v770059d~
LX*3~
SV1*HC:S5130:U2*38.94*UN*6.00***1~
DTP*472*D8*20260604~
REF*6R*t991102984v770308d~
LX*4~
SV1*HC:S5130:U2*38.94*UN*6.00***1~
DTP*472*D8*20260605~
REF*6R*t991102984v770668d~
SE*45*991102984~
GE*1*991102984~
IEA*1*991102984~
+1 -1
View File
@@ -17,7 +17,7 @@ NM1*IL*1*Doe*John****MI*ABC123~
N3*456 Member St~ N3*456 Member St~
N4*Denver*CO*80203~ N4*Denver*CO*80203~
DMG*D8*19800101*M~ DMG*D8*19800101*M~
NM1*PR*2*CO_TXIX*****PI*CO_TXIX~ NM1*PR*2*COHCPF*****PI*SKCO0~
CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~ CLM*CLM001*100.00***12:B:1*Y*A*Y*Y~
REF*G1*PA123~ REF*G1*PA123~
HI*ABK:Z00~ HI*ABK:Z00~
-10
View File
@@ -1,10 +0,0 @@
ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*11525703 *260527*2303*^*00501*000000001*0*P*:~
GS*FA*COMEDASSISTPROG*11525703*20260527*2303*1*X*005010X231A1~
ST*999*0001*005010X231A1~
AK1*HC*1*005010X222A1~
AK2*837*991102989*005010X222A1~
IK5*A~
AK9*A*1*1*1~
SE*6*0001~
GE*1*1~
IEA*1*000000001~
+2 -1
View File
@@ -68,7 +68,8 @@ def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient):
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
c = s.get(Claim, "CLP-1") # Migration 0014: composite PK (batch_id, id).
c = s.get(Claim, ("B-1", "CLP-1"))
assert c.state == ClaimState.REJECTED assert c.state == ClaimState.REJECTED
assert c.rejected_at is not None assert c.rejected_at is not None
assert "999" in (c.rejection_reason or "") assert "999" in (c.rejection_reason or "")
@@ -1,71 +0,0 @@
"""Regression: GET /api/acks, /api/ta1-acks, /api/277ca-acks must be reachable
for an authenticated admin. The PERMISSIONS matrix only listed the
``POST /api/acks`` (parse-999) entry, so the GET list/detail endpoints
returned 403 to admins and broke the Inbox / 999 ACKs pages on the UI.
This test exercises the matrix via the public login route not by
flipping ``AUTH_DISABLED`` so a missing ``("GET", "/api/<kind>-acks"):
ALL_ROLES`` entry would surface as 403.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear(monkeypatch):
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def admin_client(client):
with SessionLocal()() as db:
users.create(db, username="admin", password="adminpassword1", role="admin")
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "adminpassword1"},
)
assert resp.status_code == 200
return client
@pytest.mark.parametrize("path", [
"/api/acks",
"/api/acks/0",
"/api/ta1-acks",
"/api/ta1-acks/0",
"/api/277ca-acks",
"/api/277ca-acks/0",
])
def test_admin_can_list_and_detail_all_ack_kinds(admin_client, path):
resp = admin_client.get(path, headers={"Accept": "application/json"})
# 200 for the list endpoint (empty rows is fine), 404 for the detail
# of a non-existent id (the gate must let the request through).
assert resp.status_code in (200, 404), (
f"{path} returned {resp.status_code}: {resp.text}"
)
assert resp.status_code != 403, (
f"{path} returned 403 — PERMISSIONS matrix is missing a GET entry"
)
-142
View File
@@ -1,142 +0,0 @@
"""Tests for the dedup-ed ack ID helpers (SP27 Task 1).
Locks the contract for the helpers that ``scheduler.py`` + ``api.py``
will both import from one place in ``cyclone.handlers._ack_id``.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from cyclone.handlers._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
two77ca_synthetic_source_batch_id,
)
from cyclone.parsers.parse_999 import parse_999_text
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
def _parse(path: Path):
return parse_999_text(path.read_text(), input_file=path.name)
def test_ack_count_summary_all_accepted():
result = _parse(ACCEPTED)
recv, acc, rej, code = ack_count_summary(result)
assert recv == 1
assert acc == 1
assert rej == 0
assert code == "A"
def test_ack_count_summary_all_rejected():
result = _parse(REJECTED)
recv, acc, rej, code = ack_count_summary(result)
assert recv == 1
assert acc == 0
assert rej == 1
assert code == "R"
def test_ack_count_summary_partial_uses_p_code():
# Synthesize a 2-set 999 inline with one AK5=A and one AK5=R.
# ISA is exactly 106 chars (positions 0-105); the parser slices
# positionally then splits the body on ``~``.
# SE count = ST + AK1 + AK2_1 + AK5_1 + AK2_2 + AK5_2 + AK9 + SE = 8.
partial_999 = (
"ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID "
"*240101*1200*^*00501*000000001*0*P*:~"
"GS*FA*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X231A1~"
"ST*999*0001*005010X231A1~"
"AK1*HC*0001*1*2~"
"AK2*837*1~"
"AK5*A~"
"AK2*837*2~"
"AK5*R~"
"AK9*P*2*1*1~"
"SE*8*0001~"
"GE*1*1~"
"IEA*1*000000001~"
)
result = parse_999_text(partial_999, input_file="partial.999")
recv, acc, rej, code = ack_count_summary(result)
assert recv == 2
assert acc == 1
assert rej == 1
assert code == "P"
def test_ack_synthetic_with_pcn_and_filename_is_deterministic_and_prefix():
bsid1 = ack_synthetic_source_batch_id(
"000000001", pcn="PCN-12345", source_filename="tp_999.x12",
)
bsid2 = ack_synthetic_source_batch_id(
"000000001", pcn="PCN-12345", source_filename="tp_999.x12",
)
assert bsid1 == bsid2, "same inputs must produce same id"
assert bsid1.startswith("999-PCN-12345-")
# Hash is 8 hex chars
suffix = bsid1.split("-")[-1]
assert len(suffix) == 8 and all(
c in "0123456789abcdef" for c in suffix
), suffix
def test_ack_synthetic_with_pcn_strips_whitespace():
bsid = ack_synthetic_source_batch_id(
"000000001", pcn=" PCN ", source_filename="f.x12",
)
assert bsid.startswith("999-PCN-")
def test_ack_synthetic_without_pcn_uses_icn():
bsid = ack_synthetic_source_batch_id(
"777777777", pcn=None, source_filename="f.x12",
)
assert bsid.startswith("999-777777777-")
# <= 32 chars total (VARCHAR(32) constraint)
assert len(bsid) <= 32
def test_ack_synthetic_default_icn_when_empty():
bsid = ack_synthetic_source_batch_id("", pcn=None, source_filename=None)
# No PCN, no filename → falls through to default ICN ``000000001``
# (no hash suffix without a filename).
assert bsid == "999-000000001"
def test_two77ca_synthetic_uses_icn():
assert two77ca_synthetic_source_batch_id("000012345") == "277CA-000012345"
def test_two77ca_synthetic_empty_falls_back_to_default():
assert two77ca_synthetic_source_batch_id("") == "277CA-000000001"
assert two77ca_synthetic_source_batch_id(None) == "277CA-000000001"
def test_ack_count_summary_empty_set_responses_returns_zeros():
"""A 999 envelope without any AK2/IK5 sets is malformed per the spec,
but the helper must still return a sane (0, 0, 0, 'A') tuple the
scheduler adds it to result.errors and the caller decides."""
class _StubResult:
set_responses = []
recv, acc, rej, code = ack_count_summary(_StubResult())
assert recv == 0
assert acc == 0
assert rej == 0
# rejected == 0 → "A" (no rejection signal in this degenerate case)
assert code == "A"
def test_ack_synthetic_uses_real_sha1_truncation():
"""The hash should match hashlib.sha1(filename).hexdigest()[:8] so
a future migration to a different hash is intentional, not drift."""
expected = hashlib.sha1(b"my-file.x12").hexdigest()[:8]
bsid = ack_synthetic_source_batch_id(
"000000001", pcn=None, source_filename="my-file.x12",
)
assert bsid.endswith(f"-{expected}")
+5 -160
View File
@@ -51,25 +51,21 @@ def test_migration_0002_creates_acks_table():
def test_migration_latest_idempotent_on_fresh_db(): def test_migration_latest_idempotent_on_fresh_db():
"""Re-running the migration on the same DB must be a no-op (PRAGMA """Re-running the migration on the same DB must be a no-op (PRAGMA
user_version already at the latest version currently 19 after user_version already at the latest version currently 14 after
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
providers/payers/clearhouse, SP10's 0008 payer_rejected, providers/payers/clearhouse, SP10's 0008 payer_rejected,
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged, SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups, SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id, SP19's 0013 drop_claims_unique_constraint, SP20's 0014
SP22's 0015 drop_claims_unique_constraint, SP27-Task 11's 0016 relax_claims_remits_pk)."""
claims.matched_remittance_id index, SP27-Task 17's 0017
claim.patient_control_number backfill UPDATE, SP28's 0018
claim_acks join table, SP32's 0019
rendering_provider_npi + service_provider_npi)."""
with db.engine().begin() as c: with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v1 == 19 assert v1 == 14
# A second run should not raise and should not bump the version. # A second run should not raise and should not bump the version.
db_migrate.run(db.engine()) db_migrate.run(db.engine())
with db.engine().begin() as c: with db.engine().begin() as c:
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v2 == 19 assert v2 == 14
def test_add_ack_persists_row(): def test_add_ack_persists_row():
@@ -132,154 +128,3 @@ def test_get_ack_returns_row_when_present():
assert fetched.id == row.id assert fetched.id == row.id
assert fetched.source_batch_id == "b-1" assert fetched.source_batch_id == "b-1"
assert fetched.raw_json == {"hello": "world"} assert fetched.raw_json == {"hello": "world"}
# ---------------------------------------------------------------------------
# Hotfix 2026-06-29: acks list endpoint silently capped at limit=100
# without exposing the true total or full-set aggregates, so the Acks page
# rendered "100 on file" and KPI totals summed from the page only when the
# row count exceeded 100. These tests pin the new offset param + the
# server-side `aggregates` field so the silent-failure mode can't regress.
# ---------------------------------------------------------------------------
def _seed_acks(n: int) -> list[int]:
"""Insert n ack rows with deterministic per-row counts. Returns the ids."""
_make_batch("b-hotfix")
ids: list[int] = []
for i in range(n):
row = store.add_ack(
source_batch_id="b-hotfix",
accepted_count=i + 1,
rejected_count=i,
received_count=i + 1,
ack_code="A",
raw_json={"order": i},
)
ids.append(row.id)
return ids
def test_list_acks_endpoint_pagination_offsets_correctly():
"""`offset` walks the full set, `has_more` flips at the boundary."""
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.auth.users import create
from cyclone.db import SessionLocal
with SessionLocal()() as s:
create(s, username="u", password="p", role="admin")
s.commit()
client = TestClient(app)
client.post("/api/auth/login", json={"username": "u", "password": "p"})
_seed_acks(5)
r = client.get("/api/acks?limit=2&offset=0")
d = r.json()
assert d["total"] == 5
assert d["returned"] == 2
assert d["has_more"] is True
assert len(d["items"]) == 2
r = client.get("/api/acks?limit=2&offset=4")
d = r.json()
assert d["total"] == 5
assert d["returned"] == 1
assert d["has_more"] is False
assert len(d["items"]) == 1
def test_list_acks_endpoint_aggregates_reflect_full_set():
"""`aggregates` sums over the full row set, not the page.
Without this the Acks KPI strip silently under-reports once the
row count exceeds the page size the silent-failure the operator
flagged on 2026-06-29.
"""
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.auth.users import create
from cyclone.db import SessionLocal
with SessionLocal()() as s:
create(s, username="u", password="p", role="admin")
s.commit()
client = TestClient(app)
client.post("/api/auth/login", json={"username": "u", "password": "p"})
# 5 rows: accepted = 1+2+3+4+5 = 15, rejected = 0+1+2+3+4 = 10,
# received = same as accepted.
_seed_acks(5)
# Page 1 of 2 — full set is 5 rows, page only shows 2, but aggregates
# must reflect all 5.
r = client.get("/api/acks?limit=2&offset=0")
d = r.json()
assert d["total"] == 5
assert d["returned"] == 2
assert d["aggregates"]["accepted_count"] == 15
assert d["aggregates"]["rejected_count"] == 10
assert d["aggregates"]["received_count"] == 15
# Page 2 must return identical aggregates — page slice mustn't shift them.
r = client.get("/api/acks?limit=2&offset=2")
d = r.json()
assert d["aggregates"]["accepted_count"] == 15
assert d["aggregates"]["rejected_count"] == 10
assert d["aggregates"]["received_count"] == 15
def test_list_acks_endpoint_limit_cap_is_5000():
"""The validator still enforces an upper bound so a client can't
request 1,000,000 rows and OOM the SQLite-backed list call."""
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.auth.users import create
from cyclone.db import SessionLocal
with SessionLocal()() as s:
create(s, username="u", password="p", role="admin")
s.commit()
client = TestClient(app)
client.post("/api/auth/login", json={"username": "u", "password": "p"})
r = client.get("/api/acks?limit=10000")
assert r.status_code == 422 # FastAPI validation error
r = client.get("/api/acks?limit=5000")
assert r.status_code == 200
def test_list_acks_endpoint_offset_past_end_returns_empty_page():
"""`offset` past the row count must yield an empty page, not 500.
Pinning this so a future refactor that introduces streaming or
cursor-based pagination can't accidentally error or 500 when the
UI holds stale page state across a row count change.
"""
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.auth.users import create
from cyclone.db import SessionLocal
with SessionLocal()() as s:
create(s, username="u", password="p", role="admin")
s.commit()
client = TestClient(app)
client.post("/api/auth/login", json={"username": "u", "password": "p"})
_seed_acks(3)
r = client.get("/api/acks?limit=2&offset=99")
assert r.status_code == 200
d = r.json()
assert d["total"] == 3
assert d["returned"] == 0
assert d["has_more"] is False
assert d["items"] == []
# Aggregates must still reflect the full 3-row set on the empty page —
# otherwise a stale UI page state would silently zero out the KPI strip.
assert d["aggregates"]["accepted_count"] == 1 + 2 + 3
assert d["aggregates"]["rejected_count"] == 0 + 1 + 2
assert d["aggregates"]["received_count"] == 1 + 2 + 3
-8
View File
@@ -101,14 +101,6 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path. # Summary numbers match the JSON path.
assert parsed[3]["data"]["total_claims"] == 2 assert parsed[3]["data"]["total_claims"] == 2
assert parsed[3]["data"]["passed"] == 2 assert parsed[3]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (regression: it used to be missing
# on the stream path, only present on the JSON path, which made the
# Upload page's Export button say "no batch to export").
assert parsed[3]["type"] == "summary"
assert isinstance(parsed[3]["data"]["batch_id"], str)
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient): def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
+4 -2
View File
@@ -98,8 +98,10 @@ class TestParse277CAEndpointHappyPath:
files={"file": ("minimal_277ca.txt", text, "text/plain")}, files={"file": ("minimal_277ca.txt", text, "text/plain")},
) )
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
c1 = s.get(Claim, "c1") # Migration 0014: composite PK (batch_id, id). The _seed_claim
c2 = s.get(Claim, "c2") # helper uses batch_id="BATCH-1".
c1 = s.get(Claim, ("BATCH-1", "c1"))
c2 = s.get(Claim, ("BATCH-1", "c2"))
assert c1.payer_rejected_at is None assert c1.payer_rejected_at is None
assert c2.payer_rejected_at is not None assert c2.payer_rejected_at is not None
assert c2.payer_rejected_status_code == "A6" assert c2.payer_rejected_status_code == "A6"
-7
View File
@@ -82,13 +82,6 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
# Summary numbers match the JSON path. # Summary numbers match the JSON path.
assert parsed[7]["data"]["total_claims"] == 2 assert parsed[7]["data"]["total_claims"] == 2
assert parsed[7]["data"]["passed"] == 2 assert parsed[7]["data"]["passed"] == 2
# The streaming summary carries the server-side batch id so the
# frontend can call /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip (matches the parallel fix on
# /api/parse-837).
assert parsed[7]["type"] == "summary"
assert isinstance(parsed[7]["data"]["batch_id"], str)
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient): def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
-303
View File
@@ -1,303 +0,0 @@
"""Integration tests for /api/acks/stream and /api/ta1-acks/stream.
SP25: both endpoints follow the live-tail wire format used by
/api/claims/stream, /api/remittances/stream, /api/activity/stream:
* Snapshot: one ``item`` line per existing row (newest first).
* Closing: one ``snapshot_end`` line.
* Live: forwarded ``item`` lines for each ``ack_received`` /
``ta1_ack_received`` event published on the bus.
These tests follow the same direct-coroutine pattern as
``test_api_stream_live.py`` because ``httpx.ASGITransport`` buffers
the response and never delivers a disconnect message calling the
endpoint coroutine with a synthetic ``Request`` lets us iterate the
body iterator byte-by-byte and use ``body_iterator.aclose()`` to
simulate a client disconnect.
"""
from __future__ import annotations
import asyncio
import json
from datetime import date
import pytest
from starlette.requests import Request
from cyclone import db
from cyclone.api import app
from cyclone.api_routers.acks import acks_stream
from cyclone.api_routers.ta1_acks import ta1_acks_stream
from cyclone.pubsub import EventBus
from cyclone.store import store
# ---------------------------------------------------------------------------
# Shared helpers — mirror the patterns in test_api_stream_live.py
# ---------------------------------------------------------------------------
def _make_request(path: str = "/api/acks/stream") -> Request:
"""Synthetic Starlette ``Request`` with a no-op receive channel."""
async def receive():
# Block forever; ``is_disconnected``'s cancel scope will cancel
# this await and we'll return None (treated as "no message").
await asyncio.Event().wait()
scope = {
"type": "http",
"method": "GET",
"headers": [],
"query_string": b"",
"path": path,
"app": app,
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
}
return Request(scope, receive=receive)
def _resolve_query_defaults(endpoint) -> dict:
"""Resolve FastAPI Query(...) defaults to their inner ``.default``."""
import inspect
sig = inspect.signature(endpoint)
resolved: dict = {}
for name, param in sig.parameters.items():
default = param.default
if hasattr(default, "default"):
resolved[name] = default.default
else:
resolved[name] = default
return resolved
async def _call_endpoint(endpoint, path: str):
request = _make_request(path)
defaults = _resolve_query_defaults(endpoint)
defaults.pop("request", None)
return await endpoint(request, **defaults)
async def _read_lines(body_iter, n: int, timeout: float = 5.0) -> list[str]:
buffer = b""
lines: list[str] = []
async with asyncio.timeout(timeout):
while len(lines) < n:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
break
buffer += chunk
while b"\n" in buffer and len(lines) < n:
raw, buffer = buffer.split(b"\n", 1)
if raw:
lines.append(raw.decode("utf-8"))
return lines
async def _read_one_line(body_iter, timeout: float = 5.0) -> str | None:
buffer = b""
async with asyncio.timeout(timeout):
while True:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
return None
buffer += chunk
if b"\n" in buffer:
raw, _ = buffer.split(b"\n", 1)
if raw:
return raw.decode("utf-8")
async def _read_until_type(
body_iter, target: str, *, timeout: float = 5.0, max_lines: int = 50
) -> dict | None:
buffer = b""
seen = 0
async with asyncio.timeout(timeout):
while seen < max_lines:
try:
chunk = await body_iter.__anext__()
except StopAsyncIteration:
return None
buffer += chunk
while b"\n" in buffer:
raw, buffer = buffer.split(b"\n", 1)
if not raw:
continue
seen += 1
obj = json.loads(raw.decode("utf-8"))
if obj.get("type") == target:
return obj
return None
async def _drain_until_disconnect(body_iter, max_chunks: int = 20) -> None:
try:
await body_iter.aclose()
except (asyncio.CancelledError, GeneratorExit):
pass
# ---------------------------------------------------------------------------
# Per-test heartbeat patch — 0.2s so idle generators exit promptly.
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _short_heartbeat(monkeypatch):
monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")
@pytest.fixture
def bus() -> EventBus:
"""Fresh EventBus attached to app.state. Tests publish via ``bus.publish``.
``add_ack`` / ``add_ta1_ack`` already publish to whichever bus is
passed in, but the live-event tests want to publish synchronously
against ``app.state.event_bus`` so the existing-tail subscription
picks them up.
"""
new_bus = EventBus(max_queue_size=64)
app.state.event_bus = new_bus
return new_bus
# ---------------------------------------------------------------------------
# /api/acks/stream
# ---------------------------------------------------------------------------
async def test_acks_stream_snapshots_existing_rows(bus: EventBus):
"""An existing 999 row surfaces as the first ``item`` line."""
with db.SessionLocal()() as s:
store.add_ack(
source_batch_id="999-SEED-1",
accepted_count=1,
rejected_count=0,
received_count=1,
ack_code="A",
raw_json={
"envelope": {"control_number": "000000001"},
"set_responses": [{"set_control_number": "PCN-1"}],
},
event_bus=bus,
)
response = await _call_endpoint(acks_stream, "/api/acks/stream")
assert response.media_type.startswith("application/x-ndjson")
lines = await _read_lines(response.body_iterator, n=2)
items = [json.loads(line) for line in lines]
assert items[0]["type"] == "item"
assert items[0]["data"]["source_batch_id"] == "999-SEED-1"
assert items[0]["data"]["patient_control_number"] == "PCN-1"
assert items[1] == {"type": "snapshot_end", "data": {"count": 1}}
await _drain_until_disconnect(response.body_iterator)
async def test_acks_stream_emits_live_event(bus: EventBus):
"""A 999 written while the stream is open shows up as a live item."""
response = await _call_endpoint(acks_stream, "/api/acks/stream")
# Drain snapshot (1 snapshot_end line, no prior rows).
snap = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap == {"type": "snapshot_end", "data": {"count": 0}}
# Advance one step so ``subscribe_raw`` registers. Next line may be
# a heartbeat — that's fine, we just need the subscription live.
_ = await _read_one_line(response.body_iterator, timeout=2.0)
with db.SessionLocal()() as s:
store.add_ack(
source_batch_id="999-LIVE-1",
accepted_count=1,
rejected_count=0,
received_count=1,
ack_code="A",
raw_json={
"envelope": {"control_number": "000000002"},
"set_responses": [],
},
event_bus=bus,
)
obj = await _read_until_type(
response.body_iterator, "item", timeout=2.0,
)
assert obj is not None, "no item line arrived after publish"
assert obj["data"]["source_batch_id"] == "999-LIVE-1"
await _drain_until_disconnect(response.body_iterator)
# ---------------------------------------------------------------------------
# /api/ta1-acks/stream
# ---------------------------------------------------------------------------
async def test_ta1_acks_stream_snapshots_existing_rows(bus: EventBus):
"""An existing TA1 row surfaces as the first ``item`` line."""
with db.SessionLocal()() as s:
store.add_ta1_ack(
source_batch_id="TA1-SEED-1",
control_number="000000001",
interchange_date=date(2026, 7, 2),
interchange_time="1200",
ack_code="A",
note_code="000",
ack_generated_date=None,
sender_id="S",
receiver_id="R",
raw_json={"envelope": {"control_number": "000000001"}},
event_bus=bus,
)
response = await _call_endpoint(ta1_acks_stream, "/api/ta1-acks/stream")
assert response.media_type.startswith("application/x-ndjson")
lines = await _read_lines(response.body_iterator, n=2)
items = [json.loads(line) for line in lines]
assert items[0]["type"] == "item"
assert items[0]["data"]["control_number"] == "000000001"
assert items[0]["data"]["interchange_date"] == "2026-07-02"
assert items[1] == {"type": "snapshot_end", "data": {"count": 1}}
await _drain_until_disconnect(response.body_iterator)
async def test_ta1_acks_stream_emits_live_event(bus: EventBus):
"""A TA1 written while the stream is open shows up as a live item."""
response = await _call_endpoint(ta1_acks_stream, "/api/ta1-acks/stream")
snap = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap == {"type": "snapshot_end", "data": {"count": 0}}
_ = await _read_one_line(response.body_iterator, timeout=2.0)
with db.SessionLocal()() as s:
store.add_ta1_ack(
source_batch_id="TA1-LIVE-1",
control_number="000000099",
interchange_date=date(2026, 7, 2),
interchange_time="1330",
ack_code="A",
note_code="000",
ack_generated_date=None,
sender_id="SX",
receiver_id="RX",
raw_json={"envelope": {"control_number": "000000099"}},
event_bus=bus,
)
obj = await _read_until_type(
response.body_iterator, "item", timeout=2.0,
)
assert obj is not None, "no item line arrived after publish"
assert obj["data"]["control_number"] == "000000099"
assert obj["data"]["sender_id"] == "SX"
await _drain_until_disconnect(response.body_iterator)
-317
View File
@@ -1,317 +0,0 @@
"""Tests for POST /api/batches/{batch_id}/export-837.
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
regenerated X12 837 files. No DB state mutation. Mirrors the
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
"""
import io
import json
import zipfile
from pathlib import Path
from fastapi.testclient import TestClient
from cyclone.api import app
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
"""Parse a single 837P file and return a dict with ``batch_id`` and
``claims``.
The parse-837 happy-path response does not currently surface
``batch_id`` at the top level (it's a server-side UUID, surfaced in
409 errors but not in 200s). We look it up from the DB the Batch
row is already persisted by the time the parse endpoint returns.
"""
from cyclone import db
from cyclone.db import Batch
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
body = r.json()
with db.SessionLocal()() as s:
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
assert most_recent is not None, "expected at least one Batch row after parse"
batch_id = most_recent.id
return {"batch_id": batch_id, "claims": body["claims"]}
def _claim_ids_from_seed(seeded: dict) -> list[str]:
return [c["claim_id"] for c in seeded["claims"]]
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_happy_path_returns_zip_with_one_x12_per_claim():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("application/zip")
assert "attachment" in r.headers["content-disposition"]
assert (
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
in r.headers["content-disposition"]
)
# Per-claim failures header absent on full success.
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == len(claim_ids)
# Each entry follows the HCPF outbound naming template
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
# clearhouse TPID is "11525703"). Every name must be unique.
from cyclone.edi.filenames import is_outbound_filename
seen = set()
for name in names:
assert is_outbound_filename(name), (
f"expected HCPF outbound filename, got {name!r}"
)
assert name not in seen, f"duplicate filename: {name}"
seen.add(name)
with zf.open(name) as f:
first_line = f.readline().decode("ascii", errors="replace")
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
placeholders. The export endpoint must thread the clearhouse
submitter (dzinesco's TPID 11525703) and payer receiver
(COMEDASSISTPROG) through to the serializer."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
# Submitter block must use the clearhouse TPID + name, not
# the 'CYCLONE' placeholder.
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
assert "11525703" in text, f"{name} missing clearhouse TPID"
assert "Dzinesco" in text, f"{name} missing clearhouse name"
# Receiver block must use the CO_TXIX payer config
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
# Loop 1000A requires PER — must be present, not omitted.
assert "PER*IC*" in text, f"{name} missing required PER segment"
# SBR09 should be 'MC' (Medicaid claim filing indicator),
# not the member id.
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
sbr09 = sbr_line.rstrip("~").split("*")[9]
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
def test_each_x12_in_zip_round_trips_through_parser():
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
to the source row's raw_json (modulo recomputed validation)."""
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
by_id = {c["claim_id"]: c for c in seeded["claims"]}
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
for name in zf.namelist():
with zf.open(name) as f:
text = f.read().decode("ascii")
result = parse(text, PayerConfig(name="CO_MEDICAID"))
assert result.claims, f"{name} didn't parse back to any claims"
# Claim ids must round-trip (proves the serializer didn't drop
# or rewrite the id).
assert result.claims[0].claim.claim_id in by_id, (
f"{name} parsed to an unknown claim_id"
)
# ---------------------------------------------------------------------------
# Partial-failure surface
# ---------------------------------------------------------------------------
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
"""If one claim has raw_json=None, the ZIP still returns for the others
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
from cyclone import db
from cyclone.edi.filenames import is_outbound_filename
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
# Wipe raw_json on one claim to simulate a corrupted row.
with db.SessionLocal()() as s:
from cyclone.db import Claim
target = s.get(Claim, claim_ids[0])
target.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
# Filename uses SUCCESS count, not requested count.
expected_success = len(claim_ids) - 1
assert (
f"batch-{batch_id}-{expected_success}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header, "expected X-Cyclone-Serialize-Errors header"
errs = json.loads(err_header)
assert len(errs) == 1
assert errs[0]["claim_id"] == claim_ids[0]
assert "raw_json" in errs[0]["reason"].lower()
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
assert len(names) == expected_success
# Every entry follows HCPF outbound template; none is the
# 'claim-CLM-X.x12' placeholder from the old endpoint.
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
def test_all_claims_fail_to_serialize_returns_422():
from cyclone import db
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
claim_ids = _claim_ids_from_seed(seeded)
assert len(claim_ids) >= 1
with db.SessionLocal()() as s:
from cyclone.db import Claim
for cid in claim_ids:
c = s.get(Claim, cid)
c.raw_json = None
s.commit()
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": claim_ids},
)
assert r.status_code == 422, r.text
body = r.json()
# The 422 body surfaces the failure list so the UI can show details
# without parsing a header.
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
assert errs is not None
assert len(errs) == len(claim_ids)
for entry in errs:
assert entry["claim_id"] in claim_ids
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
def test_empty_claim_ids_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": []},
)
assert r.status_code == 400, r.text
def test_missing_claim_ids_key_returns_400():
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={},
)
assert r.status_code == 400, r.text
def test_unknown_batch_id_returns_404():
with TestClient(app) as client:
r = client.post(
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
json={"claim_ids": ["CLM-1"]},
)
assert r.status_code == 404, r.text
body = r.json()
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
def test_unknown_claim_id_silently_omitted():
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
in the errors header same convention as the resubmit endpoint."""
with TestClient(app) as client:
seeded = _seed_batch(client)
batch_id = seeded["batch_id"]
real_ids = _claim_ids_from_seed(seeded)
assert real_ids, "fixture must produce at least 1 claim"
requested = real_ids + ["CLM-GHOST"]
r = client.post(
f"/api/batches/{batch_id}/export-837",
json={"claim_ids": requested},
)
assert r.status_code == 200, r.text
# Filename uses success count = len(real_ids), not len(requested).
assert (
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
in r.headers["content-disposition"]
)
err_header = r.headers.get("x-cyclone-serialize-errors")
assert err_header
errs = json.loads(err_header)
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
names = zf.namelist()
# HCPF outbound filenames, one per real claim. We don't pin the
# exact ts (it depends on the wall clock at test time), but the
# count and the HCPF template must match.
from cyclone.edi.filenames import is_outbound_filename
assert len(names) == len(real_ids)
for name in names:
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
-359
View File
@@ -1,359 +0,0 @@
"""API tests for SP28 ack-claim surface.
Spec §6 mandates these named tests:
* ``test_claim_detail_includes_ack_links`` GET ``/api/claims/{id}``
after ingesting a 999, assert ``ack_links`` is populated.
* ``test_acks_list_includes_linked_claim_ids`` GET ``/api/acks``
after ingesting, assert ``linked_claim_ids`` is populated per row.
* ``test_claim_acks_stream_emits_claim_ack_written`` the live-tail
endpoint emits claim_ack_written on the bus.
Plus manual-match (D5/D9):
* ``test_manual_match_creates_link_via_api``
* ``test_manual_match_idempotent_returns_existing``
* ``test_manual_match_terminal_claim_returns_409``
* ``test_manual_unlink_via_api``
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
from cyclone.store import store
GAINWELL_999 = (
Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
)
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
@pytest.fixture(autouse=True)
def clear_store():
with store._lock:
store._batches.clear()
yield
with store._lock:
store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
with db.SessionLocal()() as s:
b = Batch(
id=batch_id,
kind="837p",
input_filename=f"{batch_id}.txt",
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
raw_result_json={
"envelope": {
"sender_id": "SUBMITTER",
"receiver_id": "RECEIVER",
"control_number": envelope_control,
"transaction_date": "2026-07-02",
},
},
)
s.add(b)
s.commit()
def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str,
state: ClaimState = ClaimState.SUBMITTED):
with db.SessionLocal()() as s:
c = Claim(
id=claim_id,
batch_id=batch_id,
patient_control_number=patient_control_number,
charge_amount=Decimal("100.00"),
state=state,
)
s.add(c)
s.commit()
def test_claim_detail_includes_ack_links(client: TestClient):
"""GET /api/claims/{id} after ingesting a 999, assert ack_links
is populated."""
_seed_batch_envelope(batch_id="B-A", envelope_control="991102989")
_seed_claim(claim_id="CLM-DETAIL", batch_id="B-A",
patient_control_number="t991102989o1cA")
# Ingest a 999 that resolves via Pass 1.
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
# GET /api/claims/{claim_id} — the SP4 detail endpoint must
# surface ack_links so the ClaimDrawer panel can render.
detail_resp = client.get("/api/claims/CLM-DETAIL")
assert detail_resp.status_code == 200, detail_resp.text
body = detail_resp.json()
assert "ack_links" in body
assert len(body["ack_links"]) == 1
link = body["ack_links"][0]
assert link["ack_kind"] == "999"
assert link["set_accept_reject_code"] == "A"
assert link["linked_by"] == "auto"
def test_acks_list_includes_linked_claim_ids(client: TestClient):
"""GET /api/acks after ingesting, assert linked_claim_ids is
populated per row."""
_seed_batch_envelope(batch_id="B-LIST", envelope_control="991102989")
_seed_claim(claim_id="CLM-LIST-1", batch_id="B-LIST",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
resp = client.get("/api/acks")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 1
assert "linked_claim_ids" in body["items"][0]
assert body["items"][0]["linked_claim_ids"] == ["CLM-LIST-1"]
def test_claim_acks_stream_emits_claim_ack_written(client: TestClient):
"""The per-claim live-tail endpoint streams claim_ack_written
events the moment a link is created.
Uses the direct-endpoint-invocation pattern from
``test_api_stream_live.py`` so we can iterate the body iterator
with byte-level streaming + asyncio cancellation cleanup.
"""
import asyncio
from starlette.requests import Request as StarletteRequest
from cyclone.api_routers.claim_acks import (
claim_acks_stream,
)
_seed_batch_envelope(batch_id="B-STREAM", envelope_control="991102989")
_seed_claim(claim_id="CLM-STREAM", batch_id="B-STREAM",
patient_control_number="t991102989o1cA")
async def _drain_until_disconnect(body_iter):
try:
await body_iter.aclose()
except (asyncio.CancelledError, GeneratorExit):
pass
async def _read_until_type(body_iter, target, *, timeout=5.0):
buffer = b""
async with asyncio.timeout(timeout):
while True:
chunk = await body_iter.__anext__()
buffer += chunk
while b"\n" in buffer:
raw, buffer = buffer.split(b"\n", 1)
if not raw:
continue
obj = json.loads(raw.decode("utf-8"))
if obj.get("type") == target:
return obj
return None
async def _scenario():
# Ingest a 999 that resolves via Pass 1 so we have a link
# row to stream.
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
# Build a synthetic request and call the endpoint coroutine
# directly so we get true byte-streaming.
async def receive():
await asyncio.Event().wait() # never delivered
scope = {
"type": "http",
"method": "GET",
"headers": [],
"query_string": b"",
"path": "/api/claims/CLM-STREAM/acks/stream",
"app": app,
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 50000),
}
request = StarletteRequest(scope, receive=receive)
response = await claim_acks_stream(request, claim_id="CLM-STREAM")
assert response.media_type.startswith("application/x-ndjson")
# The snapshot has 1 item + 1 snapshot_end. Read until
# snapshot_end.
snap_end = await _read_until_type(
response.body_iterator, "snapshot_end", timeout=2.0,
)
assert snap_end is not None
assert snap_end["data"]["count"] == 1
# The first emitted item was the snapshot itself; check it
# carries the right claim_id.
await _drain_until_disconnect(response.body_iterator)
asyncio.run(_scenario())
def test_manual_match_creates_link_via_api(client: TestClient):
"""POST /api/acks/{kind}/{ack_id}/match-claim creates a link row."""
# Pre-seed an ack via the API so we have an ack_id to reference.
_seed_batch_envelope(batch_id="B-MM", envelope_control="991102989")
_seed_claim(claim_id="CLM-MM", batch_id="B-MM",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
body = resp.json()
ack_id = body["ack"]["id"]
# Now manually match a second claim to the same ack.
_seed_claim(claim_id="CLM-MM-MANUAL", batch_id="B-MM",
patient_control_number="manual-pcn-001")
resp2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-MANUAL"},
)
assert resp2.status_code == 200, resp2.text
body2 = resp2.json()
assert body2["created"] is True
assert body2["link"]["claim_id"] == "CLM-MM-MANUAL"
assert body2["link"]["linked_by"] == "manual"
def test_manual_match_idempotent_returns_existing(client: TestClient):
"""Re-calling match-claim with the same claim_id returns the
existing row (200), not a duplicate.
Uses a 999 that the auto-linker can't resolve (no matching
Batch.envelope.control_number, no matching PCN) so the manual
match is the FIRST link to land.
"""
# Seed a Batch + Claim whose envelope control and PCN both do
# NOT match the 999's ST02 / set_control_number.
_seed_batch_envelope(batch_id="B-MM-IDEMP", envelope_control="UNRELATED")
_seed_claim(claim_id="CLM-MM-IDEMP", batch_id="B-MM-IDEMP",
patient_control_number="manual-test-pcn")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
# First call — created.
r1 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-IDEMP"},
)
assert r1.status_code == 200, r1.text
body1 = r1.json()
assert body1["created"] is True
# Second call — same row, idempotent.
r2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-IDEMP"},
)
assert r2.status_code == 200, r2.text
body2 = r2.json()
assert body2["created"] is False
assert body2["link"]["id"] == body1["link"]["id"]
def test_manual_match_terminal_claim_returns_409(client: TestClient):
"""A REVERSED claim cannot be matched — 409."""
_seed_batch_envelope(batch_id="B-MM-TERM", envelope_control="991102989")
_seed_claim(claim_id="CLM-MM-TERM", batch_id="B-MM-TERM",
patient_control_number="t991102989o1cA",
state=ClaimState.REVERSED)
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
resp2 = client.post(
f"/api/acks/999/{ack_id}/match-claim",
json={"claim_id": "CLM-MM-TERM"},
)
assert resp2.status_code == 409, resp2.text
def test_manual_unlink_via_api(client: TestClient):
"""DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id} removes
the link row and publishes claim_ack_dropped."""
_seed_batch_envelope(batch_id="B-UNL", envelope_control="991102989")
_seed_claim(claim_id="CLM-UNL", batch_id="B-UNL",
patient_control_number="t991102989o1cA")
text = GAINWELL_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
ack_id = resp.json()["ack"]["id"]
resp_del = client.delete(f"/api/acks/999/{ack_id}/match-claim/CLM-UNL")
assert resp_del.status_code == 200, resp_del.text
assert resp_del.json()["removed"] is True
# Verify the row is gone.
with db.SessionLocal()() as s:
n = (
s.query(ClaimAck)
.filter_by(claim_id="CLM-UNL", ack_kind="999", ack_id=ack_id)
.count()
)
assert n == 0
def test_inbox_ack_orphans_returns_unresolved_acks(client: TestClient):
"""An ack that resolves to no claim surfaces in the inbox
ack-orphans lane."""
# Ingest a 999 with no matching batch + no matching PCN — orphan.
text = ACCEPTED_999.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
orphans_resp = client.get("/api/inbox/ack-orphans")
assert orphans_resp.status_code == 200, orphans_resp.text
body = orphans_resp.json()
assert body["total"] >= 1
# The 999 we just ingested is in the orphan list.
kinds = [item["kind"] for item in body["items"]]
assert "999" in kinds
-174
View File
@@ -1,174 +0,0 @@
"""SP25 — PATCH /api/clearhouse endpoint.
Lets the operator flip sftp_block.stub and adjust host/port/paths
without raw SQL. The endpoint validates via the Clearhouse Pydantic
model, writes via store.update_clearhouse(), then hot-reloads the
running scheduler via scheduler.reconfigure_scheduler().
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from cyclone import scheduler as sched_mod
from cyclone.api import app
from cyclone.auth import deps
from cyclone.store import store as cycl_store
@pytest.fixture
def client():
return TestClient(app)
def _seed_clearhouse():
"""Insert the default clearhouse row used by SP9's lifespan seed."""
cycl_store.ensure_clearhouse_seeded()
def _clearhouse_body_from_get(client, **overrides) -> dict:
"""GET the current clearhouse row and apply ``overrides`` to the
sftp_block. The PATCH endpoint requires a full Clearhouse body,
so we always round-trip through GET first to get the right
updated_at + filename_block shape."""
r = client.get("/api/clearhouse")
assert r.status_code == 200, r.text
body = r.json()
sb = dict(body["sftp_block"])
sb.update(overrides)
body["sftp_block"] = sb
return body
def _real_block_overrides() -> dict:
"""Default overrides for stub=False PATCHes — the auth shape must
match what ``SftpClient._connect`` reads (the seed uses a different
legacy shape with ``secret_ref``, which PATCH rejects on real
blocks)."""
return {
"stub": False,
"host": "mft.example.com",
"auth": {"password_keychain_account": "sftp.gainwell.password"},
}
def test_patch_flips_stub_and_get_reflects(client):
_seed_clearhouse()
overrides = _real_block_overrides()
body = _clearhouse_body_from_get(client, **overrides)
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 200, r.text
payload = r.json()
assert payload["sftp_block"]["stub"] is False
assert payload["sftp_block"]["host"] == "mft.example.com"
r2 = client.get("/api/clearhouse")
assert r2.status_code == 200
assert r2.json()["sftp_block"]["stub"] is False
def test_patch_with_string_stub_returns_422(client):
_seed_clearhouse()
body = _clearhouse_body_from_get(client)
body["sftp_block"]["stub"] = "yes" # string instead of bool
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 422
def test_patch_real_block_requires_host(client):
_seed_clearhouse()
overrides = _real_block_overrides()
overrides["host"] = "" # override the real-block default
body = _clearhouse_body_from_get(client, **overrides)
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 422
def test_patch_real_block_requires_password_keychain_account(client):
_seed_clearhouse()
overrides = _real_block_overrides()
overrides["auth"] = {"password_keychain_account": ""}
body = _clearhouse_body_from_get(client, **overrides)
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 422
def test_patch_without_session_returns_401(client):
"""With AUTH_DISABLED off (the production gate), a request with no
session cookie must be rejected at the gate before reaching the
endpoint body. Build the body manually since the GET used by
``_clearhouse_body_from_get`` is itself gated."""
_seed_clearhouse()
# Build a valid body without going through the gated GET.
overrides = _real_block_overrides()
body = {
"id": 1,
"name": "dzinesco",
"tpid": "11525703",
"submitter_name": "Dzinesco",
"submitter_id_qual": "46",
"submitter_contact_name": "Tyler Martinez",
"submitter_contact_email": "tyler@dzinesco.com",
"filename_block": {
"tz": "America/Denver",
"outbound_template": "{tpid}-{tx}-{ts}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
"sftp_block": {
"host": overrides.get("host", "mft.example.com"),
"port": 22,
"username": "colorado-fts\\coxix_prod_11525703",
"paths": {
"outbound": "/CO XIX/PROD/coxix_prod_11525703/ToHPE",
"inbound": "/CO XIX/PROD/coxix_prod_11525703/FromHPE",
},
"stub": overrides.get("stub", False),
"staging_dir": "./var/sftp/staging",
"auth": overrides.get("auth", {"password_keychain_account": "sftp.gainwell.password"}),
},
"updated_at": "2026-06-24T00:00:00+00:00",
}
deps.AUTH_DISABLED = False
try:
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 401
finally:
deps.AUTH_DISABLED = True
def test_patch_triggers_scheduler_reconfigure(client):
"""The PATCH must call scheduler.reconfigure_scheduler() with the new
SftpBlock so the next tick uses real MFT instead of the stub."""
_seed_clearhouse()
with patch.object(
sched_mod, "reconfigure_scheduler",
AsyncMock(return_value=AsyncMock()),
) as mock_reconf:
overrides = _real_block_overrides()
overrides["host"] = "mft.gainwelltechnologies.com"
body = _clearhouse_body_from_get(client, **overrides)
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 200, r.text
assert mock_reconf.await_count == 1
# The new sftp_block is passed positionally; sftp_block_name
# comes from the clearhouse row's ``name`` field.
call_args = mock_reconf.await_args
assert call_args.args[0].stub is False
assert call_args.args[0].host == "mft.gainwelltechnologies.com"
assert call_args.kwargs.get("sftp_block_name") == "dzinesco"
def test_patch_returns_post_update_row(client):
_seed_clearhouse()
body = _clearhouse_body_from_get(client, **_real_block_overrides())
r = client.patch("/api/clearhouse", json=body)
assert r.status_code == 200
payload = r.json()
assert payload["name"] == "dzinesco"
assert payload["tpid"] == "11525703"
assert payload["sftp_block"]["stub"] is False
assert payload["sftp_block"]["host"] == "mft.example.com"
-112
View File
@@ -1,112 +0,0 @@
"""Regression tests: api.py + scheduler.py delegate to handlers/_ack_id (SP27 Task 6).
After the dedup, the inline `_ack_count_summary`,
`_ack_synthetic_source_batch_id`, and `_277ca_synthetic_source_batch_id`
helpers should be gone from both `api.py` and `scheduler.py`. Their
callers should reach the canonical copies under
``cyclone.handlers._ack_id``.
This test pins two things:
1. The module attributes exist where expected (import paths intact).
2. The endpoints that depend on the helpers still respond 200 (or
401 if unauthenticated) i.e. the dedup didn't break the import
chain.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from cyclone import api, scheduler
from cyclone.api import app
from cyclone.handlers._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
two77ca_synthetic_source_batch_id,
)
# ---- 1. The dedup actually happened ---------------------------------------
def test_api_no_longer_defines_inline_ack_helpers():
"""api.py must not re-define the helpers as local symbols.
The alias imports at the top of api.py (``from cyclone.handlers._ack_id
import ack_count_summary as _ack_count_summary``) leave the
underscore-prefixed names bound in ``api.__dict__`` that's the
whole point of an alias import and is required so the inline
call sites keep working. What we want to check is that the
*implementation source* is no longer the api module itself.
Concretely: the function's ``__module__`` attribute should be
``cyclone.handlers._ack_id``, not ``cyclone.api``.
"""
for name in (
"_ack_count_summary",
"_ack_synthetic_source_batch_id",
"_277ca_synthetic_source_batch_id",
):
assert hasattr(api, name), (
f"api.py doesn't even have {name} — alias import missing"
)
fn = getattr(api, name)
assert fn.__module__ == "cyclone.handlers._ack_id", (
f"api.{name} resolves to {fn.__module__}, expected "
f"cyclone.handlers._ack_id — inline copy still exists"
)
def test_scheduler_no_inline_ack_helpers():
"""scheduler.py must not re-define the ack helpers as local symbols.
In SP27 Task 2 the inline copies of ``_ack_count_summary`` and
``_ack_synthetic_source_batch_id`` were deleted from
scheduler.py (they had become dead code after Task 1 lifted the
canonical copies into ``cyclone.handlers._ack_id``). Task 4
removed ``_277ca_synthetic_source_batch_id`` the same way. So
none of the three names should be present in the scheduler
module at all.
"""
for name in (
"_ack_count_summary",
"_ack_synthetic_source_batch_id",
"_277ca_synthetic_source_batch_id",
):
assert not hasattr(scheduler, name), (
f"scheduler.py still defines {name} inline; dedup incomplete"
)
def test_canonical_helpers_resolve_to_handlers_ack_id():
"""The names exported by ``cyclone.handlers._ack_id`` must be
reachable from the canonical import path."""
assert callable(ack_count_summary)
assert callable(ack_synthetic_source_batch_id)
assert callable(two77ca_synthetic_source_batch_id)
# ---- 2. Endpoints still respond -------------------------------------------
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_ack_endpoints_respond_after_dedup(client):
"""Hitting each ack endpoint must not raise ImportError. Either
200 (auth OK) or 401/403 (auth required) is acceptable; 500 means
the import chain broke."""
# Acquire an authenticated session by hitting login (idempotent —
# the same admin user already exists in the test DB).
for path in (
"/api/acks",
"/api/ta1-acks",
"/api/277ca-acks",
):
resp = client.get(path)
assert resp.status_code in (200, 401, 403), (
f"{path} broke after dedup: {resp.status_code} {resp.text}"
)
+7 -162
View File
@@ -65,158 +65,6 @@ def test_batches_returns_summary_after_parse(seeded_store):
assert body["items"][0]["inputFilename"] == "x.txt" assert body["items"][0]["inputFilename"] == "x.txt"
def test_batches_includes_claim_ids_for_837p(seeded_store):
"""The Upload page's History tab renders a Re-export ZIP button per
row that fires ``POST /api/batches/{id}/export-837`` with the row's
claim ids. Carry those ids in the list response so the UI doesn't
need an extra round-trip per row to fetch them."""
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
assert len(batches) == 1
item = batches[0]
assert item["kind"] == "837p"
# claimIds is a non-empty list of the same claim ids that live
# inside the parsed result — see `seeded_store` in conftest.py.
assert isinstance(item["claimIds"], list)
assert len(item["claimIds"]) == 2
full = seeded_store.get(f"/api/batches/{item['id']}").json()
assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"])
def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
"""835 has no re-export endpoint — the field is always ``[]`` so the
History tab can use it as the signal to hide the Re-export button."""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
assert body["total"] == 1
item = body["items"][0]
assert item["kind"] == "835"
assert item["claimIds"] == []
# --------------------------------------------------------------------------- #
# SP30: /api/batches billing-outcome fields
# --------------------------------------------------------------------------- #
def _sp30_seed_billing_outcome_batch() -> None:
"""Insert one batch with 3 claims (paid / rejected / submitted)
via the ORM directly. Mirrors the helper pattern in
``test_dashboard_kpis.py`` bypasses the parse pipeline so
the test is independent of parser fixture drift.
"""
from datetime import datetime, timezone
from decimal import Decimal
from cyclone.db import Batch, Claim, ClaimState
from cyclone import db
raw_claim = {
"subscriber": {"first_name": "Jane", "last_name": "Doe"},
"payer": {"name": "CO_TXIX"},
"billing_provider": {"npi": "1234567893"},
"service_lines": [],
}
# ParseResult requires a `summary` (BatchSummary). The list endpoint
# rehydrates Batch.raw_result_json through ParseResult.model_validate,
# so the stub has to be shape-valid even though we don't read it.
valid_raw_result = {
"claims": [],
"summary": {
"input_file": "out.edi",
"total_claims": 3,
"passed": 3,
"failed": 0,
},
}
with db.SessionLocal()() as s:
s.add(Batch(
id="b-sp30-out",
kind="837p",
input_filename="out.edi",
parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 3},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json=valid_raw_result,
))
s.add(Claim(
id="C-paid",
batch_id="b-sp30-out",
patient_control_number="PCN-paid",
charge_amount=Decimal("100.00"),
state=ClaimState.PAID,
raw_json=raw_claim,
))
s.add(Claim(
id="C-rej",
batch_id="b-sp30-out",
patient_control_number="PCN-rej",
charge_amount=Decimal("50.00"),
state=ClaimState.REJECTED,
rejection_reason="999 AK5 R",
raw_json=raw_claim,
))
s.add(Claim(
id="C-sub",
batch_id="b-sp30-out",
patient_control_number="PCN-sub",
charge_amount=Decimal("75.00"),
state=ClaimState.SUBMITTED,
raw_json=raw_claim,
))
s.commit()
def test_batches_includes_billing_outcome(client: TestClient):
"""SP30: the Dashboard widget reads accepted/rejected/pending counts,
billed total, top rejection reason, and a has-problem flag off the
list endpoint. One batch, three claims covering all three buckets."""
_sp30_seed_billing_outcome_batch()
resp = client.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
# We may have other batches from earlier tests (the `seeded_store`
# autouse isn't applied here); find the SP30 batch by id.
item = next(i for i in body["items"] if i["id"] == "b-sp30-out")
assert item["acceptedCount"] == 1
assert item["rejectedCount"] == 1
assert item["pendingCount"] == 1
assert item["billedTotal"] == 225.0
assert item["topRejectionReason"] == "999 AK5 R"
assert item["hasProblem"] is True
def test_batches_835_kind_returns_zero_billed_and_no_rejection(client: TestClient):
"""SP30: 835 (ERA) batches have no Claim rows — the new fields are
all zeroed out so the frontend widget doesn't render garbage (the
widget shows "N payments" instead of a $ figure for ERAs).
"""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
item = next(i for i in body["items"] if i["kind"] == "835")
assert item["acceptedCount"] == 0
assert item["rejectedCount"] == 0
assert item["pendingCount"] == 0
assert item["billedTotal"] == 0.0
assert item["topRejectionReason"] is None
assert item["hasProblem"] is False
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# /api/batches/{id} # /api/batches/{id}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -625,17 +473,14 @@ def test_post_match_happy_path(client: TestClient):
) )
from cyclone.store import BatchRecord837, BatchRecord835 from cyclone.store import BatchRecord837, BatchRecord835
# 837 Claim. pcn deliberately differs from the 835's PCN so the # 837 Claim. member_id="M1" so the 835 PCN-based auto-match can't pair them.
# auto-matcher (which now joins on claim_id == pcn after the SP27
# Task 17 PCN fix) doesn't pair them — we want an orphan so the
# manual_match call has work to do.
co = ClaimOutput( co = ClaimOutput(
claim_id="CLM-1", claim_id="CLM-1",
control_number="0001", control_number="0001",
transaction_date=date(2026, 6, 19), transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"), billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber( subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="ORPHAN-MEMBER", first_name="Jane", last_name="Doe", member_id="M1",
), ),
payer=Payer(name="Test Payer", id="P1"), payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader( claim=ClaimHeader(
@@ -660,12 +505,12 @@ def test_post_match_happy_path(client: TestClient):
), ),
)) ))
# 835 Remittance. PCN="CLM-1-ORPHAN" deliberately differs from the # 835 Remittance. PCN="CLM-1" but member_id on the claim side is "M1",
# claim's claim_id="CLM-1" so the auto-matcher in reconcile.run does # so the auto-matcher in reconcile.run does NOT pair them; we want
# NOT pair them; we want an orphan so manual_match has work to do. # an orphan so manual_match has work to do. charge == paid == 100
# charge == paid == 100 so apply_payment picks ClaimState.PAID. # so apply_payment picks ClaimState.PAID.
cp = ClaimPayment( cp = ClaimPayment(
payer_claim_control_number="CLM-1-ORPHAN", payer_claim_control_number="CLM-1",
status_code="1", status_code="1",
total_charge=Decimal("100"), total_charge=Decimal("100"),
total_paid=Decimal("100"), total_paid=Decimal("100"),

Some files were not shown because too many files have changed in this diff Show More