new-site/scripts/workers/icc_adapters/cabs_bos_adapter.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

179 lines
6.9 KiB
Python

"""CABS BOS (Billing Output Specification) adapter.
Parses the fixed-width record-oriented CABS BOS format used by ILECs and
interexchange carriers to bill switched- and special-access revenue to
interconnecting carriers. The CABS BOS (Telcordia / Bellcore BR-902-900-
000) carries many record types; this adapter is a **best-effort**
interpreter of the most common ones.
Supported record types
----------------------
``01`` Header — billing company + bill period (context only)
``10`` Switched-Access Revenue detail
positions 5..14 OCN of interconnecting carrier (10 chars)
positions 15..20 Billing period YYMMDD (period-end)
positions 50..59 Minutes of use (right-justified, 10 digits)
positions 60..69 Revenue, implied 2 decimals (10 digits)
``20`` Special-Access Revenue detail
positions 5..14 OCN
positions 15..20 Billing period YYMMDD
positions 40..59 Circuit identifier (free-form)
positions 60..69 Revenue, implied 2 decimals
``90`` Trailer — record counts (context only)
Deferred / not parsed
---------------------
* USOC / rate-element detail lines (record 15, 25)
* Adjustments (record 40) and taxes (record 50)
* End-Office-vs-Tandem jurisdictional apportionment on record 10 —
handled downstream by ``icc_499a_line_mapping.jurisdiction_split``
* Multi-line circuit detail continuation records (11, 21)
Real CABS BOS files have many carrier-specific variants; downstream jobs
that need rate-element granularity should fall back to the raw PDF/EDI
or request an ASR-formatted companion file.
"""
from __future__ import annotations
import logging
from typing import Iterator
from .common import BaseICCAdapter, IccRevenueLine, ValidationError
logger = logging.getLogger(__name__)
class CABSBOSAdapter(BaseICCAdapter):
SOURCE_FORMAT = "cabs_bos"
# 1-based column slices drawn from the Telcordia BR-902-900-000 layout.
# Subclass tuples are (start, end_inclusive) in human positions; we
# convert to 0-based half-open slices via ``_slice()``.
_RECTYPE = (1, 2)
_OCN = (5, 14)
_BILL_PERIOD = (15, 20)
_MOU_R10 = (50, 59)
_REVENUE_R10 = (60, 69)
_CIRCUIT_R20 = (40, 59)
_REVENUE_R20 = (60, 69)
@staticmethod
def _slice(line: str, span: tuple[int, int]) -> str:
start, end = span
return line[start - 1:end]
@staticmethod
def _period_to_quarter(period_yymmdd: str) -> int | None:
"""Convert YYMMDD billing-period-end into a 1..4 quarter index."""
if not period_yymmdd or len(period_yymmdd) < 4:
return None
try:
month = int(period_yymmdd[2:4])
except ValueError:
return None
if 1 <= month <= 3:
return 1
if 4 <= month <= 6:
return 2
if 7 <= month <= 9:
return 3
if 10 <= month <= 12:
return 4
return None
@classmethod
def _implied_cents(cls, raw: str) -> int:
"""CABS BOS revenue fields are zero-padded 10-digit integers with
an implied 2-decimal scale, so '0000123456' → $1,234.56 → 123456 cents.
"""
cleaned = raw.strip()
if not cleaned:
return 0
# Handle explicit sign in leading position (some carriers use it)
negative = False
if cleaned[0] in "+-":
negative = cleaned[0] == "-"
cleaned = cleaned[1:]
if not cleaned.isdigit():
raise ValidationError("bad_revenue", f"CABS revenue non-numeric: {raw!r}")
cents = int(cleaned)
return -cents if negative else cents
def iter_rows(self, local_path: str) -> Iterator[IccRevenueLine]:
with open(local_path, "r", encoding="latin-1", errors="replace") as fh:
for lineno, line in enumerate(fh, start=1):
# Strip CRLF but keep trailing spaces that the fixed-width
# layout relies on.
stripped = line.rstrip("\r\n")
if len(stripped) < 4:
continue
rectype = self._slice(stripped, self._RECTYPE).strip()
try:
if rectype == "10":
yield self._parse_record_10(stripped, lineno)
elif rectype == "20":
yield self._parse_record_20(stripped, lineno)
# 01 / 90 / others → context-only, skipped
except ValidationError as ve:
logger.warning(
"CABS BOS row %d rejected (%s): %s",
lineno, ve.reason_code, ve.detail,
)
raise
def _parse_record_10(self, line: str, lineno: int) -> IccRevenueLine:
ocn = self._slice(line, self._OCN).strip() or None
period = self._slice(line, self._BILL_PERIOD).strip()
mou_raw = self._slice(line, self._MOU_R10).strip()
rev_raw = self._slice(line, self._REVENUE_R10)
try:
mou = self.parse_int(mou_raw) if mou_raw else None
except ValidationError:
mou = None
return IccRevenueLine(
profile_id=self.profile_id,
reporting_year=self.reporting_year,
reporting_quarter=self._period_to_quarter(period),
icc_category="term_switched_access",
counterparty_legal_name=ocn or "UNKNOWN",
counterparty_ocn=ocn,
counterparty_country="US",
revenue_cents=self._implied_cents(rev_raw),
minutes_of_use=mou,
source_line_no=lineno,
raw_row={
"rectype": "10",
"ocn": ocn,
"period": period,
"mou_raw": mou_raw,
"revenue_raw": rev_raw,
"line": line,
},
)
def _parse_record_20(self, line: str, lineno: int) -> IccRevenueLine:
ocn = self._slice(line, self._OCN).strip() or None
period = self._slice(line, self._BILL_PERIOD).strip()
circuit = self._slice(line, self._CIRCUIT_R20).strip()
rev_raw = self._slice(line, self._REVENUE_R20)
return IccRevenueLine(
profile_id=self.profile_id,
reporting_year=self.reporting_year,
reporting_quarter=self._period_to_quarter(period),
icc_category="special_access",
counterparty_legal_name=ocn or "UNKNOWN",
counterparty_ocn=ocn,
counterparty_country="US",
revenue_cents=self._implied_cents(rev_raw),
minutes_of_use=None, # special-access is not MOU-billed
source_line_no=lineno,
raw_row={
"rectype": "20",
"ocn": ocn,
"period": period,
"circuit": circuit,
"revenue_raw": rev_raw,
"line": line,
},
)