"""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, }, )