#!/usr/bin/env node /** * Reimporta COBRANZA PREVENTA 060926.xlsx a PlanPago + Pago + AplicacionPago * con mapeo EXPLÍCITO por encabezado (reemplaza a importar-cobranza-preventa.js, * que fabricaba fechas 'ago 2025' y dejaba montoPagado en 0). * * Uso: * node scripts/importar-cobranza-maestro.js [--dry] [ruta.xlsx] * * Reglas: * - Limpia TODO PlanPago/Pago/AplicacionPago antes de insertar (salvo --dry). * - Fila 3 de cada hoja = encabezados (búsqueda por nombre, las hojas varían * de ancho 45-76 cols). Columnas con encabezado fecha (01/12/24…) = grid * mensual de pagos. * - Plan: CTE, NOMBRE, APARTADO PAGADO + ENGANCHE PAGADO → engancheTotal, * MESES + MES INICIO + MONTO MENSUALIDAD → calendario programado. * - Pagos programados: numPagos mensualidades desde MES INICIO * (montoPagado=0; el módulo deriva VENCIDO por fecha). * - Celda de grid con valor > 0 = abono real del mes → AplicacionPago * (fechaPago = mes de la columna) sobre la mensualidad de ese mes; * si no hay mensualidad programada en ese mes → Pago standalone pagado. */ import { PrismaClient } from '@prisma/client' import XLSX from 'xlsx' import { readFileSync } from 'fs' import { fileURLToPath } from 'url' import { dirname, join } from 'path' const __dirname = dirname(fileURLToPath(import.meta.url)) if (!process.env.DATABASE_URL) { try { const env = readFileSync(join(__dirname, '..', '.env'), 'utf8') const linea = env.split('\n').find((l) => l.startsWith('DATABASE_URL=')) if (linea) process.env.DATABASE_URL = linea.slice('DATABASE_URL='.length).trim().replace(/^"|"$/g, '') } catch { /* usar el del entorno */ } } const prisma = new PrismaClient() const DRY = process.argv.includes('--dry') const RUTA = process.argv.slice(2).find((a) => !a.startsWith('--')) || '/root/COBRANZA PREVENTA 060926.xlsx' const HOJAS = ['ZAFIRO', 'MUTUO', 'SOLE', 'ATELIER', 'ESTANZA', 'SOLANA I', 'SOLANA II', 'UMBRAL', 'DIAGONAL 40', 'SENDA'] // ── Helpers ────────────────────────────────────────────────────────── function norm(h) { return String(h ?? '') .replace(/\n/g, ' ') .trim() .toUpperCase() .replace(/\./g, ' ') .replace(/\s+/g, ' ') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') } const toStr = (v) => (v == null ? null : String(v).trim() || null) const toFloat = (v) => { if (v == null || v === '') return null if (typeof v === 'number') return v const n = parseFloat(String(v).replace(/[$,\s]/g, '')) return Number.isFinite(n) ? n : null } /** Date UTC media-noche desde Date | 'dd/mm/yy' | Date-like; null si inválida */ function aFecha(v) { if (v instanceof Date) return isNaN(v.getTime()) ? null : v if (v == null) return null const s = String(v).trim() if (!s) return null const dmy = s.match(/^(\d{1,2})[/\-](\d{1,2})[/\-](\d{2,4})$/) if (dmy) { let y = parseInt(dmy[3], 10) if (y < 100) y += 2000 const m = parseInt(dmy[2], 10) if (m < 1 || m > 12) return null return new Date(Date.UTC(y, m - 1, parseInt(dmy[1], 10))) } const n = Date.parse(s) return isNaN(n) ? null : new Date(n) } function addMeses(fecha, n) { return new Date(Date.UTC(fecha.getUTCFullYear(), fecha.getUTCMonth() + n, fecha.getUTCDate())) } const claveMes = (f) => `${f.getUTCFullYear()}-${f.getUTCMonth()}` // Encabezado → campo del plan (los que el modelo tiene) const CAMPOS = { 'CASA': 'unidad', 'DEPTO': 'unidad', 'CTE': 'ventaRef', 'NOMBRE': 'clienteNombre', 'FECHA VENTA': 'fechaVenta', 'APARTADO PAGADO': 'apartado', 'APARTADO': 'apartado', 'ENGANCHE PAGADO': 'enganche', 'ENGANCHE': 'enganche', 'MESES': 'meses', 'MENSUALIDADES': 'meses', 'MES INICIO': 'mesInicio', 'MES FINAL': 'mesFinal', 'MONTO MENSUALIDAD': 'mensualidad', 'MONTO MENSUALID': 'mensualidad', 'MONTO MENSUAL': 'mensualidad', } // ── Lectura ────────────────────────────────────────────────────────── function leer() { console.log(`Leyendo: ${RUTA} ${DRY ? '(DRY-RUN, no escribe)' : '(BORRA y reimporta)'}`) const wb = XLSX.readFile(RUTA, { cellDates: true }) const planes = [] let sinFechaInicio = 0 for (const nombreHoja of HOJAS) { const sheet = wb.Sheets[nombreHoja] if (!sheet) { console.log(`⚠ Hoja "${nombreHoja}" no existe, saltando`); continue } const filas = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null }) // buscar fila de encabezados: la que trae NOMBRE y CTE let headerIdx = -1 for (let i = 0; i < Math.min(filas.length, 6); i++) { const hs = (filas[i] || []).map(norm) if (hs.includes('NOMBRE') && hs.includes('CTE')) { headerIdx = i; break } } if (headerIdx === -1) { console.log(`⚠ "${nombreHoja}": sin encabezados, saltando`); continue } const headers = (filas[headerIdx] || []).map(norm) const col = {} const mesesGrid = [] // { colIdx, fecha } const vistos = new Set() for (let c = 0; c < headers.length; c++) { const h = headers[c] if (!h) continue // encabezado con fecha (Date o 'dd/mm/yy') → columna del grid mensual const raw = filas[headerIdx][c] const f = aFecha(raw) ?? aFecha(h) if (f) { mesesGrid.push({ colIdx: c, fecha: f }); continue } const campo = CAMPOS[h] ?? (h.startsWith('MONTO MENSUALID') ? 'mensualidad' : null) if (!campo || vistos.has(campo)) continue vistos.add(campo) col[campo] = c } let planesHoja = 0, pagosProg = 0, abonos = 0, standalone = 0 for (let r = headerIdx + 1; r < filas.length; r++) { const fila = filas[r] if (!fila) continue const clienteNombre = toStr(fila[col.clienteNombre]) if (!clienteNombre || clienteNombre.length < 3 || /TOTAL/i.test(clienteNombre)) continue const unidadCodigo = col.unidad != null ? (toStr(fila[col.unidad]) ?? '') : '' const ventaRef = col.ventaRef != null ? toStr(fila[col.ventaRef]) : null const apartado = col.apartado != null ? (toFloat(fila[col.apartado]) ?? 0) : 0 const enganche = col.enganche != null ? (toFloat(fila[col.enganche]) ?? 0) : 0 const meses = col.meses != null ? (parseInt(String(fila[col.meses] ?? '0'), 10) || 0) : 0 const mesInicio = col.mesInicio != null ? aFecha(fila[col.mesInicio]) : null const mensualidad = col.mensualidad != null ? (toFloat(fila[col.mensualidad]) ?? 0) : 0 // abonos del grid: valor > 0 por columna-mes const abonosGrid = [] for (const g of mesesGrid) { const monto = toFloat(fila[g.colIdx]) if (monto && monto > 0) abonosGrid.push({ fecha: g.fecha, monto }) } const fechaInicio = mesInicio ?? (col.fechaVenta != null ? aFecha(fila[col.fechaVenta]) : null) ?? abonosGrid[0]?.fecha ?? null if (!fechaInicio) { sinFechaInicio++; continue } planes.push({ proyecto: nombreHoja, fechaVenta: col.fechaVenta != null ? aFecha(fila[col.fechaVenta]) : null, plan: { ventaRef, clienteNombre, clienteTelefono: null, unidadCodigo, proyecto: nombreHoja, fechaInicio, engancheTotal: apartado + enganche, // se recalcula en escribir() con el plan completo numPagos: meses, mensualidad, tasaMoraMensual: 0, status: 'ACTIVO', }, abonosGrid, }) planesHoja++ } console.log(` ${nombreHoja}: ${planesHoja} planes, ${mesesGrid.length} columnas-mes`) } if (sinFechaInicio) console.log(`⚠ ${sinFechaInicio} filas sin fecha de inicio ni venta, saltadas`) console.log(`Total planes: ${planes.length}`) return planes } // ── Escritura ──────────────────────────────────────────────────────── async function escribir(planesLeidos) { console.log('\nBorrando planes/pagos/aplicaciones/notas previos…') const borrados = await prisma.planPago.deleteMany({}) // cascada: Pago, AplicacionPago, NotaPlan console.log(`Planes borrados: ${borrados.count}`) let totalPagos = 0, totalAbonos = 0, totalStandalone = 0, totalInicial = 0 for (const { plan, fechaVenta, abonosGrid } of planesLeidos) { // 1) calendario programado: mensualidades desde MES INICIO const pagosProg = [] if (plan.numPagos > 0 && plan.mensualidad > 0) { for (let i = 0; i < plan.numPagos; i++) { pagosProg.push({ numPago: i + 1, fechaVencimiento: addMeses(plan.fechaInicio, i), monto: plan.mensualidad, montoPagado: 0, }) } } // Invariante del módulo: Σ pagos.monto == engancheTotal. El apartado+enganche // (ya pagado según el maestro) entra como pago 0 y engancheTotal = plan completo. const inicialMonto = (plan.engancheTotal ?? 0) if (inicialMonto > 0) { pagosProg.unshift({ numPago: 0, fechaVencimiento: fechaVenta ?? plan.fechaInicio, monto: inicialMonto, montoPagado: inicialMonto, }) totalInicial++ } plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0) // 2) abonos del grid sobre la mensualidad del mismo mes; sobrantes → standalone const aplicaciones = [] // { idxPago, fechaPago, monto } const standalone = [] for (const ab of abonosGrid) { // idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0) const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha)) if (idx !== -1) { aplicaciones.push({ idx, fechaPago: ab.fecha, monto: ab.monto }) } else { standalone.push({ fecha: ab.fecha, monto: ab.monto }) } } // aplicar abonos al montoPagado (sin pasar del monto programado) for (const ap of aplicaciones) { const p = pagosProg[ap.idx] p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto) } // Si el maestro registra pagos por encima del plan declarado (filas con // MESES/MENSUALIDAD vacíos pero grid lleno), el total del plan es lo pagado. const totalPagadoAcumulado = pagosProg.reduce((a, p) => a + p.montoPagado, 0) + standalone.reduce((a, s) => a + s.monto, 0) if (totalPagadoAcumulado > plan.engancheTotal) { plan.engancheTotal = Math.round(totalPagadoAcumulado * 100) / 100 } const creado = await prisma.planPago.create({ data: plan }) // crear pagos programados let numSiguiente = pagosProg.length - 1 for (const p of pagosProg) { await prisma.pago.create({ data: { planId: creado.id, numPago: p.numPago, fechaVencimiento: p.fechaVencimiento, monto: p.monto, montoPagado: p.montoPagado }, }) } // standalone: abonos de meses sin mensualidad programada const idsCreados = await prisma.pago.findMany({ where: { planId: creado.id }, select: { id: true, numPago: true } }) const porNum = new Map(idsCreados.map((p) => [p.numPago, p.id])) for (const s of standalone) { numSiguiente++ const pago = await prisma.pago.create({ data: { planId: creado.id, numPago: numSiguiente, fechaVencimiento: s.fecha, monto: s.monto, montoPagado: s.monto }, }) porNum.set(pago.numPago, pago.id) totalStandalone++ } totalPagos += pagosProg.length // 3) aplicaciones de pago (abonos con fecha real) for (const ap of aplicaciones) { const pagoId = porNum.get(ap.idx) if (!pagoId) continue await prisma.aplicacionPago.create({ data: { pagoId, fechaPago: ap.fechaPago, monto: ap.monto, referencia: 'Importación maestro cobranza 06/09/26', capturadoPorId: null }, }) totalAbonos++ } } console.log(`\nPlanes: ${planesLeidos.length} | Pagos programados: ${totalPagos} (incl. ${totalInicial} pagos iniciales apartado+enganche) | Standalone: ${totalStandalone} | Abonos (AplicacionPago): ${totalAbonos}`) } async function verificar() { const planes = await prisma.planPago.count() const pagos = await prisma.pago.count() const abonos = await prisma.aplicacionPago.count() const conAbono = await prisma.pago.count({ where: { montoPagado: { gt: 0 } } }) console.log(`\nVerificación: planes=${planes} pagos=${pagos} abonos=${abonos} pagosConAbono=${conAbono}`) const muestra = await prisma.planPago.findFirst({ where: { numPagos: { gte: 4 } }, include: { pagos: { orderBy: { numPago: 'asc' }, take: 6, include: { aplicaciones: true } } } }) if (muestra) { console.log(`\nMuestra ${muestra.proyecto} ${muestra.unidadCodigo} ${muestra.clienteNombre} (numPagos=${muestra.numPagos}, mensualidad=${muestra.mensualidad}):`) for (const p of muestra.pagos) { const apps = p.aplicaciones.map((a) => `${a.monto}@${a.fechaPago.toISOString().slice(0, 10)}`).join(', ') console.log(` #${p.numPago} venc=${p.fechaVencimiento.toISOString().slice(0, 10)} monto=${p.monto} pagado=${p.montoPagado}${apps ? ` abonos: ${apps}` : ''}`) } } const porProyecto = await prisma.planPago.groupBy({ by: ['proyecto'], _count: true, orderBy: { proyecto: 'asc' } }) console.log('Planes por proyecto:', porProyecto.map((x) => `${x.proyecto}=${x._count}`).join(' ')) } async function main() { const planesLeidos = leer() if (!DRY) { await escribir(planesLeidos) await verificar() } else { const conGrid = planesLeidos.filter((p) => p.abonosGrid.length > 0).length console.log(`\n(dry-run) planes con abonos en grid: ${conGrid}; nada escrito`) } await prisma.$disconnect() } main().catch((err) => { console.error('Error:', err); process.exit(1) })