diff --git a/backend/services.py b/backend/services.py index 5bbacdb..e2f9297 100644 --- a/backend/services.py +++ b/backend/services.py @@ -745,9 +745,10 @@ def alumno_estado_guardar(matricula, ano, mes, estado, observaciones, usuario=No "actualizado_por": usuario or None, } sb.table("cobranza_estado_alumno").upsert(payload, on_conflict="matricula,ano,mes").execute() - # Invalidar la tabla de cobranza para recalcular la columna SALDO POR RETIRAR + # Invalidar cobranza (columna SALDO POR RETIRAR) y el apartado Retiros from cache_manager import cache_invalidate cache_invalidate("cobranza") + cache_invalidate("retiros") return {"status": "ok"} def alumnos_por_retirar(ano, mes): @@ -758,6 +759,188 @@ def alumnos_por_retirar(ano, mes): .execute()) return {"matriculas": [str(r["matricula"]) for r in (res.data or [])]} + +# ── RETIROS (apartado) ───────────────────────────────────────────────────── +def retiros(ano="2026", mes=8): + """Apartado RETIROS: alumnos marcados POR RETIRAR en (ano, mes), con sus datos + del cronograma de cobranza. El estado POR RETIRAR y la info manual son de ese mes.""" + def _load(): + try: + mats_ret = set(alumnos_por_retirar(ano, mes).get("matriculas", [])) + except Exception: + mats_ret = set() + if not mats_ret: + return {"filas": []} + dm = get_dm() + proc = CobranzaLogic(dm).processor # para clasificar SEDE por programa (REPLACE_META) + crono = dm.ejecutar_consulta_cronograma_cobranza(str(ano)) or [] + # primera fila por matrícula (todas comparten los datos de cabecera) + idx = {} + for f in crono: + m = str(f.get("num_matricula", "")).strip() + if m and m not in idx: + 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)) + except Exception: + extra = {} + from datetime import datetime as _dtnow + _hoy = _dtnow.now() + cob_idx = {} + try: + alum = cobranza_detalle_todos(str(ano), str(_hoy.month), "TODOS").get("alumnos", []) + for a in alum: + cob_idx[str(a.get("matricula", "")).strip()] = a + except Exception: + cob_idx = {} + # Info manual guardada en Supabase de ESTE mes (una sola lectura). Indexado por matrícula. + try: + info_idx = retiros_info_obtener(ano, mes) + except Exception: + info_idx = {} + def _d(v): + return str(v or "")[:10] + filas = [] + for m in mats_ret: + f = idx.get(str(m)) + if not f: + continue + ex = extra.get(str(m), {}) + cb = cob_idx.get(str(m), {}) + inf = info_idx.get(str(m), {}) + monto_ant = round((float(cb.get("cta_ant", 0) or 0) - float(cb.get("cob_ant", 0) or 0)), 2) + monto_cur = round((float(cb.get("cta_cur", 0) or 0) - float(cb.get("cob_cur", 0) or 0)), 2) + _prog = str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip() + filas.append({ + "matricula": str(m), + "sede": str(proc.clasificar_sede_meta(_prog)), + "fch_matricula": _d(f.get("fch_matricula")), + "asesor": str(f.get("dsc_sectorista", "") or "").strip(), + "vendedor": ex.get("dsc_vendedor", ""), + "programa": str(f.get("dsc_det_programa", "") or f.get("dsc_programa", "") or "").strip(), + "cod_detalle": ex.get("cod_detalle", ""), + "fch_inicio": _d(f.get("fch_inicio")), + "fch_retiro": _d(f.get("fch_retiro")), + "alumno": ex.get("dsc_alumno", ""), + "num_cuotas_pagadas": ex.get("num_cuotas_pagadas", 0), + "num_cuotas_vencidas": ex.get("num_cuotas_vencidas", 0), + "fch_vencimiento": ex.get("fch_venc_mas_antigua", ""), + "monto_venc_ant": monto_ant, + "monto_venc_cur": monto_cur, + "imp_total_pagado": ex.get("imp_total_pagado", 0), + "monto_dejo_pagar": round(float(ex.get("inv_neta", 0) or 0) - float(ex.get("imp_total_pagado", 0) or 0), 2), + "n_asistencia": inf.get("n_asistencia"), + "n_faltas": inf.get("n_faltas"), + "ultimo_dia_asist": inf.get("ultimo_dia_asist") or "", + "hasta_sesion": inf.get("hasta_sesion") or "", + "fch_ult_resp": inf.get("fch_ult_resp") or "", + "fch_envio_correo": inf.get("fch_envio_correo") or "", + "retiro_wsp_class": inf.get("retiro_wsp_class") or "", + "motivo_retiro": inf.get("motivo_retiro") or "", + "detalle_retiro": inf.get("detalle_retiro") or "", + }) + filas.sort(key=lambda x: x["fch_matricula"], reverse=True) + return {"filas": filas} + return cache_get_or_set("retiros", (str(ano), str(mes)), _load) + + +_RETIROS_INFO_CAMPOS = [ + "n_asistencia", "n_faltas", "ultimo_dia_asist", "hasta_sesion", + "fch_ult_resp", "fch_envio_correo", "retiro_wsp_class", + "motivo_retiro", "detalle_retiro", +] + +def _ri_num(v): + if v is None or v == "": + return None + try: + return int(v) + except Exception: + return None + +def _ri_manual_payload(campos): + """Solo las 9 columnas manuales, normalizadas para Supabase.""" + c = campos or {} + return { + "n_asistencia": _ri_num(c.get("n_asistencia")), + "n_faltas": _ri_num(c.get("n_faltas")), + "ultimo_dia_asist": c.get("ultimo_dia_asist") or None, + "hasta_sesion": c.get("hasta_sesion") or None, + "fch_ult_resp": c.get("fch_ult_resp") or None, + "fch_envio_correo": c.get("fch_envio_correo") or None, + "retiro_wsp_class": c.get("retiro_wsp_class") or None, + "motivo_retiro": c.get("motivo_retiro") or None, + "detalle_retiro": c.get("detalle_retiro") or None, + } + +def retiros_info_obtener(ano, mes): + """Lee la info manual guardada de (ano, mes) en una sola consulta. + Devuelve un dict indexado por matrícula (egress mínimo: 1 lectura).""" + sb = _sb() + cols = "matricula," + ",".join(_RETIROS_INFO_CAMPOS) + res = (sb.table("retiros_info").select(cols) + .eq("ano", int(ano)).eq("mes", int(mes)).execute()) + out = {} + for r in (res.data or []): + out[str(r.get("matricula", "")).strip()] = r + return out + +def retiros_info_guardar(matricula, ano, mes, campos, usuario=None): + """INFO: guarda SOLO las 9 columnas manuales de un alumno en (matricula, ano, mes).""" + sb = _sb() + from datetime import datetime, timezone + payload = { + "matricula": str(matricula), + "ano": int(ano), + "mes": int(mes), + **_ri_manual_payload(campos), + "actualizado_en": datetime.now(timezone.utc).isoformat(), + "actualizado_por": usuario or None, + } + sb.table("retiros_info").upsert(payload, on_conflict="matricula,ano,mes").execute() + from cache_manager import cache_invalidate + cache_invalidate("retiros") + return {"status": "ok"} + +def retiros_snapshot_guardar(ano, mes, filas, usuario=None): + """GUARDAR: reescribe TODO el mes. Borra los registros de (ano, mes) y reinserta + las filas recibidas con TODAS las columnas (snapshot calculado + 9 manuales).""" + sb = _sb() + from datetime import datetime, timezone + ano = int(ano); mes = int(mes) + now = datetime.now(timezone.utc).isoformat() + registros = [] + for f in (filas or []): + registros.append({ + "matricula": str(f.get("matricula", "")).strip(), + "ano": ano, "mes": mes, + "sede": f.get("sede") or None, + "fch_matricula": f.get("fch_matricula") or None, + "sectorista": f.get("asesor") or None, + "asesor_comercial": f.get("vendedor") or None, + "programa": f.get("programa") or None, + "cod_detalle": (str(f.get("cod_detalle")) if f.get("cod_detalle") not in (None, "") else None), + "fch_inicio": f.get("fch_inicio") or None, + "fch_retiro": f.get("fch_retiro") or None, + "alumno": f.get("alumno") or None, + "num_cuotas_pagadas": _ri_num(f.get("num_cuotas_pagadas")), + "num_cuotas_vencidas": _ri_num(f.get("num_cuotas_vencidas")), + "fch_vencimiento": f.get("fch_vencimiento") or None, + "monto_venc_ant": f.get("monto_venc_ant"), + "monto_venc_cur": f.get("monto_venc_cur"), + "monto_dejo_pagar": f.get("monto_dejo_pagar"), + **_ri_manual_payload(f), + "actualizado_en": now, + "actualizado_por": usuario or None, + }) + sb.table("retiros_info").delete().eq("ano", ano).eq("mes", mes).execute() + if registros: + sb.table("retiros_info").insert(registros).execute() + from cache_manager import cache_invalidate + cache_invalidate("retiros") + return {"status": "ok", "guardados": len(registros)} + def usuarios_listar(): sb = _sb() perfiles = sb.table("perfiles").select("id, nombre, rol, activo").execute().data or []