0. THE FLOW (summary)
A+ ──(monthly maintenance)──▶ MASTER ──(onboarding)──▶ NEW CLIENT
(source office, (your OD developers, (Casas, Hallandale,
audited plans) curated library) Benitez, etc.)
| Direction | When | Command |
|---|---|---|
| A+ → MASTER | Maintenance (1×/month) | python push_plans.py --source 10 --dest master --update |
| MASTER → CLIENT | New client onboarding | python push_plans.py --source master --dest <client> |
- A+ =
--source 10. The office with audited plans (Plan Updated - BC). - MASTER =
--dest master/--source master. Your Open Dental developers, the curated library. - For onboarding use the default BC filter (not
--all).
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:
- Extract all plans from any office to a Google Sheet (backup / review).
- Copy complete plans (Carrier + Employer + Code Groups + Benefits with frequencies/TreatArea) from one OD base to another.
- Build a master "100% updated" on our own Open Dental account.
- Onboard a new client by copying the master to their base.
- Sync monthly: update plans that changed and add new ones.
- Copy a specific plan manually when we loaded it by hand.
2. Key Open Dental concepts
- Each office is a separate database. IDs (
PlanNum,CarrierNum,EmployerNum,CodeGroupNum,ProcCat) are local: can't be copied as-is between bases. That's why the copier maps by name (carrier, employer, code group, category) and creates whatever's missing in the destination. - A plan consists of: the
InsPlan(template: carrier, group, notes, type) +Benefits(coverages and frequency limitations with theirTreatArea). - Code Groups (Setup → Code Groups): groups of procedure codes (e.g. "Perio Maint" = D4910) used by frequency benefits. If they don't exist in destination, the benefit fails.
- Procedure Codes (ADA/CDT): developer/demo bases come almost empty of codes. If a benefit points to a code that doesn't exist in destination →
procCode is invalid. - Patient data is NOT copied (no PatPlan / InsSub). Only the plan template.
- The API doesn't allow deleting InsPlans, only hiding them (
IsHidden).
3. Components (scripts)
| Script | Purpose |
|---|---|
extract_plans.py | Extracts plans from an office → Google Sheet (Plans / Benefits / Meta). |
push_plans.py | Copies plans OD→OD. Modes: filter, all, manual plan, sync. |
import_proc_codes.py | Imports ADA/CDT codes to destination (for empty developer/demo bases). |
hide_plans.py | Hides plans by PlanNum (API doesn't allow deletion). |
debug_benefit.py | Read-only diagnostics for benefits that fail. |
find_plan.py | Search 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_id | Office |
|---|---|
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 (our own Open Dental developers) |
--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 filterPlan 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:
--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 - BCorPlan Updated 2026 - BCin GroupName/PlanNote — these DO go to the client
--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.
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)
- TreatArea / frequencies:
GET /benefitsdoesn't returnTreatAreain OD < 25.3.7; read separately frombenefittable via/queries/ShortQueryand forced with aPUT. - Missing Code Groups: created in destination with their Proc Codes before loading benefits.
- Missing ADA codes (
procCode is invalid): imported withimport_proc_codes.py; also creates categories (definition.Category=ProcCodeCats) and fills mandatoryAbbrDesc/procCat. - BlueBook: forced
IsBlueBookEnabled=false(master doesn't have that feature → was throwingBlueBook is not enabled by this dental office). - Office-local FKs (
FeeSched,ClaimFormNum, etc.): not copied — set manually in destination if needed (script flags which it reset). - Dedup: by carrier+group+groupnum; plans with empty group always created (not collapsed); hidden plans don't block.
- Idempotency: ledger prevents duplicates on re-run; sync (
--update) uses that mapping to update the correct destination plan. - Hidden plans from source are NOT copied: dummies/incomplete plans stay
IsHiddenin master; the copier skips them (except via explicit--plans). If a client was receiving incomplete plans, they most likely came from a hidden master plan. - Created visible: copied plans are forced to
IsHidden=false(don't inherit hidden state from source). - Gateway timeouts (502/503/504): automatically retried with backoff (OD server is sometimes slow; not a data error).
- Find a PlanNum:
find_plan.py --client <id> --search "<carrier or group>".
6. Recommended flow for new destination from scratch
- (If developer/demo base)
import_proc_codes.py --source 10 --dest <destination> - 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 markerPlan Updated - BC). - If OK, real small copy:
push_plans.py --source master --dest <destination> --limit 3. - Review 2-3 plans in destination Open Dental (frequencies/benefits OK).
- Full load (new client):
push_plans.py --source master --dest <destination>— without--all, uses default BC filter. - Monthly:
push_plans.py --source 10 --dest master --updateto keep master synced from A+.
--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)
| Flag | What 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. |
--all | ignore filter, copy all plans. |
--plans 7314,7311 | specific source PlanNums (manual copy). |
--update | sync: refresh imported + create new. |
--limit N | only first N (testing). |
--no-dedup | don't skip duplicates. |
--dry-run | no writes, shows what it would do. |
--ledger <file> | ledger path. |
extract_plans.py (office → Google Sheet)
| Flag | What it does |
|---|---|
--clients <ids> | offices comma-separated, or all. |
--filter "a,b" | same filter as above. |
--all | extract all plans. |
--sheet-id <id> | destination Google Sheet. |
--prefix <txt> | tab prefix (default Master). |
--dry-run | count only, no write. |
import_proc_codes.py (ADA codes source → destination)
| Flag | What it does |
|---|---|
--source <id> / --dest <id> | source (has codes) and destination. |
--limit N | only first N missing. |
--dry-run | count 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)
push_ledger_<dest>.json— source plan → destination PlanNum mapping (idempotency/sync).proccode_ledger_<dest>.json— ADA codes already imported.
9. Appendix — script source code
/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()
