"""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=``, ``b=`` (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= and ?b= 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": , "match": }`` 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": , "deletedMatches": }``. 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", )