diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 2c71dc4..875fea4 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -292,12 +292,22 @@ async def parse_835_endpoint( # Always run the validator; attach the report so the JSON path can # surface it and the NDJSON path can fold the counts into the summary. + # 835 validation is batch-level, so pass/fail applies uniformly to every + # claim payment in the batch (passed=N or 0, failed=0 or N). report = validate_835(result, config) - passed = 1 if report.passed else 0 - failed = 1 if not report.passed else 0 + n = len(result.claims) + claim_ids = [c.payer_claim_control_number for c in result.claims] + if report.passed: + passed, failed, failed_claim_ids = n, 0, [] + else: + passed, failed, failed_claim_ids = 0, n, claim_ids result = result.model_copy(update={ "validation": report, - "summary": result.summary.model_copy(update={"passed": passed, "failed": failed}), + "summary": result.summary.model_copy(update={ + "passed": passed, + "failed": failed, + "failed_claim_ids": failed_claim_ids, + }), }) if strict: diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 10124b9..b43f087 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -148,17 +148,26 @@ def parse_835( raise click.UsageError("No claim payments found in file") from None # Run validation; populate summary.passed/failed from the report. + # 835 validation is batch-level (BPR/CLP/SVC balancing is cross-cutting), + # so pass/fail applies uniformly to every claim payment in the batch: + # when the batch passes, all N claims "passed" (passed=N, failed=0); + # when the batch fails, all N claims "failed" (passed=0, failed=N). report = validate_835(result, config) if strict: promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings] new_errors = report.errors + promoted report = report.model_copy(update={"errors": new_errors, "passed": not new_errors}) - passed = 1 if report.passed else 0 - failed = 1 if not report.passed else 0 + n = len(result.claims) + claim_ids = [c.payer_claim_control_number for c in result.claims] + if report.passed: + passed, failed, failed_claim_ids = n, 0, [] + else: + passed, failed, failed_claim_ids = 0, n, claim_ids result = result.model_copy(update={"validation": report}) result.summary = result.summary.model_copy(update={ "passed": passed, "failed": failed, + "failed_claim_ids": failed_claim_ids, "issues_by_rule": _count_issues(report), }) diff --git a/backend/tests/test_api_835.py b/backend/tests/test_api_835.py index 7503a69..571522b 100644 --- a/backend/tests/test_api_835.py +++ b/backend/tests/test_api_835.py @@ -41,7 +41,8 @@ def test_parse_835_endpoint_returns_json(client: TestClient): assert "claims" in body and len(body["claims"]) == 2 assert "summary" in body assert body["summary"]["total_claims"] == 2 - assert body["summary"]["passed"] == 1 + assert body["summary"]["passed"] == 2 + assert body["summary"]["failed"] == 0 # --------------------------------------------------------------------------- # @@ -79,7 +80,7 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient): # Summary numbers match the JSON path. assert parsed[7]["data"]["total_claims"] == 2 - assert parsed[7]["data"]["passed"] == 1 + assert parsed[7]["data"]["passed"] == 2 def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient): @@ -123,7 +124,7 @@ def test_parse_835_endpoint_handles_payer_query_param(client: TestClient): assert resp.status_code == 200, (payer, resp.text) body = resp.json() assert body["summary"]["total_claims"] == 2 - assert body["summary"]["passed"] == 1 + assert body["summary"]["passed"] == 2 def test_parse_835_endpoint_rejects_unknown_payer(client: TestClient): diff --git a/backend/tests/test_cli_835.py b/backend/tests/test_cli_835.py index 2ccc59f..549f6d2 100644 --- a/backend/tests/test_cli_835.py +++ b/backend/tests/test_cli_835.py @@ -9,6 +9,7 @@ from cyclone.cli import main FIXTURE = Path(__file__).parent / "fixtures" / "minimal_835.txt" UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt" +CO_FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" def test_cli_parse_835_writes_outputs(tmp_path: Path): @@ -71,3 +72,21 @@ def test_cli_parse_835_generic_payer(tmp_path: Path): assert result.exit_code == 0, result.output summary = json.loads((tmp_path / "summary.json").read_text()) assert summary["passed"] == 1 + + +def test_cli_parse_835_summary_counts_consistent(tmp_path: Path): + """Regression: passed + failed must equal total_claims for any batch size. + + The CO Medicaid fixture has 2 claim payments; if the batch validates + cleanly, both must show as passed (passed=2, failed=0). A pre-fix + implementation set passed=1 regardless of claim count. + """ + runner = CliRunner() + result = runner.invoke(main, ["parse-835", str(CO_FIXTURE), "--output-dir", str(tmp_path)]) + assert result.exit_code == 0, result.output + summary = json.loads((tmp_path / "summary.json").read_text()) + assert summary["total_claims"] == 2 + assert summary["passed"] + summary["failed"] == summary["total_claims"] + assert summary["passed"] == 2 + assert summary["failed"] == 0 + assert summary["failed_claim_ids"] == []