216 lines
8.9 KiB
Markdown
216 lines
8.9 KiB
Markdown
# Sub-project 3 — Backend EDI Features: Design Spec
|
|
|
|
**Date:** 2026-06-20
|
|
**Status:** Approved
|
|
**Branch:** `edi-features`
|
|
|
|
## 1. Scope
|
|
|
|
Four backend EDI feature areas extending cyclone beyond the 837P parser and 835 ERA parser already shipped:
|
|
|
|
1. **Additional 837P validation rules** — REF\*G1 enforcement for adjustment-frequency claims (R034, gated on `cfg.require_ref_g1_for_adjustments`); BHT06 transaction-type-code allowlist (R035, gated on `cfg.allowed_bht06`).
|
|
2. **835 CAS deep-parsing** — surface CARC/RARC reason-code labels alongside the existing CAS persistence.
|
|
3. **999 ACK** — parse inbound 999s, serialize outbound 999s, optionally auto-generate a 999 in response to an inbound 837P.
|
|
4. **270/271 eligibility** — parse 270 requests and 271 responses, serialize 270 requests.
|
|
|
|
## 2. Out of scope (deferred to future sub-projects)
|
|
|
|
- TA1 / 997 transaction sets (Cyclone doesn't generate them today; deferred)
|
|
- Real-time payer eligibility round-trip (would require an outbound HTTP client + payer credentials)
|
|
- 999 ACK UI drilldown beyond a list page
|
|
- Per-claim ACK row linking in the DB
|
|
|
|
## 3. Locked decisions
|
|
|
|
### 3.1 CAS reason-code lookup = inline Python dict
|
|
|
|
A new `parsers/cas_codes.py` module with ~150 CARC codes (the most common in Colorado Medicaid remits: CO-45, CO-97, PR-1, PR-2, PR-3, CO-16, CO-29, CO-50, CO-109, CO-119, etc.). No external CSV dependency — same shape as `claim_status_label(code)` at `parsers/models_835.py:55-71`.
|
|
|
|
```python
|
|
def reason_label(group_code: str, reason_code: str) -> str
|
|
def all_known_codes() -> list[tuple[str, str, str]] # (group, reason, label)
|
|
```
|
|
|
|
Includes a `LAST_UPDATED = "2026-06-20"` module constant for refresh tracking.
|
|
|
|
### 3.2 999 ACKs get a dedicated `acks` table
|
|
|
|
A new migration `0002_acks.sql` adds the table rather than reusing `batches` with a `kind="999"` + `parent_batch_id` FK. Cleaner symmetry with the `cas_adjustments` / `matches` patterns. The `acks` table holds the *response*, not the source batch.
|
|
|
|
```sql
|
|
CREATE TABLE acks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
|
accepted_count INTEGER NOT NULL DEFAULT 0,
|
|
rejected_count INTEGER NOT NULL DEFAULT 0,
|
|
received_count INTEGER NOT NULL DEFAULT 0,
|
|
ack_code TEXT NOT NULL,
|
|
parsed_at DATETIME NOT NULL,
|
|
raw_json TEXT
|
|
);
|
|
CREATE INDEX ix_acks_source_batch_id ON acks(source_batch_id);
|
|
```
|
|
|
|
### 3.3 Auto-999 generation gated by `?ack=true`
|
|
|
|
`POST /api/parse-837` gains a new `ack: bool = Query(False)` parameter. When True, after `store.add(record)`, build a 999 from the validation report:
|
|
|
|
- For each claim: AK2 → AK5 with `A` (accepted) for passed validation, `R` (rejected) for failed, `E` (accepted-with-errors) for warnings.
|
|
- Serialize via `serialize_999`, persist the resulting `Ack` row.
|
|
- Add `body["ack"] = { id, accepted_count, rejected_count, raw_999_text }` to the JSON response.
|
|
|
|
Default behavior unchanged (no auto-ACK).
|
|
|
|
### 3.4 270/271 = API-only, no UI, no DB persistence
|
|
|
|
Ephemeral request/response round-trip. The operator pastes a 270 query, gets a printable 270 file, submits manually, pastes the 271 back. v1 captures the minimum useful surface (EQ inquiry + EB coverage-benefit response); anything more is a separate sub-project.
|
|
|
|
## 4. Schemas
|
|
|
|
### 4.1 999 models (`parsers/models_999.py`)
|
|
|
|
```python
|
|
class ParseResult999(BaseModel):
|
|
envelope: Envelope
|
|
functional_groups: list[FunctionalGroupResponse] # AK1/AK2/AK9
|
|
|
|
class FunctionalGroupResponse(BaseModel):
|
|
ak1: AcknowledgmentHeader # identifies the source functional group
|
|
sets: list[SetFunctionalGroupResponse] # one per ST/SE set
|
|
|
|
class AcknowledgmentHeader(BaseModel):
|
|
functional_id_code: str # e.g. "HC"
|
|
group_control_number: str # from GS06
|
|
|
|
class SetFunctionalGroupResponse(BaseModel):
|
|
ak2: SetHeader | None
|
|
segment_errors: list[SegmentError]
|
|
element_errors: list[ElementError]
|
|
ak5: SegmentAcceptReject # A / R / E / W
|
|
|
|
class SegmentError(BaseModel):
|
|
ak3: SegmentContext
|
|
loop_id: str | None
|
|
segment_position: int
|
|
|
|
class ElementError(BaseModel):
|
|
ak3_ref: int # FK by row id
|
|
element_position: int
|
|
|
|
class SegmentAcceptReject(BaseModel):
|
|
code: str # A=accepted, R=rejected, E=accepted-with-errors, W=warning, X=rejected-all
|
|
|
|
class FunctionalGroupAck(BaseModel):
|
|
ak1: AcknowledgmentHeader
|
|
received_count: int # AK905
|
|
accepted_count: int # AK906
|
|
rejected_count: int # AK907
|
|
ack_code: str # AK909
|
|
```
|
|
|
|
### 4.2 270 request models (`parsers/models_270.py`)
|
|
|
|
```python
|
|
class ParseResult270(BaseModel):
|
|
envelope: Envelope
|
|
information_source: InformationSource270 # 2100A — payer
|
|
information_receiver: InformationReceiver270 # 2100B — receiver
|
|
subscriber: Subscriber270 # 2000C
|
|
patient: Patient270 | None # 2100C
|
|
inquiries: list[EligibilityBenefitInquiry] # EQ segments
|
|
|
|
class EligibilityBenefitInquiry(BaseModel):
|
|
service_type_code: str # EQ01
|
|
coverage_info: str | None
|
|
|
|
class Patient270(BaseModel):
|
|
first_name: str | None
|
|
last_name: str | None
|
|
member_id: str | None
|
|
dob: date | None
|
|
gender: str | None
|
|
relationship: str | None
|
|
```
|
|
|
|
### 4.3 271 response models (`parsers/models_271.py`)
|
|
|
|
```python
|
|
class ParseResult271(BaseModel):
|
|
envelope: Envelope
|
|
information_source: InformationSource271
|
|
information_receiver: InformationReceiver271
|
|
subscriber: Subscriber271
|
|
patient: Patient271 | None
|
|
coverage_benefits: list[CoverageBenefit] # EB segments, the heart of 271
|
|
|
|
class CoverageBenefit(BaseModel):
|
|
service_type_code: str # EB01
|
|
coverage_level: str # EB02 (INDividual, FAMily, etc.)
|
|
service_type_description: str | None # resolved from inline dict
|
|
benefit_amount: Decimal | None
|
|
in_plan_network: bool | None
|
|
authorization_required: bool | None
|
|
plan_coverage_description: str | None
|
|
time_qualifier: str | None
|
|
benefit_date: date | None
|
|
additional_info: list[BenefitAdditionalInfo]
|
|
```
|
|
|
|
### 4.4 `acks` ORM model
|
|
|
|
```python
|
|
class Ack(Base):
|
|
__tablename__ = "acks"
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
source_batch_id: Mapped[str] = mapped_column(
|
|
String(64), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False,
|
|
)
|
|
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
rejected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
received_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
ack_code: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
|
|
```
|
|
|
|
## 5. API additions
|
|
|
|
| Method | Path | Body / Query | Returns |
|
|
|--------|------|--------------|---------|
|
|
| POST | `/api/parse-999` | file upload | `ParseResult999` + persisted `acks` row |
|
|
| POST | `/api/parse-837?ack=true` | file upload | existing response + `body["ack"]` |
|
|
| GET | `/api/acks` | — | list of `Ack` records |
|
|
| GET | `/api/acks/{id}` | — | `Ack` + parsed 999 detail |
|
|
| POST | `/api/eligibility/request` | JSON body | raw 270 text |
|
|
| POST | `/api/eligibility/parse-271` | text body or file upload | `ParseResult271` summary |
|
|
|
|
## 6. UI additions
|
|
|
|
- New `/acks` page (`src/pages/Acks.tsx`) — list view grouped by source batch with "Download 999" button.
|
|
- `useAcks` hook (`src/hooks/useAcks.ts`).
|
|
- `api.listAcks()` + `api.getAck(id)` in `src/lib/api.ts`.
|
|
- Sidebar nav entry with `CheckCircle2` icon.
|
|
- Remittances page gains an "Adjustments" expansion row that lists CAS rows with reason-code labels.
|
|
|
|
No new UI for 270/271 (API-only per §3.4).
|
|
|
|
## 7. Migration ordering
|
|
|
|
`0002_acks.sql` runs after `0001_initial.sql`. `db_migrate.run()` is idempotent — re-running on a fresh DB is a no-op. `create_all` is also a no-op when migration is current (per SP2 pattern). No data backfill needed (acks table is new).
|
|
|
|
## 8. Test coverage targets
|
|
|
|
| Phase | New tests | Total target |
|
|
|-------|-----------|--------------|
|
|
| P1 — 837P rules | ~6 | ~211 backend |
|
|
| P2 — CAS deep-parsing | ~4 | ~215 backend |
|
|
| P3 — 999 ACK | ~10 backend + ~2 frontend | ~225 backend / ~10 frontend |
|
|
| P4 — 270/271 | ~4 backend | ~229 backend / ~10 frontend |
|
|
| P5 — smoke | — | (verification) |
|
|
|
|
## 9. Risk areas
|
|
|
|
1. **270/271 scope creep.** X12 specs are large; we capture only the minimum useful surface.
|
|
2. **999 auto-gen semantics.** "Rejected" is loosely defined; we use validation errors as the rejection signal. Document this in the OpenAPI/scalar docs.
|
|
3. **CARC codes drift.** Inline dict is a snapshot dated 2026-06-20. Plan to refresh annually.
|
|
4. **Schema migration ordering.** Idempotent on fresh DBs; no data backfill. Confirmed safe. |