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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
345
scripts/workers/services/bdc_filing.py
Normal file
345
scripts/workers/services/bdc_filing.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue