feat(backend): add reconcile.run orchestrator with match + apply + CAS agg
This commit is contained in:
@@ -18,10 +18,12 @@ Match algorithm:
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, Optional, Protocol
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from cyclone.db import ClaimState
|
||||
|
||||
|
||||
@@ -206,3 +208,111 @@ def split_unmatched(
|
||||
[c for c in claims if c.id not in matched_claim_ids],
|
||||
[r for r in remits if r.id not in matched_remit_ids],
|
||||
)
|
||||
|
||||
|
||||
# --- reconcile.run() orchestrator ---------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReconcileResult:
|
||||
matched: int = 0
|
||||
unmatched_claims: int = 0
|
||||
unmatched_remittances: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
def run(session, batch_id: str) -> ReconcileResult:
|
||||
"""Orchestrate reconciliation for an 835 batch.
|
||||
|
||||
Expects Batch + Remittance + CasAdjustment rows already persisted
|
||||
(this is called from store.add() AFTER commit). 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.
|
||||
Raises on failure (caller catches and writes activity event).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from cyclone.db import (
|
||||
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
|
||||
ClaimState,
|
||||
)
|
||||
|
||||
new_remits = list(
|
||||
session.execute(
|
||||
select(Remittance).where(Remittance.batch_id == batch_id)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
unmatched_claims = list(
|
||||
session.execute(
|
||||
select(Claim).where(Claim.matched_remittance_id.is_(None))
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
matches = match(unmatched_claims, new_remits)
|
||||
|
||||
applied = 0
|
||||
skipped = 0
|
||||
for m in matches:
|
||||
if m.is_reversal:
|
||||
intent = apply_reversal(m.claim, m.remittance)
|
||||
else:
|
||||
intent = apply_payment(
|
||||
m.claim, m.remittance,
|
||||
charge=m.claim.charge_amount, paid=m.remittance.total_paid,
|
||||
status_code=m.remittance.status_code,
|
||||
)
|
||||
|
||||
if intent.skipped:
|
||||
session.add(ActivityEvent(
|
||||
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
|
||||
batch_id=batch_id, claim_id=m.claim.id,
|
||||
remittance_id=m.remittance.id,
|
||||
payload_json={"reason": intent.reason,
|
||||
"current_state": getattr(m.claim.state, "value", m.claim.state)},
|
||||
))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
session.add(Match(
|
||||
claim_id=m.claim.id, remittance_id=m.remittance.id,
|
||||
strategy=m.strategy,
|
||||
matched_at=datetime.now(timezone.utc),
|
||||
prior_claim_state=intent.prior_claim_state,
|
||||
is_reversal=m.is_reversal,
|
||||
))
|
||||
if not m.is_reversal:
|
||||
m.claim.state = intent.new_state
|
||||
else:
|
||||
m.claim.state = ClaimState.REVERSED
|
||||
m.claim.matched_remittance_id = m.remittance.id
|
||||
|
||||
session.add(ActivityEvent(
|
||||
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
|
||||
batch_id=batch_id, claim_id=m.claim.id,
|
||||
remittance_id=m.remittance.id,
|
||||
payload_json={"new_state": m.claim.state.value},
|
||||
))
|
||||
applied += 1
|
||||
|
||||
# Aggregate CAS adjustments per Remittance.
|
||||
for r in new_remits:
|
||||
total = session.execute(
|
||||
select(func.sum(CasAdjustment.amount))
|
||||
.where(CasAdjustment.remittance_id == r.id)
|
||||
).scalar_one() or Decimal("0")
|
||||
r.adjustment_amount = total
|
||||
|
||||
# Partition.
|
||||
unmatched_claims_after, unmatched_remits_after = split_unmatched(
|
||||
unmatched_claims, new_remits,
|
||||
[m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
|
||||
)
|
||||
|
||||
return ReconcileResult(
|
||||
matched=applied,
|
||||
unmatched_claims=len(unmatched_claims_after),
|
||||
unmatched_remittances=len(unmatched_remits_after),
|
||||
skipped=skipped,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user