new-site/scripts/workers/services/bdc_filing.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

345 lines
14 KiB
Python

"""FCC Broadband Data Collection (BDC) / Form 477 filing handler.
BDC submissions are due June 1 (Dec-31 snapshot) and December 1 (Jun-30
snapshot). Voice-only carriers still use the legacy Form 477 voice
subscription data template.
Flow:
1. Produce a summary packet from the carrier's availability data (pulled
from the order's ``intake_data``). We reuse ``BaseServiceHandler._fill_template``
with a lightweight DOCX template if present; otherwise we emit a
plain text summary (the packet is informational — the portal
submission is the real filing).
2. Launch undetected browser, log into the BDC portal at
https://bdc.fcc.gov/, navigate to the active filing window, upload
the availability CSV (from intake_data or a computed skeleton), and
submit.
3. Capture confirmation and persist.
Idempotency: if the carrier already has a BDC filing within the last 180
days, skip the portal submission.
"""
from __future__ import annotations
import csv
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay
logger = logging.getLogger(__name__)
BDC_URL = os.environ.get("FCC_BDC_URL", "https://bdc.fcc.gov/")
BDC_STORAGE_STATE = os.environ.get(
"FCC_BDC_STORAGE_STATE", "/app/data/fcc_bdc_session.json"
)
class BDCFilingHandler(BaseServiceHandler):
"""Unified BDC filing handler.
Drives all three catalog slugs:
* ``bdc-filing`` — both broadband deployment + voice subscription
* ``bdc-broadband`` — broadband deployment only
* ``bdc-voice`` — voice subscription only (formerly Form 477 Voice)
Mode is derived from ``intake_data.bdc_mode`` first (explicit override),
otherwise from the service slug on the order. The portal submission
only fills the blocks that match the mode; skipped blocks aren't
overwritten on the BDC portal side.
"""
SERVICE_SLUG = "bdc-filing"
SERVICE_NAME = "BDC Filing (Broadband + Voice)"
REQUIRES_LLM = False
# Slug → default mode lookup.
_SLUG_TO_MODE = {
"bdc-filing": "both",
"bdc-broadband": "broadband",
"bdc-voice": "voice",
}
def _resolve_mode(self, order_data: dict) -> str:
"""broadband | voice | both — explicit intake override wins."""
explicit = (order_data.get("intake_data") or {}).get("bdc_mode")
if explicit in ("broadband", "voice", "both"):
return explicit
slug = (order_data.get("service_slug")
or order_data.get("custom_order_type")
or self.SERVICE_SLUG)
return self._SLUG_TO_MODE.get(slug, "both")
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
intake = order_data.get("intake_data") or {}
mode = self._resolve_mode(order_data)
logger.info("BDCFilingHandler: order %s mode=%s", order_number, mode)
generated: list[str] = []
# ── 1. Build availability CSV from intake_data (broadband only) ──
csv_path = None
if mode in ("broadband", "both"):
csv_path = self._build_availability_csv(
order_number, entity, intake, work_dir
)
if csv_path:
generated.append(csv_path)
# Summary text for the customer (always — it's the deliverable).
summary_path = self._build_summary_text(
order_number, entity, intake, work_dir, mode=mode,
)
if summary_path:
generated.append(summary_path)
# ── 2. Idempotency ──────────────────────────────────────────────
if entity_id and filing_state.already_filed(entity_id, "bdc"):
logger.info(
"BDCFilingHandler: BDC already filed for entity %s within 180 days",
entity_id,
)
return generated
# ── 2a. Auto-filing toggle ──────────────────────────────────────
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"BDCFilingHandler: %s — staging for admin review (order=%s)",
decision.reason, order_number,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=f"BDC availability CSV ready. Submit via {BDC_URL}.",
)
return generated
# ── 3. Portal submission ────────────────────────────────────────
confirmation_path, confirmation_number = await self._submit_to_bdc(
order_number=order_number,
entity=entity,
csv_path=csv_path,
work_dir=work_dir,
mode=mode,
intake=intake,
)
if confirmation_path:
generated.append(confirmation_path)
if entity_id and confirmation_number:
filing_state.record_bdc_filing(entity_id, confirmation_number)
return generated
# ------------------------------------------------------------------ #
# CSV / summary
# ------------------------------------------------------------------ #
def _build_availability_csv(
self, order_number: str, entity: dict, intake: dict, work_dir: str
) -> str | None:
rows = intake.get("availability_rows") or []
if not rows:
logger.warning(
"BDCFilingHandler: no availability_rows in intake_data for %s"
"emitting empty template",
order_number,
)
rows = []
csv_path = os.path.join(
work_dir, f"bdc_availability_{order_number}.csv"
)
fieldnames = [
"frn",
"provider_id",
"brand_name",
"location_id",
"technology",
"max_advertised_download_speed",
"max_advertised_upload_speed",
"low_latency",
"business_residential_code",
]
with open(csv_path, "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
out: dict = {k: row.get(k, "") for k in fieldnames}
out.setdefault("frn", entity.get("frn", ""))
out.setdefault("brand_name", entity.get("dba_name") or entity.get("legal_name", ""))
writer.writerow(out)
return csv_path
def _build_summary_text(
self, order_number: str, entity: dict, intake: dict, work_dir: str,
*, mode: str = "both",
) -> str | None:
txt_path = os.path.join(work_dir, f"bdc_summary_{order_number}.txt")
mode_label = {
"broadband": "Broadband deployment only",
"voice": "Voice subscription only (formerly Form 477 Voice)",
"both": "Broadband deployment + Voice subscription",
}.get(mode, mode)
lines = [
f"FCC BDC Filing Summary",
f"Order: {order_number}",
f"Carrier: {entity.get('legal_name', '')}",
f"FRN: {entity.get('frn', '')}",
f"Scope: {mode_label}",
f"Filing Date: {datetime.now().strftime('%Y-%m-%d')}",
"",
]
if mode in ("broadband", "both"):
lines.append(f"Availability rows submitted: {len(intake.get('availability_rows') or [])}")
if mode in ("voice", "both"):
lines.append(f"Voice subscribers (formerly Form 477): {intake.get('voice_subscribers', 0)}")
lines.append(f"Filing window snapshot: {intake.get('snapshot_date', 'unspecified')}")
with open(txt_path, "w") as fh:
fh.write("\n".join(lines) + "\n")
return txt_path
# ------------------------------------------------------------------ #
# BDC portal submission
# ------------------------------------------------------------------ #
async def _submit_to_bdc(
self,
*,
order_number: str,
entity: dict,
csv_path: str | None,
work_dir: str,
mode: str = "both",
intake: dict | None = None,
) -> tuple[str | None, str]:
intake = intake or {}
frn = entity.get("frn", "").strip()
needs_csv = mode in ("broadband", "both")
if not frn:
self._create_admin_todo(
order_number,
"BDC filing requires an FRN. Missing prerequisite — "
"order a CORES/FRN registration first or capture the FRN on the entity.",
)
return None, ""
if needs_csv and not csv_path:
self._create_admin_todo(
order_number,
f"BDC {mode} filing requires an availability CSV but none was generated. "
"Review the carrier's intake_data.availability_rows.",
)
return None, ""
storage_state = BDC_STORAGE_STATE if os.path.exists(BDC_STORAGE_STATE) else None
confirmation_path = os.path.join(
work_dir, f"bdc_confirmation_{order_number}.pdf"
)
confirmation_number = ""
try:
async with undetected_browser(
headless=True, storage_state=storage_state,
) as (ctx, page):
await page.goto(BDC_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
if "login" in page.url.lower():
self._create_admin_todo(
order_number,
f"BDC portal required login for FRN {frn}. Run the FCC Access "
f"Helper to authorize our account, export session to "
f"{BDC_STORAGE_STATE}, then re-dispatch {order_number}.",
)
return None, ""
# Start a new filing for the active window.
await page.click("text=File Data")
await human_delay()
# Select the active filing window (most recent).
await page.click("text=Current Filing Window")
await human_delay()
# Broadband deployment block — availability CSV.
if mode in ("broadband", "both") and csv_path:
await page.set_input_files(
'input[type="file"][name="availability_data"]',
csv_path,
)
await human_delay(2.0, 4.0)
# Voice subscriber block (formerly Form 477 Voice). Intake
# provides voice_subscribers as an integer; we only fill when
# the mode includes voice.
if mode in ("voice", "both"):
voice_subs = int(
intake.get("voice_subscribers")
or entity.get("voice_subscribers")
or 0
)
if voice_subs:
await page.fill('input[name="voice_subscribers"]', str(voice_subs))
# Certify + submit.
await page.check('input[name="officer_certification"]')
await page.click('button:has-text("Submit Filing")')
await page.wait_for_selector("text=Confirmation", timeout=120000)
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Confirmation" in line:
parts = line.split(":", 1)
if len(parts) == 2 and parts[1].strip():
confirmation_number = parts[1].strip()
break
await page.pdf(path=confirmation_path, format="Letter")
logger.info(
"BDCFilingHandler: filed FRN %s, confirmation %s",
frn, confirmation_number,
)
return confirmation_path, confirmation_number
except Exception as exc:
logger.exception("BDCFilingHandler: portal submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"BDC submission failed for FRN {frn}: {exc}. Packet is in MinIO; "
"file manually at https://bdc.fcc.gov/.",
)
return None, ""
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)