180 lines
7.5 KiB
Python
180 lines
7.5 KiB
Python
"""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" |