2dfe213af0
Inserts a per-claim-id check_duplicate() walk into submit_file between
the payer-mismatch guard and the BatchRecord837 DB write (Task 9).
If any CLM01 in the parsed 837P file was submitted within the 30-day
window recorded in the submission_dedup table, submit_file raises
DuplicateClaimError before any DB write or SFTP upload. The DB-first,
upload-second invariant is preserved: a duplicate blocks both the
BatchRecord837 write AND the paramiko upload.
The api_routers/submission.submit_batch per-file loop special-cases
DuplicateClaimError BEFORE the generic Exception handler so the
200-with-results status-code contract documented at the top of the
module is preserved (per-file failures stay in the body). The
exception remains 409-class by design — a future caller that
propagates it past the per-file loop will surface 409 at the FastAPI
layer.
Approach A per the Task 9 spec (raise, don't return a SubmitResult).
Tests:
- test_submission_dedup.test_submit_file_raises_on_duplicate_claim_id
pre-populates the dedup table via record_submission('CLM001', ...),
calls submit_file against minimal_837p.txt, expects DuplicateClaimError
with structured claim_id / original_submission_at attributes, and
pins the DB-write invariant (no Batch row landed) plus an
AssertionError sentinel on the SFTP factory.
213 lines
8.6 KiB
Python
213 lines
8.6 KiB
Python
"""SP37 Task 6: HTTP endpoint for the canonical submit-batch flow.
|
|
|
|
Thin wrapper around ``cyclone.submission.submit_file`` — same logic as
|
|
the ``cyclone submit-batch`` CLI (SP37 Task 5), just framed as JSON in
|
|
/ JSON out and gated by ``matrix_gate``. The walker pattern, ``._*``
|
|
AppleDouble skip, ``limit`` semantics, and per-file outcomes all match
|
|
the CLI byte-for-byte so a batch run via the CLI and the same batch
|
|
run via this endpoint produce identical DB + SFTP state.
|
|
|
|
The endpoint deliberately does NOT inject an ``sftp_client_factory``:
|
|
``submit_file`` defaults to its paramiko-based factory so SKIPPED is
|
|
reachable in production (the ``SftpClient`` wrapper has no ``stat()``).
|
|
Tests monkey-patch ``cyclone.api_routers.submission.submit_file``
|
|
itself; that avoids the paramiko factory entirely without touching
|
|
the helper's contract.
|
|
|
|
Status code contract (per Task 6 spec §4):
|
|
- 200: completed run. Per-file failures live in the JSON body.
|
|
- 401: not authenticated (matrix_gate).
|
|
- 404: no clearhouse seeded (config-level "missing" → 4xx, not 5xx).
|
|
- 409: clearhouse SFTP block is in stub mode (refuses to upload).
|
|
- 422: ``ingest_dir`` missing on disk OR Pydantic body validation
|
|
failed (missing fields, wrong types).
|
|
- 5xx: truly unexpected exceptions propagate (do not swallow).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from cyclone.auth.deps import matrix_gate
|
|
from cyclone.store import store as cycl_store
|
|
from cyclone.store.exceptions import DuplicateClaimError
|
|
from cyclone.submission.core import submit_file
|
|
from cyclone.submission.result import SubmitOutcome, SubmitResult
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# No `prefix=` here — every other gated router in this package declares
|
|
# the full path in the decorator (clearhouse.py uses "/api/clearhouse",
|
|
# parse.py uses "/api/parse-837", etc.). The decorator sets the full URL.
|
|
router = APIRouter(
|
|
tags=["submission"],
|
|
dependencies=[Depends(matrix_gate)],
|
|
)
|
|
|
|
|
|
class SubmitBatchRequest(BaseModel):
|
|
"""Body schema for ``POST /api/submit-batch``.
|
|
|
|
``ingest_dir`` has no default so Pydantic raises 422 when it's
|
|
missing — better UX than letting the walker crash on a missing
|
|
path. ``validate_files`` and ``actor`` default so a minimal client
|
|
can skip them. ``limit`` truncates the file list after the walker
|
|
collects it (mirrors the CLI's post-collection ``if i > limit:
|
|
break`` semantics, but applied as a slice since the HTTP body
|
|
model is type-checked up-front).
|
|
|
|
The ``validate_files`` field is aliased to ``validate`` in the JSON
|
|
body to mirror the CLI's ``--validate`` flag and avoid the
|
|
hardcoded Pydantic warning about ``validate`` shadowing
|
|
``BaseModel.validate``. ``populate_by_name=True`` lets tests
|
|
construct the model with either key.
|
|
"""
|
|
model_config = ConfigDict(populate_by_name=True, protected_namespaces=())
|
|
|
|
ingest_dir: str
|
|
validate_files: bool = Field(default=True, alias="validate")
|
|
actor: str = "api-submit-batch"
|
|
limit: int | None = None
|
|
|
|
|
|
@router.post("/api/submit-batch")
|
|
def submit_batch(body: SubmitBatchRequest):
|
|
"""Submit every ``batch-*-claims/*.x12`` under ``ingest_dir``.
|
|
|
|
Walks ``ingest_dir`` for any directory matching ``batch-*-claims``,
|
|
collects each one's ``*.x12`` files (sorted, with ``._*``
|
|
AppleDouble files skipped), truncates to ``limit`` if set, then
|
|
calls :func:`cyclone.submission.submit_file` per file with the
|
|
seeded clearhouse's ``sftp_block`` and ``actor`` from the body.
|
|
|
|
Returns counts (``submitted`` / ``skipped`` / ``failed``) plus a
|
|
per-file ``results`` array. Per-file failures NEVER change the
|
|
HTTP status code — the response is 200 whenever the run itself
|
|
completed.
|
|
"""
|
|
|
|
# 1. Config-level guards. Order matters: a missing clearhouse is a
|
|
# 404 (config-level "missing"), but if it IS present and in stub
|
|
# mode the operator's request is a 409 (configured-but-wrong).
|
|
clearhouse = cycl_store.get_clearhouse()
|
|
if clearhouse is None:
|
|
raise HTTPException(
|
|
status_code=404, detail="no clearhouse seeded",
|
|
)
|
|
sftp_block = clearhouse.sftp_block
|
|
if sftp_block.stub:
|
|
raise HTTPException(
|
|
status_code=409, detail="clearhouse SFTP block is in stub mode",
|
|
)
|
|
|
|
# 2. Resolve + validate the ingest dir. Use ``resolve()`` so a
|
|
# symlink-relative path still produces a stable error message.
|
|
root = Path(body.ingest_dir).resolve()
|
|
if not root.exists():
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"ingest_dir does not exist: {root}",
|
|
)
|
|
|
|
# 3. Walker — must match the CLI EXACTLY. Same sort, same ``._*``
|
|
# AppleDouble skip. Any drift here is a quiet split between the
|
|
# two surfaces and silently produces different batch outcomes.
|
|
files: list[Path] = []
|
|
for batch_dir in sorted(root.glob("batch-*-claims")):
|
|
files.extend(sorted(
|
|
p for p in batch_dir.glob("*.x12")
|
|
if not p.name.startswith("._")
|
|
))
|
|
|
|
# 4. ``limit`` truncates after collection. The CLI uses an inline
|
|
# ``if i > limit: break``; we slice instead because the HTTP
|
|
# body model validates ``limit`` up-front (Pydantic-level int
|
|
# check) and slicing keeps the walker branchless.
|
|
if body.limit is not None:
|
|
files = files[: body.limit]
|
|
|
|
if not files:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"no batch-*-claims/*.x12 files found under {root}",
|
|
)
|
|
|
|
# 5. Per-file submit. Wrap the helper call in try/except so an
|
|
# unexpected exception in submit_file surfaces as a per-file
|
|
# failure (outcome="unexpected") instead of crashing the whole
|
|
# run. The helper's own SubmitOutcome enum covers every typed
|
|
# failure path; an uncaught exception here is a true
|
|
# surprise (bug or service outage mid-loop).
|
|
results: list[dict] = []
|
|
submitted = skipped = failed = 0
|
|
for src in files:
|
|
try:
|
|
r = submit_file(
|
|
src,
|
|
sftp_block=sftp_block,
|
|
actor=body.actor,
|
|
validate=body.validate_files,
|
|
# No ``sftp_client_factory`` — submit_file's default
|
|
# paramiko factory opens the real MFT. Tests
|
|
# monkey-patch submit_file itself instead of wiring a
|
|
# factory here.
|
|
)
|
|
except DuplicateClaimError as exc:
|
|
# SP41 Task 9: 409-class domain exception (see the exception
|
|
# docstring on ``DuplicateClaimError``). The per-file loop
|
|
# preserves the 200-with-results status-code contract
|
|
# documented at the top of this module, so we surface the
|
|
# duplicate as a per-file failure with UNEXPECTED_ERROR
|
|
# outcome — the structured ``claim_id`` / ``original_submission_at``
|
|
# attributes ride along in the error string so the operator
|
|
# can see which CLM01 in the batch tripped the guard. Caught
|
|
# BEFORE the generic ``Exception`` handler so a future
|
|
# caller propagating the exception still gets 409-class
|
|
# treatment at the FastAPI layer.
|
|
log.warning(
|
|
"submit-batch duplicate claim on %s: claim_id=%r original=%s",
|
|
src.name, exc.claim_id, exc.original_submission_at,
|
|
)
|
|
r = SubmitResult(
|
|
file=src.name,
|
|
outcome=SubmitOutcome.UNEXPECTED_ERROR,
|
|
error=(
|
|
f"DuplicateClaimError: claim_id {exc.claim_id!r} was "
|
|
f"already submitted at "
|
|
f"{exc.original_submission_at.isoformat()}; "
|
|
f"within 30-day window"
|
|
),
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
log.exception(
|
|
"submit-batch unexpected error on %s", src.name,
|
|
)
|
|
r = SubmitResult(
|
|
file=src.name,
|
|
outcome=SubmitOutcome.UNEXPECTED_ERROR,
|
|
error=f"{exc.__class__.__name__}: {exc}",
|
|
)
|
|
|
|
if r.outcome == SubmitOutcome.SUBMITTED:
|
|
submitted += 1
|
|
elif r.outcome == SubmitOutcome.SKIPPED:
|
|
skipped += 1
|
|
else:
|
|
failed += 1
|
|
|
|
results.append({
|
|
"file": r.file,
|
|
"outcome": r.outcome.value,
|
|
"batch_id": r.batch_id,
|
|
"error": r.error,
|
|
})
|
|
|
|
return {
|
|
"submitted": submitted,
|
|
"skipped": skipped,
|
|
"failed": failed,
|
|
"results": results,
|
|
} |