feat(sp36): extract reconciliation router
Move GET /api/reconciliation/unmatched, GET /api/batch-diff, POST /api/reconciliation/match, and POST /api/reconciliation/unmatch from api.py to api_routers/reconciliation.py. Read views + manual match/unmatch write paths via store.manual_match / store.manual_unmatch. The Side-by-side batch-diff divider is preserved between routes 1 and 2; the lazy imports of cyclone.batch_diff.diff_batches_to_wire and cyclone.store.NotMatchedError inside their handlers are preserved verbatim. Orphan imports AlreadyMatchedError and InvalidStateError removed from api.py.
This commit is contained in:
@@ -78,9 +78,7 @@ from cyclone.parsers.batch_ack_builder import build_ack_for_batch
|
|||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
from cyclone.parsers.validator_835 import validate as validate_835
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
from cyclone.store import (
|
from cyclone.store import (
|
||||||
AlreadyMatchedError,
|
|
||||||
BatchRecord,
|
BatchRecord,
|
||||||
InvalidStateError,
|
|
||||||
store,
|
store,
|
||||||
utcnow,
|
utcnow,
|
||||||
)
|
)
|
||||||
@@ -2447,147 +2445,6 @@ def _svc_to_dict(svc) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
|
|
||||||
def get_reconciliation_unmatched() -> dict:
|
|
||||||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
|
||||||
|
|
||||||
Powers the reconciliation review surface: every Claim with no
|
|
||||||
paired Remittance appears on the left, every Remittance with no
|
|
||||||
paired Claim appears on the right. The two lists are always present
|
|
||||||
(empty list, never absent) so the UI can index unconditionally.
|
|
||||||
"""
|
|
||||||
return store.list_unmatched(kind="both")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Side-by-side diff between two batches (SP3 P4 / T18)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
|
|
||||||
def get_batch_diff(
|
|
||||||
a: str | None = Query(None),
|
|
||||||
b: str | None = Query(None),
|
|
||||||
) -> dict:
|
|
||||||
"""Return a side-by-side diff of two batches identified by id.
|
|
||||||
|
|
||||||
Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).
|
|
||||||
|
|
||||||
Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
|
|
||||||
projector shapes):
|
|
||||||
- ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
|
|
||||||
inputFilename, claimCount)
|
|
||||||
- ``added`` — claims present in B but not A
|
|
||||||
- ``removed`` — claims present in A but not B
|
|
||||||
- ``changed`` — claims present in both, with field deltas
|
|
||||||
- ``summary`` — precomputed counts
|
|
||||||
|
|
||||||
Errors:
|
|
||||||
- 400 — missing ``a`` or ``b``
|
|
||||||
- 404 — either batch id is unknown
|
|
||||||
|
|
||||||
Pure read endpoint — never mutates the store. Both 837P and 835
|
|
||||||
batches are accepted (mixed-kind diffs are valid: comparing the
|
|
||||||
submitted claims against the matching remittances).
|
|
||||||
"""
|
|
||||||
if not a or not b:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
a_rec, b_rec = store.load_two_for_diff(a, b)
|
|
||||||
except LookupError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail={"error": "Not found", "detail": str(exc)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Lazy import — keeps the module's import surface small until the
|
|
||||||
# endpoint is actually hit. Mirrors the same pattern used by other
|
|
||||||
# endpoint-local helpers (e.g. reconciler).
|
|
||||||
from cyclone.batch_diff import diff_batches_to_wire
|
|
||||||
|
|
||||||
return diff_batches_to_wire(a_rec, b_rec)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
|
|
||||||
def post_reconciliation_match(body: dict) -> dict:
|
|
||||||
"""Manually pair a Claim with a Remittance (operator override).
|
|
||||||
|
|
||||||
Body: ``{"claim_id": ..., "remit_id": ...}``. Returns
|
|
||||||
``{"claim": <ui>, "match": <ui>}`` on success. Errors:
|
|
||||||
- 400: missing ``claim_id`` or ``remit_id``
|
|
||||||
- 404: claim or remittance not found
|
|
||||||
- 409: claim already matched, or apply_* returned a noop
|
|
||||||
(claim in terminal state) — detail echoes ``current_state``
|
|
||||||
and ``activity_kind`` so the UI can render a precise message.
|
|
||||||
"""
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
remit_id = body.get("remit_id")
|
|
||||||
if not claim_id or not remit_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="claim_id and remit_id required",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return store.manual_match(claim_id, remit_id)
|
|
||||||
except AlreadyMatchedError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail={"error": "already_matched", "message": str(e)},
|
|
||||||
)
|
|
||||||
except InvalidStateError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail={
|
|
||||||
"error": "invalid_state",
|
|
||||||
"current_state": e.current_state,
|
|
||||||
"activity_kind": e.activity_kind,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except LookupError:
|
|
||||||
# manual_match raises LookupError when the claim or remittance
|
|
||||||
# row is missing (we catch the parent class so any future
|
|
||||||
# KeyError subclasses in the store get the same treatment).
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="claim_or_remit_not_found",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
|
|
||||||
def post_reconciliation_unmatch(body: dict) -> dict:
|
|
||||||
"""Remove the current match for a Claim; reset Claim to submitted.
|
|
||||||
|
|
||||||
Body: ``{"claim_id": ...}``. Returns
|
|
||||||
``{"claim": <ui>, "deletedMatches": <count>}``. Errors:
|
|
||||||
- 400: missing ``claim_id``
|
|
||||||
- 404: claim not found
|
|
||||||
- 409: claim has no current match (NotMatchedError is mapped
|
|
||||||
by the store; we surface 409 to match the manual_match contract)
|
|
||||||
"""
|
|
||||||
from cyclone.store import NotMatchedError
|
|
||||||
claim_id = body.get("claim_id")
|
|
||||||
if not claim_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="claim_id required",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return store.manual_unmatch(claim_id)
|
|
||||||
except NotMatchedError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail={"error": "not_matched", "message": str(e)},
|
|
||||||
)
|
|
||||||
except LookupError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="claim_not_found",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
|
||||||
def list_remittances(
|
def list_remittances(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from cyclone.api_routers import (
|
|||||||
eligibility,
|
eligibility,
|
||||||
health,
|
health,
|
||||||
payers,
|
payers,
|
||||||
|
reconciliation,
|
||||||
ta1_acks,
|
ta1_acks,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ routers: list[APIRouter] = [
|
|||||||
eligibility.router, # gated
|
eligibility.router, # gated
|
||||||
health.router, # public — health probes must work pre-auth
|
health.router, # public — health probes must work pre-auth
|
||||||
payers.router, # gated
|
payers.router, # gated
|
||||||
|
reconciliation.router, # gated
|
||||||
ta1_acks.router, # gated
|
ta1_acks.router, # gated
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Reconciliation read views + manual match/unmatch write paths.
|
||||||
|
|
||||||
|
Four endpoints:
|
||||||
|
|
||||||
|
- ``GET /api/reconciliation/unmatched`` — list of unmatched Claims
|
||||||
|
and unmatched Remittances (``store.list_unmatched(kind="both")``).
|
||||||
|
- ``GET /api/batch-diff`` — side-by-side diff of two
|
||||||
|
batches (used by the Batch Diff page). Lazy-imports
|
||||||
|
:func:`cyclone.batch_diff.diff_batches_to_wire` to keep the
|
||||||
|
module's import surface small until the endpoint is actually
|
||||||
|
hit.
|
||||||
|
- ``POST /api/reconciliation/match`` — manually pair a Claim with
|
||||||
|
a Remittance (``store.manual_match``). Surfaces ``AlreadyMatchedError`` /
|
||||||
|
``InvalidStateError`` as 409, and ``LookupError`` from the store
|
||||||
|
as 404 (``claim_or_remit_not_found``).
|
||||||
|
- ``POST /api/reconciliation/unmatch`` — unpair a Claim (``store.manual_unmatch``).
|
||||||
|
Surfaces ``NotMatchedError`` as 409.
|
||||||
|
|
||||||
|
All four are read-or-manual-override surfaces used by the
|
||||||
|
Reconciliation page (the page that pairs Claims with Remittances
|
||||||
|
when neither side has an automatic match key).
|
||||||
|
|
||||||
|
SP36 Task 8: this block moved here from ``api.py:2450`` (the
|
||||||
|
4 routes interleaved with a side-by-side batch-diff divider).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from cyclone.auth.deps import matrix_gate
|
||||||
|
from cyclone.store import (
|
||||||
|
AlreadyMatchedError,
|
||||||
|
InvalidStateError,
|
||||||
|
store,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reconciliation/unmatched")
|
||||||
|
def get_reconciliation_unmatched() -> dict:
|
||||||
|
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||||||
|
|
||||||
|
Powers the reconciliation review surface: every Claim with no
|
||||||
|
paired Remittance appears on the left, every Remittance with no
|
||||||
|
paired Claim appears on the right. The two lists are always present
|
||||||
|
(empty list, never absent) so the UI can index unconditionally.
|
||||||
|
"""
|
||||||
|
return store.list_unmatched(kind="both")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Side-by-side diff between two batches (SP3 P4 / T18)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/batch-diff")
|
||||||
|
def get_batch_diff(
|
||||||
|
a: str | None = Query(None),
|
||||||
|
b: str | None = Query(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Return a side-by-side diff of two batches identified by id.
|
||||||
|
|
||||||
|
Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).
|
||||||
|
|
||||||
|
Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
|
||||||
|
projector shapes):
|
||||||
|
- ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
|
||||||
|
inputFilename, claimCount)
|
||||||
|
- ``added`` — claims present in B but not A
|
||||||
|
- ``removed`` — claims present in A but not B
|
||||||
|
- ``changed`` — claims present in both, with field deltas
|
||||||
|
- ``summary`` — precomputed counts
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 400 — missing ``a`` or ``b``
|
||||||
|
- 404 — either batch id is unknown
|
||||||
|
|
||||||
|
Pure read endpoint — never mutates the store. Both 837P and 835
|
||||||
|
batches are accepted (mixed-kind diffs are valid: comparing the
|
||||||
|
submitted claims against the matching remittances).
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
a_rec, b_rec = store.load_two_for_diff(a, b)
|
||||||
|
except LookupError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": "Not found", "detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lazy import — keeps the module's import surface small until the
|
||||||
|
# endpoint is actually hit. Mirrors the same pattern used by other
|
||||||
|
# endpoint-local helpers (e.g. reconciler).
|
||||||
|
from cyclone.batch_diff import diff_batches_to_wire
|
||||||
|
|
||||||
|
return diff_batches_to_wire(a_rec, b_rec)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/reconciliation/match")
|
||||||
|
def post_reconciliation_match(body: dict) -> dict:
|
||||||
|
"""Manually pair a Claim with a Remittance (operator override).
|
||||||
|
|
||||||
|
Body: ``{"claim_id": ..., "remit_id": ...}``. Returns
|
||||||
|
``{"claim": <ui>, "match": <ui>}`` on success. Errors:
|
||||||
|
- 400: missing ``claim_id`` or ``remit_id``
|
||||||
|
- 404: claim or remittance not found
|
||||||
|
- 409: claim already matched, or apply_* returned a noop
|
||||||
|
(claim in terminal state) — detail echoes ``current_state``
|
||||||
|
and ``activity_kind`` so the UI can render a precise message.
|
||||||
|
"""
|
||||||
|
claim_id = body.get("claim_id")
|
||||||
|
remit_id = body.get("remit_id")
|
||||||
|
if not claim_id or not remit_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="claim_id and remit_id required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return store.manual_match(claim_id, remit_id)
|
||||||
|
except AlreadyMatchedError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={"error": "already_matched", "message": str(e)},
|
||||||
|
)
|
||||||
|
except InvalidStateError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"error": "invalid_state",
|
||||||
|
"current_state": e.current_state,
|
||||||
|
"activity_kind": e.activity_kind,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
# manual_match raises LookupError when the claim or remittance
|
||||||
|
# row is missing (we catch the parent class so any future
|
||||||
|
# KeyError subclasses in the store get the same treatment).
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="claim_or_remit_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/reconciliation/unmatch")
|
||||||
|
def post_reconciliation_unmatch(body: dict) -> dict:
|
||||||
|
"""Remove the current match for a Claim; reset Claim to submitted.
|
||||||
|
|
||||||
|
Body: ``{"claim_id": ...}``. Returns
|
||||||
|
``{"claim": <ui>, "deletedMatches": <count>}``. Errors:
|
||||||
|
- 400: missing ``claim_id``
|
||||||
|
- 404: claim not found
|
||||||
|
- 409: claim has no current match (NotMatchedError is mapped
|
||||||
|
by the store; we surface 409 to match the manual_match contract)
|
||||||
|
"""
|
||||||
|
from cyclone.store import NotMatchedError
|
||||||
|
claim_id = body.get("claim_id")
|
||||||
|
if not claim_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="claim_id required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return store.manual_unmatch(claim_id)
|
||||||
|
except NotMatchedError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={"error": "not_matched", "message": str(e)},
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="claim_not_found",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user