9bca4b608a
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
"""SP9 — HCPF X12 File Naming Standards helper tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from cyclone.edi.filenames import (
|
|
ALLOWED_FILE_TYPES,
|
|
OUTBOUND_RE,
|
|
INBOUND_RE,
|
|
build_outbound_filename,
|
|
is_inbound_filename,
|
|
is_outbound_filename,
|
|
parse_inbound_filename,
|
|
)
|
|
from cyclone.providers import InboundFilename
|
|
|
|
MT = ZoneInfo("America/Denver")
|
|
|
|
|
|
# ----- build_outbound_filename --------------------------------------------
|
|
|
|
|
|
def test_build_outbound_with_explicit_mt():
|
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
|
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
|
# HCPF outbound format: tp prefix on the tpid
|
|
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
|
|
|
|
|
|
def test_build_outbound_default_extension():
|
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
|
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
|
assert name.endswith(".x12")
|
|
|
|
|
|
def test_build_outbound_custom_extension():
|
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
|
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
|
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
|
|
|
|
|
|
def test_build_outbound_uses_mt_when_no_arg():
|
|
# Snapshot test — the timestamp will be very recent; check format only
|
|
name = build_outbound_filename("11525703", "837P")
|
|
assert OUTBOUND_RE.match(name), name
|
|
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
|
|
parts = name.split("-")
|
|
assert len(parts) == 4
|
|
assert parts[0] == "tp11525703"
|
|
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
|
|
|
|
|
def test_build_outbound_rejects_non_numeric_tpid():
|
|
now = datetime(2026, 6, 20, tzinfo=MT)
|
|
with pytest.raises(ValueError, match="tpid must be digits"):
|
|
build_outbound_filename("abc123", "837P", now_mt=now)
|
|
|
|
|
|
def test_build_outbound_rejects_invalid_tx():
|
|
now = datetime(2026, 6, 20, tzinfo=MT)
|
|
with pytest.raises(ValueError, match="tx must be uppercase alnum"):
|
|
build_outbound_filename("11525703", "837-lower", now_mt=now)
|
|
|
|
|
|
def test_build_outbound_rejects_naive_dt():
|
|
with pytest.raises(ValueError, match="timezone-aware"):
|
|
build_outbound_filename("11525703", "837P", now_mt=datetime(2026, 6, 20))
|
|
|
|
|
|
def test_build_outbound_converts_other_tz_to_mt():
|
|
# 2026-06-20 19:22:43 UTC = 2026-06-20 13:22:43 MT (during DST)
|
|
from datetime import timezone
|
|
now_utc = datetime(2026, 6, 20, 19, 22, 43, 505_000, tzinfo=timezone.utc)
|
|
name = build_outbound_filename("11525703", "837P", now_mt=now_utc)
|
|
assert "20260620132243505" in name
|
|
|
|
|
|
# ----- parse_inbound_filename ---------------------------------------------
|
|
|
|
|
|
def test_parse_inbound_999_real_prodfile():
|
|
# Real production 999 filename from docs/prodfiles/FromHPE/
|
|
name = "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
|
|
parsed = parse_inbound_filename(name)
|
|
assert parsed.tpid == "11525703"
|
|
assert parsed.orig_tx == "837P"
|
|
assert parsed.tracking == "M019048402"
|
|
assert parsed.ts == "20260520231513488"
|
|
assert parsed.file_type == "999"
|
|
assert parsed.ext == "x12"
|
|
|
|
|
|
def test_parse_inbound_ta1_real_prodfile():
|
|
name = "TP11525703-837P_M019044969-20260520180505477-1of1_TA1.x12"
|
|
parsed = parse_inbound_filename(name)
|
|
assert parsed.file_type == "TA1"
|
|
assert parsed.tracking == "M019044969"
|
|
|
|
|
|
def test_parse_inbound_277():
|
|
name = "TP11525703-837P_M019110219-20260601003507042-1of1_277.x12"
|
|
parsed = parse_inbound_filename(name)
|
|
assert parsed.file_type == "277"
|
|
|
|
|
|
def test_parse_inbound_rejects_missing_tp_prefix():
|
|
with pytest.raises(ValueError, match="Not a valid HCPF inbound"):
|
|
parse_inbound_filename("11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
|
|
|
|
|
def test_parse_inbound_rejects_wrong_segment_count():
|
|
with pytest.raises(ValueError):
|
|
parse_inbound_filename("TP11525703_837P_M019048402-1of1_999.x12")
|
|
|
|
|
|
def test_parse_inbound_rejects_unknown_file_type():
|
|
with pytest.raises(ValueError, match="not in allowed HCPF set"):
|
|
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_XXX.x12")
|
|
|
|
|
|
def test_parse_inbound_rejects_non_x12_ext():
|
|
with pytest.raises(ValueError):
|
|
parse_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.txt")
|
|
|
|
|
|
# ----- round-trip ---------------------------------------------------------
|
|
|
|
|
|
def test_roundtrip_outbound_to_inbound():
|
|
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
|
|
# prefixes are required). The two regexes use different shapes —
|
|
# round-trip via tpid only.
|
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
|
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
|
assert OUTBOUND_RE.match(out)
|
|
assert "11525703" in out
|
|
assert "837P" in out
|
|
assert out.endswith("1of1.x12")
|
|
|
|
|
|
# ----- validators ---------------------------------------------------------
|
|
|
|
|
|
def test_is_outbound_filename():
|
|
# HCPF outbound always has the lowercase "tp" prefix
|
|
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
|
|
# Bare tpid (no tp prefix) is no longer a valid outbound filename
|
|
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
|
# Uppercase TP prefix is the inbound shape, not outbound
|
|
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
|
|
assert not is_outbound_filename("not-a-filename")
|
|
|
|
|
|
def test_is_inbound_filename():
|
|
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
|
# Lowercase tp prefix is the outbound shape, not inbound
|
|
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
|
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
|
|
|
|
|
def test_allowed_file_types_includes_277ca():
|
|
assert "277CA" in ALLOWED_FILE_TYPES
|
|
assert "TA1" in ALLOWED_FILE_TYPES
|
|
assert "999" in ALLOWED_FILE_TYPES
|
|
assert "835" in ALLOWED_FILE_TYPES
|