feat(backend): POST /api/reconciliation/{match,unmatch} + InvalidStateError
This commit is contained in:
@@ -33,7 +33,13 @@ from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.parse_835 import parse as parse_835
|
||||
from cyclone.parsers.validator_835 import validate as validate_835
|
||||
from cyclone.store import BatchRecord, store, utcnow
|
||||
from cyclone.store import (
|
||||
AlreadyMatchedError,
|
||||
BatchRecord,
|
||||
InvalidStateError,
|
||||
store,
|
||||
utcnow,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -511,6 +517,83 @@ def get_reconciliation_unmatched() -> dict:
|
||||
return store.list_unmatched(kind="both")
|
||||
|
||||
|
||||
@app.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",
|
||||
)
|
||||
|
||||
|
||||
@app.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",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/remittances")
|
||||
def list_remittances(
|
||||
request: Request,
|
||||
|
||||
@@ -16,7 +16,7 @@ New API (T12):
|
||||
- list_unmatched(kind="both")
|
||||
- manual_match(claim_id, remit_id)
|
||||
- manual_unmatch(claim_id)
|
||||
- AlreadyMatchedError, NotMatchedError exception classes
|
||||
- AlreadyMatchedError, NotMatchedError, InvalidStateError exception classes
|
||||
|
||||
Backward-compat shims for tests that relied on the in-memory internals:
|
||||
- ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles
|
||||
@@ -70,6 +70,25 @@ class NotMatchedError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class InvalidStateError(Exception):
|
||||
"""Raised when an apply_* pure fn returns a skipped ApplyIntent.
|
||||
|
||||
``reconcile.apply_payment`` / ``apply_reversal`` may return
|
||||
``skipped=True`` (e.g. claim already in a terminal state, or reversal
|
||||
on a non-paid claim). The store surfaces that as ``InvalidStateError``
|
||||
rather than silently pairing. The T15 API endpoint maps this to a
|
||||
409 Conflict and echoes ``current_state`` and ``activity_kind`` so
|
||||
the UI can render a precise message.
|
||||
"""
|
||||
|
||||
def __init__(self, current_state: str, activity_kind: str = "invalid_state"):
|
||||
self.current_state = current_state
|
||||
self.activity_kind = activity_kind
|
||||
super().__init__(
|
||||
f"invalid state {current_state} for apply (kind={activity_kind})"
|
||||
)
|
||||
|
||||
|
||||
BatchKind = Literal["837p", "835"]
|
||||
|
||||
|
||||
@@ -1049,8 +1068,9 @@ class CycloneStore:
|
||||
7. Commit; return ``{"claim": <ui>, "match": <ui>}``.
|
||||
|
||||
``reconcile.apply_payment`` may return a noop (claim in terminal
|
||||
state); we surface that as ``ValueError`` rather than silently
|
||||
pairing, because the operator clearly intended a state change.
|
||||
state); we surface that as ``InvalidStateError`` rather than
|
||||
silently pairing, because the operator clearly intended a state
|
||||
change. The T15 API endpoint maps this to a 409 Conflict.
|
||||
"""
|
||||
from cyclone import reconcile as _reconcile
|
||||
|
||||
@@ -1080,8 +1100,14 @@ class CycloneStore:
|
||||
)
|
||||
|
||||
if intent.skipped or intent.new_state is None:
|
||||
raise ValueError(
|
||||
f"manual_match refused: {intent.reason or 'no state change'}"
|
||||
current = (
|
||||
claim.state.value
|
||||
if hasattr(claim.state, "value")
|
||||
else str(claim.state)
|
||||
)
|
||||
raise InvalidStateError(
|
||||
current_state=current,
|
||||
activity_kind=intent.activity_kind,
|
||||
)
|
||||
|
||||
new_state = intent.new_state
|
||||
|
||||
Reference in New Issue
Block a user