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.pubsub import EventBus
|
||||
from cyclone.store import (
|
||||
AlreadyMatchedError,
|
||||
BatchRecord,
|
||||
InvalidStateError,
|
||||
store,
|
||||
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)])
|
||||
def list_remittances(
|
||||
request: Request,
|
||||
|
||||
@@ -19,6 +19,7 @@ from cyclone.api_routers import (
|
||||
eligibility,
|
||||
health,
|
||||
payers,
|
||||
reconciliation,
|
||||
ta1_acks,
|
||||
)
|
||||
|
||||
@@ -31,6 +32,7 @@ routers: list[APIRouter] = [
|
||||
eligibility.router, # gated
|
||||
health.router, # public — health probes must work pre-auth
|
||||
payers.router, # gated
|
||||
reconciliation.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