feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
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.
This commit is contained in:
+316
-11
@@ -35,7 +35,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cyclone import __version__, db
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
||||
from sqlalchemy import desc, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
@@ -420,11 +420,18 @@ async def parse_837(
|
||||
ack_body = _build_and_persist_ack(rec.id)
|
||||
if ack_body is not None:
|
||||
body["ack"] = ack_body
|
||||
# Surface the server-side batch id so the frontend can call
|
||||
# /api/batches/{id}/export-837 (and any other batch-scoped
|
||||
# endpoint) without a separate listBatches round-trip.
|
||||
body["batch_id"] = rec.id
|
||||
return JSONResponse(content=body)
|
||||
|
||||
# Default: NDJSON stream.
|
||||
# Default: NDJSON stream. Pass the server-side batch id so the
|
||||
# streaming client (the React Upload page) can call batch-scoped
|
||||
# endpoints like /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip.
|
||||
return StreamingResponse(
|
||||
_ndjson_stream(result),
|
||||
_ndjson_stream(result, batch_id=rec.id),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -606,9 +613,12 @@ async def parse_835_endpoint(
|
||||
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
||||
return JSONResponse(content=body)
|
||||
|
||||
# Default: NDJSON stream.
|
||||
# Default: NDJSON stream. Pass the server-side batch id so the
|
||||
# streaming client can call batch-scoped endpoints without a
|
||||
# separate GET /api/batches round-trip (see /api/parse-837 for the
|
||||
# parallel change).
|
||||
return StreamingResponse(
|
||||
_ndjson_stream_835(result),
|
||||
_ndjson_stream_835(result, batch_id=rec.id),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -1295,6 +1305,215 @@ def inbox_resubmit_rejected(
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/batches/{batch_id}/export-837")
|
||||
def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||
|
||||
Body shape: ``{"claim_ids": [str, ...]}``.
|
||||
|
||||
Each successfully serialized claim becomes an entry in the ZIP named
|
||||
per the HCPF X12 File Naming Standards:
|
||||
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
|
||||
millisecond offset so every file in the bundle has a unique name).
|
||||
The ``serialize_837_for_resubmit`` serializer is used so every file
|
||||
gets a unique interchange / group control number — back-to-back
|
||||
exports of the same set must produce different envelopes (required
|
||||
by X12).
|
||||
|
||||
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
|
||||
the clearhouse singleton (dzinesco's identity in the seeded config)
|
||||
and the receiver block (NM1*40) is populated from the per-payer
|
||||
config. Without this wiring, the serializer falls back to
|
||||
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
|
||||
|
||||
No DB state is mutated by this endpoint — it is read-only. Compare
|
||||
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
|
||||
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
|
||||
intentionally separate.
|
||||
|
||||
Responses:
|
||||
200 — ``application/zip`` with the .x12 entries. Per-claim failures
|
||||
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
|
||||
(JSON-encoded array of ``{claim_id, reason}``).
|
||||
400 — ``claim_ids`` missing or empty.
|
||||
404 — ``batch_id`` unknown.
|
||||
422 — every claim failed to serialize; body is JSON listing all
|
||||
failures (``{"detail": {"serialize_errors": [...]}}``).
|
||||
"""
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from cyclone.edi.filenames import build_outbound_filename
|
||||
|
||||
ids = body.get("claim_ids") or []
|
||||
if not ids:
|
||||
raise HTTPException(400, "claim_ids required")
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
batch = s.get(Batch, batch_id)
|
||||
if batch is None:
|
||||
raise HTTPException(404, f"unknown batch: {batch_id}")
|
||||
|
||||
serialize_errors: list[dict] = []
|
||||
ordered_rows: list[tuple[str, "Claim"]] = []
|
||||
for cid in ids:
|
||||
c = s.get(Claim, cid)
|
||||
if c is None:
|
||||
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
|
||||
continue
|
||||
ordered_rows.append((cid, c))
|
||||
|
||||
# Pull clearhouse identity (submitter). If unseeded, the serializer
|
||||
# falls back to placeholder defaults — degraded but not a hard error.
|
||||
ch = store.get_clearhouse()
|
||||
submitter_kwargs: dict = {}
|
||||
if ch is not None:
|
||||
submitter_kwargs = {
|
||||
"sender_id": ch.tpid,
|
||||
"submitter_name": ch.submitter_name,
|
||||
"submitter_contact_name": ch.submitter_contact_name,
|
||||
"submitter_contact_email": ch.submitter_contact_email,
|
||||
}
|
||||
# Submitter phone is not in the clearhouse config today, but if
|
||||
# it ever is, wire it here. Email is the canonical contact
|
||||
# channel for HCPF submissions per the SP9 spec.
|
||||
if getattr(ch, "submitter_contact_phone", None):
|
||||
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
||||
|
||||
# Resolve per-claim payer config so each file's receiver (NM1*40)
|
||||
# and SBR09 are correct. Cache so we don't re-query the same payer.
|
||||
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
||||
_payer_cache: dict[str, dict | None] = {}
|
||||
|
||||
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
|
||||
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
||||
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
||||
cache_key = pid or pname
|
||||
if cache_key in _payer_cache:
|
||||
return _payer_cache[cache_key]
|
||||
cfg: dict | None = None
|
||||
with db.SessionLocal()() as ss:
|
||||
# 1. Exact match on (payer_id, "837P")
|
||||
if pid:
|
||||
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
||||
if row is not None:
|
||||
cfg = dict(row.config_json)
|
||||
# 2. Fallback: any row whose payer_id matches the parsed payer.name
|
||||
# (HCPF files emit "SKCO0" in NM109 but the canonical
|
||||
# payer_id in the DB is "CO_TXIX" — name-matching is the
|
||||
# pragmatic lookup for that case).
|
||||
if cfg is None and pname:
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.all()
|
||||
)
|
||||
for r in row:
|
||||
cj = dict(r.config_json)
|
||||
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
|
||||
cfg = cj
|
||||
break
|
||||
if (r.payer_id or "").upper() == pname.upper():
|
||||
cfg = cj
|
||||
break
|
||||
# 3. Last resort: first 837P row in the table.
|
||||
if cfg is None:
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
cfg = dict(row.config_json)
|
||||
_payer_cache[cache_key] = cfg
|
||||
return cfg
|
||||
|
||||
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
|
||||
# defaults to the parsed payer name/ID if no config row matches.
|
||||
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
|
||||
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
||||
receiver_id = (
|
||||
payer_cfg.get("receiver_id")
|
||||
or (claim_obj.payer.id if claim_obj.payer else None)
|
||||
or "RECEIVER"
|
||||
)
|
||||
receiver_name = (
|
||||
payer_cfg.get("receiver_name")
|
||||
or (claim_obj.payer.name if claim_obj.payer else None)
|
||||
or receiver_id
|
||||
)
|
||||
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
||||
return {
|
||||
"receiver_id": receiver_id,
|
||||
"receiver_name": receiver_name,
|
||||
"claim_filing_indicator_code": sbr09,
|
||||
}
|
||||
|
||||
# Base MT timestamp for HCPF filenames. We add a per-claim
|
||||
# millisecond offset so each file in the ZIP has a unique 17-digit
|
||||
# ts (HCPF requires that; the spec also enforces "1of1" for the
|
||||
# sequence element).
|
||||
base_ts = datetime.now(ZoneInfo("America/Denver"))
|
||||
|
||||
def _per_claim_filename(idx: int, cid: str) -> str:
|
||||
if ch is None:
|
||||
# No clearhouse — fall back to a per-claim friendly name.
|
||||
return f"claim-{cid}.x12"
|
||||
# Millisecond offset, with second/minute rollover.
|
||||
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
|
||||
ts_mt = base_ts.fromtimestamp(
|
||||
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
|
||||
)
|
||||
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for idx, (cid, c) in enumerate(ordered_rows, start=1):
|
||||
if not c.raw_json:
|
||||
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
|
||||
continue
|
||||
try:
|
||||
claim_obj = ClaimOutput.model_validate(c.raw_json)
|
||||
except Exception as exc:
|
||||
serialize_errors.append(
|
||||
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
|
||||
text = serialize_837_for_resubmit(
|
||||
claim_obj, interchange_index=idx, **kwargs
|
||||
)
|
||||
except SerializeError837 as exc:
|
||||
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
|
||||
continue
|
||||
zf.writestr(_per_claim_filename(idx, cid), text)
|
||||
|
||||
success_count = len(ids) - len(serialize_errors)
|
||||
if serialize_errors and success_count == 0:
|
||||
# Every claim failed — surface the failure list in the body so the
|
||||
# UI can render a useful error toast (the response is not a ZIP).
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={"serialize_errors": serialize_errors},
|
||||
)
|
||||
|
||||
buf.seek(0)
|
||||
headers = {
|
||||
"Content-Disposition": (
|
||||
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
|
||||
),
|
||||
}
|
||||
if serialize_errors:
|
||||
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/inbox/export.csv")
|
||||
def inbox_export_csv(lane: str):
|
||||
"""Stream a CSV for a single lane."""
|
||||
@@ -2321,6 +2540,51 @@ def submit_to_clearhouse(body: dict):
|
||||
if ch is None:
|
||||
raise HTTPException(status_code=500, detail="clearhouse not seeded")
|
||||
|
||||
# Submitter (Loop 1000A) comes from the clearhouse config. The
|
||||
# receiver (NM1*40) and SBR09 come from the per-payer config and
|
||||
# are resolved per-claim below. Without this wiring, the
|
||||
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
|
||||
# the file would be rejected by HCPF.
|
||||
submitter_kwargs = {
|
||||
"sender_id": ch.tpid,
|
||||
"submitter_name": ch.submitter_name,
|
||||
"submitter_contact_name": ch.submitter_contact_name,
|
||||
"submitter_contact_email": ch.submitter_contact_email,
|
||||
}
|
||||
if getattr(ch, "submitter_contact_phone", None):
|
||||
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
||||
|
||||
# Build a payer_id → PayerConfig837 map once so we can look up the
|
||||
# receiver + SBR09 default for each claim.
|
||||
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
||||
|
||||
def _resolve_payer_cfg(claim_obj) -> dict | None:
|
||||
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
||||
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
||||
with db.SessionLocal()() as ss:
|
||||
if pid:
|
||||
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
||||
if row is not None:
|
||||
return dict(row.config_json)
|
||||
if pname:
|
||||
rows = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
cj = dict(r.config_json)
|
||||
if (r.payer_id or "").upper() == pname.upper():
|
||||
return cj
|
||||
if pname.lower() in str(cj).lower():
|
||||
return cj
|
||||
row = (
|
||||
ss.query(_PayerConfigORM)
|
||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||
.first()
|
||||
)
|
||||
return dict(row.config_json) if row else None
|
||||
|
||||
client = make_client(ch.sftp_block)
|
||||
results = []
|
||||
for cid in claim_ids:
|
||||
@@ -2329,6 +2593,34 @@ def submit_to_clearhouse(body: dict):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
||||
continue
|
||||
# Re-resolve the claim so we can look up its payer config. We
|
||||
# re-parse the stored x12_text to get a ClaimOutput (same path
|
||||
# the serializer uses).
|
||||
try:
|
||||
from cyclone.parsers.parse_837 import parse as _parse837
|
||||
claim_row_obj = _load_claim_row(cid)
|
||||
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
|
||||
raise RuntimeError("no stored x12_text for claim")
|
||||
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
|
||||
claim_obj = parsed.claims[0] if parsed.claims else None
|
||||
except Exception:
|
||||
claim_obj = None
|
||||
if claim_obj is not None:
|
||||
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
||||
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
|
||||
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
|
||||
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
||||
# Re-serialize with the proper envelope values.
|
||||
try:
|
||||
x12_text = _serialize_claim_for_submit(
|
||||
cid,
|
||||
**{**submitter_kwargs, "receiver_id": receiver_id,
|
||||
"receiver_name": receiver_name,
|
||||
"claim_filing_indicator_code": sbr09},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
|
||||
continue
|
||||
filename = build_outbound_filename(ch.tpid, "837P")
|
||||
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
|
||||
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
|
||||
@@ -2357,9 +2649,20 @@ def submit_to_clearhouse(body: dict):
|
||||
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
|
||||
|
||||
|
||||
def _serialize_claim_for_submit(claim_id: str) -> str:
|
||||
def _load_claim_row(claim_id: str):
|
||||
"""Helper: load a Claim row by id (or return None)."""
|
||||
with db.SessionLocal()() as s:
|
||||
return s.get(Claim, claim_id)
|
||||
|
||||
|
||||
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
|
||||
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
|
||||
serializer to avoid pulling FastAPI machinery at module import time."""
|
||||
serializer to avoid pulling FastAPI machinery at module import time.
|
||||
|
||||
Optional ``**kwargs`` are forwarded to the serializer — used to
|
||||
pass through clearhouse submitter info and per-payer receiver info
|
||||
so the regenerated file matches what the HCPF MFT expects.
|
||||
"""
|
||||
from cyclone.parsers.serialize_837 import serialize_837
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
@@ -2371,15 +2674,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
raw = row.raw_json or {}
|
||||
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
|
||||
return _serialize_claim_from_raw(row, raw)
|
||||
return _serialize_claim_from_raw(row, raw, **kwargs)
|
||||
|
||||
|
||||
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
||||
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
|
||||
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
|
||||
|
||||
For SP9 this delegates to the existing serialize_837 helper if the
|
||||
claim has a complete raw_segments array. Otherwise it returns a
|
||||
minimal placeholder.
|
||||
minimal placeholder. ``**kwargs`` are forwarded to the serializer
|
||||
so callers can pass through submitter / receiver / SBR09 values
|
||||
from the clearhouse and per-payer configs.
|
||||
"""
|
||||
from cyclone.parsers.serialize_837 import serialize_837
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
@@ -2389,7 +2694,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
|
||||
if isinstance(raw, dict) and raw.get("x12_text"):
|
||||
result = parse(raw["x12_text"])
|
||||
if result.claims:
|
||||
return serialize_837(result.claims[0])
|
||||
return serialize_837(result.claims[0], **kwargs)
|
||||
# Fallback: raise so the caller sees an error.
|
||||
raise RuntimeError(
|
||||
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
|
||||
|
||||
@@ -93,19 +93,40 @@ def ndjson_stream_list(
|
||||
}) + "\n"
|
||||
|
||||
|
||||
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
||||
def ndjson_stream_837(
|
||||
result: ParseResult, batch_id: str | None = None,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → claims → summary.
|
||||
|
||||
The ``batch_id`` is the server-side UUID assigned by the persistence
|
||||
layer when the batch is ingested; the JSON response path exposes it
|
||||
as the top-level ``batch_id`` field, but the NDJSON stream needs it
|
||||
inline on the summary event so streaming clients can call
|
||||
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
|
||||
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
|
||||
supplied the summary omits the field, preserving backward compat
|
||||
with clients that don't expect it.
|
||||
"""
|
||||
envelope_obj = (
|
||||
result.envelope.model_dump() if result.envelope is not None else None
|
||||
)
|
||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||
for claim in result.claims:
|
||||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||
summary_data = json.loads(result.summary.model_dump_json())
|
||||
if batch_id is not None:
|
||||
summary_data["batch_id"] = batch_id
|
||||
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
||||
def ndjson_stream_835(
|
||||
result: ParseResult835, batch_id: str | None = None,
|
||||
) -> Iterator[bytes]:
|
||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
|
||||
|
||||
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
|
||||
merged into the summary event.
|
||||
"""
|
||||
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
||||
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
||||
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
||||
for claim in result.claims:
|
||||
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||
summary_data = json.loads(result.summary.model_dump_json())
|
||||
if batch_id is not None:
|
||||
summary_data["batch_id"] = batch_id
|
||||
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
||||
|
||||
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
|
||||
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
||||
|
||||
Outbound (we send):
|
||||
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||
Example: 11525703-837P-20260620132243505-1of1.x12
|
||||
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||
Example: tp11525703-837P-20260620132243505-1of1.x12
|
||||
|
||||
Inbound (HPE sends to our ToHPE):
|
||||
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
||||
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
|
||||
# Regexes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Outbound: 11525703-837P-20260620132243505-1of1.x12
|
||||
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
|
||||
# - tp: literal "tp" prefix
|
||||
# - tpid: 1+ digits
|
||||
# - tx: 1+ alnum
|
||||
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
||||
# - seq: literal "1of1"
|
||||
# - ext: 1+ alnum
|
||||
OUTBOUND_RE = re.compile(
|
||||
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
||||
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
||||
)
|
||||
|
||||
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
|
||||
@@ -79,7 +80,8 @@ def build_outbound_filename(
|
||||
time in ``America/Denver`` is used.
|
||||
|
||||
Returns:
|
||||
Filename like "11525703-837P-20260620132243505-1of1.x12"
|
||||
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
|
||||
(note the ``tp`` prefix per HCPF outbound spec).
|
||||
|
||||
Raises:
|
||||
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
||||
@@ -99,7 +101,10 @@ def build_outbound_filename(
|
||||
# Format: yyyymmddhhmmssSSS — 17 digits total
|
||||
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
||||
assert len(ts) == 17
|
||||
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
|
||||
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
|
||||
# we receive from HPE inbound (which uses uppercase TP) and the
|
||||
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
|
||||
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
|
||||
frequency_code: str | None = None
|
||||
provider_signature: str | None = None
|
||||
assignment: str | None = None
|
||||
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
|
||||
release_of_info: str | None = None
|
||||
prior_auth: str | None = None
|
||||
|
||||
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
|
||||
place_of_service: str | None = None
|
||||
service_date: date | None = None
|
||||
provider_reference: str | None = None
|
||||
# SV1-07 — Diagnosis Code Pointer. Points to one or more
|
||||
# diagnosis codes in the parent claim's HI segment ("1".. "12",
|
||||
# space-separated when multiple). For 837P with a non-empty HI
|
||||
# segment, SV1-07 is required by HCPF / Gainwell. The parser
|
||||
# captures it from the source; the serializer defaults to "1"
|
||||
# when the claim has at least one diagnosis and no explicit
|
||||
# pointer was captured (matches the common single-dx case).
|
||||
dx_pointer: str | None = None
|
||||
|
||||
|
||||
class ValidationIssue(_Base):
|
||||
|
||||
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
||||
frequency_code=freq or None,
|
||||
provider_signature=clm[6] if len(clm) > 6 else None,
|
||||
assignment=clm[7] if len(clm) > 7 else None,
|
||||
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
|
||||
release_of_info=clm[9] if len(clm) > 9 else None,
|
||||
)
|
||||
|
||||
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
||||
except Exception:
|
||||
units = None
|
||||
place_of_service = seg[5] if len(seg) > 5 else None
|
||||
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
|
||||
# already use unit_type in SV1-03; SV1-06 is rarely populated and
|
||||
# is not required by HCPF.
|
||||
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
|
||||
# diagnosis). Required by HCPF when the claim has diagnoses.
|
||||
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
|
||||
service_date: date | None = None
|
||||
provider_ref: str | None = None
|
||||
|
||||
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
||||
place_of_service=place_of_service,
|
||||
service_date=service_date,
|
||||
provider_reference=provider_ref,
|
||||
dx_pointer=dx_pointer,
|
||||
),
|
||||
idx,
|
||||
)
|
||||
|
||||
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
|
||||
_FUNCTIONAL_ID_HEALTH_CARE,
|
||||
sender_id,
|
||||
receiver_id,
|
||||
_today_yymmdd(),
|
||||
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
|
||||
# (6 digits) for the older format, but the GS segment is the
|
||||
# newer ANSI X12 format and requires the full year.
|
||||
_today_yyyymmdd(),
|
||||
_today_hhmm(),
|
||||
group_control_number,
|
||||
"X",
|
||||
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
|
||||
"""PER segment — submitter contact. Returns empty when no contact info."""
|
||||
if not contact_name and not contact_phone:
|
||||
return ""
|
||||
parts = [
|
||||
"PER",
|
||||
"IC", # PER01 — contact function code (Information Contact)
|
||||
contact_name or "",
|
||||
"TE", # PER03 — phone qualifier
|
||||
contact_phone or "",
|
||||
]
|
||||
def _build_per(
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None,
|
||||
contact_email: str | None = None,
|
||||
email_qual: str = "EM",
|
||||
) -> str:
|
||||
"""PER segment — submitter contact (Loop 1000A).
|
||||
|
||||
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
|
||||
(Submitter Name) and at least PER01 must be present, so this
|
||||
builder always emits a segment. PER01 = "IC" (Information Contact).
|
||||
|
||||
The remaining elements are filled from the available contact info:
|
||||
name, then email (preferred — Gainwell/HCPF expect this), then phone.
|
||||
"""
|
||||
parts = ["PER", "IC"]
|
||||
if contact_name:
|
||||
parts.append(contact_name)
|
||||
if contact_email:
|
||||
parts.append(email_qual) # PER03 — email qualifier (default "EM")
|
||||
parts.append(contact_email) # PER04 — the email itself
|
||||
elif contact_phone:
|
||||
parts.append("TE") # PER03 — phone qualifier
|
||||
parts.append(contact_phone) # PER04 — the phone itself
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
|
||||
def _build_sbr(relationship_code: str | None, member_id: str | None,
|
||||
payer_name: str | None) -> str:
|
||||
def _build_sbr(
|
||||
individual_relationship_code: str | None,
|
||||
claim_filing_indicator_code: str | None,
|
||||
) -> str:
|
||||
"""SBR segment — subscriber information.
|
||||
|
||||
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
|
||||
the most common case for professional claims; the parser does not store
|
||||
this on the canonical Subscriber model so we cannot thread it through
|
||||
without adding a model field.
|
||||
Slot layout (X12 005010X222A1):
|
||||
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
|
||||
(Patient = primary). The parser does not capture this
|
||||
field on ``ClaimOutput`` so we default it.
|
||||
SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
|
||||
The parser does not capture this either; we default ``"18"``
|
||||
for the common self-pay case.
|
||||
SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
|
||||
``"16"`` for Medicare Part B, etc. The canonical
|
||||
PayerConfig837 carries ``sbr09_default``; we thread it in
|
||||
from the caller.
|
||||
|
||||
The member_id and payer name do NOT belong in SBR — the member_id
|
||||
lives in NM109 of the NM1*IL segment, and the payer name is in
|
||||
NM103 of NM1*PR. (Earlier revisions of this function put them in
|
||||
SBR06 / SBR09, which is wrong and rejected by HCPF.)
|
||||
"""
|
||||
parts = [
|
||||
"SBR",
|
||||
relationship_code or "P",
|
||||
"", # SBR02 — group number
|
||||
"", # SBR03 — group name
|
||||
"", # SBR04 — claim filing indicator code
|
||||
"", # SBR05 — sequence number code
|
||||
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
|
||||
"", # SBR07
|
||||
"", # SBR08
|
||||
member_id or "", # SBR09 — claim submitter's id
|
||||
"P", # SBR01 — primary
|
||||
individual_relationship_code or "18", # SBR02 — self
|
||||
"", # SBR03 — group number
|
||||
"", # SBR04 — group name
|
||||
"", # SBR05 — insurance type code
|
||||
"", # SBR06 — coordination of benefits
|
||||
"", # SBR07 — yes/no condition
|
||||
"", # SBR08 — employment status code
|
||||
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
|
||||
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
||||
claim.provider_signature or "Y", # CLM06
|
||||
claim.assignment or "Y", # CLM07
|
||||
"", # CLM08 — benefit assignment certification
|
||||
# CLM08 — Benefits Assignment Certification. X12 837P requires
|
||||
# this when CLM07 = "Y" (the common case for in-network
|
||||
# professional claims). Default to "Y" when the source did
|
||||
# not capture one — matches what 99% of HCPF files look like.
|
||||
claim.benefits_assignment_certification or "Y", # CLM08
|
||||
claim.release_of_info or "Y", # CLM09
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
|
||||
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
||||
|
||||
|
||||
def _build_sv1(line) -> str:
|
||||
"""SV1 segment — professional service line."""
|
||||
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
|
||||
"""SV1 segment — professional service line.
|
||||
|
||||
X12 005010X222A1 layout (837P):
|
||||
SV1-01 composite procedure identifier
|
||||
SV1-02 monetary amount (charge)
|
||||
SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
|
||||
SV1-04 service unit count — ``line.units``
|
||||
SV1-05 place of service code — ``line.place_of_service``
|
||||
SV1-06 **NOT USED** by this guide (must be empty)
|
||||
SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
|
||||
parent claim has an HI segment)
|
||||
|
||||
The parser captures the original SV1-07 pointer when present; the
|
||||
serializer defaults it to ``"1"`` (pointing at the first HI
|
||||
diagnosis) when the claim has diagnoses and no explicit pointer
|
||||
was captured. When the claim has no HI segment we leave SV1-07
|
||||
empty to match the spec.
|
||||
"""
|
||||
proc = line.procedure
|
||||
code = proc.code if proc else ""
|
||||
mods = proc.modifiers if proc else []
|
||||
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
||||
charge = f"{Decimal(line.charge or 0):.2f}"
|
||||
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
||||
sv1_07 = dx_pointer or ""
|
||||
parts = [
|
||||
"SV1",
|
||||
composite,
|
||||
charge,
|
||||
line.unit_type or "UN",
|
||||
units,
|
||||
line.place_of_service or "",
|
||||
composite, # SV1-01
|
||||
charge, # SV1-02
|
||||
line.unit_type or "UN", # SV1-03 — unit basis code
|
||||
units, # SV1-04
|
||||
line.place_of_service or "", # SV1-05
|
||||
"", # SV1-06 — NOT USED in 837P
|
||||
sv1_07, # SV1-07 — diagnosis pointer
|
||||
]
|
||||
return _ELEM.join(parts) + _SEG
|
||||
|
||||
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_submitter_block(sender_id: str, submitter_name: str | None,
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None) -> list[str]:
|
||||
def _build_submitter_block(
|
||||
sender_id: str,
|
||||
submitter_name: str | None,
|
||||
contact_name: str | None,
|
||||
contact_phone: str | None,
|
||||
contact_email: str | None = None,
|
||||
email_qual: str = "EM",
|
||||
) -> list[str]:
|
||||
out = [
|
||||
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
||||
]
|
||||
per = _build_per(contact_name, contact_phone)
|
||||
if per:
|
||||
out.append(per)
|
||||
# PER is required by X12 (at least PER01). _build_per always emits
|
||||
# the segment; the submitter block always has exactly one.
|
||||
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
|
||||
return out
|
||||
|
||||
|
||||
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
|
||||
def _build_subscriber_block(
|
||||
subscriber,
|
||||
claim_filing_indicator_code: str | None,
|
||||
) -> list[str]:
|
||||
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||
out = [
|
||||
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
||||
_build_sbr("18", subscriber.member_id, payer_name),
|
||||
_build_sbr("18", claim_filing_indicator_code),
|
||||
_build_nm1(
|
||||
"IL", "IL",
|
||||
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
||||
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _build_service_lines_block(service_lines) -> list[str]:
|
||||
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
|
||||
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
|
||||
"""Per line: LX / SV1 / DTP*472 / REF*6R.
|
||||
|
||||
``has_diagnoses`` is True when the parent claim emits an HI segment;
|
||||
in that case SV1-07 is required by X12 and we default each line's
|
||||
pointer to ``"1"`` (the first HI diagnosis) unless the source
|
||||
captured a different pointer on the line itself.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for idx, line in enumerate(service_lines or [], start=1):
|
||||
out.append(_build_lx(idx))
|
||||
out.append(_build_sv1(line))
|
||||
# Prefer the line's captured pointer (parser pulled SV1-07
|
||||
# when present). Fall back to "1" only when the claim has
|
||||
# diagnoses and the source had no explicit pointer — the
|
||||
# common single-diagnosis case.
|
||||
line_pointer = getattr(line, "dx_pointer", None)
|
||||
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
|
||||
out.append(_build_sv1(line, dx_pointer=effective_pointer))
|
||||
dtp = _build_dtp_472(line.service_date)
|
||||
if dtp:
|
||||
out.append(dtp)
|
||||
@@ -450,7 +524,10 @@ def serialize_837(
|
||||
submitter_name: str | None = None,
|
||||
submitter_contact_name: str | None = None,
|
||||
submitter_contact_phone: str | None = None,
|
||||
submitter_contact_email: str | None = None,
|
||||
submitter_contact_email_qual: str = "EM",
|
||||
receiver_name: str | None = None,
|
||||
claim_filing_indicator_code: str | None = None,
|
||||
interchange_control_number: str = "000000001",
|
||||
group_control_number: str = "1",
|
||||
) -> str:
|
||||
@@ -462,6 +539,16 @@ def serialize_837(
|
||||
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
||||
the configured values.
|
||||
|
||||
The submitter block (Loop 1000A) always emits a PER segment per the
|
||||
X12 spec — the canonical clearhouse config provides the contact
|
||||
name and email so callers should pass them through.
|
||||
|
||||
The claim filing indicator (SBR09) is read from the per-payer
|
||||
config (``PayerConfig837.sbr09_default``); callers should pass it
|
||||
in. If not passed, SBR09 is left empty (which causes the
|
||||
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
|
||||
its check — degraded but not a hard error).
|
||||
|
||||
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
||||
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
||||
edits propagate to the output.
|
||||
@@ -481,11 +568,13 @@ def serialize_837(
|
||||
),
|
||||
]
|
||||
segments.extend(_build_submitter_block(
|
||||
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
|
||||
sender_id, submitter_name,
|
||||
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
|
||||
submitter_contact_email_qual,
|
||||
))
|
||||
segments.extend(_build_receiver_block(receiver_id, receiver_name))
|
||||
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
||||
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
|
||||
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
|
||||
segments.extend(_build_payer_block(claim.payer))
|
||||
|
||||
# Claim-level editable segments.
|
||||
@@ -497,7 +586,10 @@ def serialize_837(
|
||||
segments.append(_build_hi(claim.diagnoses))
|
||||
|
||||
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
||||
segments.extend(_build_service_lines_block(claim.service_lines))
|
||||
segments.extend(_build_service_lines_block(
|
||||
claim.service_lines,
|
||||
has_diagnoses=bool(claim.diagnoses),
|
||||
))
|
||||
|
||||
# SE segment count includes ST (line 3, 1-based) through SE itself
|
||||
# — i.e. the entire ST..SE block inclusive.
|
||||
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
|
||||
claim: ClaimOutput,
|
||||
*,
|
||||
interchange_index: int,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||||
interchange + group control numbers for a bundle position.
|
||||
|
||||
Interchange number = ``f"{interchange_index:09d}"``.
|
||||
Group number = ``str(interchange_index)``.
|
||||
|
||||
All other keyword arguments (sender_id, receiver_id, submitter_*
|
||||
and receiver_* contact info, claim_filing_indicator_code) are
|
||||
forwarded to :func:`serialize_837` unchanged so callers — like the
|
||||
export and SFTP-submit endpoints — can pass through clearhouse +
|
||||
payer config without copying the signature.
|
||||
"""
|
||||
return serialize_837(
|
||||
claim,
|
||||
interchange_control_number=f"{interchange_index:09d}",
|
||||
group_control_number=str(interchange_index),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -2277,7 +2277,7 @@ class CycloneStore:
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
filename_block={
|
||||
"tz": "America/Denver",
|
||||
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
||||
},
|
||||
sftp_block={
|
||||
|
||||
Reference in New Issue
Block a user