feat(backend): add store mappers (claim, remittance, provider, activity)
This commit is contained in:
@@ -14,8 +14,8 @@ from typing import Iterable, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from cyclone.parsers.models import ParseResult
|
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||||
from cyclone.parsers.models_835 import ParseResult835
|
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
|
||||||
|
|
||||||
|
|
||||||
BatchKind = Literal["837p", "835"]
|
BatchKind = Literal["837p", "835"]
|
||||||
@@ -66,3 +66,132 @@ store = InMemoryStore()
|
|||||||
def utcnow_iso() -> str:
|
def utcnow_iso() -> str:
|
||||||
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
|
"""ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mappers: rich backend models → simpler UI types.
|
||||||
|
# The shape of the returned dicts must match the TypeScript interfaces in
|
||||||
|
# `src/types/index.ts`. Keep both sides in sync.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def to_ui_claim(
|
||||||
|
claim: ClaimOutput,
|
||||||
|
*,
|
||||||
|
batch_id: str,
|
||||||
|
parsed_at: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Map a 837P ClaimOutput to the UI's `Claim` shape.
|
||||||
|
|
||||||
|
Status rules (per spec section 6.2, evaluated in priority order):
|
||||||
|
- !passed → denied
|
||||||
|
- passed and any warnings → pending
|
||||||
|
- passed and frequency_code == "1" → submitted
|
||||||
|
- otherwise → draft
|
||||||
|
"""
|
||||||
|
v = claim.validation
|
||||||
|
if not v.passed:
|
||||||
|
status = "denied"
|
||||||
|
elif v.warnings:
|
||||||
|
status = "pending"
|
||||||
|
elif claim.claim.frequency_code == "1":
|
||||||
|
status = "submitted"
|
||||||
|
else:
|
||||||
|
status = "draft"
|
||||||
|
|
||||||
|
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": status,
|
||||||
|
"denialReason": None,
|
||||||
|
"submissionDate": parsed_at,
|
||||||
|
"batchId": batch_id,
|
||||||
|
"parsedAt": parsed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def to_ui_remittance(
|
||||||
|
cp: ClaimPayment,
|
||||||
|
*,
|
||||||
|
batch_id: str,
|
||||||
|
parsed_at: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Map an 835 ClaimPayment to the UI's `Remittance` shape.
|
||||||
|
|
||||||
|
Status mapping (per spec section 6.3):
|
||||||
|
- status_code 21/22 → reconciled (reversal/correction)
|
||||||
|
- otherwise → received
|
||||||
|
"""
|
||||||
|
code = cp.status_code
|
||||||
|
if code in {"21", "22"}:
|
||||||
|
status = "reconciled"
|
||||||
|
else:
|
||||||
|
status = "received"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": cp.payer_claim_control_number,
|
||||||
|
"claimId": cp.original_claim_id or "",
|
||||||
|
"payerName": "",
|
||||||
|
"paidAmount": float(cp.total_paid or 0.0),
|
||||||
|
"adjustmentAmount": 0.0,
|
||||||
|
"status": status,
|
||||||
|
"denialReason": None,
|
||||||
|
"receivedDate": parsed_at,
|
||||||
|
"batchId": batch_id,
|
||||||
|
"parsedAt": parsed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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: str,
|
||||||
|
npi: str | None = None,
|
||||||
|
amount: float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"kind": kind,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"npi": npi,
|
||||||
|
"amount": amount,
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,3 +122,84 @@ def test_add_and_get_round_trip():
|
|||||||
def test_get_missing_returns_none():
|
def test_get_missing_returns_none():
|
||||||
s = InMemoryStore()
|
s = InMemoryStore()
|
||||||
assert s.get("does-not-exist") is None
|
assert s.get("does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Task 2: Mappers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ui_claim_maps_status_submitted():
|
||||||
|
from cyclone.store import to_ui_claim
|
||||||
|
claim = _make_claim("CLM-1")
|
||||||
|
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||||||
|
assert out["id"] == "CLM-1"
|
||||||
|
assert out["status"] == "submitted" # passed=True, frequency_code="1"
|
||||||
|
assert out["providerNpi"] == "1234567890"
|
||||||
|
assert out["batchId"] == "b1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ui_claim_maps_status_pending_when_warnings():
|
||||||
|
from cyclone.store import to_ui_claim
|
||||||
|
claim = _make_claim("CLM-2")
|
||||||
|
claim.validation = ValidationReport(
|
||||||
|
passed=True, errors=[],
|
||||||
|
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
|
||||||
|
)
|
||||||
|
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||||||
|
assert out["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ui_claim_maps_status_denied_when_failed():
|
||||||
|
from cyclone.store import to_ui_claim
|
||||||
|
claim = _make_claim("CLM-3")
|
||||||
|
claim.validation = ValidationReport(
|
||||||
|
passed=False,
|
||||||
|
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||||||
|
assert out["status"] == "denied"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ui_remittance_maps_status_received_for_paid():
|
||||||
|
from cyclone.store import to_ui_remittance
|
||||||
|
cp = ClaimPayment(
|
||||||
|
payer_claim_control_number="PCN-1",
|
||||||
|
status_code="1",
|
||||||
|
status_label="Processed as Primary",
|
||||||
|
total_charge=Decimal("100.00"),
|
||||||
|
total_paid=Decimal("80.00"),
|
||||||
|
patient_responsibility=Decimal("0.00"),
|
||||||
|
claim_filing_indicator="MC",
|
||||||
|
original_claim_id=None,
|
||||||
|
frequency_code=None,
|
||||||
|
facility_type=None,
|
||||||
|
service_payments=[],
|
||||||
|
)
|
||||||
|
out = to_ui_remittance(cp, batch_id="b1", parsed_at="2026-06-19T12:00:00Z")
|
||||||
|
assert out["status"] == "received"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_ui_provider_aggregates_claims():
|
||||||
|
from cyclone.store import to_ui_provider
|
||||||
|
out = to_ui_provider(
|
||||||
|
npi="1234567890", name="Acme Clinic", tax_id="99-9999999",
|
||||||
|
address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100",
|
||||||
|
claim_count=5, outstanding_ar=250.0,
|
||||||
|
)
|
||||||
|
assert out["npi"] == "1234567890"
|
||||||
|
assert out["claimCount"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_activity_event_uses_iso_timestamp():
|
||||||
|
from cyclone.store import to_activity_event
|
||||||
|
out = to_activity_event(
|
||||||
|
id="a1", kind="claim_submitted",
|
||||||
|
message="Claim CLM-1 submitted",
|
||||||
|
timestamp="2026-06-19T12:00:00Z",
|
||||||
|
npi="1234567890", amount=100.0,
|
||||||
|
)
|
||||||
|
assert out["id"] == "a1"
|
||||||
|
assert out["kind"] == "claim_submitted"
|
||||||
|
assert out["amount"] == 100.0
|
||||||
|
|||||||
Reference in New Issue
Block a user