feat(sp25): read-side defensive stub for synthetic-batch rows
This commit is contained in:
@@ -9,7 +9,8 @@ a fresh DB state per-test.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timezone
|
||||
from datetime import date, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import (
|
||||
@@ -20,12 +21,85 @@ from cyclone.db import (
|
||||
Match,
|
||||
Remittance,
|
||||
)
|
||||
from cyclone.parsers.models import ParseResult
|
||||
from cyclone.parsers.models_835 import ParseResult835
|
||||
from cyclone.parsers.models import BatchSummary, Envelope, ParseResult
|
||||
from cyclone.parsers.models_835 import (
|
||||
FinancialInfo,
|
||||
ParseResult835,
|
||||
Payee835,
|
||||
Payer835,
|
||||
ReassociationTrace,
|
||||
)
|
||||
|
||||
from .records import BatchRecord, BatchRecord837, BatchRecord835
|
||||
|
||||
|
||||
def _stub_parse_result_837(row: Batch) -> ParseResult:
|
||||
"""Synthesize a minimal ``ParseResult`` for a 837p row missing ``raw_result_json``.
|
||||
|
||||
SP25: ``CycloneStore.reconcile_orphan_st02s()`` seeds anchor ``Batch``
|
||||
rows for orphan 999 ACK ST02s with ``raw_result_json=NULL`` — no
|
||||
source 837 was ever parsed for them. Returning a typed stub keeps
|
||||
the dashboard's recent-batches list and the ``/api/batches`` endpoint
|
||||
rendering instead of 500'ing on Pydantic validation.
|
||||
|
||||
All counters zero, summary.control_number echoes the row's
|
||||
``transaction_set_control_number`` (the SCN the orphan 999 acks
|
||||
reference), so the batch list widget surfaces the right SCN.
|
||||
"""
|
||||
today = row.parsed_at.date() if row.parsed_at else date.today()
|
||||
return ParseResult(
|
||||
envelope=None,
|
||||
claims=[],
|
||||
summary=BatchSummary(
|
||||
input_file=row.input_filename,
|
||||
control_number=row.transaction_set_control_number,
|
||||
transaction_date=today,
|
||||
total_claims=0,
|
||||
passed=0,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _stub_parse_result_835(row: Batch) -> ParseResult835:
|
||||
"""Synthesize a minimal ``ParseResult835`` for an 835 row missing ``raw_result_json``.
|
||||
|
||||
Mirrors ``_stub_parse_result_837``; 835 requires non-None envelope,
|
||||
financial_info, trace, payer, payee — minimal real-value shells are
|
||||
the only way Pydantic will accept the stub.
|
||||
"""
|
||||
today = row.parsed_at.date() if row.parsed_at else date.today()
|
||||
return ParseResult835(
|
||||
envelope=Envelope(
|
||||
sender_id="",
|
||||
receiver_id="",
|
||||
control_number=row.transaction_set_control_number or "",
|
||||
transaction_date=today,
|
||||
),
|
||||
financial_info=FinancialInfo(
|
||||
handling_code="",
|
||||
paid_amount=Decimal("0.00"),
|
||||
credit_debit_flag="",
|
||||
),
|
||||
trace=ReassociationTrace(
|
||||
trace_type_code="",
|
||||
trace_number="",
|
||||
originating_company_id="",
|
||||
),
|
||||
payer=Payer835(name=""),
|
||||
payee=Payee835(name="", npi=""),
|
||||
claims=[],
|
||||
summary=BatchSummary(
|
||||
input_file=row.input_filename,
|
||||
control_number=row.transaction_set_control_number,
|
||||
transaction_date=today,
|
||||
total_claims=0,
|
||||
passed=0,
|
||||
failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _BatchesShim:
|
||||
"""Drop-in replacement for the old in-memory ``_batches`` list.
|
||||
|
||||
@@ -59,13 +133,26 @@ def _row_to_record(row: Batch) -> BatchRecord:
|
||||
is ``DateTime(timezone=True)``. We re-attach UTC so the
|
||||
``BatchRecord`` validator (``parsed_at must be tz-aware``)
|
||||
passes.
|
||||
|
||||
SP25: if ``raw_result_json`` is missing or empty (e.g. the row
|
||||
was seeded by ``reconcile_orphan_st02s()`` and never had a
|
||||
real parse attached), we hydrate a typed *stub* with empty
|
||||
claim lists and zero counters so the dashboard's recent-batches
|
||||
list can still render the row. ``raw_result_json`` is left
|
||||
untouched — the stub is read-side only. See
|
||||
``_stub_parse_result_837`` / ``_stub_parse_result_835``.
|
||||
"""
|
||||
if row.kind == "835":
|
||||
result_cls = ParseResult835
|
||||
else:
|
||||
result_cls = ParseResult
|
||||
payload = row.raw_result_json or {}
|
||||
result = result_cls.model_validate(payload)
|
||||
payload = row.raw_result_json
|
||||
if payload:
|
||||
result = result_cls.model_validate(payload)
|
||||
elif row.kind == "835":
|
||||
result = _stub_parse_result_835(row)
|
||||
else:
|
||||
result = _stub_parse_result_837(row)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""SP25: defensive read-side stub for synthetic ``<synthetic:orphan-reconcile>``
|
||||
``Batch`` rows.
|
||||
|
||||
Background: ``CycloneStore.reconcile_orphan_st02s()`` (sp38) seeds ``Batch``
|
||||
rows for orphan 999 ACK ST02s so the envelope index can resolve
|
||||
``source_batch_id`` lookups. Those rows are inserted with
|
||||
``raw_result_json=NULL`` because no source 837 file was ever parsed
|
||||
for them. The read-side helper ``_row_to_record()`` in
|
||||
``store/batches.py`` was written assuming the column is always
|
||||
populated, so it raises a Pydantic ``ValidationError`` on ``summary:
|
||||
Field required`` the moment it sees a synthetic row — which makes
|
||||
``GET /api/batches?limit=5`` return HTTP 500.
|
||||
|
||||
This file pins the contract: a synthetic (or otherwise unparseable)
|
||||
``Batch`` row MUST still hydrate to a typed ``BatchRecord`` with an
|
||||
empty ``claims`` list and the ST02 echoed on ``result.summary.control_number``,
|
||||
so the dashboard "Recent batches" widget can render them as
|
||||
"synthetic: 0 claims" without the whole list blowing up.
|
||||
|
||||
Live state witnessed during the 2026-07-07 dashboard postmortem:
|
||||
the sp38 reconcile pass on the production DB seeded 4 such rows
|
||||
(SCN 991102986–989). Once the stub lands, ``/api/batches`` returns 200
|
||||
instead of 500 and the dashboard "Recent batches" panel surfaces the
|
||||
real underlying batches (the test runs).
|
||||
|
||||
Note: the rows inserted here are *ORM-level* fixtures (Batch rows with
|
||||
NULL raw_result_json, used to exercise the read-path's defensive
|
||||
deserialize). They are NOT synthetic EDI — the actual claim data for
|
||||
the recover-ingest path lives under ``ingest/`` and is replayed by the
|
||||
operator after this defensive stub ships.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cyclone import db, store
|
||||
from cyclone.db import Batch
|
||||
from cyclone.store.records import BatchRecord837, BatchRecord835
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _insert_synthetic(s, *, kind: str, st02: str, filename: str = "<synthetic:orphan-reconcile>"):
|
||||
bid = f"syn-{st02}"
|
||||
s.add(Batch(
|
||||
id=bid,
|
||||
kind=kind,
|
||||
input_filename=filename,
|
||||
parsed_at=_now(),
|
||||
transaction_set_control_number=st02,
|
||||
raw_result_json=None,
|
||||
))
|
||||
s.commit()
|
||||
return bid
|
||||
|
||||
|
||||
def test_row_to_record_handles_null_raw_json_837p():
|
||||
"""837p synthetic row → stub BatchRecord837 with empty claims[] + SCN preserved."""
|
||||
s = db.SessionLocal()()
|
||||
try:
|
||||
_insert_synthetic(s, kind="837p", st02="991102986")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
# No raw_result_json on the row → _row_to_record must NOT raise.
|
||||
rows = store.list_batches(limit=50)
|
||||
|
||||
syn = [r for r in rows if r.id == "syn-991102986"]
|
||||
assert len(syn) == 1
|
||||
rec = syn[0]
|
||||
assert isinstance(rec, BatchRecord837)
|
||||
assert rec.result.claims == []
|
||||
assert rec.result.summary.total_claims == 0
|
||||
assert rec.result.summary.control_number == "991102986"
|
||||
assert rec.result.summary.passed == 0
|
||||
assert rec.result.summary.failed == 0
|
||||
|
||||
|
||||
def test_row_to_record_handles_null_raw_json_835():
|
||||
"""835 synthetic row → stub BatchRecord835 with empty claims[].claims."""
|
||||
s = db.SessionLocal()()
|
||||
try:
|
||||
_insert_synthetic(s, kind="835", st02="991102987")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
rows = store.list_batches(limit=50)
|
||||
syn = [r for r in rows if r.id == "syn-991102987"]
|
||||
assert len(syn) == 1
|
||||
rec = syn[0]
|
||||
assert isinstance(rec, BatchRecord835)
|
||||
assert rec.result.claims == []
|
||||
# summary control_number echoes ST02.
|
||||
assert rec.result.summary.control_number == "991102987"
|
||||
|
||||
|
||||
def test_row_to_record_handles_empty_string_raw_json():
|
||||
"""raw_result_json='' (empty string) gets the same defensive treatment."""
|
||||
s = db.SessionLocal()()
|
||||
try:
|
||||
bid = "syn-empty"
|
||||
s.add(Batch(
|
||||
id=bid, kind="837p",
|
||||
input_filename="<synthetic:orphan-reconcile>",
|
||||
parsed_at=_now(),
|
||||
transaction_set_control_number="991102988",
|
||||
raw_result_json="", # empty string instead of None
|
||||
))
|
||||
s.commit()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
rows = store.list_batches(limit=50)
|
||||
syn = [r for r in rows if r.id == "syn-empty"]
|
||||
assert len(syn) == 1
|
||||
rec = syn[0]
|
||||
assert rec.result.claims == []
|
||||
assert rec.result.summary.total_claims == 0
|
||||
|
||||
|
||||
def test_row_to_record_normal_row_still_parses():
|
||||
"""Regression guard: a well-formed raw_result_json still deserializes fully.
|
||||
|
||||
Batch.raw_result_json is JSONText-backed, so we store a dict; the type
|
||||
decorator takes care of the JSON round-trip.
|
||||
"""
|
||||
payload = {
|
||||
"envelope": {
|
||||
"sender_id": "11525703",
|
||||
"receiver_id": "RECEIVERID",
|
||||
"control_number": "000000001",
|
||||
"transaction_date": "2026-07-07",
|
||||
"transaction_time": "120000",
|
||||
"implementation_guide": "005010X222A1",
|
||||
},
|
||||
"claims": [],
|
||||
"summary": {
|
||||
"input_file": "single-claim.x12",
|
||||
"control_number": "000000001",
|
||||
"transaction_date": "2026-07-07",
|
||||
"total_claims": 1,
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
},
|
||||
}
|
||||
s = db.SessionLocal()()
|
||||
try:
|
||||
s.add(Batch(
|
||||
id="syn-normal",
|
||||
kind="837p",
|
||||
input_filename="single-claim.x12",
|
||||
parsed_at=_now(),
|
||||
transaction_set_control_number="991102977",
|
||||
raw_result_json=payload,
|
||||
))
|
||||
s.commit()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
rows = store.list_batches(limit=50)
|
||||
norm = [r for r in rows if r.id == "syn-normal"]
|
||||
assert len(norm) == 1
|
||||
rec = norm[0]
|
||||
assert rec.result.summary.total_claims == 1
|
||||
assert rec.result.summary.passed == 1
|
||||
Reference in New Issue
Block a user