7abe94a3a8
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.
179 lines
6.4 KiB
Python
179 lines
6.4 KiB
Python
"""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",
|
|
)
|