EN
SOP-IT-005 · v1.0
24 Jun 2026

SOP — Migración y gestión de Insurance Plans en Open Dental

Scripts Python (terminal) para extraer, copiar, sincronizar y mantener planes vía REST API · Proyecto: api open dental stedi claims

0. EL FLUJO (resumen)

        A+  ──(mantenimiento mensual)──▶  MASTER  ──(onboarding)──▶  CLIENTE NUEVO
     (oficina fuente,                  (tu OD developers,          (Casas, Hallandale,
      planes auditados)                 biblioteca curada)          Benitez, etc.)
Regla de oro: el cliente nuevo SIEMPRE sale del MASTER, nunca directo de A+.
DirecciónCuándoComando
A+ → MASTERMantenimiento (1×/mes)python push_plans.py --source 10 --dest master --update
MASTER → CLIENTEOnboarding de cliente nuevopython push_plans.py --source master --dest <cliente>

1. Qué se logró

Construimos un set de scripts que reemplaza el trabajo manual de cargar planes de seguro plan por plan. Con esto podemos:

Estado actual: master cargado desde A+ (oficina 10) — 431 planes únicos, 1014 ADA codes importados, 0 errores.

2. Conceptos clave de Open Dental

3. Componentes (scripts)

ScriptPara qué
extract_plans.pyExtrae planes de una oficina → Google Sheet (Plans / Benefits / Meta).
push_plans.pyCopia planes OD→OD. Modos: filtro, todos, plan manual, sync.
import_proc_codes.pyImporta los ADA/CDT codes al destino (para bases developer/demo vacías).
hide_plans.pyOculta planes por PlanNum (la API no permite borrarlos).
debug_benefit.pyDiagnóstico read-only de benefits que fallan.
find_plan.pyBusca un PlanNum en una oficina por carrier o grupo. Útil cuando necesitás identificar un plan rápido.

Las oficinas viven en clients/*.json (cada una con su open_dental.customer_key).

Legend de oficinas (client_id ↔ oficina)

client_idOficina
10A+ Dental of Aventura (A Plus)
hallandale-dentalHallandale Dental Care
benitez-dentalBenitez Dental Center
casas-family-dentistryDr Casas Family Dentistry
masterBrandaCare Master (nuestro Open Dental developers propio)
Nota: en todos los comandos de abajo, --source 10 = A+.

Antes de cualquier comando

cd "/Users/yamibrandan/Documents/Claude/Projects/api open dental stedi claims"
source .venv/bin/activate

4. Procedimientos

4.A — Sacar TODOS los planes de una oficina a un Sheet (backup)

python extract_plans.py --clients 10 --all --sheet-id <SHEET_ID>
  • Solo los marcados como actualizados: quitar --all (filtro default Plan Updated - BC,Plan Updated 2026 - BC).
  • Varias oficinas: --clients 10,hallandale-dental,benitez-dental.
  • El Sheet destino debe estar compartido como editor con el service account: claims-audit-bot@claims-audit-495602.iam.gserviceaccount.com

4.B — Copiar planes a otra base (master, o cliente nuevo)

Siempre probar primero con --dry-run y/o --limit:

# Ver qué haría con 3 planes (no escribe nada):
python push_plans.py --source 10 --dest master --limit 3 --dry-run

# Copiar de verdad los marcados "Plan Updated - BC":
python push_plans.py --source 10 --dest master

# Todos los planes (sin filtro):
python push_plans.py --source 10 --dest master --all

Onboardear un cliente nuevo desde el master:

IMPORTANTE — NO uses --all en onboarding: el master tiene dos tipos de planes:
  • Planes viejos developer que ya estaban en la base antes de empezar (los "8-9 ya existentes" que aparecen al principio) — NO los queremos en el cliente
  • Tus planes curados de A+ con marcador Plan Updated - BC o Plan Updated 2026 - BC en GroupName/PlanNote — estos sí van al cliente
Si pasás --all agarra los primeros por PlanNum (los viejos developer). Sin --all usa el filtro default BC y solo migra los auditados.
# 1. Probar sin escribir, confirmar que los planes listados son los BC curados:
python push_plans.py --source master --dest <cliente-nuevo> --limit 3 --dry-run

# 2. Si los 3 son los correctos, hacer la prueba real con 3:
python push_plans.py --source master --dest <cliente-nuevo> --limit 3

# 3. Si todo OK, migrar todos los BC curados:
python push_plans.py --source master --dest <cliente-nuevo>

El copiador crea solo en el destino lo que falte (carriers, employers, code groups), nunca pisa un plan existente, y deduplica (no repite el mismo carrier+grupo). Lleva un ledger en output/push_ledger_<dest>.json con qué plan de origen quedó en qué PlanNum del destino.

4.C — Importar los ADA codes (solo si el destino es una base developer/demo vacía)

Las oficinas reales ya los tienen. Solo hace falta en nuestra base master/developer:

python import_proc_codes.py --source 10 --dest master --dry-run   # ver cuántos faltan
python import_proc_codes.py --source 10 --dest master             # importar

Copia los códigos de una oficina que los tenga completos (A+), crea las categorías faltantes y rellena los campos obligatorios. Es idempotente (saltea los que ya están).

4.D — Sync mensual (actualizar cambios + agregar nuevos) ← se corre A MANO 1×/mes

Lo lanzás vos cuando quieras, no es automático. (--source 10 = A+, --dest master = tu OD developers.)

# Ver qué cambiaría sin escribir:
python push_plans.py --source 10 --dest master --update --dry-run

# Aplicar: actualiza los planes ya importados (refresca sus benefits) y crea los nuevos:
python push_plans.py --source 10 --dest master --update

Con --update, los planes que ya están en el master se refrescan (borra y recrea sus benefits con lo último de origen) y los planes nuevos se crean. El resumen distingue: X creados, Y actualizados, Z salteados.

Opcional: si algún día lo querés sin tocar nada, se puede agendar en la Mac con launchd/cron (Cowork no sirve para esto porque no tiene acceso a Open Dental). No es necesario — el flujo normal es correrlo a mano.

4.E — Copiar un plan puntual a mano (lo cargué manual en una oficina)

Indicás el PlanNum de origen y la oficina de la que viene:

python push_plans.py --source 10 --dest master --plans 7314
# varios: --plans 7314,7311,6840

4.F — Limpiar planes de prueba (ocultar, ya que no se pueden borrar)

python hide_plans.py --dest master --plans 21,22,23
# revertir: agregar --unhide

5. Cosas que aprendimos (gotchas resueltos en el script)

6. Flujo recomendado para un destino nuevo desde cero

  1. (Si es base developer/demo) import_proc_codes.py --source 10 --dest <destino>
  2. Prueba chica con dry-run: push_plans.py --source master --dest <destino> --limit 3 --dry-run — confirmar que los planes listados sean los BC curados (con marcador Plan Updated - BC).
  3. Si OK, copia chica de verdad: push_plans.py --source master --dest <destino> --limit 3.
  4. Revisar 2-3 planes en Open Dental del destino (frequencies/benefits OK).
  5. Carga completa (cliente nuevo): push_plans.py --source master --dest <destino>sin --all, usa filtro default BC.
  6. Mensual: push_plans.py --source 10 --dest master --update para mantener el master actualizado desde A+.
Cuándo SÍ usar --all: solo cuando estás copiando entre 2 oficinas reales (ej. A+ → otra oficina cliente) donde todos los planes son válidos. NO al copiar del master a un cliente.

7. Referencia completa de flags

push_plans.py (copiar / sync OD→OD)

FlagQué hace
--source <id>(requerido) oficina origen (client_id, ej. 10).
--dest <id>(requerido) oficina destino (ej. master).
--filter "a,b"términos OR en GroupName/GroupNum/PlanNote. Default: Plan Updated - BC,Plan Updated 2026 - BC.
--allignora el filtro, copia todos los planes.
--plans 7314,7311PlanNums puntuales de origen (copia manual). Ignora el filtro.
--updatesync: refresca los ya importados + crea los nuevos.
--limit Nsolo los primeros N (para probar).
--no-dedupno saltear duplicados (copia cada InsPlan aunque se repita).
--dry-runno escribe nada, muestra qué haría.
--ledger <file>ruta del ledger (default output/push_ledger_<dest>.json).

extract_plans.py (oficina → Google Sheet)

FlagQué hace
--clients <ids>oficinas separadas por coma, o all. Default all.
--filter "a,b"mismo filtro que arriba.
--allextrae todos los planes.
--sheet-id <id>Google Sheet destino.
--prefix <txt>prefijo de las pestañas (default Master).
--dry-runsolo cuenta, no escribe.

import_proc_codes.py (ADA codes origen → destino)

FlagQué hace
--source <id> / --dest <id>origen (que tiene los códigos) y destino.
--limit Nsolo los primeros N faltantes.
--dry-runsolo cuenta.

hide_plans.py (ocultar planes)

--dest <id> --plans 21,22,23 · agregar --unhide para revertir.

debug_benefit.py (diagnóstico read-only)

--source <id> --dest <id> --benefits <nums> ó --plan <PlanNum>.

8. Configuración

Oficinas — clients/<id>.json, mínimo

{
  "client_id": "master",
  "display_name": "BrandaCare Master (OD propio)",
  "has_od_api": true,
  "open_dental": {
    "base_url": "https://api.opendental.com/api/v1",
    "developer_key": "JLHKpmFGT7aTdh7a",
    "customer_key": "<CUSTOMER_KEY_DE_ESA_OFICINA>"
  }
}

Google Sheet (para extract_plans.py)

Crear un Sheet en blanco y compartirlo como editor con el service account: claims-audit-bot@claims-audit-495602.iam.gserviceaccount.com.

Archivos que generan los scripts (carpeta output/)

9. Anexo — código fuente de los scripts

Nota: El código completo está acá embebido como referencia (clic en cada bloque para expandir). Los archivos vivos están en /Users/yamibrandan/Documents/Claude/Projects/api open dental stedi claims/ y son los que se ejecutan.
extract_plans.py — clic para ver código fuente
#!/usr/bin/env python3
"""Extractor de Insurance Plans de Open Dental → Google Sheet master.

Lee TODOS los InsPlan de uno o varios clientes (oficinas), opcionalmente
filtrando por un marcador en GroupName / GroupNum / PlanNote (ej:
"Plan Updated - BC", "Plan Updated 2026 - BC"), trae los Benefits completos
incluidas las frequencies / TreatArea, y vuelca todo a un Google Sheet master
con tres pestañas: Plans, Benefits, Meta.

Es el paso 1 del flujo de onboarding:
    Clientes  →  [extract_plans.py]  →  Sheet master "planes 100% updated"
    Sheet master / OD propio  →  copiar a cliente nuevo

NO toca datos del paciente (sin PatPlan / InsSub). Solo lectura sobre OD.

Uso:
  # Todos los clientes con OD API, solo los planes marcados:
  python extract_plans.py --clients all --sheet-id <SHEET_ID>

  # Un cliente puntual, todos sus planes (sin filtro):
  python extract_plans.py --clients hallandale-dental --all --sheet-id <SHEET_ID>

  # Probar sin escribir nada (solo cuenta):
  python extract_plans.py --clients all --dry-run

El SHEET_ID es un Google Sheet en blanco que tenés que compartir (como editor)
con el service account:  ver service_account.json -> client_email.
"""
from __future__ import annotations

import argparse
import sys
import time
from datetime import datetime

import requests

from web.clients import get_client, list_clients

# ----------------------------------------------------------------------------
# Cliente OD minimalista (self-contained, no toca open_dental.py)
# ----------------------------------------------------------------------------

TREAT_AREA_ENUM = ["None", "Surf", "Tooth", "Mouth", "Quad", "Sextant", "Arch", "ToothRange"]

PLAN_TYPE_LABELS = {
    "": "Category Percentage",
    "p": "PPO Percentage",
    "f": "Flat Copay",
    "c": "Capitation",
}


def plan_type_label(t) -> str:
    key = "" if t is None else str(t)
    base = PLAN_TYPE_LABELS.get(key, key)
    return f"{base} ({key})" if key else base


class ODClient:
    """Wrapper mínimo sobre la REST API de Open Dental con retry."""

    def __init__(self, base_url: str, developer_key: str, customer_key: str, timeout: int = 90):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.s = requests.Session()
        self.s.headers.update({
            "Authorization": f"ODFHIR {developer_key}/{customer_key}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        })

    def _req(self, method: str, path: str, params=None, json=None, max_retries: int = 3):
        url = f"{self.base_url}/{path.lstrip('/')}"
        last = None
        for attempt in range(max_retries):
            try:
                r = self.s.request(method, url, params=params, json=json, timeout=self.timeout)
            except (requests.ReadTimeout, requests.ConnectionError) as e:
                last = e
                if attempt < max_retries - 1:
                    time.sleep(2 ** (attempt + 1))
                continue
            if r.status_code < 200 or r.status_code >= 300:
                body = (r.text or "").strip().replace("\n", " ")[:300]
                raise RuntimeError(f"{r.status_code} {method} {path} → {body}")
            return r.json() if r.text else None
        raise last if last else RuntimeError(f"Fallo tras {max_retries} intentos: {path}")

    def get(self, path, params=None):
        return self._req("GET", path, params=params)

    def post(self, path, body):
        return self._req("POST", path, json=body)

    def put(self, path, body):
        return self._req("PUT", path, json=body)

    def delete(self, path):
        return self._req("DELETE", path)

    def get_all(self, path, page_size: int = 100) -> list:
        """GET paginado por Offset."""
        out: list = []
        offset = 0
        sep = "&" if "?" in path else "?"
        while True:
            page = self.get(f"{path}{sep}Offset={offset}") or []
            if not isinstance(page, list) or not page:
                break
            out.extend(page)
            if len(page) < page_size:
                break
            offset += len(page)
            if offset > 50000:
                raise RuntimeError(f"Loop excesivo en {path}")
        return out

    def short_query(self, sql: str):
        """PUT /queries/ShortQuery — lee TreatArea directo de la tabla benefit."""
        return self._req("PUT", "/queries/ShortQuery", json={"SqlCommand": sql})


# ----------------------------------------------------------------------------
# Lógica de extracción
# ----------------------------------------------------------------------------

def index_by(rows, key) -> dict:
    out = {}
    for r in rows or []:
        if isinstance(r, dict) and r.get(key) is not None:
            out[r[key]] = r
    return out


def num_or_blank(v):
    if v in (None, "", -1, -1.0):
        return ""
    return v


def augment_treat_area(od: ODClient, plan_num, benefits, log):
    """Mergea TreatArea (que el GET /benefits no devuelve en OD < 25.3.7)."""
    if not benefits:
        return
    try:
        rows = od.short_query(f"SELECT BenefitNum, TreatArea FROM benefit WHERE PlanNum = {int(plan_num)}")
        if not isinstance(rows, list):
            return
        by_bn = {}
        for r in rows:
            try:
                idx = int(r.get("TreatArea"))
            except (TypeError, ValueError):
                idx = 0
            by_bn[str(r.get("BenefitNum"))] = TREAT_AREA_ENUM[idx] if 0 <= idx < len(TREAT_AREA_ENUM) else "None"
        for b in benefits:
            bn = str(b.get("BenefitNum"))
            if bn in by_bn:
                b["TreatArea"] = by_bn[bn]
    except Exception as e:  # noqa: BLE001
        log(f"    ⚠ TreatArea via /queries falló (plan {plan_num}): {e}")


def describe_benefit(b: dict) -> str:
    parts = [b.get("BenefitType") or "?"]
    if b.get("procCode"):
        parts.append(b["procCode"])
    if b.get("CovCatNum"):
        parts.append(f"cat#{b['CovCatNum']}")
    if b.get("CodeGroupNum"):
        parts.append(f"grp#{b['CodeGroupNum']}")
    pct = b.get("Percent")
    if isinstance(pct, (int, float)) and pct >= 0:
        parts.append(f"{pct}%")
    amt = b.get("MonetaryAmt")
    if isinstance(amt, (int, float)) and amt >= 0:
        parts.append(f"${amt}")
    if b.get("Quantity"):
        q = f"qty={b['Quantity']}"
        if b.get("QuantityQualifier") and b["QuantityQualifier"] != "None":
            q += f" {b['QuantityQualifier']}"
        parts.append(q)
    if b.get("TimePeriod") and b["TimePeriod"] != "None":
        parts.append(b["TimePeriod"])
    if b.get("TreatArea") and b["TreatArea"] != "None":
        parts.append(f"TreatArea={b['TreatArea']}")
    if b.get("CoverageLevel") and b["CoverageLevel"] != "None":
        parts.append(b["CoverageLevel"])
    return " · ".join(str(p) for p in parts)


PLAN_HEADERS = [
    "Oficina", "PlanNum", "Carrier", "Carrier ElectID", "GroupName", "GroupNum",
    "PlanType", "Employer", "PlanNote", "Marcador", "IsHidden", "#Benefits",
]
BENEFIT_HEADERS = [
    "Oficina", "PlanNum", "Carrier", "GroupName", "BenefitType", "Categoría",
    "procCode", "CodeGroup", "Percent", "MonetaryAmt", "Quantity", "QtyQualifier",
    "TimePeriod", "TreatArea", "CoverageLevel", "Resumen",
]


def extract_office(client: dict, terms: list[str], extract_all: bool, log):
    """Devuelve (plan_rows, benefit_rows, scanned, matched) para un cliente."""
    od_cfg = client.get("open_dental", {})
    office = client.get("display_name") or client.get("client_id") or "?"
    od = ODClient(
        od_cfg.get("base_url", "https://api.opendental.com/api/v1"),
        od_cfg.get("developer_key", ""),
        od_cfg.get("customer_key", ""),
    )

    log(f"\n═════ {office} ═════")
    all_plans = od.get_all("/insplans")
    log(f"✓ {len(all_plans)} planes en la oficina")

    carriers = index_by(od.get_all("/carriers"), "CarrierNum")
    covcats = index_by(od.get_all("/covcats"), "CovCatNum")
    codegroups = index_by(od.get_all("/codegroups"), "CodeGroupNum")
    employers = index_by(od.get_all("/employers"), "EmployerNum")

    def matches(p):
        hay = "  ".join(str(p.get(k, "") or "") for k in ("GroupName", "GroupNum", "PlanNote")).lower()
        return [t for t in terms if t in hay]

    matched = all_plans if extract_all else [p for p in all_plans if matches(p)]
    log(f"✓ {len(matched)} planes {'a exportar' if extract_all else 'matchean el filtro'}")

    plan_rows, benefit_rows = [], []
    for p in matched:
        carrier = carriers.get(p.get("CarrierNum"), {})
        emp = employers.get(p.get("EmployerNum"), {}) if p.get("EmployerNum") else {}
        marker = "" if extract_all else " / ".join(matches(p))

        try:
            benefits = od.get(f"/benefits?PlanNum={p['PlanNum']}") or []
            augment_treat_area(od, p["PlanNum"], benefits, log)
        except Exception as e:  # noqa: BLE001
            benefits = []
            log(f"  ⚠ Benefits del plan {p.get('PlanNum')} fallaron: {e}")

        log(f"  · Plan {p.get('PlanNum')} · {carrier.get('CarrierName') or 'Carrier#' + str(p.get('CarrierNum'))}"
            f" · {p.get('GroupName') or '—'} · {len(benefits)} benefits")

        is_hidden = "YES" if p.get("IsHidden") in (True, "true") else ""
        plan_rows.append([
            office, p.get("PlanNum"), carrier.get("CarrierName", ""), carrier.get("ElectID", ""),
            p.get("GroupName", ""), p.get("GroupNum", ""), plan_type_label(p.get("PlanType")),
            emp.get("EmpName", ""), p.get("PlanNote", ""), marker, is_hidden, len(benefits),
        ])

        for b in benefits:
            cc = covcats.get(b.get("CovCatNum"), {}) if b.get("CovCatNum") else {}
            cg = codegroups.get(b.get("CodeGroupNum"), {}) if b.get("CodeGroupNum") else {}
            benefit_rows.append([
                office, p.get("PlanNum"), carrier.get("CarrierName", ""), p.get("GroupName", ""),
                b.get("BenefitType", ""), cc.get("Description") or cc.get("EbenefitCat") or "",
                b.get("procCode", ""), cg.get("GroupName", ""),
                num_or_blank(b.get("Percent")), num_or_blank(b.get("MonetaryAmt")),
                b.get("Quantity", "") or "",
                b.get("QuantityQualifier", "") if b.get("QuantityQualifier") not in (None, "None") else "",
                b.get("TimePeriod", "") if b.get("TimePeriod") not in (None, "None") else "",
                b.get("TreatArea", "") if b.get("TreatArea") not in (None, "None") else "",
                b.get("CoverageLevel", "") if b.get("CoverageLevel") not in (None, "None") else "",
                describe_benefit(b),
            ])

    return plan_rows, benefit_rows, len(all_plans), len(matched)


# ----------------------------------------------------------------------------
# Google Sheet
# ----------------------------------------------------------------------------

def write_tab(sh, title, headers, rows):
    import gspread
    try:
        ws = sh.worksheet(title)
        ws.clear()
    except gspread.WorksheetNotFound:
        ws = sh.add_worksheet(title=title, rows=max(len(rows) + 50, 100), cols=len(headers))
    ws.update(values=[headers] + rows, range_name="A1")
    try:
        ws.freeze(rows=1)
        ws.format("1:1", {"textFormat": {"bold": True}})
    except Exception:  # noqa: BLE001
        pass


def write_to_sheet(sheet_id, service_account_file, prefix, plan_rows, benefit_rows, meta_rows):
    import gspread
    from google.oauth2.service_account import Credentials

    scopes = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"]
    creds = Credentials.from_service_account_file(service_account_file, scopes=scopes)
    client = gspread.authorize(creds)
    sh = client.open_by_key(sheet_id)

    write_tab(sh, f"{prefix} - Plans", PLAN_HEADERS, plan_rows)
    write_tab(sh, f"{prefix} - Benefits", BENEFIT_HEADERS, benefit_rows)
    write_tab(sh, f"{prefix} - Meta", ["Campo", "Valor"], meta_rows)
    return sh.url


# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------

def resolve_clients(arg: str) -> list[dict]:
    if arg.strip().lower() == "all":
        ids = [c["client_id"] for c in list_clients() if c.get("has_od_api")]
    else:
        ids = [x.strip() for x in arg.split(",") if x.strip()]
    out = []
    for cid in ids:
        c = get_client(cid)
        if not c:
            print(f"⚠ Cliente '{cid}' no encontrado en clients/ — salteado", flush=True)
            continue
        if not c.get("open_dental", {}).get("customer_key"):
            print(f"⚠ Cliente '{cid}' sin customer_key de OD — salteado", flush=True)
            continue
        out.append(c)
    return out


def main():
    ap = argparse.ArgumentParser(description="Extrae Insurance Plans de Open Dental a un Google Sheet master.")
    ap.add_argument("--clients", default="all", help="client_ids separados por coma, o 'all' (default)")
    ap.add_argument("--filter", default="Plan Updated - BC,Plan Updated 2026 - BC",
                    help="Términos (coma = OR) buscados en GroupName/GroupNum/PlanNote")
    ap.add_argument("--all", action="store_true", help="Ignora el filtro y extrae TODOS los planes")
    ap.add_argument("--sheet-id", default="", help="ID del Google Sheet master destino")
    ap.add_argument("--service-account", default="./service_account.json")
    ap.add_argument("--prefix", default="Master", help="Prefijo de las pestañas (default: Master)")
    ap.add_argument("--dry-run", action="store_true", help="No escribe nada, solo cuenta")
    args = ap.parse_args()

    def log(m):
        print(m, flush=True)

    terms = [t.strip().lower() for t in args.filter.split(",") if t.strip()]
    if not args.all and not terms:
        ap.error("Pasá --filter con al menos un término o usá --all")

    clients = resolve_clients(args.clients)
    if not clients:
        ap.error("No hay clientes válidos para extraer")

    log(f"▸ Extracción | clientes: {', '.join(c.get('client_id') for c in clients)}"
        f" | {'TODOS los planes' if args.all else 'filtro: ' + ' / '.join(terms)}")

    all_plans, all_benefits, per_office = [], [], []
    for c in clients:
        try:
            pr, br, scanned, matched = extract_office(c, terms, args.all, log)
        except Exception as e:  # noqa: BLE001
            log(f"✗ {c.get('client_id')} falló: {e}")
            per_office.append([c.get("display_name") or c.get("client_id"), "ERROR", str(e)])
            continue
        all_plans.extend(pr)
        all_benefits.extend(br)
        per_office.append([c.get("display_name") or c.get("client_id"), scanned, matched])

    log(f"\n▸ Total: {len(all_plans)} planes, {len(all_benefits)} benefits")
    for row in per_office:
        log(f"   {row[0]}: {row[2]} / {row[1]} planes" if not isinstance(row[1], str) else f"   {row[0]}: {row[1]} ({row[2]})")

    if args.dry_run:
        log("\n(dry-run: no se escribió nada)")
        return

    if not all_plans:
        log("\n⚠ Nada para escribir.")
        return

    sheet_id = args.sheet_id or clients[0].get("google_sheet", {}).get("sheet_id", "")
    if not sheet_id:
        ap.error("Falta --sheet-id (Google Sheet master destino)")

    stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
    meta_rows = [
        ["Exportado", stamp],
        ["Clientes", " | ".join(c.get("display_name") or c.get("client_id") for c in clients)],
        ["Filtro", "(todos los planes)" if args.all else " / ".join(terms)],
        ["Planes exportados", len(all_plans)],
        ["Benefits exportados", len(all_benefits)],
        ["Generado por", "extract_plans.py (Open Dental API)"],
    ]

    log("\n▸ Escribiendo Google Sheet...")
    url = write_to_sheet(sheet_id, args.service_account, args.prefix, all_plans, all_benefits, meta_rows)
    log(f"★ Listo: {len(all_plans)} planes, {len(all_benefits)} benefits")
    log(f"  {url}")


if __name__ == "__main__":
    sys.exit(main())
push_plans.py — clic para ver código fuente
#!/usr/bin/env python3
"""Copia Insurance Plans entre bases de Open Dental (OD → OD).

Replica InsPlan + Carrier + Employer + Benefits (incluidas frequencies/TreatArea)
de una oficina ORIGEN a una oficina DESTINO vía la REST API, resolviendo las FKs
locales (CovCat por categoría, CodeGroup por nombre, Carrier/Employer por nombre).
NO copia datos del paciente.

Es el port a Python de copyOnePlan_ del Apps Script (Code.gs), sin límite de 6 min.

Flujo de onboarding:
    A+ (u otro cliente)  →  [push_plans.py]  →  tu Open Dental master
    master               →  [push_plans.py]  →  cliente nuevo al onboardear

Uso:
  # PROBÁ primero con pocos planes y sin escribir:
  python push_plans.py --source 10 --dest master --limit 3 --dry-run

  # Copiar de verdad los marcados "Plan Updated - BC" de A+ a tu master:
  python push_plans.py --source 10 --dest master

  # Todos los planes (sin filtro):
  python push_plans.py --source 10 --dest master --all

'source' y 'dest' son client_ids de clients/*.json (cada uno con su bloque
open_dental.customer_key). Creá clients/master.json para tu OD propio.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

from web.clients import get_client
from extract_plans import ODClient, augment_treat_area  # reusa el motor del extractor


# ----------------------------------------------------------------------------
# Payload builders (whitelist según docs OD) — port de Code.gs
# ----------------------------------------------------------------------------

INSPLAN_SAFE = [
    "GroupName", "GroupNum", "PlanNote",
    "PlanType", "ClaimsUseUCR", "IsMedical",
    "ShowBaseUnits", "CodeSubstNone", "IsHidden",
    "MonthRenew", "CobRule", "ExclusionFeeRule",
    "IsBlueBookEnabled",
    "InsPlansZeroWriteOffsOnAnnualMaxOverride",
    "InsPlansZeroWriteOffsOnFreqOrAgingOverride",
]
CARRIER_SAFE = ["CarrierName", "Address", "Address2", "City", "State", "Zip",
                "Phone", "ElectID", "NoSendElect", "IsHidden"]


def build_insplan_payload(p, dest_carrier_num, dest_employer_num, log):
    local_fk = [k for k in ("FeeSched", "CopayFeeSched", "ManualFeeSchedNum", "BillingType",
                            "FilingCode", "FilingCodeSubtype", "ClaimFormNum") if p.get(k)]
    if local_fk:
        log(f"    ⚠ FKs office-local reseteados (configurá manual en destino si hace falta): {', '.join(local_fk)}")

    payload = {"CarrierNum": dest_carrier_num, "EmployerNum": dest_employer_num}
    for k in INSPLAN_SAFE:
        if p.get(k) is not None:
            payload[k] = p[k]

    # BlueBook es una feature opcional que el master no tiene habilitada.
    # Forzamos false siempre para evitar "BlueBook is not enabled by this dental office".
    payload["IsBlueBookEnabled"] = "false"
    return payload


def build_benefit_payload(b, dest_plan_num, covcat_map, codegroup_map):
    if not b.get("BenefitType"):
        return None
    payload = {
        "PlanNum": dest_plan_num,
        "BenefitType": b["BenefitType"],
        "CoverageLevel": b.get("CoverageLevel") or "None",
    }
    if b.get("CovCatNum"):
        m = covcat_map.get(b["CovCatNum"])
        if m:
            payload["CovCatNum"] = m
    if b.get("procCode"):
        payload["procCode"] = b["procCode"]
    if b.get("CodeGroupNum"):
        m = codegroup_map.get(b["CodeGroupNum"])
        if m:
            payload["CodeGroupNum"] = m

    if b.get("Percent") not in (None, -1):
        payload["Percent"] = b["Percent"]
    if b.get("MonetaryAmt") not in (None, -1, -1.0):
        payload["MonetaryAmt"] = b["MonetaryAmt"]
    if b.get("Quantity"):
        payload["Quantity"] = b["Quantity"]

    if b.get("TimePeriod"):
        payload["TimePeriod"] = b["TimePeriod"]
    if b.get("QuantityQualifier"):
        payload["QuantityQualifier"] = b["QuantityQualifier"]

    ta = b.get("TreatArea")
    if ta and ta != "None":
        payload["TreatArea"] = ta
    elif ta == "None" and b["BenefitType"] == "Limitations":
        payload["TreatArea"] = "None"
    return payload


# ----------------------------------------------------------------------------
# Lookups / mapas (con cache por destino)
# ----------------------------------------------------------------------------

def _find_by(rows, pred):
    for r in rows:
        if pred(r):
            return r
    return None


def find_or_create_carrier(dest, src_carrier, cache, log):
    name = (src_carrier.get("CarrierName") or "").strip()
    if not name:
        raise RuntimeError("Carrier origen sin nombre")
    lst = cache.setdefault("carriers", dest.get_all("/carriers"))
    t = name.lower()
    hit = _find_by(lst, lambda x: (x.get("CarrierName") or "").strip().lower() == t)
    if hit:
        return hit["CarrierNum"]
    payload = {k: src_carrier[k] for k in CARRIER_SAFE if src_carrier.get(k) is not None}
    created = dest.post("/carriers", payload)
    if not created or not created.get("CarrierNum"):
        raise RuntimeError(f"Respuesta inesperada al crear Carrier: {created}")
    lst.append(created)
    log(f"    ✓ Carrier creado: {created.get('CarrierName')} (#{created['CarrierNum']})")
    return created["CarrierNum"]


def find_or_create_employer(dest, src_emp, cache, log):
    name = (src_emp.get("EmpName") or "").strip()
    if not name:
        return 0
    lst = cache.setdefault("employers", dest.get_all("/employers"))
    t = name.lower()
    hit = _find_by(lst, lambda x: (x.get("EmpName") or "").strip().lower() == t)
    if hit:
        return hit["EmployerNum"]
    created = dest.post("/employers", {"EmpName": name})
    if not created or not created.get("EmployerNum"):
        raise RuntimeError(f"Respuesta inesperada al crear Employer: {created}")
    lst.append(created)
    log(f"    ✓ Employer creado: {created.get('EmpName')} (#{created['EmployerNum']})")
    return created["EmployerNum"]


def build_covcat_map(src, dest, src_benefits, cache, log):
    needed = {b["CovCatNum"] for b in src_benefits if b.get("CovCatNum")}
    if not needed:
        return {}
    src_all = cache.setdefault("src_covcats", src.get("/covcats") or [])
    dst_all = cache.setdefault("dst_covcats", dest.get("/covcats") or [])
    src_by = {c["CovCatNum"]: c for c in src_all}
    out, unmapped = {}, 0
    for num in needed:
        cc = src_by.get(num)
        if not cc:
            out[num] = 0
            continue
        match = None
        if cc.get("EbenefitCat") and cc["EbenefitCat"] != "None":
            match = _find_by(dst_all, lambda x: x.get("EbenefitCat") == cc["EbenefitCat"])
        if not match and cc.get("Description"):
            t = cc["Description"].strip().lower()
            match = _find_by(dst_all, lambda x: (x.get("Description") or "").strip().lower() == t)
        if match:
            out[num] = match["CovCatNum"]
        else:
            out[num] = 0
            unmapped += 1
    if unmapped:
        log(f"    ⚠ CovCat sin mapear en destino: {unmapped}/{len(needed)}")
    return out


CODEGROUP_SAFE = ["GroupName", "ProcCodes", "CodeGroupFixed", "IsHidden", "ShowInAgeLimit"]


def build_codegroup_map(src, dest, src_benefits, cache, log):
    needed = {b["CodeGroupNum"] for b in src_benefits if b.get("CodeGroupNum")}
    if not needed:
        return {}
    try:
        src_all = cache.setdefault("src_cg", src.get("/codegroups") or [])
        dst_all = cache.setdefault("dst_cg", dest.get("/codegroups") or [])
    except Exception:
        return {}
    src_by = {c["CodeGroupNum"]: c for c in src_all}
    out, created = {}, 0
    for num in needed:
        cg = src_by.get(num)
        if not cg:
            out[num] = 0
            continue
        t = (cg.get("GroupName") or "").strip().lower()
        match = _find_by(dst_all, lambda x: (x.get("GroupName") or "").strip().lower() == t)
        if match:
            out[num] = match["CodeGroupNum"]
            continue
        # No existe en destino → crearlo (igual que carriers/employers)
        try:
            payload = {k: cg[k] for k in CODEGROUP_SAFE if cg.get(k) is not None}
            new_cg = dest.post("/codegroups", payload)
            if new_cg and new_cg.get("CodeGroupNum"):
                dst_all.append(new_cg)
                out[num] = new_cg["CodeGroupNum"]
                created += 1
            else:
                out[num] = 0
        except Exception as e:  # noqa: BLE001
            out[num] = 0
            log(f"    ⚠ No pude crear CodeGroup '{cg.get('GroupName')}': {str(e)[:120]}")
    if created:
        log(f"    ✓ CodeGroups creados en destino: {created}")
    return out


# ----------------------------------------------------------------------------
# Copia de un plan
# ----------------------------------------------------------------------------

def _post_benefits(dest, plan_num, benefits, covcat_map, codegroup_map, log):
    """Crea los benefits de un plan en destino. Devuelve (ok, skip, fail)."""
    ok = skip = fail = 0
    for b in benefits:
        try:
            bp = build_benefit_payload(b, plan_num, covcat_map, codegroup_map)
            if not bp:
                skip += 1
                continue
            created = dest.post("/benefits", bp)
            # OD a veces ignora TreatArea en el POST → forzar con PUT
            want = bp.get("TreatArea")
            got = created.get("TreatArea") if created else None
            if want and want != "None" and (not got or got == "None") and created and created.get("BenefitNum"):
                try:
                    dest.put(f"/benefits/{created['BenefitNum']}", {"TreatArea": want})
                except Exception:  # noqa: BLE001
                    pass
            ok += 1
        except Exception as e:  # noqa: BLE001
            fail += 1
            log(f"    ⚠ Benefit #{b.get('BenefitNum')} falló: {str(e)[:120]}")
    return ok, skip, fail


def copy_one_plan(src, dest, src_plan_num, src_id, cache, dedup_index, ledger, dry_run, log, update_mode=False):
    # ¿Ya lo importé antes? (ledger = clave exacta source#plannum → PlanNum master)
    lkey = f"{src_id}#{src_plan_num}"
    already = ledger.get(lkey) if (ledger is not None) else None

    if already and not update_mode:
        log(f"  ═ Plan {src_plan_num} · ya importado antes (master PlanNum {already}) — salteado")
        return {"ok": True, "skipped": True}

    p = src.get(f"/insplans/{src_plan_num}")
    carrier = src.get(f"/carriers/{p['CarrierNum']}")
    group = p.get("GroupName") or "—"
    log(f"  ═ Plan {src_plan_num} · {carrier.get('CarrierName')} · {group}")

    gname = (p.get("GroupName") or "").strip().lower()
    gnum = (p.get("GroupNum") or "").strip().lower()

    # Dedup contra existentes (solo al CREAR uno nuevo; si ya está mapeado en ledger, se actualiza)
    if not already and dedup_index is not None and (gname or gnum):
        dkey = ((carrier.get("CarrierName") or "").strip().lower(), gname, gnum)
        if dkey in dedup_index:
            log(f"    ↷ ya existe en destino (PlanNum {dedup_index[dkey]}) — salteado")
            return {"ok": True, "skipped": True}

    benefits = src.get(f"/benefits?PlanNum={src_plan_num}") or []
    augment_treat_area(src, src_plan_num, benefits, log)

    employer = None
    if p.get("EmployerNum"):
        try:
            employer = src.get(f"/employers/{p['EmployerNum']}")
        except Exception as e:  # noqa: BLE001
            log(f"    ⚠ Employer ({p['EmployerNum']}): {e}")

    if dry_run:
        action = "actualizaría" if already else "crearía"
        log(f"    (dry-run) {action} el plan + {len(benefits)} benefits")
        return {"ok": True, "dry": True, "benefits": len(benefits)}

    covcat_map = build_covcat_map(src, dest, benefits, cache, log)
    codegroup_map = build_codegroup_map(src, dest, benefits, cache, log)
    dest_carrier_num = find_or_create_carrier(dest, carrier, cache, log)
    dest_employer_num = find_or_create_employer(dest, employer, cache, log) if employer else 0
    plan_payload = build_insplan_payload(p, dest_carrier_num, dest_employer_num, log)

    # ─── UPDATE: el plan ya existe en master (sync) → refrescar plan + benefits
    if already:
        master_pn = already
        try:
            dest.get(f"/insplans/{master_pn}")
        except Exception:  # noqa: BLE001
            log(f"    ⚠ PlanNum master {master_pn} ya no existe — lo creo de nuevo")
            master_pn = None
        if master_pn:
            dest.put(f"/insplans/{master_pn}", plan_payload)
            for eb in (dest.get(f"/benefits?PlanNum={master_pn}") or []):
                try:
                    dest.delete(f"/benefits/{eb['BenefitNum']}")
                except Exception:  # noqa: BLE001
                    pass
            ok, skip, fail = _post_benefits(dest, master_pn, benefits, covcat_map, codegroup_map, log)
            log(f"    ↻ Plan actualizado → PlanNum {master_pn} · benefits: {ok} ok"
                + (f", {skip} skip" if skip else "") + (f", {fail} err" if fail else ""))
            return {"ok": True, "updated": True, "new_plan_num": master_pn, "benefits_ok": ok, "benefits_fail": fail}

    # ─── CREATE: plan nuevo
    new_plan = dest.post("/insplans", plan_payload)
    if not new_plan or not new_plan.get("PlanNum"):
        raise RuntimeError(f"Respuesta inesperada al crear InsPlan: {new_plan}")
    new_plan_num = new_plan["PlanNum"]
    ok, skip, fail = _post_benefits(dest, new_plan_num, benefits, covcat_map, codegroup_map, log)
    log(f"    ✓ Plan creado → PlanNum {new_plan_num} · benefits: {ok} ok"
        + (f", {skip} skip" if skip else "") + (f", {fail} err" if fail else ""))
    if dedup_index is not None and (gname or gnum):
        dedup_index[((carrier.get("CarrierName") or "").strip().lower(), gname, gnum)] = new_plan_num
    if ledger is not None:
        ledger[lkey] = new_plan_num
    return {"ok": True, "new_plan_num": new_plan_num, "benefits_ok": ok, "benefits_fail": fail}


# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------

def office_client(client_id):
    c = get_client(client_id)
    if not c:
        raise SystemExit(f"❌ Cliente '{client_id}' no encontrado en clients/")
    od = c.get("open_dental", {})
    if not od.get("customer_key"):
        raise SystemExit(f"❌ Cliente '{client_id}' sin customer_key de OD")
    return c, ODClient(od.get("base_url", "https://api.opendental.com/api/v1"),
                       od.get("developer_key", ""), od.get("customer_key", ""))


def build_dedup_index(dest, cache, log):
    """Indexa los planes ya existentes en destino por (carrier, group, groupnum)."""
    carriers = cache.setdefault("carriers", dest.get_all("/carriers"))
    cname = {c["CarrierNum"]: (c.get("CarrierName") or "").strip().lower() for c in carriers}
    idx = {}
    for p in dest.get_all("/insplans"):
        if p.get("IsHidden") in (True, "true"):
            continue  # los planes ocultos no bloquean (dummies de prueba)
        key = (
            cname.get(p.get("CarrierNum"), ""),
            (p.get("GroupName") or "").strip().lower(),
            (p.get("GroupNum") or "").strip().lower(),
        )
        idx[key] = p.get("PlanNum")
    log(f"▸ Destino: {len(idx)} planes ya existentes indexados (dedup)")
    return idx


def main():
    ap = argparse.ArgumentParser(description="Copia Insurance Plans OD→OD reusando el motor probado.")
    ap.add_argument("--source", required=True, help="client_id origen (ej. 10 = A+)")
    ap.add_argument("--dest", required=True, help="client_id destino (ej. master = tu OD propio)")
    ap.add_argument("--filter", default="Plan Updated - BC,Plan Updated 2026 - BC",
                    help="Términos (coma = OR) en GroupName/GroupNum/PlanNote")
    ap.add_argument("--all", action="store_true", help="Ignora el filtro y copia TODOS los planes")
    ap.add_argument("--plans", default="", help="PlanNums de origen específicos (coma). Para copiar planes puntuales a mano.")
    ap.add_argument("--update", action="store_true", help="Modo sync: actualiza los planes ya importados (refresca benefits) y agrega los nuevos.")
    ap.add_argument("--limit", type=int, default=0, help="Copiar solo los primeros N planes (0 = sin tope)")
    ap.add_argument("--no-dedup", action="store_true", help="No saltear planes ya existentes en destino")
    ap.add_argument("--ledger", default="", help="Archivo de registro de importados (default: output/push_ledger_<dest>.json)")
    ap.add_argument("--dry-run", action="store_true", help="No escribe nada, solo muestra qué haría")
    args = ap.parse_args()

    def log(m):
        print(m, flush=True)

    want_plans = {int(x) for x in args.plans.split(",") if x.strip().isdigit()}
    terms = [t.strip().lower() for t in args.filter.split(",") if t.strip()]
    if not want_plans and not args.all and not terms:
        ap.error("Pasá --plans, --filter o --all")
    if args.source == args.dest:
        ap.error("source y dest no pueden ser el mismo cliente")

    src_client, src = office_client(args.source)
    dst_client, dest = office_client(args.dest)

    scope = ("plans " + args.plans) if want_plans else ("TODOS" if args.all else "filtro: " + " / ".join(terms))
    log(f"▸ {src_client.get('display_name')} → {dst_client.get('display_name')}"
        f" | {scope}"
        + (" | SYNC/UPDATE" if args.update else "")
        + (f" | limit {args.limit}" if args.limit else "")
        + (" | DRY-RUN" if args.dry_run else ""))

    all_plans = src.get_all("/insplans")
    log(f"✓ {len(all_plans)} planes en origen")

    def matches(p):
        hay = "  ".join(str(p.get(k, "") or "") for k in ("GroupName", "GroupNum", "PlanNote")).lower()
        return any(t in hay for t in terms)

    if want_plans:
        selected = [p for p in all_plans if p.get("PlanNum") in want_plans]
        missing_nums = want_plans - {p.get("PlanNum") for p in selected}
        if missing_nums:
            log(f"⚠ No encontré en origen: {sorted(missing_nums)}")
    elif args.all:
        selected = all_plans
    else:
        selected = [p for p in all_plans if matches(p)]
    if args.limit:
        selected = selected[:args.limit]
    log(f"✓ {len(selected)} planes a copiar\n")

    # Ledger: registro de planes de origen ya importados (clave exacta source#plannum)
    ledger_path = Path(args.ledger) if args.ledger else Path("output") / f"push_ledger_{args.dest}.json"
    ledger = {}
    if ledger_path.exists():
        try:
            ledger = json.loads(ledger_path.read_text())
            log(f"▸ Ledger: {len(ledger)} planes ya importados antes ({ledger_path})")
        except (json.JSONDecodeError, OSError):
            log(f"⚠ No pude leer el ledger {ledger_path}, arranco vacío")

    def save_ledger():
        if args.dry_run:
            return
        ledger_path.parent.mkdir(parents=True, exist_ok=True)
        ledger_path.write_text(json.dumps(ledger, indent=2))

    cache = {}
    # Dedup por Carrier+GroupName+GroupNum contra lo que ya exista en destino
    dedup_index = None if args.no_dedup else build_dedup_index(dest, cache, log)
    done = updated = skipped = failed = 0
    for i, p in enumerate(selected, 1):
        log(f"[{i}/{len(selected)}]")
        try:
            r = copy_one_plan(src, dest, p["PlanNum"], args.source, cache, dedup_index,
                              ledger, args.dry_run, log, update_mode=args.update)
            if r.get("skipped"):
                skipped += 1
            elif r.get("updated"):
                updated += 1
            else:
                done += 1
                save_ledger()  # guardado incremental: si se corta, no re-crea lo ya hecho
        except Exception as e:  # noqa: BLE001
            failed += 1
            log(f"    ✗ Plan {p.get('PlanNum')} falló: {str(e)[:200]}")

    save_ledger()
    log(f"\n▸ Resumen: {done} creados, {updated} actualizados, {skipped} salteados, "
        f"{failed} con error (de {len(selected)})")
    if args.dry_run:
        log("(dry-run: no se escribió nada)")


if __name__ == "__main__":
    sys.exit(main())
import_proc_codes.py — clic para ver código fuente
#!/usr/bin/env python3
"""Importa Procedure Codes (ADA/CDT) de una oficina origen a tu OD master.

Las bases 'developer'/demo a veces vienen sin la lista de ADA codes cargada,
entonces cualquier benefit que apunte a un código puntual falla con
'procCode is invalid.'. Este script copia los procedure codes de A+ (que los
tiene completos) al master, creando solo los que falten.

Copia: ProcCode, Descript, AbbrDesc, TreatArea y la categoría (procCat por nombre).
NO toca pacientes ni benefits. Idempotente: saltea los que ya existen + ledger.

Uso:
  python import_proc_codes.py --source 10 --dest master --dry-run      # ver qué faltan
  python import_proc_codes.py --source 10 --dest master --limit 20     # probar 20
  python import_proc_codes.py --source 10 --dest master                # todos los faltantes
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path

from push_plans import office_client

# Campos seguros a copiar del procedurecode de origen
# (AbbrDesc y procCat se setean aparte porque OD los exige no-vacíos)
PROC_SAFE = [
    "ProcCode", "Descript", "TreatArea",
    "ProcTime", "IsHygiene", "IsProsth", "IsRadiology", "NoBillIns",
]


def build_proccat_map(src, log):
    """DefNum → ItemName de las categorías de procedure codes (definitions)."""
    try:
        defs = src.get_all("/definitions") if hasattr(src, "get_all") else (src.get("/definitions") or [])
    except Exception as e:  # noqa: BLE001
        log(f"⚠ No pude leer /definitions (categorías): {e}")
        return {}
    out = {}
    for d in defs or []:
        if d.get("DefNum") is not None:
            out[d["DefNum"]] = d.get("ItemName") or ""
    return out


def ensure_categories(dest, needed_names, log, dry_run):
    """Crea en destino las categorías de procedure code (definition.Category=ProcCodeCats) que falten."""
    try:
        defs = dest.get_all("/definitions")
    except Exception as e:  # noqa: BLE001
        log(f"⚠ No pude leer /definitions del destino: {e}")
        return
    existing = {
        (d.get("ItemName") or "").strip().lower()
        for d in defs
        if str(d.get("Category")) in ("ProcCodeCats", "11")
    }
    need = {n.strip() for n in needed_names if n and n.strip()}
    missing = [n for n in need if n.lower() not in existing]
    if not missing:
        return
    log(f"▸ Categorías faltantes en destino: {len(missing)} → {', '.join(sorted(missing))}")
    if dry_run:
        return
    created = 0
    for name in missing:
        try:
            dest.post("/definitions", {"category": "ProcCodeCats", "ItemName": name})
            created += 1
            log(f"  ✓ Categoría creada: {name}")
        except Exception as e:  # noqa: BLE001
            log(f"  ⚠ No pude crear categoría '{name}': {str(e)[:100]}")
    log(f"✓ {created} categorías creadas en destino\n")


def main():
    ap = argparse.ArgumentParser(description="Importa Procedure Codes (ADA) origen → master.")
    ap.add_argument("--source", required=True, help="client_id origen (ej. 10 = A+)")
    ap.add_argument("--dest", required=True, help="client_id destino (ej. master)")
    ap.add_argument("--limit", type=int, default=0, help="Solo los primeros N faltantes (0 = todos)")
    ap.add_argument("--ledger", default="", help="Default: output/proccode_ledger_<dest>.json")
    ap.add_argument("--dry-run", action="store_true", help="No escribe nada, solo cuenta")
    args = ap.parse_args()

    def log(m):
        print(m, flush=True)

    _, src = office_client(args.source)
    _, dest = office_client(args.dest)

    log(f"▸ Procedure Codes: {args.source} → {args.dest}" + (" | DRY-RUN" if args.dry_run else ""))

    src_codes = src.get_all("/procedurecodes")
    log(f"✓ {len(src_codes)} procedure codes en origen")
    dest_codes = dest.get_all("/procedurecodes")
    have = {str(c.get("ProcCode", "")).strip().upper() for c in dest_codes}
    log(f"✓ {len(have)} ya existen en destino")

    catmap = build_proccat_map(src, log)

    missing = [c for c in src_codes if str(c.get("ProcCode", "")).strip().upper() not in have]
    if args.limit:
        missing = missing[:args.limit]
    log(f"✓ {len(missing)} códigos a importar\n")

    # Asegurar que las categorías que usan los códigos faltantes existan en destino
    needed_cats = [catmap.get(c.get("ProcCat")) or "Never Used" for c in missing]
    ensure_categories(dest, needed_cats, log, args.dry_run)

    # Ledger
    ledger_path = Path(args.ledger) if args.ledger else Path("output") / f"proccode_ledger_{args.dest}.json"
    ledger = {}
    if ledger_path.exists():
        try:
            ledger = set(json.loads(ledger_path.read_text()))
        except (json.JSONDecodeError, OSError):
            ledger = set()
    else:
        ledger = set()

    def save_ledger():
        if args.dry_run:
            return
        ledger_path.parent.mkdir(parents=True, exist_ok=True)
        ledger_path.write_text(json.dumps(sorted(ledger), indent=2))

    done = skipped = failed = 0
    for i, c in enumerate(missing, 1):
        code = str(c.get("ProcCode", "")).strip()
        if not code:
            continue
        if code.upper() in ledger:
            skipped += 1
            continue

        payload = {k: c[k] for k in PROC_SAFE if c.get(k) not in (None, "")}
        # AbbrDesc es obligatorio y no puede ir vacío → fallback a la descripción
        abbr = (str(c.get("AbbrDesc") or "").strip()) or (str(c.get("Descript") or "").strip()) or code
        payload["AbbrDesc"] = abbr[:50]
        # Categoría obligatoria → si no resuelve, default "Never Used"
        cat_name = catmap.get(c.get("ProcCat")) or "Never Used"
        payload["procCat"] = cat_name

        if args.dry_run:
            if i <= 30:
                log(f"  (dry) {code} · {c.get('Descript', '')[:50]} · cat={cat_name}")
            done += 1
            continue

        try:
            dest.post("/procedurecodes", payload)
            ledger.add(code.upper())
            done += 1
            if done % 50 == 0:
                log(f"  … {done} importados")
                save_ledger()
        except Exception as e:  # noqa: BLE001
            failed += 1
            log(f"  ⚠ {code} falló: {str(e)[:120]}")

    save_ledger()
    log(f"\n▸ Resumen: {done} importados, {skipped} ya estaban, {failed} con error (de {len(missing)})")
    if args.dry_run:
        log("(dry-run: no se escribió nada)")
    elif done and not args.dry_run:
        log("Ahora reintentá el copiador: los benefits con procCode puntual ya deberían entrar.")


if __name__ == "__main__":
    main()
hide_plans.py — clic para ver código fuente
#!/usr/bin/env python3
"""Oculta (IsHidden=true) planes de una oficina por PlanNum.

La API de Open Dental no permite borrar InsPlans, pero sí ocultarlos: así
desaparecen del listado y dejan de bloquear el dedup del copiador. Útil para
limpiar los planes dummy de prueba.

Uso:
  python hide_plans.py --dest master --plans 21,22,23,24,25,26
"""
from __future__ import annotations

import argparse

from push_plans import office_client


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dest", required=True, help="client_id (ej. master)")
    ap.add_argument("--plans", required=True, help="PlanNums separados por coma")
    ap.add_argument("--unhide", action="store_true", help="Revertir (IsHidden=false)")
    args = ap.parse_args()

    _, dest = office_client(args.dest)
    val = "false" if args.unhide else "true"

    ok = fail = 0
    for pn in args.plans.split(","):
        pn = pn.strip()
        if not pn:
            continue
        try:
            dest.put(f"/insplans/{pn}", {"IsHidden": val})
            print(f"  ✓ Plan {pn} → IsHidden={val}")
            ok += 1
        except Exception as e:  # noqa: BLE001
            print(f"  ⚠ Plan {pn}: {str(e)[:120]}")
            fail += 1
    print(f"\n▸ {ok} ocultados, {fail} con error")


if __name__ == "__main__":
    main()
debug_benefit.py — clic para ver código fuente
#!/usr/bin/env python3
"""Diagnóstico solo-lectura de benefits que fallan al copiar (procCode invalid).

Muestra el benefit de origen (procCode, type, categoría) y chequea si ese
procCode existe en el destino. NO crea ni borra nada.

Uso:
  python debug_benefit.py --source 10 --dest master --benefits 24850,105602,105603,897,898,105774,105775
  python debug_benefit.py --source 10 --dest master --plan 35     # todos los de un plan
"""
from __future__ import annotations

import argparse

from push_plans import office_client


def proc_exists_in_dest(dest, proc_code) -> str:
    try:
        res = dest.get(f"/procedurecodes?ProcCode={proc_code}")
        if isinstance(res, list) and res:
            return f"SÍ existe (CodeNum {res[0].get('CodeNum')})"
        return "❌ NO existe en destino"
    except Exception as e:  # noqa: BLE001
        return f"error consultando: {str(e)[:80]}"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--source", required=True)
    ap.add_argument("--dest", required=True)
    ap.add_argument("--benefits", default="", help="BenefitNums separados por coma")
    ap.add_argument("--plan", default="", help="PlanNum origen: dump de todos sus benefits")
    args = ap.parse_args()

    _, src = office_client(args.source)
    _, dest = office_client(args.dest)

    benefits = []
    if args.plan:
        benefits = src.get(f"/benefits?PlanNum={args.plan}") or []
    elif args.benefits:
        for bn in args.benefits.split(","):
            bn = bn.strip()
            if bn:
                try:
                    benefits.append(src.get(f"/benefits/{bn}"))
                except Exception as e:  # noqa: BLE001
                    print(f"  ⚠ no pude traer benefit {bn}: {e}")
    else:
        ap.error("Pasá --benefits o --plan")

    print(f"\n{'BenefitNum':>10} | {'Type':14} | {'procCode':10} | CovCat | CodeGrp | {'%':>4} | {'$':>6} | destino")
    print("-" * 110)
    for b in benefits:
        if not isinstance(b, dict):
            continue
        pc = b.get("procCode") or ""
        dest_status = proc_exists_in_dest(dest, pc) if pc else "(sin procCode)"
        print(f"{str(b.get('BenefitNum')):>10} | {str(b.get('BenefitType') or ''):14} | {pc:10} | "
              f"{str(b.get('CovCatNum') or ''):>6} | {str(b.get('CodeGroupNum') or ''):>7} | "
              f"{str(b.get('Percent') if b.get('Percent') not in (None,-1) else ''):>4} | "
              f"{str(b.get('MonetaryAmt') if b.get('MonetaryAmt') not in (None,-1) else ''):>6} | {dest_status}")


if __name__ == "__main__":
    main()