ES
SOP-IT-005 · v1.0
Jun 24, 2026

SOP — Insurance Plans Migration & Management in Open Dental

Python scripts (terminal) to extract, copy, sync and maintain plans via REST API · Project: api open dental stedi claims

0. THE FLOW (summary)

        A+  ──(monthly maintenance)──▶  MASTER  ──(onboarding)──▶  NEW CLIENT
     (source office,                  (your OD developers,        (Casas, Hallandale,
      audited plans)                   curated library)            Benitez, etc.)
Golden rule: a new client ALWAYS comes from MASTER, never directly from A+.
DirectionWhenCommand
A+ → MASTERMaintenance (1×/month)python push_plans.py --source 10 --dest master --update
MASTER → CLIENTNew client onboardingpython push_plans.py --source master --dest <client>

1. What was achieved

We built a set of scripts that replaces the manual work of loading insurance plans one by one. With this we can:

Current status: master loaded from A+ (office 10) — 431 unique plans, 1014 ADA codes imported, 0 errors.

2. Key Open Dental concepts

3. Components (scripts)

ScriptPurpose
extract_plans.pyExtracts plans from an office → Google Sheet (Plans / Benefits / Meta).
push_plans.pyCopies plans OD→OD. Modes: filter, all, manual plan, sync.
import_proc_codes.pyImports ADA/CDT codes to destination (for empty developer/demo bases).
hide_plans.pyHides plans by PlanNum (API doesn't allow deletion).
debug_benefit.pyRead-only diagnostics for benefits that fail.
find_plan.pySearch a PlanNum in an office by carrier or group. Useful when you need to identify a plan quickly.

Offices live in clients/*.json (each with its open_dental.customer_key).

Office legend (client_id ↔ office)

client_idOffice
10A+ Dental of Aventura (A Plus)
hallandale-dentalHallandale Dental Care
benitez-dentalBenitez Dental Center
casas-family-dentistryDr Casas Family Dentistry
masterBrandaCare Master (our own Open Dental developers)
Note: in all commands below, --source 10 = A+.

Before any command

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

4. Procedures

4.A — Extract ALL plans from an office to a Sheet (backup)

python extract_plans.py --clients 10 --all --sheet-id <SHEET_ID>
  • Only ones marked as updated: remove --all (default filter Plan Updated - BC,Plan Updated 2026 - BC).
  • Multiple offices: --clients 10,hallandale-dental,benitez-dental.
  • Destination Sheet must be shared as editor with the service account: claims-audit-bot@claims-audit-495602.iam.gserviceaccount.com

4.B — Copy plans to another base (master, or new client)

Always test first with --dry-run and/or --limit:

# See what it would do with 3 plans (no writes):
python push_plans.py --source 10 --dest master --limit 3 --dry-run

# Actually copy ones marked "Plan Updated - BC":
python push_plans.py --source 10 --dest master

# All plans (no filter):
python push_plans.py --source 10 --dest master --all

Onboard a new client from master:

IMPORTANT — DO NOT use --all in onboarding: the master has two types of plans:
  • Old developer plans that were already in the base before we started (the "8-9 already existing" ones that show at the beginning) — we do NOT want these in the client
  • Your curated plans from A+ with marker Plan Updated - BC or Plan Updated 2026 - BC in GroupName/PlanNote — these DO go to the client
With --all it picks the first ones by PlanNum (the old dev ones). Without --all it uses the default BC filter and migrates only the audited ones.
# 1. Test without writing, confirm listed plans are the BC curated ones:
python push_plans.py --source master --dest <new-client> --limit 3 --dry-run

# 2. If the 3 are correct, do a real test with 3:
python push_plans.py --source master --dest <new-client> --limit 3

# 3. If all OK, migrate all BC curated plans:
python push_plans.py --source master --dest <new-client>

The copier creates only what's missing in destination (carriers, employers, code groups), never overwrites an existing plan, and dedupes (doesn't repeat the same carrier+group). It keeps a ledger at output/push_ledger_<dest>.json mapping source plans to destination PlanNums.

4.C — Import ADA codes (only if destination is empty developer/demo base)

Real offices already have them. Only needed for our master/developer base:

python import_proc_codes.py --source 10 --dest master --dry-run   # see how many missing
python import_proc_codes.py --source 10 --dest master             # import

Copies codes from an office that has them complete (A+), creates missing categories and fills mandatory fields. Idempotent (skips existing).

4.D — Monthly sync (update changes + add new) ← run MANUALLY 1×/month

You launch it whenever you want, it's not automatic.

# See what would change without writing:
python push_plans.py --source 10 --dest master --update --dry-run

# Apply: updates already-imported plans (refreshes their benefits) and creates new ones:
python push_plans.py --source 10 --dest master --update

With --update, plans already in master are refreshed (deletes and recreates their benefits with latest from source) and new plans are created. Summary distinguishes: X created, Y updated, Z skipped.

Optional: if someday you want it hands-off, can be scheduled on the Mac with launchd/cron (Cowork doesn't work for this — no Open Dental access). Not necessary — normal flow is to run by hand.

4.E — Copy a specific plan manually (loaded by hand in an office)

Specify source PlanNum and the office it comes from:

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

4.F — Clean up test plans (hide, since they can't be deleted)

python hide_plans.py --dest master --plans 21,22,23
# revert: add --unhide

5. Things we learned (gotchas resolved in the script)

6. Recommended flow for new destination from scratch

  1. (If developer/demo base) import_proc_codes.py --source 10 --dest <destination>
  2. Small test with dry-run: push_plans.py --source master --dest <destination> --limit 3 --dry-run — confirm listed plans are the BC curated ones (with marker Plan Updated - BC).
  3. If OK, real small copy: push_plans.py --source master --dest <destination> --limit 3.
  4. Review 2-3 plans in destination Open Dental (frequencies/benefits OK).
  5. Full load (new client): push_plans.py --source master --dest <destination>without --all, uses default BC filter.
  6. Monthly: push_plans.py --source 10 --dest master --update to keep master synced from A+.
When to actually use --all: only when copying between 2 real offices (e.g. A+ → another client office) where all plans are valid. NOT when copying from master to a client.

7. Complete flag reference

push_plans.py (copy / sync OD→OD)

FlagWhat it does
--source <id>(required) source office.
--dest <id>(required) destination office.
--filter "a,b"OR terms in GroupName/GroupNum/PlanNote. Default: Plan Updated - BC,Plan Updated 2026 - BC.
--allignore filter, copy all plans.
--plans 7314,7311specific source PlanNums (manual copy).
--updatesync: refresh imported + create new.
--limit Nonly first N (testing).
--no-dedupdon't skip duplicates.
--dry-runno writes, shows what it would do.
--ledger <file>ledger path.

extract_plans.py (office → Google Sheet)

FlagWhat it does
--clients <ids>offices comma-separated, or all.
--filter "a,b"same filter as above.
--allextract all plans.
--sheet-id <id>destination Google Sheet.
--prefix <txt>tab prefix (default Master).
--dry-runcount only, no write.

import_proc_codes.py (ADA codes source → destination)

FlagWhat it does
--source <id> / --dest <id>source (has codes) and destination.
--limit Nonly first N missing.
--dry-runcount only.

hide_plans.py (hide plans)

--dest <id> --plans 21,22,23 · add --unhide to revert.

debug_benefit.py (read-only diagnostics)

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

8. Configuration

Offices — clients/<id>.json, minimum

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

Google Sheet (for extract_plans.py)

Create a blank Sheet and share as editor with service account: claims-audit-bot@claims-audit-495602.iam.gserviceaccount.com.

Files generated by the scripts (output/ folder)

9. Appendix — script source code

Note: Full source code embedded here for reference (click each block to expand). Live files are at /Users/yamibrandan/Documents/Claude/Projects/api open dental stedi claims/ and are the ones that execute.
extract_plans.py — click to view source code
#!/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 — click to view source code
#!/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 — click to view source code
#!/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 — click to view source code
#!/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 — click to view source code
#!/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()