Primer commit
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auditoría completa de custom fields usando SQLite (rápida, sin hits a API).
|
||||
|
||||
Verifica que las 49 sucursales tengan EXACTAMENTE la misma configuración que
|
||||
Marca en sus custom fields (contact + opportunity). Lee únicamente la tabla
|
||||
`object_schemas` de SQLite — esa tabla se mantiene fresca vía
|
||||
`sync_account_metadata` en sync_engine. Si dudas de la frescura, corre antes:
|
||||
|
||||
python -c "import sync_engine; \\
|
||||
[sync_engine.sync_account_metadata(a['location_id'], a['token']) \\
|
||||
for a in sync_engine.parse_accounts_csv()]"
|
||||
|
||||
Categorías de hallazgos por sucursal (vs Marca):
|
||||
|
||||
MISSING_IN_BRANCH — campo existe en Marca y no en sucursal.
|
||||
EXTRA_IN_BRANCH — campo existe en sucursal y no en Marca.
|
||||
NAME_MISMATCH — mismo fieldKey, distinto display name.
|
||||
DATATYPE_MISMATCH — mismo fieldKey, distinto dataType.
|
||||
OPTIONS_MISMATCH — mismo fieldKey, opciones distintas (orden o valores).
|
||||
POSITION_MISMATCH — mismo fieldKey, posición distinta en UI (cosmético).
|
||||
|
||||
También emite hallazgos cross-object dentro de cada cuenta:
|
||||
|
||||
XOBJ_DATATYPE_MISMATCH — mismo nombre normalizado, dataTypes distintos
|
||||
entre contact y opportunity.
|
||||
XOBJ_NAME_CASE — mismo nombre normalizado, casing distinto.
|
||||
XOBJ_OPTIONS_DIFFER — opciones distintas entre el field homólogo.
|
||||
|
||||
Output:
|
||||
- JSON ejecutivo + XLSX en generated/reports/audit_custom_fields/.
|
||||
- Resumen por stdout: cuentas afectadas por categoría, lista de hallazgos.
|
||||
|
||||
Filtros excluidos automáticamente (decisiones de negocio confirmadas):
|
||||
- `contact.Sucursal` SINGLE_OPTIONS vs `opportunity.Sucursal` TEXT
|
||||
es divergencia intencional. El audit lo registra como `IGNORED_BY_RULE`
|
||||
en lugar de DATATYPE_MISMATCH.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT_DIR not in sys.path:
|
||||
sys.path.insert(0, ROOT_DIR)
|
||||
|
||||
from paths import DB_PATH, REPORT_AUDIT_CUSTOM_FIELDS # noqa: E402
|
||||
|
||||
BRAND_LOCATION_ID = "GbKkBpCmKu2QmloKFHy3"
|
||||
|
||||
# Reglas de negocio explícitas — divergencias confirmadas como intencionales
|
||||
# por el owner del proyecto. (campo_normalizado, "object_a", "object_b")
|
||||
INTENTIONAL_DIVERGENCES = {
|
||||
("sucursal", "contact", "opportunity"),
|
||||
}
|
||||
|
||||
|
||||
def normalize_name(value):
|
||||
s = str(value or "").strip().lower()
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def extract_options(raw_json):
|
||||
"""Devuelve [labels] desde el raw_json del schema."""
|
||||
try:
|
||||
data = json.loads(raw_json or "{}")
|
||||
except Exception:
|
||||
return []
|
||||
opts = data.get("options")
|
||||
if isinstance(opts, list):
|
||||
out = []
|
||||
for o in opts:
|
||||
if isinstance(o, dict):
|
||||
lbl = o.get("label") or o.get("key")
|
||||
if lbl is not None: out.append(lbl)
|
||||
elif o is not None:
|
||||
out.append(str(o))
|
||||
return out
|
||||
legacy = data.get("picklistOptions")
|
||||
if isinstance(legacy, list):
|
||||
return [str(o) for o in legacy if o is not None]
|
||||
return []
|
||||
|
||||
|
||||
def field_position(raw_json):
|
||||
try:
|
||||
return json.loads(raw_json or "{}").get("position")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_schemas(conn):
|
||||
"""Devuelve dict[(location_id, object_key)] -> list[field dict].
|
||||
|
||||
Solo custom fields (excluye STANDARD_FIELD).
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
rows = cur.execute("""
|
||||
SELECT location_id, object_key, field_id, field_name, field_key,
|
||||
field_type, raw_json
|
||||
FROM object_schemas
|
||||
WHERE field_type != 'STANDARD_FIELD'
|
||||
ORDER BY location_id, object_key, field_name
|
||||
""").fetchall()
|
||||
out = defaultdict(list)
|
||||
for r in rows:
|
||||
out[(r["location_id"], r["object_key"])].append({
|
||||
"id": r["field_id"],
|
||||
"name": r["field_name"],
|
||||
"fieldKey": r["field_key"],
|
||||
"dataType": r["field_type"],
|
||||
"options": extract_options(r["raw_json"]),
|
||||
"position": field_position(r["raw_json"]),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def load_accounts(conn):
|
||||
cur = conn.cursor()
|
||||
rows = cur.execute("SELECT location_id, nombre, type FROM accounts ORDER BY nombre").fetchall()
|
||||
return [{"location_id": r["location_id"], "nombre": r["nombre"], "type": r["type"]} for r in rows]
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Comparaciones
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def compare_branch_to_brand(brand_fields, branch_fields, object_key):
|
||||
"""Genera findings por una sucursal vs Marca para un object_key dado."""
|
||||
findings = []
|
||||
brand_by_fk = {f["fieldKey"]: f for f in brand_fields if f.get("fieldKey")}
|
||||
branch_by_fk = {f["fieldKey"]: f for f in branch_fields if f.get("fieldKey")}
|
||||
|
||||
for fk, bf in brand_by_fk.items():
|
||||
if fk not in branch_by_fk:
|
||||
# Probar match por nombre normalizado (por si el fieldKey divergió)
|
||||
target_norm = normalize_name(bf.get("name"))
|
||||
match = next((f for f in branch_fields
|
||||
if normalize_name(f.get("name")) == target_norm), None)
|
||||
if not match:
|
||||
findings.append({
|
||||
"category": "MISSING_IN_BRANCH",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"fieldKey": fk,
|
||||
"dataType": bf.get("dataType"),
|
||||
})
|
||||
continue
|
||||
findings.append({
|
||||
"category": "FIELDKEY_DIVERGED",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"brand_fieldKey": fk,
|
||||
"branch_fieldKey": match.get("fieldKey"),
|
||||
})
|
||||
# Igual comparar atributos
|
||||
brf = match
|
||||
else:
|
||||
brf = branch_by_fk[fk]
|
||||
|
||||
if bf.get("name") != brf.get("name"):
|
||||
findings.append({
|
||||
"category": "NAME_MISMATCH",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"fieldKey": fk,
|
||||
"brand_name": bf.get("name"),
|
||||
"branch_name": brf.get("name"),
|
||||
})
|
||||
|
||||
if bf.get("dataType") != brf.get("dataType"):
|
||||
findings.append({
|
||||
"category": "DATATYPE_MISMATCH",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"fieldKey": fk,
|
||||
"brand_dataType": bf.get("dataType"),
|
||||
"branch_dataType": brf.get("dataType"),
|
||||
})
|
||||
|
||||
if list(bf.get("options") or []) != list(brf.get("options") or []):
|
||||
findings.append({
|
||||
"category": "OPTIONS_MISMATCH",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"fieldKey": fk,
|
||||
"brand_options": bf.get("options") or [],
|
||||
"branch_options": brf.get("options") or [],
|
||||
})
|
||||
|
||||
# Position diff es cosmético — lo marcamos aparte
|
||||
if bf.get("position") is not None and brf.get("position") is not None \
|
||||
and bf["position"] != brf["position"]:
|
||||
findings.append({
|
||||
"category": "POSITION_MISMATCH",
|
||||
"object": object_key,
|
||||
"field_name": bf.get("name"),
|
||||
"fieldKey": fk,
|
||||
"brand_position": bf.get("position"),
|
||||
"branch_position": brf.get("position"),
|
||||
})
|
||||
|
||||
# Campos extra en branch (que no están en Marca por fieldKey ni por nombre)
|
||||
brand_norms = {normalize_name(f.get("name")) for f in brand_fields}
|
||||
for f in branch_fields:
|
||||
fk = f.get("fieldKey")
|
||||
if fk in brand_by_fk:
|
||||
continue
|
||||
if normalize_name(f.get("name")) in brand_norms:
|
||||
continue
|
||||
findings.append({
|
||||
"category": "EXTRA_IN_BRANCH",
|
||||
"object": object_key,
|
||||
"field_name": f.get("name"),
|
||||
"fieldKey": fk,
|
||||
"dataType": f.get("dataType"),
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def compare_cross_object(contact_fields, opp_fields, account_name):
|
||||
"""Findings cross-object dentro de una cuenta."""
|
||||
findings = []
|
||||
contact_by_norm = defaultdict(list)
|
||||
opp_by_norm = defaultdict(list)
|
||||
for f in contact_fields:
|
||||
contact_by_norm[normalize_name(f.get("name"))].append(f)
|
||||
for f in opp_fields:
|
||||
opp_by_norm[normalize_name(f.get("name"))].append(f)
|
||||
|
||||
for norm, cs in contact_by_norm.items():
|
||||
if norm not in opp_by_norm:
|
||||
continue
|
||||
for c in cs:
|
||||
for o in opp_by_norm[norm]:
|
||||
# Excluir divergencias intencionales
|
||||
if (norm, "contact", "opportunity") in INTENTIONAL_DIVERGENCES:
|
||||
if c.get("dataType") != o.get("dataType"):
|
||||
findings.append({
|
||||
"category": "IGNORED_BY_RULE",
|
||||
"field_norm": norm,
|
||||
"rule": "sucursal_datatype_intentional",
|
||||
"contact_name": c.get("name"),
|
||||
"opportunity_name": o.get("name"),
|
||||
"contact_dataType": c.get("dataType"),
|
||||
"opportunity_dataType": o.get("dataType"),
|
||||
})
|
||||
continue
|
||||
|
||||
if c.get("name") != o.get("name"):
|
||||
findings.append({
|
||||
"category": "XOBJ_NAME_CASE",
|
||||
"field_norm": norm,
|
||||
"contact_name": c.get("name"),
|
||||
"opportunity_name": o.get("name"),
|
||||
})
|
||||
if c.get("dataType") != o.get("dataType"):
|
||||
findings.append({
|
||||
"category": "XOBJ_DATATYPE_MISMATCH",
|
||||
"field_norm": norm,
|
||||
"contact_name": c.get("name"),
|
||||
"opportunity_name": o.get("name"),
|
||||
"contact_dataType": c.get("dataType"),
|
||||
"opportunity_dataType": o.get("dataType"),
|
||||
})
|
||||
c_opts = list(c.get("options") or [])
|
||||
o_opts = list(o.get("options") or [])
|
||||
if c_opts and o_opts and c_opts != o_opts:
|
||||
findings.append({
|
||||
"category": "XOBJ_OPTIONS_DIFFER",
|
||||
"field_norm": norm,
|
||||
"contact_name": c.get("name"),
|
||||
"opportunity_name": o.get("name"),
|
||||
"contact_options": c_opts,
|
||||
"opportunity_options": o_opts,
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Reporte
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def write_xlsx(report, path):
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill("solid", fgColor="305496")
|
||||
crit_fill = PatternFill("solid", fgColor="F8CBAD")
|
||||
warn_fill = PatternFill("solid", fgColor="FFE699")
|
||||
cosm_fill = PatternFill("solid", fgColor="DDEBF7")
|
||||
info_fill = PatternFill("solid", fgColor="E2EFDA")
|
||||
|
||||
def hdr(ws, cols):
|
||||
ws.append(cols)
|
||||
for cell in ws[1]:
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
severity_map = {
|
||||
"MISSING_IN_BRANCH": ("CRITICAL", crit_fill),
|
||||
"EXTRA_IN_BRANCH": ("CRITICAL", crit_fill),
|
||||
"DATATYPE_MISMATCH": ("CRITICAL", crit_fill),
|
||||
"FIELDKEY_DIVERGED": ("CRITICAL", crit_fill),
|
||||
"OPTIONS_MISMATCH": ("WARNING", warn_fill),
|
||||
"NAME_MISMATCH": ("WARNING", warn_fill),
|
||||
"XOBJ_DATATYPE_MISMATCH": ("CRITICAL", crit_fill),
|
||||
"XOBJ_NAME_CASE": ("WARNING", warn_fill),
|
||||
"XOBJ_OPTIONS_DIFFER": ("WARNING", warn_fill),
|
||||
"POSITION_MISMATCH": ("COSMETIC", cosm_fill),
|
||||
"IGNORED_BY_RULE": ("INFO", info_fill),
|
||||
}
|
||||
|
||||
# Hoja 1: Resumen ejecutivo
|
||||
ws = wb.active
|
||||
ws.title = "Resumen"
|
||||
hdr(ws, ["Categoría", "Severidad", "Sucursales afectadas", "Total hallazgos"])
|
||||
per_cat = defaultdict(lambda: {"branches": set(), "count": 0})
|
||||
for loc_id, loc in report["branches"].items():
|
||||
for f in loc["findings"]:
|
||||
per_cat[f["category"]]["branches"].add(loc_id)
|
||||
per_cat[f["category"]]["count"] += 1
|
||||
for cat, agg in sorted(per_cat.items()):
|
||||
sev, fill = severity_map.get(cat, ("?", None))
|
||||
ws.append([cat, sev, len(agg["branches"]), agg["count"]])
|
||||
if fill:
|
||||
for cell in ws[ws.max_row]:
|
||||
cell.fill = fill
|
||||
ws.column_dimensions["A"].width = 32
|
||||
ws.column_dimensions["C"].width = 22
|
||||
ws.column_dimensions["D"].width = 22
|
||||
|
||||
# Hoja 2: Marca vs Sucursales (campo por campo)
|
||||
ws2 = wb.create_sheet("Marca vs Sucursales")
|
||||
hdr(ws2, [
|
||||
"Sucursal", "Location ID", "Objeto", "Categoría", "Severidad",
|
||||
"Campo", "fieldKey", "Detalle",
|
||||
])
|
||||
for loc_id, loc in report["branches"].items():
|
||||
for f in loc["findings"]:
|
||||
cat = f["category"]
|
||||
if cat.startswith("XOBJ_") or cat == "IGNORED_BY_RULE":
|
||||
continue
|
||||
sev, fill = severity_map.get(cat, ("?", None))
|
||||
detail_bits = []
|
||||
for k in ("brand_name","branch_name","brand_dataType","branch_dataType",
|
||||
"brand_options","branch_options","brand_position","branch_position",
|
||||
"brand_fieldKey","branch_fieldKey"):
|
||||
if k in f:
|
||||
v = f[k]
|
||||
if isinstance(v, list):
|
||||
v = "; ".join(map(str, v))
|
||||
detail_bits.append(f"{k}={v}")
|
||||
ws2.append([
|
||||
loc["name"], loc_id, f.get("object",""), cat, sev,
|
||||
f.get("field_name",""), f.get("fieldKey","") or f.get("brand_fieldKey",""),
|
||||
" | ".join(detail_bits),
|
||||
])
|
||||
if fill:
|
||||
for cell in ws2[ws2.max_row]:
|
||||
cell.fill = fill
|
||||
for col, w in [("A", 32), ("D", 26), ("F", 30), ("G", 36), ("H", 80)]:
|
||||
ws2.column_dimensions[col].width = w
|
||||
|
||||
# Hoja 3: Cross-object por cuenta
|
||||
ws3 = wb.create_sheet("Cross-object")
|
||||
hdr(ws3, [
|
||||
"Cuenta", "Location ID", "Categoría", "Severidad", "Campo (norm)",
|
||||
"Contact name", "Opportunity name", "Detalle",
|
||||
])
|
||||
for loc_id, loc in report["branches"].items():
|
||||
for f in loc["findings"]:
|
||||
cat = f["category"]
|
||||
if not (cat.startswith("XOBJ_") or cat == "IGNORED_BY_RULE"):
|
||||
continue
|
||||
sev, fill = severity_map.get(cat, ("?", None))
|
||||
detail_bits = []
|
||||
for k in ("contact_dataType","opportunity_dataType",
|
||||
"contact_options","opportunity_options","rule"):
|
||||
if k in f:
|
||||
v = f[k]
|
||||
if isinstance(v, list):
|
||||
v = "; ".join(map(str, v))
|
||||
detail_bits.append(f"{k}={v}")
|
||||
ws3.append([
|
||||
loc["name"], loc_id, cat, sev,
|
||||
f.get("field_norm",""),
|
||||
f.get("contact_name",""), f.get("opportunity_name",""),
|
||||
" | ".join(detail_bits),
|
||||
])
|
||||
if fill:
|
||||
for cell in ws3[ws3.max_row]:
|
||||
cell.fill = fill
|
||||
for col, w in [("A", 32), ("C", 26), ("E", 22), ("F", 30), ("G", 30), ("H", 80)]:
|
||||
ws3.column_dimensions[col].width = w
|
||||
|
||||
# Hoja 4: Inventario completo (Marca)
|
||||
ws4 = wb.create_sheet("Inventario Marca")
|
||||
hdr(ws4, ["Objeto", "Posición", "Campo", "fieldKey", "dataType", "Opciones"])
|
||||
for obj, fields in [("contact", report["brand_inventory"]["contact"]),
|
||||
("opportunity", report["brand_inventory"]["opportunity"])]:
|
||||
for f in sorted(fields, key=lambda x: x.get("position") or 9999):
|
||||
ws4.append([
|
||||
obj, f.get("position",""), f.get("name",""), f.get("fieldKey",""),
|
||||
f.get("dataType",""),
|
||||
"; ".join(map(str, f.get("options") or [])),
|
||||
])
|
||||
for col, w in [("C", 30), ("D", 36), ("E", 18), ("F", 80)]:
|
||||
ws4.column_dimensions[col].width = w
|
||||
|
||||
wb.save(path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Auditoría completa de custom fields desde SQLite.")
|
||||
parser.add_argument("--json", help="Ruta de salida JSON.")
|
||||
parser.add_argument("--xlsx", help="Ruta de salida XLSX.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
schemas = load_schemas(conn)
|
||||
accounts = load_accounts(conn)
|
||||
accounts_by_id = {a["location_id"]: a for a in accounts}
|
||||
brand = accounts_by_id.get(BRAND_LOCATION_ID)
|
||||
if not brand:
|
||||
raise SystemExit(f"Marca {BRAND_LOCATION_ID} no encontrada en SQLite.")
|
||||
|
||||
brand_contact = schemas.get((BRAND_LOCATION_ID, "contact"), [])
|
||||
brand_opp = schemas.get((BRAND_LOCATION_ID, "opportunity"), [])
|
||||
|
||||
report = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"source": f"SQLite ({DB_PATH})",
|
||||
"brand": {
|
||||
"location_id": BRAND_LOCATION_ID,
|
||||
"name": brand["nombre"],
|
||||
"field_counts": {
|
||||
"contact": len(brand_contact),
|
||||
"opportunity": len(brand_opp),
|
||||
},
|
||||
},
|
||||
"brand_inventory": {
|
||||
"contact": brand_contact,
|
||||
"opportunity": brand_opp,
|
||||
},
|
||||
"branches": {},
|
||||
}
|
||||
|
||||
# Procesar cada cuenta (incluyendo Marca para validar cross-object)
|
||||
for acc in accounts:
|
||||
loc_id = acc["location_id"]
|
||||
c = schemas.get((loc_id, "contact"), [])
|
||||
o = schemas.get((loc_id, "opportunity"), [])
|
||||
findings = []
|
||||
if loc_id != BRAND_LOCATION_ID:
|
||||
findings.extend(compare_branch_to_brand(brand_contact, c, "contact"))
|
||||
findings.extend(compare_branch_to_brand(brand_opp, o, "opportunity"))
|
||||
findings.extend(compare_cross_object(c, o, acc["nombre"]))
|
||||
report["branches"][loc_id] = {
|
||||
"name": acc["nombre"],
|
||||
"type": acc["type"],
|
||||
"field_counts": {"contact": len(c), "opportunity": len(o)},
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
# Stdout: resumen
|
||||
total_branches = sum(1 for k in report["branches"] if k != BRAND_LOCATION_ID)
|
||||
print(f"AUDITORÍA COMPLETA — Marca + {total_branches} sucursales\n")
|
||||
print(f"Inventario Marca: contact={len(brand_contact)} cf, opportunity={len(brand_opp)} cf\n")
|
||||
|
||||
# Resumen por categoría (todos los hallazgos)
|
||||
per_cat_branches = defaultdict(set)
|
||||
per_cat_count = defaultdict(int)
|
||||
for loc_id, loc in report["branches"].items():
|
||||
for f in loc["findings"]:
|
||||
per_cat_branches[f["category"]].add(loc_id)
|
||||
per_cat_count[f["category"]] += 1
|
||||
|
||||
print("Hallazgos por categoría:")
|
||||
if not per_cat_count:
|
||||
print(" (sin hallazgos — schema 100% alineado)")
|
||||
else:
|
||||
for cat in sorted(per_cat_count):
|
||||
print(f" {cat:32} {per_cat_count[cat]:>5} hallazgos {len(per_cat_branches[cat]):>3} cuentas")
|
||||
|
||||
# Sucursales 100% limpias (vs Marca + cross-object sin XOBJ críticos)
|
||||
critical_cats = {
|
||||
"MISSING_IN_BRANCH","EXTRA_IN_BRANCH","DATATYPE_MISMATCH",
|
||||
"FIELDKEY_DIVERGED","NAME_MISMATCH","OPTIONS_MISMATCH",
|
||||
"XOBJ_DATATYPE_MISMATCH","XOBJ_NAME_CASE","XOBJ_OPTIONS_DIFFER",
|
||||
}
|
||||
fully_aligned = []
|
||||
with_issues = []
|
||||
for loc_id, loc in report["branches"].items():
|
||||
if loc_id == BRAND_LOCATION_ID: continue
|
||||
crits = [f for f in loc["findings"] if f["category"] in critical_cats]
|
||||
(fully_aligned if not crits else with_issues).append((loc["name"], loc_id, len(crits)))
|
||||
|
||||
print(f"\nSucursales 100% alineadas (sin hallazgos críticos): {len(fully_aligned)}/{total_branches}")
|
||||
print(f"Sucursales con hallazgos críticos: {len(with_issues)}")
|
||||
for n, i, c in sorted(with_issues, key=lambda x: -x[2]):
|
||||
print(f" {c:>4} hallazgos — {n} ({i})")
|
||||
|
||||
# Outputs
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
os.makedirs(REPORT_AUDIT_CUSTOM_FIELDS, exist_ok=True)
|
||||
json_path = args.json or os.path.join(REPORT_AUDIT_CUSTOM_FIELDS, f"audit_full_db_{ts}.json")
|
||||
xlsx_path = args.xlsx or os.path.join(REPORT_AUDIT_CUSTOM_FIELDS, f"audit_full_db_{ts}.xlsx")
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(report, fh, ensure_ascii=False, indent=2, default=str)
|
||||
write_xlsx(report, xlsx_path)
|
||||
print(f"\nJSON: {json_path}")
|
||||
print(f"XLSX: {xlsx_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user