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