feat(sp38): CycloneStore.find_ack_orphan_st02_summary + reconcile_orphan_st02s

Implements the store-helper half of the SP38 spec (tasks 1-3 of
docs/superpowers/plans/2026-07-07-cyclone-orphan-ack-housekeeping.md).

Two new CycloneStore methods + one extraction helper in claim_acks.py:

  - find_ack_orphan_st02_summary() -> list[dict]
    Per-ST02 breakdown of orphan 999 acks. Returns one row per
    distinct (st02, ack_count, has_batch, batch_id). Sorted by
    ack_count DESC so the heaviest backlog surfaces first in CLI
    output. 999-only — 277ca/ta1 orphans have different ST02
    semantics (interchange control number vs source 837 ST02)
    and aggregating them would conflate unrelated identifiers.

  - reconcile_orphan_st02s(*, dry_run=False) -> dict
    One-shot synthetic-batch seeder. For every orphan ST02 without
    a batches row, insert one with kind='837p',
    input_filename='<synthetic:orphan-reconcile>' (the sentinel),
    transaction_set_control_number=<st02>, and totals_json +
    validation_json documenting the orphan-reconcile provenance.
    Idempotent: re-running after a successful pass is a no-op.
    dry_run=True returns the plan shape without writing.

  - _iter_orphan_999_st02s() -> Iterator[(st02, count)]
    Extracted walk helper. The existing find_ack_orphans('999')
    refactored to consume it for the link check; the new summary
    uses it for the per-ST02 aggregation. Accepts both ORM dicts
    and raw sqlite3 strings for raw_json (the SQLAlchemy JSON
    type hands back Python dicts, but tests can hand strings).

11 new tests (test_ack_orphan_summary.py) cover:
  - per-ST02 counts with duplicates
  - has_batch=True when a batches row covers the ST02
  - sort order (heaviest first)
  - exclusion of 999s with a claim_acks link
  - skip of malformed/empty raw_json
  - empty-DB case
  - reconcile creates synthetic rows for missing ST02s only
  - reconcile skips ST02s with existing batches
  - idempotency
  - --dry-run flag
  - ack_count preserved in totals_json

Live-verified against ~/.local/share/cyclone/cyclone.db: 4 distinct
orphan ST02s, 804 total orphan rows. After reconcile, all 4 marked
has_batch=yes.
This commit is contained in:
Nora
2026-07-07 12:53:40 -06:00
parent 8890627014
commit ad14b56732
3 changed files with 568 additions and 72 deletions
+320
View File
@@ -0,0 +1,320 @@
"""SP38: store-helper tests for ``find_ack_orphan_st02_summary``.
The summary is the per-ST02 breakdown the ``cyclone ack-orphans``
CLI commands consume. It enumerates every distinct orphan 999 ST02
+ ack count + whether a ``batches`` row already covers that ST02.
Tests live here (not in ``test_apply_claim_ack_links.py``) because
the helper is a sp38 surface, not an sp28 invariant. Keeping the
tests in a sibling file matches the ``cyclone-tests`` convention.
The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides
a fresh ``tmp_path/test.db`` for every test — no manual DB setup
needed here.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
from cyclone import db
from cyclone.db import Ack, Batch, ClaimAck
from cyclone.store import CycloneStore
def _now():
"""A single shared 'now' for deterministic test timestamps."""
return datetime.now(timezone.utc)
def _ingest_999(s, st02: str, *, batch_id: str | None = None) -> int:
"""Insert a 999 ack row whose set_responses[0].set_control_number == st02.
Returns the new ack id. The 999 is an orphan (no claim_acks row
is inserted) so it surfaces in the summary.
"""
raw = {
"envelope": {
"sender_id": "SUBMITTERID",
"receiver_id": "RECEIVERID",
"control_number": "000000099",
"transaction_date": "2024-01-01",
"implementation_guide": "005010X231A1",
},
"functional_group_acks": [],
"set_responses": [
{"set_control_number": st02, "transaction_set_identifier": "837",
"ak2": {"functional_id_code": "837"}, "segment_errors": [],
"set_accept_reject": {"code": "A"}}
],
"summary": {"accepted_count": 1, "rejected_count": 0},
}
ack = Ack(
source_batch_id=batch_id or f"999-{st02}-test",
accepted_count=1,
rejected_count=0,
received_count=1,
ack_code="A",
parsed_at=_now(),
raw_json=json.dumps(raw),
)
s.add(ack)
s.flush()
return ack.id
def _seed_batch(s, *, st02: str, batch_id: str | None = None) -> str:
"""Insert a batches row with the given ST02. Returns the batch id."""
import uuid
bid = batch_id or uuid.uuid4().hex
s.add(Batch(
id=bid,
kind="837p",
input_filename="test.837p",
transaction_set_control_number=st02,
parsed_at=_now(),
))
s.flush()
return bid
def test_summary_returns_per_st02_counts():
"""Two distinct orphan ST02s surface as two separate summary rows."""
with db.SessionLocal()() as s:
_ingest_999(s, "991102989")
_ingest_999(s, "991102989") # same ST02 twice
_ingest_999(s, "991102988")
s.commit()
summary = CycloneStore().find_ack_orphan_st02_summary()
by_st02 = {row["st02"]: row for row in summary}
assert by_st02["991102989"]["ack_count"] == 2
assert by_st02["991102988"]["ack_count"] == 1
assert by_st02["991102989"]["has_batch"] is False
assert by_st02["991102988"]["has_batch"] is False
def test_summary_sets_has_batch_true_when_batches_row_exists():
"""When a batches row has the orphan ST02, ``has_batch`` is True
and ``batch_id`` matches the batches row's id."""
with db.SessionLocal()() as s:
bid = _seed_batch(s, st02="991102977")
_ingest_999(s, "991102977", batch_id=bid)
s.commit()
summary = CycloneStore().find_ack_orphan_st02_summary()
assert len(summary) == 1
row = summary[0]
assert row["st02"] == "991102977"
assert row["ack_count"] == 1
assert row["has_batch"] is True
assert row["batch_id"] == bid
def test_summary_sorted_by_ack_count_descending():
"""The summary is sorted so the heaviest orphans surface first
in the CLI output (matches the spec's intent that operators
triage the biggest backlog first)."""
with db.SessionLocal()() as s:
for _ in range(3):
_ingest_999(s, "991102988")
_ingest_999(s, "991102987")
for _ in range(5):
_ingest_999(s, "991102989")
s.commit()
summary = CycloneStore().find_ack_orphan_st02_summary()
counts = [row["ack_count"] for row in summary]
assert counts == sorted(counts, reverse=True)
assert counts[0] == 5 # 991102989 has the most
def test_summary_excludes_999s_that_have_a_claim_acks_link():
"""A 999 with a claim_acks row is NOT an orphan and must not
appear in the summary."""
with db.SessionLocal()() as s:
linked_id = _ingest_999(s, "991102977")
_ingest_999(s, "991102988") # orphan, no link
# Manually insert a claim_acks link for the first 999.
s.add(ClaimAck(
claim_id="CLM-1",
batch_id="b1",
ack_id=linked_id,
ack_kind="999",
ak2_index=0,
set_control_number="991102977",
set_accept_reject_code="A",
linked_at=_now(),
linked_by="auto",
))
s.commit()
summary = CycloneStore().find_ack_orphan_st02_summary()
by_st02 = {row["st02"]: row for row in summary}
assert "991102977" not in by_st02 # linked → not orphan
assert by_st02["991102988"]["ack_count"] == 1
def test_summary_skips_999s_with_malformed_raw_json():
"""999s whose raw_json is missing set_responses or is malformed
JSON are SKIPPED — they can't be reconciled, so they don't
contribute to the summary."""
with db.SessionLocal()() as s:
# Valid orphan
_ingest_999(s, "991102988")
# Malformed JSON — raw_json is not parseable
s.add(Ack(
source_batch_id="999-malformed",
accepted_count=1, rejected_count=0, received_count=1,
ack_code="A",
parsed_at=_now(),
raw_json="{not valid json",
))
# Empty set_responses
s.add(Ack(
source_batch_id="999-empty",
accepted_count=1, rejected_count=0, received_count=1,
ack_code="A",
parsed_at=_now(),
raw_json=json.dumps({"envelope": {}, "set_responses": [], "summary": {}}),
))
s.commit()
summary = CycloneStore().find_ack_orphan_st02_summary()
by_st02 = {row["st02"]: row for row in summary}
assert set(by_st02.keys()) == {"991102988"}
assert by_st02["991102988"]["ack_count"] == 1
def test_summary_empty_when_no_orphans():
"""With zero orphans, the summary is an empty list."""
summary = CycloneStore().find_ack_orphan_st02_summary()
assert summary == []
# ---- Task 3: reconcile_orphan_st02s --------------------------------------- #
def test_reconcile_creates_synthetic_batches_for_missing_st02s():
"""``reconcile_orphan_st02s`` inserts one synthetic batch row per
orphan ST02 that doesn't already have a batches row.
Each synthetic row uses the sentinel ``input_filename`` and
``kind = '837p'`` so they're trivially distinguishable in
queries (the spec: grep for ``<synthetic:orphan-reconcile>``
to find every row this SP38 created).
"""
with db.SessionLocal()() as s:
_ingest_999(s, "991102989")
_ingest_999(s, "991102988")
s.commit()
plan = CycloneStore().reconcile_orphan_st02s(dry_run=False)
assert plan["created"] == 2
assert plan["skipped"] == 0
assert len(plan["synthetic_batch_ids"]) == 2
with db.SessionLocal()() as s:
rows = (
s.query(Batch)
.filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
.all()
)
assert len(rows) == 2
assert all(r.kind == "837p" for r in rows)
st02s = {r.transaction_set_control_number for r in rows}
assert st02s == {"991102989", "991102988"}
def test_reconcile_skips_st02s_that_already_have_a_batch():
"""``reconcile`` does NOT create a synthetic row for an ST02
that already has a batches row — the operator's intent is to
fill gaps, not duplicate coverage."""
with db.SessionLocal()() as s:
existing = _seed_batch(s, st02="991102977")
_ingest_999(s, "991102977", batch_id=existing)
_ingest_999(s, "991102988") # orphan, no batch
s.commit()
plan = CycloneStore().reconcile_orphan_st02s(dry_run=False)
assert plan["created"] == 1
assert plan["skipped"] == 1
with db.SessionLocal()() as s:
synthetic = (
s.query(Batch)
.filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
.all()
)
assert len(synthetic) == 1
assert synthetic[0].transaction_set_control_number == "991102988"
def test_reconcile_is_idempotent():
"""Re-running reconcile after a successful pass is a no-op:
``created=0, skipped=N``."""
with db.SessionLocal()() as s:
_ingest_999(s, "991102989")
s.commit()
CycloneStore().reconcile_orphan_st02s(dry_run=False)
plan2 = CycloneStore().reconcile_orphan_st02s(dry_run=False)
assert plan2["created"] == 0
assert plan2["skipped"] == 1
with db.SessionLocal()() as s:
n = (
s.query(Batch)
.filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
.count()
)
assert n == 1
def test_reconcile_dry_run_does_not_write():
"""``dry_run=True`` returns the same plan shape but does not
insert any rows. Operators use this to preview the reconcile
before committing."""
with db.SessionLocal()() as s:
_ingest_999(s, "991102989")
s.commit()
plan = CycloneStore().reconcile_orphan_st02s(dry_run=True)
assert plan["created"] == 1
assert plan["skipped"] == 0
with db.SessionLocal()() as s:
n = (
s.query(Batch)
.filter(Batch.input_filename == "<synthetic:orphan-reconcile>")
.count()
)
assert n == 0 # no row inserted
def test_reconcile_records_ack_count_in_totals_json():
"""The synthetic row's ``totals_json`` includes ``ack_count`` so
future operators can see the orphan weight without re-querying."""
import json
with db.SessionLocal()() as s:
for _ in range(4):
_ingest_999(s, "991102989")
s.commit()
CycloneStore().reconcile_orphan_st02s(dry_run=False)
with db.SessionLocal()() as s:
row = (
s.query(Batch)
.filter(Batch.transaction_set_control_number == "991102989")
.one()
)
totals = json.loads(row.totals_json or "{}")
assert totals.get("orphan_reconcile") is True
assert totals.get("ack_count") == 4