new-site/scripts/workers/icc_adapters/iconectiv_8yy_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

193 lines
7.4 KiB
Python

"""iconectiv 8YY Query Report adapter.
Parses the monthly 8YY query-counts-and-revenue report produced by
iconectiv (formerly Ericsson / Somos toll-free number administration).
The report drives the database-query component of 8YY originating access
charges — each query to the 8YY routing database is billable at a
tariff-specified per-query rate.
Supported layouts
-----------------
* **XML** (default, `.xml`): ``<QueryReport>`` root with a single
``<Period year="YYYY" month="MM"/>`` followed by N ``<Query>`` elements,
each containing ``<OCN>``, ``<CarrierName>`` (optional), ``<Queries>``
(integer count), and ``<Revenue>`` (decimal USD).
* **QRY pipe-delimited** (`.qry`): one record per line, fields separated
by ``|``: ``OCN|QUERIES|REVENUE|PERIOD`` where PERIOD is ``YYYYMM``.
Header row ``OCN|QUERIES|REVENUE|PERIOD`` is tolerated and skipped.
Output
------
Every record yields ``icc_category='8yy_orig_access'``,
``minutes_of_use=None`` (8YY database queries are discrete events, not
duration), ``counterparty_country='US'``. Reporting quarter is derived
from the record's period month.
Deferred
--------
* XML schema validation / multiple ``<Period>`` blocks per file
* Multi-currency revenue (assumes USD)
* Query-type breakdown (POTS Dip vs LRN Dip) — treated as one category
"""
from __future__ import annotations
import csv
import logging
import os
from typing import Iterator
from .common import BaseICCAdapter, IccRevenueLine, ValidationError
logger = logging.getLogger(__name__)
try:
from lxml import etree as _etree # pragma: no cover
_USE_LXML = True
except ImportError:
import xml.etree.ElementTree as _etree
_USE_LXML = False
class IconectivQRYAdapter(BaseICCAdapter):
SOURCE_FORMAT = "8yy_qry"
@staticmethod
def _month_to_quarter(month: int) -> int | 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
def iter_rows(self, local_path: str) -> Iterator[IccRevenueLine]:
ext = os.path.splitext(local_path)[1].lower()
if ext in (".xml",):
yield from self._iter_xml(local_path)
return
# Sniff by content when extension is unknown
with open(local_path, "rb") as fh:
head = fh.read(256).lstrip()
if head.startswith(b"<"):
yield from self._iter_xml(local_path)
else:
yield from self._iter_pipe(local_path)
# ------------------------------------------------------------------
# XML layout
# ------------------------------------------------------------------
def _iter_xml(self, local_path: str) -> Iterator[IccRevenueLine]:
try:
tree = _etree.parse(local_path)
except Exception as exc:
raise ValidationError("bad_xml", f"could not parse XML: {exc}") from exc
root = tree.getroot()
period_el = root.find(".//Period")
period_year = self.reporting_year
period_month: int | None = None
if period_el is not None:
try:
period_year = int(period_el.get("year") or self.reporting_year)
except ValueError:
period_year = self.reporting_year
try:
pm = period_el.get("month")
period_month = int(pm) if pm else None
except ValueError:
period_month = None
quarter = self._month_to_quarter(period_month) if period_month else None
lineno = 0
for q in root.iter("Query"):
lineno += 1
ocn_el = q.find("OCN")
rev_el = q.find("Revenue")
queries_el = q.find("Queries")
name_el = q.find("CarrierName")
ocn = (ocn_el.text or "").strip() if ocn_el is not None else ""
revenue_text = (rev_el.text or "").strip() if rev_el is not None else ""
queries_text = (queries_el.text or "").strip() if queries_el is not None else ""
name = (name_el.text or "").strip() if name_el is not None else ""
if not ocn and not name:
raise ValidationError("missing_ocn", f"Query row {lineno} has no OCN or CarrierName")
try:
revenue_cents = self.parse_cents(revenue_text)
query_count = self.parse_int(queries_text) if queries_text else None
except ValidationError:
raise
yield IccRevenueLine(
profile_id=self.profile_id,
reporting_year=period_year,
reporting_quarter=quarter,
icc_category="8yy_orig_access",
counterparty_legal_name=name or ocn,
counterparty_ocn=ocn or None,
counterparty_country="US",
revenue_cents=revenue_cents,
minutes_of_use=None,
source_line_no=lineno,
raw_row={
"ocn": ocn,
"name": name,
"queries": query_count,
"revenue_raw": revenue_text,
"period_year": period_year,
"period_month": period_month,
},
)
# ------------------------------------------------------------------
# Pipe-delimited layout
# ------------------------------------------------------------------
def _iter_pipe(self, local_path: str) -> Iterator[IccRevenueLine]:
with open(local_path, "r", encoding="utf-8", errors="replace", newline="") as fh:
reader = csv.reader(fh, delimiter="|")
for i, row in enumerate(reader, start=1):
if not row or not any(cell.strip() for cell in row):
continue
# Skip the header row
if i == 1 and row[0].strip().upper() == "OCN":
continue
if len(row) < 4:
raise ValidationError(
"bad_row",
f"8yy QRY row {i} has {len(row)} fields, expected 4",
)
ocn = row[0].strip()
queries_raw = row[1].strip()
revenue_raw = row[2].strip()
period_raw = row[3].strip()
period_year = self.reporting_year
period_month: int | None = None
if len(period_raw) >= 6:
try:
period_year = int(period_raw[:4])
period_month = int(period_raw[4:6])
except ValueError:
pass
quarter = self._month_to_quarter(period_month) if period_month else None
yield IccRevenueLine(
profile_id=self.profile_id,
reporting_year=period_year,
reporting_quarter=quarter,
icc_category="8yy_orig_access",
counterparty_legal_name=ocn,
counterparty_ocn=ocn or None,
counterparty_country="US",
revenue_cents=self.parse_cents(revenue_raw),
minutes_of_use=None,
source_line_no=i,
raw_row={
"ocn": ocn,
"queries_raw": queries_raw,
"revenue_raw": revenue_raw,
"period_raw": period_raw,
},
)