0. EL FLUJO (resumen)
A+ ──(mantenimiento mensual)──▶ MASTER ──(onboarding)──▶ CLIENTE NUEVO
(oficina fuente, (tu OD developers, (Casas, Hallandale,
planes auditados) biblioteca curada) Benitez, etc.)
| Dirección | Cuándo | Comando |
|---|---|---|
| A+ → MASTER | Mantenimiento (1×/mes) | python push_plans.py --source 10 --dest master --update |
| MASTER → CLIENTE | Onboarding de cliente nuevo | python push_plans.py --source master --dest <cliente> |
- A+ =
--source 10. Es la oficina que tiene los planes auditados (Plan Updated - BC). - MASTER =
--dest master/--source master. Es tu Open Dental developers, la biblioteca curada. - Para onboarding usar el filtro BC por default (no
--all).
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:
- Extraer todos los planes de cualquier oficina a un Google Sheet (backup / revisión).
- Copiar planes completos (Carrier + Employer + Code Groups + Benefits con frequencies/TreatArea) de una base de Open Dental a otra.
- Construir un master "100% updated" en nuestra cuenta propia de Open Dental.
- Onboardear un cliente nuevo copiando el master a su base.
- Sincronizar mensualmente: actualizar planes que cambiaron y agregar los nuevos.
- Copiar un plan puntual a mano cuando lo cargamos manualmente.
2. Conceptos clave de Open Dental
- Cada oficina es una base de datos separada. Los IDs (
PlanNum,CarrierNum,EmployerNum,CodeGroupNum,ProcCat) son locales: no se pueden copiar tal cual entre bases. Por eso el copiador mapea por nombre (carrier, employer, code group, categoría) y crea lo que falte en el destino. - Un plan se compone de: el
InsPlan(la plantilla: carrier, grupo, notas, tipo) + losBenefits(coberturas y frequency limitations con suTreatArea). - Code Groups (Setup → Code Groups): grupos de procedure codes (ej. "Perio Maint" = D4910) que usan los benefits de frecuencia. Si no existen en el destino, el benefit falla.
- Procedure Codes (ADA/CDT): las bases developer/demo vienen casi vacías de códigos. Si un benefit apunta a un código que no existe en el destino →
procCode is invalid. - No se copian datos del paciente (sin PatPlan / InsSub). Solo la plantilla del plan.
- La API no deja borrar InsPlans, solo ocultarlos (
IsHidden).
3. Componentes (scripts)
| Script | Para qué |
|---|---|
extract_plans.py | Extrae planes de una oficina → Google Sheet (Plans / Benefits / Meta). |
push_plans.py | Copia planes OD→OD. Modos: filtro, todos, plan manual, sync. |
import_proc_codes.py | Importa los ADA/CDT codes al destino (para bases developer/demo vacías). |
hide_plans.py | Oculta planes por PlanNum (la API no permite borrarlos). |
debug_benefit.py | Diagnóstico read-only de benefits que fallan. |
find_plan.py | Busca 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_id | Oficina |
|---|---|
10 | A+ Dental of Aventura (A Plus) |
hallandale-dental | Hallandale Dental Care |
benitez-dental | Benitez Dental Center |
casas-family-dentistry | Dr Casas Family Dentistry |
master | BrandaCare Master (nuestro Open Dental developers propio) |
--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 defaultPlan 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:
--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 - BCoPlan Updated 2026 - BCen GroupName/PlanNote — estos sí van al cliente
--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.
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)
- TreatArea / frequencies: el
GET /benefitsno devuelveTreatAreaen OD < 25.3.7; se lee aparte de la tablabenefitcon/queries/ShortQueryy se fuerza con unPUT. - Code Groups faltantes: se crean en el destino con sus Proc Codes antes de cargar benefits.
- ADA codes faltantes (
procCode is invalid): se importan conimport_proc_codes.py; además crea las categorías (definition.Category=ProcCodeCats) y rellenaAbbrDesc/procCatobligatorios. - BlueBook: se fuerza
IsBlueBookEnabled=false(el master no tiene esa feature → dabaBlueBook is not enabled by this dental office). - FKs office-local (
FeeSched,ClaimFormNum, etc.): no se copian — se configuran a mano en el destino si hacen falta (el script avisa cuáles reseteó). - Dedup: por carrier+grupo+groupnum; los planes con grupo vacío siempre se crean (no se colapsan); los planes ocultos no bloquean.
- Idempotencia: el ledger evita duplicar al re-correr; el sync (
--update) usa ese mapeo para actualizar el plan correcto del destino. - Planes ocultos del origen NO se copian: los dummies/incompletos quedan
IsHiddenen el master; el copiador los omite (salvo--plansexplícito). Si un cliente recibía planes incompletos, casi seguro venían de un plan oculto del master. - Se crean visibles: los planes copiados se fuerzan a
IsHidden=false(no heredan el oculto del origen). - Timeouts de gateway (502/503/504): se reintentan automáticamente con backoff (el servidor de OD a veces tarda; no es un error de datos).
- Buscar un PlanNum:
find_plan.py --client <id> --search "<carrier o grupo>".
6. Flujo recomendado para un destino nuevo desde cero
- (Si es base developer/demo)
import_proc_codes.py --source 10 --dest <destino> - 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 marcadorPlan Updated - BC). - Si OK, copia chica de verdad:
push_plans.py --source master --dest <destino> --limit 3. - Revisar 2-3 planes en Open Dental del destino (frequencies/benefits OK).
- Carga completa (cliente nuevo):
push_plans.py --source master --dest <destino>— sin--all, usa filtro default BC. - Mensual:
push_plans.py --source 10 --dest master --updatepara mantener el master actualizado desde A+.
--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)
| Flag | Qué 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. |
--all | ignora el filtro, copia todos los planes. |
--plans 7314,7311 | PlanNums puntuales de origen (copia manual). Ignora el filtro. |
--update | sync: refresca los ya importados + crea los nuevos. |
--limit N | solo los primeros N (para probar). |
--no-dedup | no saltear duplicados (copia cada InsPlan aunque se repita). |
--dry-run | no escribe nada, muestra qué haría. |
--ledger <file> | ruta del ledger (default output/push_ledger_<dest>.json). |
extract_plans.py (oficina → Google Sheet)
| Flag | Qué hace |
|---|---|
--clients <ids> | oficinas separadas por coma, o all. Default all. |
--filter "a,b" | mismo filtro que arriba. |
--all | extrae todos los planes. |
--sheet-id <id> | Google Sheet destino. |
--prefix <txt> | prefijo de las pestañas (default Master). |
--dry-run | solo cuenta, no escribe. |
import_proc_codes.py (ADA codes origen → destino)
| Flag | Qué hace |
|---|---|
--source <id> / --dest <id> | origen (que tiene los códigos) y destino. |
--limit N | solo los primeros N faltantes. |
--dry-run | solo 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/)
push_ledger_<dest>.json— mapa plan de origen → PlanNum del destino (idempotencia/sync).proccode_ledger_<dest>.json— códigos ADA ya importados.
9. Anexo — código fuente de los scripts
/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()
