From 2e5e3f3b9be0fb074bb6b28b2db230036c97055e Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 5 Oct 2026 12:32:39 -0500 Subject: [PATCH] Actualizar backend/services.py --- backend/services.py | 824 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 806 insertions(+), 18 deletions(-) diff --git a/backend/services.py b/backend/services.py index cfee5fa..733b0c6 100644 --- a/backend/services.py +++ b/backend/services.py @@ -100,12 +100,31 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): # PENDIENTES: recalcular desde el MISMO detalle del "Ver" (__TODOS__), para que # la tabla principal y el popup Ver salgan SIEMPRE idénticos (misma fuente/regla). try: + # El dm es compartido; un refresco en 2º plano (con_overrides=False) puede + # haber borrado los overrides entre medio. Re-forzarlos aquí para Comisiones. + if con_overrides: + dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) + dm._fecha_canc_overrides = comisiones_overrides_fecha() + dm._inv_neta_overrides = comisiones_overrides_inv_neta() det = logic.obtener_detalle_vendedor("__TODOS__", str(ano), str(mes), _excl_ret) pend_filas = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] + # Aplicar el MISMO filtro de SEDE/PROGRAMA que la tabla (por eso la columna + # "MES PASADO" = Venta Pendientes debe cambiar con el filtro de Programa/Sede). + _sede_set = {s.strip().upper() for s in str(sede or "TODOS").split(",") if s.strip()} + _prog_set = {p.strip().upper() for p in str(programa or "TODOS").split(",") if p.strip()} + _sede_all = (not _sede_set) or ("TODOS" in _sede_set) + _prog_all = (not _prog_set) or ("TODOS" in _prog_set) pend_por_vend = {} for f in pend_filas: if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": continue + # Filtro sede [14] y programa (clasificado desde el nombre [16]/[8]) + if (not _sede_all or not _prog_all) and len(f) >= 15: + if not _sede_all and str(f[14]).strip().upper() not in _sede_set: + continue + _prog_nombre = f[16] if len(f) > 16 else f[8] + if not _prog_all and _clasif_filtro_programa(dm, _prog_nombre) not in _prog_set: + continue vend = str(f[0]).strip() # INV NETA está en la posición [7] como "S/ x" try: @@ -115,10 +134,14 @@ def ventas(ano, mes, sede="TODOS", programa="TODOS", con_overrides=False): acc = pend_por_vend.setdefault(vend, {"monto": 0.0, "cant": 0}) acc["monto"] += monto acc["cant"] += 1 - # Sobrescribir PENDIENTES en cada registro de la tabla principal + # Sobrescribir PENDIENTES en cada registro de la tabla principal. + # Match tolerante por nombre (normalizado) para no fallar por puntos/comas. + def _nrm(x): + return " ".join(str(x).upper().replace(".", "").replace(",", "").split()) + pend_norm = { _nrm(k): val for k, val in pend_por_vend.items() } for d in datos: v = str(d.get("VENDEDOR", "")).strip() - acc = pend_por_vend.get(v, {"monto": 0.0, "cant": 0}) + acc = pend_por_vend.get(v) or pend_norm.get(_nrm(v)) or {"monto": 0.0, "cant": 0} d["PENDIENTES"] = round(acc["monto"], 2) d["INSCRITOS_PENDIENTES"] = acc["cant"] except Exception as e: @@ -182,7 +205,8 @@ def comisiones_detalle_todos(ano, mes): def _load(): dm = get_dm() logic = VentasLogic(dm) - filas = logic.buscar_todos_matriculados() + tc_por_mes = comisiones_tc_todos_los_meses() # {(ano,mes): imp_tc} + filas = logic.buscar_todos_matriculados(tc_por_mes) filas = _aplicar_overrides_comisiones(filas) return {"filas": _serializar_filas(filas)} return cache_get_or_set("comisiones_det_todos", ("GLOBAL",), _load) @@ -279,7 +303,7 @@ def _aplicar_overrides_comisiones(filas): # ── COBRANZA ─────────────────────────────────────────────────────────────── -def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS"): +def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS", prog_filtro="TODOS", venc_hasta="TODOS", frec_filtro="TODOS"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) @@ -288,16 +312,16 @@ def cobranza(ano, mes, sectorista="TODOS", agrupacion="SEDE", sede_filtro="TODOS mats_retirar = set(alumnos_por_retirar(ano, mes).get("matriculas", [])) except Exception: mats_retirar = set() - filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta, mats_retirar) + filas = logic.obtener_datos_tabla(str(ano), str(mes), sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta, mats_retirar, frec_filtro) sectoristas = logic.obtener_lista_sectoristas(str(ano), str(mes)) return { "filas": _serializar_filas(filas), "sectoristas": sectoristas or ["TODOS"], } - return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta), _load) + return cache_get_or_set("cobranza", (ano, mes, sectorista, agrupacion, sede_filtro, prog_filtro, venc_hasta, frec_filtro), _load) -def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS"): +def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_filtro="TODOS", sede_filtro="TODOS", venc_hasta="TODOS", frec_filtro="TODOS"): def _load(): dm = get_dm() logic = CobranzaLogic(dm) @@ -311,7 +335,7 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi # Mapa MATRICULA -> programa, para filtrar por tipo de programa y/o sede (REPLACE_META) mats_prog = None if not _pf_all or not _sf_all: - crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") + crudos_pf = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro) mats_prog = set() for d in (crudos_pf or []): prog = d.get("PROGRAMA", "") or d.get("dsc_programa", "") @@ -320,10 +344,10 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi if ok_prog and ok_sede: mats_prog.add(str(d.get("MATRICULA", ""))) if agrupacion == "ASESOR": - datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh) + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), grupo, "TODOS", vh, frec_filtro) elif agrupacion == "SEDE": # Filtrar por sede usando el MISMO clasificador del processor (sede.json) - crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS") + crudos = proc.obtener_detalle_programa(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro) grupo_up = str(grupo).upper() mats_sede = set() for d in (crudos or []): @@ -331,16 +355,16 @@ def cobranza_detalle(grupo, ano, mes, sectorista, agrupacion="PROGRAMA", prog_fi sede_det = str(proc.clasificar_sede(prog)).upper() if sede_det == grupo_up: mats_sede.add(str(d.get("MATRICULA", ""))) - todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh) + todas = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, "TODOS", vh, frec_filtro) # Solo alumnos de la sede (sin la fila TOTAL global; el frontend recalcula el total) datos = [f for f in (todas or []) if str(f[0]) in mats_sede] else: # PROGRAMA - datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh) + datos = logic.obtener_detalle_programa_formateado(str(ano), str(mes), sectorista, grupo, vh, frec_filtro) # Filtro por Sede y/o tipo de programa (TEAC/TERC/...) si aplica if mats_prog is not None: datos = [f for f in (datos or []) if str(f[0]) in mats_prog or str(f[0]).upper() == "TOTAL"] return {"filas": _serializar_filas(datos)} - return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta), _load) + return cache_get_or_set("cobranza_det", (grupo, ano, mes, sectorista, agrupacion, prog_filtro, sede_filtro, venc_hasta, frec_filtro), _load) # ── RENTABILIDAD ─────────────────────────────────────────────────────────── @@ -795,6 +819,16 @@ def retiros(ano="2026", mes=8): mats_ret = set() if not mats_ret: return {"filas": [], "guardado": False} + # Matrículas marcadas POR RETIRAR desde el BUSCADOR de Retiros (para pintarlas). + mats_buscador = set() + try: + _rb = (_sb().table("cobranza_estado_alumno") + .select("matricula,actualizado_por") + .eq("estado", "POR RETIRAR").eq("ano", int(ano)).eq("mes", int(mes)) + .eq("actualizado_por", "BUSCADOR_RETIROS").execute()) + mats_buscador = {str(r["matricula"]) for r in (_rb.data or [])} + except Exception: + mats_buscador = set() dm = get_dm() proc = CobranzaLogic(dm).processor # para clasificar SEDE por programa (REPLACE_META) crono = dm.ejecutar_consulta_cronograma_cobranza(str(ano)) or [] @@ -806,16 +840,15 @@ def retiros(ano="2026", mes=8): idx[m] = f # dsc_vendedor + cod_detalle por matrícula (consulta ligera, solo las POR RETIRAR) try: - extra = dm.traer_vendedor_cod_detalle(list(mats_ret)) + extra = dm.traer_vendedor_cod_detalle(list(mats_ret), ano, mes) except Exception: extra = {} # Saldos MES ANTERIOR y MES EN CURSO por matrícula (mismos datos de Cobranza). # cta_ant-cob_ant = saldo mes anterior; cta_cur-cob_cur = saldo mes en curso. - from datetime import datetime as _dtnow - _hoy = _dtnow.now() cob_idx = {} try: - alum = cobranza_detalle_todos(str(ano), str(_hoy.month), "TODOS").get("alumnos", []) + # Usar el MES DEL FILTRO (no la fecha de hoy) para los saldos vencidos. + alum = cobranza_detalle_todos(str(ano), str(mes), "TODOS").get("alumnos", []) for a in alum: cob_idx[str(a.get("matricula", "")).strip()] = a except Exception: @@ -840,6 +873,7 @@ def retiros(ano="2026", mes=8): _prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip() filas.append({ "matricula": str(m), + "origen_buscador": (str(m) in mats_buscador), # marcado desde Buscar Alumno "sede": str(proc.clasificar_sede_meta(_prog)), "fch_matricula": _d(f.get("fch_matricula")), "asesor": str(f.get("dsc_sectorista", "") or "").strip(), @@ -1266,6 +1300,145 @@ def comisiones_tc_del_mes(ano, mes): return None +def comisiones_metas_listar(ano, mes): + """Lista las metas por asesor de un mes (tabla comisiones_metas_asesor).""" + sb = _sb() + res = (sb.table("comisiones_metas_asesor").select("*") + .eq("ano", int(ano)).eq("mes", int(mes)).execute()) + return {"metas": res.data or []} + + +def comisiones_metas_guardar(ano, mes, filas): + """Upsert de metas por asesor en comisiones_metas_asesor. + filas = [{asesor, grupo, meta, pct_bajo_meta, pct_sobre_meta}]. + Ignora filas totalmente vacías (sin ningún valor).""" + from datetime import datetime, timezone + sb = _sb() + ahora = datetime.now(timezone.utc).isoformat() + payload = [] + for f in filas: + asesor = str(f.get("asesor", "") or "").strip() + grupo = str(f.get("grupo", "") or "").strip() + if not asesor or not grupo: + continue + meta = f.get("meta") + bajo = f.get("pct_bajo_meta") + sobre = f.get("pct_sobre_meta") + # Ignorar filas totalmente vacías (sin ningún valor) + if (meta in (None, "")) and (bajo in (None, "")) and (sobre in (None, "")): + continue + payload.append({ + "ano": int(ano), + "mes": int(mes), + "asesor": asesor, + "grupo": grupo, + "meta": float(meta or 0), + "pct_bajo_meta": float(bajo or 0), + "pct_sobre_meta": float(sobre or 0), + "actualizado_en": ahora, + }) + if payload: + sb.table("comisiones_metas_asesor").upsert( + payload, on_conflict="ano,mes,asesor,grupo").execute() + return {"status": "ok"} + + +def _factura_fecha_norm(valor): + """Normaliza fch_emision (datetime/date/str) a (str 'yyyy-mm-dd', mes:int). + Devuelve ('', 0) si no se puede interpretar.""" + from datetime import datetime, date + if valor is None: + return "", 0 + # datetime/date nativos (pyodbc suele devolver datetime) + if isinstance(valor, (datetime, date)): + return valor.strftime("%Y-%m-%d"), int(valor.month) + s = str(valor).strip() + if not s: + return "", 0 + # Tomar solo la parte de fecha (por si viene con hora) + s = s.replace("T", " ").split(" ")[0].strip() + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%m/%d/%Y", "%Y/%m/%d"): + try: + d = datetime.strptime(s, fmt) + return d.strftime("%Y-%m-%d"), int(d.month) + except Exception: + continue + return s, 0 + + +def facturas_buscar(ano, mes=0): + """Facturas del año para el buscador del modal de vendedores manuales. + Deduplica por num_comprobante (una fila por comprobante, la primera) y + devuelve por cada una el monto ya convertido a SOLES: + - SOL → monto_soles = imp_subtotal + - DOL + TC mes → monto_soles = imp_subtotal * TC_manual_del_mes + - DOL sin TC → monto_soles = imp_subtotal * imp_tc (de la factura) + El TC del mes se toma del mes/año del VENDEDOR que se está editando + (parámetro mes), no del mes de cada factura.""" + def _load(): + dm = get_dm() + filas = dm.ejecutar_consulta_facturas_cobranza(str(ano)) + tc_mes = comisiones_tc_del_mes(ano, mes) if int(mes or 0) != 0 else None + vistos = set() + out = [] + for f in (filas or []): + comp = str(f.get("num_comprobante", "") or "").strip() + if not comp or comp in vistos: + continue + vistos.add(comp) + fch_str, mes_f = _factura_fecha_norm(f.get("fch_emision")) + cod_moneda = str(f.get("cod_moneda", "") or "").strip().upper() + try: imp_subtotal = float(f.get("imp_subtotal", 0) or 0) + except Exception: imp_subtotal = 0.0 + try: imp_igv = float(f.get("imp_igv", 0) or 0) + except Exception: imp_igv = 0.0 + try: imp_total = float(f.get("imp_total", 0) or 0) + except Exception: imp_total = 0.0 + try: imp_tc_fac = float(f.get("imp_tc", 0) or 0) + except Exception: imp_tc_fac = 0.0 + # Monto en soles + if cod_moneda == "DOL": + tc = tc_mes if (tc_mes and tc_mes > 0) else (imp_tc_fac if imp_tc_fac > 0 else 1.0) + monto_soles = imp_subtotal * tc + else: + monto_soles = imp_subtotal + out.append({ + "num_comprobante": comp, + "fch_emision": fch_str, + "mes": mes_f, + "dsc_deudor": str(f.get("dsc_deudor", "") or ""), + "cod_estado": str(f.get("cod_estado", "") or ""), + "cod_moneda": cod_moneda, + "imp_subtotal": imp_subtotal, + "imp_igv": imp_igv, + "imp_total": imp_total, + "imp_tc": imp_tc_fac, + "monto_soles": round(monto_soles, 2), + }) + # tc_mes: TC manual del mes (o None) — lo usa el "Agregar manual" en dólares. + return {"facturas": out, "tc_mes": (tc_mes if tc_mes and tc_mes > 0 else None)} + return cache_get_or_set("facturas_buscar", (ano, mes), _load) + + +def comisiones_tc_todos_los_meses(): + """Devuelve dict {(ano, mes): imp_tc} de TODOS los meses configurados (TC > 0). + Se usa para convertir dólares en el buscador según el mes de cada cancelación.""" + out = {} + try: + sb = _sb() + res = sb.table("comisiones_config_mensual").select("ano,mes,imp_tc").execute() + for r in (res.data or []): + try: + tc = float(r.get("imp_tc", 0) or 0) + if tc > 0: + out[(int(r["ano"]), int(r["mes"]))] = tc + except Exception: + pass + except Exception: + pass + return out + + def vendedores_manuales_listar(ano, mes): sb = _sb() query = sb.table("vendedores_manuales").select("*").eq("ano", int(ano)) @@ -1301,7 +1474,8 @@ def vendedores_manuales_actualizar(id, descripcion, fch_emision, monto): def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): """Reemplaza TODAS las filas de un vendedor manual en una sola operación. - filas = lista de dicts {descripcion, fch_emision, monto}. Si filas vacía → elimina el vendedor.""" + filas = lista de dicts {descripcion, fch_emision, monto, num_comprobante?}. + Si filas vacía → elimina el vendedor.""" sb = _sb() # Borrar las existentes de ese vendedor/mes sb.table("vendedores_manuales").delete().eq("nombre", nombre).eq("ano", int(ano)).eq("mes", int(mes)).execute() @@ -1313,6 +1487,7 @@ def vendedores_manuales_guardar_lote(nombre, ano, mes, filas): "descripcion": f.get("descripcion","") or "", "fch_emision": f.get("fch_emision","") or "", "monto": float(f.get("monto",0) or 0), + "num_comprobante": f.get("num_comprobante","") or "", "ano": int(ano), "mes": int(mes), }) if payload: @@ -1344,3 +1519,616 @@ def comisiones_override_restaurar(num_matricula): cache_invalidate("ventas") cache_invalidate("comisiones_det_todos") return {"status": "ok"} + + +# ── EXPORT A EXCEL (Comisiones: una hoja por asesor, 2 cuadros) ───────────── +def comisiones_export_excel(ano, mes): + """Genera un Excel (bytes) con UNA HOJA POR ASESOR. + Cada hoja lleva 2 cuadros con los alumnos de 'Venta P.C' (inscritos pagados + del mes) de ese asesor, para el año/mes indicados: + Cuadro 1 (arriba): programas TEAC/TERC — 14 columnas. + Cuadro 2 (abajo, tras 2 filas en blanco): SEMINARIOS/OTROS/otros — 11 + columnas (sin Promedio Cuota, Días de Anticipación, Valor Cuota Adicional). + NO altera ninguna lógica de cálculo; sólo lee obtener_detalle_vendedor(). + """ + from io import BytesIO + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + dm = get_dm() + # Overrides de Comisiones (igual que ventas_detalle / ventas con_overrides=True) + dm._tc_override_mes = comisiones_tc_del_mes(ano, mes) + dm._fecha_canc_overrides = comisiones_overrides_fecha() + dm._inv_neta_overrides = comisiones_overrides_inv_neta() + logic = VentasLogic(dm) + + # Lista de asesores del mes (desde la tabla de ventas con overrides). + res_ventas = ventas(ano, mes, "TODOS", "TODOS", True) + vendedores = [] + _vistos = set() + for d in (res_ventas.get("datos") or []): + v = str(d.get("VENDEDOR", "")).strip() + up = v.upper() + if not v or up in ("SIN VENDEDOR", "TOTAL GENERAL"): + continue + if up in _vistos: + continue + _vistos.add(up) + vendedores.append(v) + + # Encabezados + HEAD_FULL = [ + "Nombre", "Fecha Matricula", "Vendedor", "Moneda", "Inversion", + "Saldo Matricula", "Saldo Cuota 1", "Cuota", "Programa", "Fecha Inicio", + "Fecha de Pago", "Promedio Cuota", "Dias de Anticipacion", + "Valor Cuota Adicional", + ] + HEAD_SHORT = HEAD_FULL[:11] # termina en "Fecha de Pago" + + def _num(x): + """Convierte '"S/ 1,234"' / '1,234' / '' a número (float) o None si vacío.""" + s = str(x if x is not None else "").replace("S/", "").replace(",", "").strip() + if s in ("", "-", "None"): + return None + try: + return float(s) + except Exception: + return None + + # Estilos + head_font = Font(bold=True, color="FFFFFF") + head_fill = PatternFill("solid", fgColor="305496") + head_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin = Side(style="thin", color="BFBFBF") + border = Border(left=thin, right=thin, top=thin, bottom=thin) + red_font = Font(color="FF0000", bold=True) + + def _fila_valores(f, corto): + """Devuelve la lista de valores en el orden de columnas, marcando cuáles + van en rojo (índice de columna -> True).""" + # Índices de la fila cruda de obtener_detalle_vendedor: + moneda = str(f[17]) if len(f) > 17 else "" + vals = [ + f[1], # Nombre + f[2], # Fecha Matricula + f[0], # Vendedor + moneda, # Moneda + _num(f[7]), # Inversion (soles) + _num(f[10]), # Saldo_Matricula + _num(f[11]), # Saldo Cuota 1 + "", # Cuota (vacío) + f[8], # Programa + f[9], # Fecha Inicio + f[3], # Fecha de Pago + ] + rojos = {} + if not corto: + prom = _num(f[5]) # Promedio Cuota + dias = _num(f[12]) if len(f) > 12 else None # Dias de Anticipacion + # Días de Anticipación: si es negativo -> 0. + dias_val = 0 if (dias is not None and dias < 0) else (dias if dias is not None else "") + vals.append(prom if prom is not None else "") + vals.append(dias_val) + # Valor Cuota Adicional (recalculado con la nueva regla): + # Promedio Cuota - 650 si Días de Anticipación es 0,1,2,3 + # Promedio Cuota - 640 si Días de Anticipación es 4 o más + # Si el resultado es <= 0 -> 0 (en rojo). + _dias_int = int(dias_val) if isinstance(dias_val, (int, float)) else 0 + if prom is None: + adic = 0 + else: + resta = 650 if _dias_int in (0, 1, 2, 3) else 640 + adic = prom - resta + if adic <= 0: + vals.append(0) + rojos[13] = True + else: + vals.append(adic) + return vals, rojos + + total_font = Font(bold=True) + total_fill = PatternFill("solid", fgColor="DDEBF7") + + def _escribir_cuadro(ws, start_row, headers, filas, corto): + # Fila de encabezados + for c, h in enumerate(headers, start=1): + cell = ws.cell(row=start_row, column=c, value=h) + cell.font = head_font + cell.fill = head_fill + cell.alignment = head_align + cell.border = border + r = start_row + 1 + sum_inv = 0.0 # columna 5 = Inversion + sum_adic = 0.0 # columna 14 = Valor Cuota Adicional (solo cuadro completo) + for f in filas: + vals, rojos = _fila_valores(f, corto) + for c, v in enumerate(vals, start=1): + cell = ws.cell(row=r, column=c, value=v) + cell.border = border + if rojos.get(c - 1): + cell.font = red_font + # acumular totales + try: sum_inv += float(vals[4] or 0) # Inversion (col 5, índice 4) + except: pass + if not corto and len(vals) >= 14: + try: sum_adic += float(vals[13] or 0) # Valor Cuota Adicional + except: pass + r += 1 + # Fila TOTAL GENERAL (suma Inversion y Valor Cuota Adicional) + cell = ws.cell(row=r, column=1, value="TOTAL GENERAL") + cell.font = total_font; cell.fill = total_fill; cell.border = border + for c in range(2, len(headers) + 1): + cc = ws.cell(row=r, column=c) + cc.fill = total_fill; cc.border = border + ci = ws.cell(row=r, column=5, value=round(sum_inv, 2)) # Inversion + ci.font = total_font; ci.fill = total_fill; ci.border = border + if not corto: + ca = ws.cell(row=r, column=14, value=round(sum_adic, 2)) # Valor Cuota Adic. + ca.font = total_font; ca.fill = total_fill; ca.border = border + r += 1 + return r, round(sum_inv, 2) # (próxima fila libre, total inversión del cuadro) + + def _solo_nombre(vend): + # De "PERALTA C. ALMENDRA LUCIA" -> "ALMENDRA" (primera palabra tras la + # inicial + punto). Si no hay ese patrón, toma la última palabra. + partes = str(vend or "").split() + for i, p in enumerate(partes): + if p.endswith(".") and len(p) <= 2: # inicial tipo "C." + if i + 1 < len(partes): + return partes[i + 1] + return partes[-1] if partes else str(vend or "Asesor") + + def _sanear_hoja(nombre, usados): + # Excel: máx 31 chars y sin : \ / ? * [ ]. Usa solo el NOMBRE. + base = _solo_nombre(nombre) + for ch in ':\\/?*[]': + base = base.replace(ch, " ") + base = " ".join(base.split()).strip() or "Asesor" + base = base[:31] + nombre_final = base + i = 2 + while nombre_final.upper() in usados: + suf = f" ({i})" + nombre_final = base[:31 - len(suf)] + suf + i += 1 + usados.add(nombre_final.upper()) + return nombre_final + + wb = openpyxl.Workbook() + wb.remove(wb.active) # quitar hoja por defecto + hojas_usadas = set() + + def _clasificar(filas): + """Separa las filas en cuadro1 (LIMA TEAC/TERC) y cuadro2 (resto).""" + c1, c2 = [], [] + for f in filas: + if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": + continue + nombre_prog = f[16] if len(f) > 16 else (f[8] if len(f) > 8 else "") + try: + clase = str(logic.clasificar_programa(nombre_prog)).upper() + except Exception: + clase = "OTROS" + sede = str(f[14]).strip().upper() if len(f) > 14 else "LIMA" + if clase in ("TEAC", "TERC") and sede == "LIMA": + c1.append(f) + else: + c2.append(f) + return c1, c2 + + def _sumar_por_grupo(filas): + """Suma la Inversión Neta (soles) por grupo: PT (TEAC/TERC, cualquier sede), + ESP (SEMINARIOS), OTROS (el resto). Devuelve dict {PT, ESP, OTROS}.""" + tot = {"PT": 0.0, "ESP": 0.0, "OTROS": 0.0} + for f in filas: + if not f or str(f[0]).strip().upper() == "TOTAL GENERAL": + continue + nombre_prog = f[16] if len(f) > 16 else (f[8] if len(f) > 8 else "") + try: + clase = str(logic.clasificar_programa(nombre_prog)).upper() + except Exception: + clase = "OTROS" + inv = _num(f[7]) or 0.0 + if clase in ("TEAC", "TERC"): + tot["PT"] += inv + elif clase == "SEMINARIOS": + tot["ESP"] += inv + else: + tot["OTROS"] += inv + return tot + + # Acumulador del RESUMEN: por asesor, montos por grupo y período. + resumen = [] # [{nombre, pt_ant, pt_cur, esp_ant, esp_cur, otr_ant, otr_cur}] + + def _escribir_bloque(ws, start_row, titulo, filas): + """Escribe un bloque completo: título + 2 cuadros + TOTAL. Devuelve la + próxima fila libre. Omite el cuadro que no tenga datos.""" + c1, c2 = _clasificar(filas) + # Título del bloque (MES EN CURSO / MESES PASADOS) + tcell = ws.cell(row=start_row, column=1, value=titulo) + tcell.font = Font(bold=True, size=12, color="1F4E79") + r = start_row + 1 + inv1 = inv2 = 0.0 + if c1: + r, inv1 = _escribir_cuadro(ws, r, HEAD_FULL, c1, corto=False) + r += 2 + if c2: + r, inv2 = _escribir_cuadro(ws, r, HEAD_SHORT, c2, corto=True) + r += 1 + # 2 filas abajo: TOTAL (col D) + suma de inversiones de ambos cuadros (col E) + rtot = r + 2 + cd = ws.cell(row=rtot, column=4, value="TOTAL") + cd.font = total_font; cd.fill = total_fill; cd.border = border + ce = ws.cell(row=rtot, column=5, value=round(inv1 + inv2, 2)) + ce.font = total_font; ce.fill = total_fill; ce.border = border + return rtot + 1, (bool(c1) or bool(c2)) + + for vend in vendedores: + try: + det = logic.obtener_detalle_vendedor(vend, str(ano), str(mes), False) + except Exception as e: + print(f"[comisiones_export] {vend}: {e}") + det = {} + filas_pc = det.get("Venta P.C", []) if isinstance(det, dict) else [] + filas_pend = det.get("Venta Pendientes", []) if isinstance(det, dict) else [] + # Aplicar overrides de Comisiones a las filas (montos/fechas editados) + filas_pc = _aplicar_overrides_comisiones(list(filas_pc)) + filas_pend = _aplicar_overrides_comisiones(list(filas_pend)) + + # Si el asesor NO tiene datos en ninguno de los dos bloques, NO crear su hoja. + c1_pc, c2_pc = _clasificar(filas_pc) + c1_pp, c2_pp = _clasificar(filas_pend) + if not (c1_pc or c2_pc or c1_pp or c2_pp): + continue + + # Acumular para el RESUMEN (MES EN CURSO = Venta P.C; MES ANTERIOR = Pendientes) + _cur = _sumar_por_grupo(filas_pc) + _ant = _sumar_por_grupo(filas_pend) + if (_cur["PT"] + _cur["ESP"] + _cur["OTROS"] + _ant["PT"] + _ant["ESP"] + _ant["OTROS"]) > 0: + resumen.append({ + "nombre": _solo_nombre(vend), + "pt_ant": _ant["PT"], "pt_cur": _cur["PT"], + "esp_ant": _ant["ESP"], "esp_cur": _cur["ESP"], + "otr_ant": _ant["OTROS"], "otr_cur": _cur["OTROS"], + }) + + ws = wb.create_sheet(title=_sanear_hoja(vend, hojas_usadas)) + anchos = [26, 14, 20, 8, 12, 14, 13, 8, 24, 12, 13, 14, 16, 18] + for i, w in enumerate(anchos, start=1): + ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w + + # BLOQUE 1: MES EN CURSO (Venta P.C) + prox, _ = _escribir_bloque(ws, 1, "MES EN CURSO", filas_pc) + # BLOQUE 2: MESES PASADOS (Venta Pendientes) — 3 filas más abajo + _escribir_bloque(ws, prox + 3, "MESES PASADOS", filas_pend) + + # Agregar VENDEDORES MANUALES al final del RESUMEN: todo su monto va a + # OTROS → MES EN CURSO. Agrupados por nombre de vendedor manual. + try: + _sb_m = _sb() + _res_m = (_sb_m.table("vendedores_manuales").select("nombre,monto") + .eq("ano", int(ano)).eq("mes", int(mes)).execute()) + _man_por_nombre = {} + for _row in (_res_m.data or []): + try: _mt = float(_row.get("monto", 0) or 0) + except: _mt = 0.0 + if _mt <= 0: + continue + _nm = str(_row.get("nombre", "") or "").strip() or "OTROS" + _man_por_nombre[_nm] = _man_por_nombre.get(_nm, 0.0) + _mt + for _nm, _mt in _man_por_nombre.items(): + resumen.append({ + "nombre": _nm, + "pt_ant": 0.0, "pt_cur": 0.0, + "esp_ant": 0.0, "esp_cur": 0.0, + "otr_ant": 0.0, "otr_cur": _mt, # manual → OTROS, mes en curso + }) + except Exception as e: + print(f"[comisiones_export RESUMEN manuales] {e}") + + # ── HOJA "RESUMEN" (al inicio): por asesor, PT / ESPECIALIDAD / OTROS × (MES + # ANTERIOR, MES EN CURSO) + TOTAL. PT = TEAC+TERC, ESP = SEMINARIOS, OTROS = resto. + ws_r = wb.create_sheet(title="RESUMEN", index=0) + for col, w in zip("ABCDEFGH", [20, 13, 13, 13, 13, 13, 13, 14]): + ws_r.column_dimensions[col].width = w + cen = Alignment(horizontal="center", vertical="center", wrap_text=True) + mfmt = '#,##0' + grp_fill = PatternFill("solid", fgColor="B4C6E7") + sub_fill = PatternFill("solid", fgColor="DDEBF7") + hdr_font = Font(bold=True) + + # Fila 1: cabeceras de grupo (fusionadas de a 2) + ASESOR y TOTAL (2 filas alto) + ws_r.merge_cells("A1:A2") + c = ws_r.cell(row=1, column=1, value="ASESOR"); c.font = hdr_font; c.alignment = cen; c.fill = grp_fill; c.border = border + ws_r.merge_cells("B1:C1"); ws_r.merge_cells("D1:E1"); ws_r.merge_cells("F1:G1") + for col, txt in ((2, "PT"), (4, "ESPECIALIDAD"), (6, "OTROS")): + cc = ws_r.cell(row=1, column=col, value=txt); cc.font = hdr_font; cc.alignment = cen; cc.fill = grp_fill; cc.border = border + ws_r.cell(row=1, column=col+1).fill = grp_fill; ws_r.cell(row=1, column=col+1).border = border + ws_r.merge_cells("H1:H2") + ct = ws_r.cell(row=1, column=8, value="TOTAL"); ct.font = hdr_font; ct.alignment = cen; ct.fill = grp_fill; ct.border = border + ws_r.cell(row=2, column=8).fill = grp_fill; ws_r.cell(row=2, column=8).border = border + # Fila 2: subcabeceras MES ANTERIOR / MES EN CURSO por grupo + subs = ["", "MES ANTERIOR", "MES EN CURSO", "MES ANTERIOR", "MES EN CURSO", "MES ANTERIOR", "MES EN CURSO", ""] + for i, s in enumerate(subs, start=1): + if s: + cs = ws_r.cell(row=2, column=i, value=s); cs.font = hdr_font; cs.alignment = cen; cs.fill = sub_fill; cs.border = border + + # Filas de asesores + r = 3 + T = {"pt_ant":0.0,"pt_cur":0.0,"esp_ant":0.0,"esp_cur":0.0,"otr_ant":0.0,"otr_cur":0.0} + def _cell(row, col, val, money=True, bold=False, center=True): + x = ws_r.cell(row=row, column=col, value=(round(val,2) if (money and isinstance(val,(int,float))) else val)) + x.border = border + if money and isinstance(val,(int,float)): x.number_format = mfmt + if bold: x.font = hdr_font + if center: x.alignment = Alignment(horizontal="center", vertical="center") + return x + for a in resumen: + total = a["pt_ant"]+a["pt_cur"]+a["esp_ant"]+a["esp_cur"]+a["otr_ant"]+a["otr_cur"] + nm = ws_r.cell(row=r, column=1, value=a["nombre"]); nm.border = border; nm.font = hdr_font + nm.alignment = Alignment(horizontal="center", vertical="center") + _cell(r,2,a["pt_ant"] or ""); _cell(r,3,a["pt_cur"] or "") + _cell(r,4,a["esp_ant"] or ""); _cell(r,5,a["esp_cur"] or "") + _cell(r,6,a["otr_ant"] or ""); _cell(r,7,a["otr_cur"] or "") + _cell(r,8,total, bold=True) + for k in T: T[k] += a[k] + r += 1 + # Fila de subtotales por columna + tot_fill = PatternFill("solid", fgColor="DDEBF7") + _cell(r,1,"", money=False); ws_r.cell(row=r,column=1).fill = tot_fill; ws_r.cell(row=r,column=1).border = border + for col, val in ((2,T["pt_ant"]),(3,T["pt_cur"]),(4,T["esp_ant"]),(5,T["esp_cur"]),(6,T["otr_ant"]),(7,T["otr_cur"])): + x = _cell(r,col,val,bold=True); x.fill = tot_fill + gtot = sum(T.values()) + xt = _cell(r,8,gtot,bold=True); xt.fill = PatternFill("solid", fgColor="FFF2CC") + r += 1 + # Fila: total por grupo (ant+cur) fusionado + ws_r.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3) + _cell(r,2,T["pt_ant"]+T["pt_cur"],bold=True); ws_r.cell(row=r,column=3).border = border + ws_r.merge_cells(start_row=r, start_column=4, end_row=r, end_column=5) + _cell(r,4,T["esp_ant"]+T["esp_cur"],bold=True); ws_r.cell(row=r,column=5).border = border + ws_r.merge_cells(start_row=r, start_column=6, end_row=r, end_column=7) + _cell(r,6,T["otr_ant"]+T["otr_cur"],bold=True); ws_r.cell(row=r,column=7).border = border + + # ── Tablita JEFE COMERCIAL (2 filas más abajo): PT+SEDES / ESPECIALIDAD / OTROS ── + r2 = r + 3 + # Encabezados (fila r2): UNIDAD, MES ANTERIOR, MES EN CURSO, TOTAL + heads2 = [("A", ""), ("B", "UNIDAD"), ("C", "MES ANTERIOR"), ("D", "MES EN CURSO"), ("E", "TOTAL")] + ws_r.cell(row=r2, column=2, value="UNIDAD").font = hdr_font + ws_r.cell(row=r2, column=2).fill = sub_fill + for col, txt in ((3, "MES ANTERIOR"), (4, "MES EN CURSO"), (5, "TOTAL")): + hc = ws_r.cell(row=r2, column=col, value=txt); hc.font = hdr_font; hc.alignment = cen; hc.fill = sub_fill; hc.border = border + ws_r.cell(row=r2, column=2).alignment = cen; ws_r.cell(row=r2, column=2).border = border + # Columna A fusionada (r2..r2+3) = "JEFE COMERCIAL" + ws_r.merge_cells(start_row=r2, start_column=1, end_row=r2+3, end_column=1) + jc = ws_r.cell(row=r2, column=1, value="JEFE COMERCIAL"); jc.font = hdr_font + jc.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True); jc.border = border + # Filas de datos + filas_jc = [ + ("PT+ SEDES", T["pt_ant"], T["pt_cur"]), + ("ESPECIALIDAD", T["esp_ant"], T["esp_cur"]), + ("OTROS", T["otr_ant"], T["otr_cur"]), + ] + gran_jc = sum(T.values()) + rr = r2 + 1 + for nombre_u, ant, cur in filas_jc: + u = ws_r.cell(row=rr, column=2, value=nombre_u); u.border = border; u.font = hdr_font + u.alignment = Alignment(horizontal="center", vertical="center") + _cell(rr, 3, ant); _cell(rr, 4, cur) + rr += 1 + # TOTAL (columna E) fusionado para las 3 filas + ws_r.merge_cells(start_row=r2+1, start_column=5, end_row=r2+3, end_column=5) + tg = ws_r.cell(row=r2+1, column=5, value=round(gran_jc, 2)) + tg.font = hdr_font; tg.number_format = mfmt; tg.border = border + tg.alignment = Alignment(horizontal="center", vertical="center") + for _rb in range(r2+1, r2+4): + ws_r.cell(row=_rb, column=5).border = border + + # ── HOJA "OTROS": vendedores manuales de ENERO hasta el mes del filtro ── + _MESES_NOM = ["", "ENERO", "FEBRERO", "MARZO", "ABRIL", "MAYO", "JUNIO", + "JULIO", "AGOSTO", "SEPTIEMBRE", "OCTUBRE", "NOVIEMBRE", "DICIEMBRE"] + man_filas = [] + try: + sb = _sb() + res = (sb.table("vendedores_manuales").select("*") + .eq("ano", int(ano)).lte("mes", int(mes)).execute()) + man_filas = res.data or [] + except Exception as e: + print(f"[comisiones_export OTROS] {e}") + # Ordenar por mes y luego por vendedor + man_filas.sort(key=lambda r: (int(r.get("mes", 0) or 0), str(r.get("nombre", "")))) + + ws_o = wb.create_sheet(title="OTROS") + anchos_o = [16, 22, 15, 17, 34, 12, 17, 16] + for i, w in enumerate(anchos_o, start=1): + ws_o.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w + HEAD_O = ["MES", "VENDEDOR", "FECHA EMISION", "FACTURA", "EMPRESA", + "ESTADO", "FECHA CANCELACION", "IMPORTE SUBTOTAL"] + center = Alignment(horizontal="center", vertical="center") + left_al = Alignment(horizontal="left", vertical="center") + money_fmt = '"S/" #,##0.00' + mes_title_font = Font(bold=True, size=12, color="FFFFFF") + mes_title_fill = PatternFill("solid", fgColor="1F4E79") + + # Omitir los de monto 0; agrupar por mes. + def _m(r_): + try: return float(r_.get("monto", 0) or 0) + except: return 0.0 + por_mes = {} + for f in man_filas: + if _m(f) <= 0: + continue + mn = int(f.get("mes", 0) or 0) + por_mes.setdefault(mn, []).append(f) + + r = 1 + gran_total = 0.0 + for mn in sorted(por_mes.keys()): + nombre_mes = _MESES_NOM[mn] if 1 <= mn <= 12 else str(mn) + # Título del mes (fila fusionada) + ws_o.merge_cells(start_row=r, start_column=1, end_row=r, end_column=8) + tc = ws_o.cell(row=r, column=1, value=nombre_mes) + tc.font = mes_title_font; tc.fill = mes_title_fill; tc.alignment = center + r += 1 + # Encabezados del cuadro + for c, h in enumerate(HEAD_O, start=1): + cell = ws_o.cell(row=r, column=c, value=h) + cell.font = head_font; cell.fill = head_fill; cell.alignment = head_align; cell.border = border + r += 1 + suma_mes = 0.0 + for f in por_mes[mn]: + monto = _m(f) + suma_mes += monto + vals = [ + nombre_mes, + f.get("nombre", "") or "", + f.get("fch_emision", "") or "", + f.get("num_comprobante", "") or "", # FACTURA + f.get("descripcion", "") or "", # EMPRESA + "", "", # ESTADO / FECHA CANCELACION (vacío) + monto, # IMPORTE SUBTOTAL + ] + for c, v in enumerate(vals, start=1): + cell = ws_o.cell(row=r, column=c, value=v) + cell.border = border + cell.alignment = left_al if c == 5 else center + if c == 8: + cell.number_format = money_fmt + r += 1 + # Total del mes + tcm = ws_o.cell(row=r, column=1, value=f"TOTAL {nombre_mes}") + tcm.font = total_font; tcm.fill = total_fill; tcm.border = border; tcm.alignment = center + for c in range(2, 9): + cc = ws_o.cell(row=r, column=c); cc.fill = total_fill; cc.border = border + tv = ws_o.cell(row=r, column=8, value=round(suma_mes, 2)) + tv.font = total_font; tv.fill = total_fill; tv.border = border + tv.number_format = money_fmt; tv.alignment = center + gran_total += suma_mes + r += 2 # 1 fila en blanco entre meses + + # Gran total general al final + if por_mes: + gc = ws_o.cell(row=r, column=1, value="TOTAL GENERAL") + gc.font = Font(bold=True, color="FFFFFF"); gc.fill = PatternFill("solid", fgColor="305496") + gc.border = border; gc.alignment = center + for c in range(2, 9): + cc = ws_o.cell(row=r, column=c); cc.fill = PatternFill("solid", fgColor="305496"); cc.border = border + gv = ws_o.cell(row=r, column=8, value=round(gran_total, 2)) + gv.font = Font(bold=True, color="FFFFFF"); gv.fill = PatternFill("solid", fgColor="305496") + gv.border = border; gv.number_format = money_fmt; gv.alignment = center + + # Si no hubo ningún asesor, dejar una hoja informativa (Excel exige >=1 hoja) + if not wb.sheetnames: + ws = wb.create_sheet(title="Sin datos") + ws.cell(row=1, column=1, value=f"Sin asesores para {ano}-{mes}") + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +# ── RESPALDO DIARIO de fch_cancelacion_cuota1 en comisiones_overrides ── +# Objetivo: guardar la fecha de cancelación de la cuota 1 ANTES de que el SQL +# la pierda (por ejemplo cuando la matrícula pasa a ANU). Regla: +# - Solo matrículas con fch_matricula de máx 7 meses de antigüedad. +# - Si en Supabase (comisiones_overrides.fecha_cancelacion1) YA hay algo +# (fecha o "__VACIO__") -> NO se toca (respeta ediciones manuales/respaldos). +# - Solo se escribe cuando el campo está vacío/None y el SQL sí tiene fecha. +# - Si la matrícula no existe en la tabla, se crea la fila con solo esa fecha. +# Corre 1 vez al día (ver main.py / arranque). Formato de fecha: dd/mm/yyyy. +def respaldar_fechas_cancelacion(meses_atras=7): + from datetime import datetime, timedelta + dm = get_dm() + if not getattr(dm, "supabase_client", None): + print("[respaldo_fechas] Supabase no conectado; se omite.") + return {"status": "skip", "motivo": "sin supabase"} + + # Fecha de corte: hoy menos N meses (aprox 30 días por mes) + corte = datetime.now() - timedelta(days=meses_atras * 31) + corte_sql = corte.strftime("%Y-%m-%d") + + # 1) SQL: matrículas de los últimos ~7 meses con su fch_cancelacion de la cuota 1. + sql = """ + SELECT m.num_matricula, + (SELECT TOP 1 c.fch_cancelacion + FROM sgede_cronograma_matricula c + WHERE c.cod_localidad = m.cod_localidad + AND c.num_matricula = m.num_matricula + AND c.num_refinanciamiento = 1 + AND c.num_cuota = 1) AS fch_cancelacion_cuota1 + FROM sgeca_matricula m + WHERE m.fch_matricula >= ? + """ + sql_por_mat = {} + try: + conn = dm.get_connection() + cur = conn.cursor() + cur.execute(sql, corte_sql) + for row in cur.fetchall(): + mat = str(row[0]).strip() + if mat.endswith(".0"): + mat = mat[:-2] + fch = row[1] + if not fch: + continue # sin fecha de cancelación -> no interesa + try: + if hasattr(fch, "strftime"): + fch_str = fch.strftime("%d/%m/%Y") + else: + fch_str = datetime.strptime(str(fch)[:10], "%Y-%m-%d").strftime("%d/%m/%Y") + except Exception: + continue + sql_por_mat[mat] = fch_str + conn.close() + except Exception as e: + print(f"[respaldo_fechas] error SQL: {e}") + return {"status": "error", "detalle": str(e)} + + if not sql_por_mat: + return {"status": "ok", "insertados": 0, "actualizados": 0, "revisados": 0} + + # 2) Supabase: fecha_cancelacion1 actual por matrícula (para no pisar lo que ya hay). + sb = dm.supabase_client + existentes = {} + try: + res = sb.table("comisiones_overrides").select("num_matricula,fecha_cancelacion1").execute() + for r in (res.data or []): + existentes[str(r["num_matricula"])] = r.get("fecha_cancelacion1") + except Exception as e: + print(f"[respaldo_fechas] error leyendo overrides: {e}") + return {"status": "error", "detalle": str(e)} + + # 3) Decidir: solo escribir donde el campo está vacío/None (nunca pisar). + insertados, actualizados = 0, [] + for mat, fch_str in sql_por_mat.items(): + actual = existentes.get(mat, "__NO_EXISTE__") + if actual == "__NO_EXISTE__": + # Fila nueva: crear con solo la fecha + try: + sb.table("comisiones_overrides").insert( + {"num_matricula": mat, "fecha_cancelacion1": fch_str}).execute() + insertados += 1 + except Exception as e: + print(f"[respaldo_fechas] insert {mat} falló: {e}") + else: + sv = "" if actual is None else str(actual).strip() + if sv == "": # existe la fila pero la fecha está vacía -> completar + try: + sb.table("comisiones_overrides").update( + {"fecha_cancelacion1": fch_str}).eq("num_matricula", mat).execute() + actualizados.append(mat) + except Exception as e: + print(f"[respaldo_fechas] update {mat} falló: {e}") + # si sv tiene fecha o "__VACIO__" -> NO se toca + + if insertados or actualizados: + try: + from cache_manager import cache_invalidate + cache_invalidate("ventas") + cache_invalidate("ventas_det") + cache_invalidate("comisiones_det_todos") + except Exception: + pass + + print(f"[respaldo_fechas] revisados={len(sql_por_mat)} nuevos={insertados} completados={len(actualizados)}") + return {"status": "ok", "insertados": insertados, + "actualizados": len(actualizados), "revisados": len(sql_por_mat)}