#!/usr/bin/env node /** * Reimporta el maestro de estatus de venta (/root/Reporte_EstatusVenta.xlsx) * a la tabla CapturaDepartamento, con mapeo EXPLÍCITO por hoja. * * Uso: * node scripts/importar-captura-maestro.js [--dry] [ruta.xlsx] * * Reemplaza a importar-maestro.js (mapeo difuso que dejó campos sin importar, * unidades colapsadas y fechas mal parseadas). * * Reglas: * - Limpia TODA CapturaDepartamento antes de insertar (salvo --dry). * - Headers por nombre normalizado (acentos/espacios/puntos); primera * aparición gana si un campo se repite en la misma hoja. * - Fechas → 'YYYY-MM-DD' (lo que espera el input date de la UI). * - Celdas de OBRA: fecha → campo `_fecha`; True/False → checkbox; 'n/a' → nada. * - Unidades: formato canónico del cotizador * (SENDA-MZA-LOTE-DEPTO, U-101, ATELIER-1, ESTANZA-1A, SOLANA-1A/B...). * - Las hojas DG se procesan al final y sus valores prevalecen. */ 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)) // ── .env ───────────────────────────────────────────────────────────── 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 que venga del entorno */ } } const prisma = new PrismaClient() const DRY = process.argv.includes('--dry') const RUTA = process.argv.slice(2).find((a) => !a.startsWith('--')) || '/root/Reporte_EstatusVenta.xlsx' const USUARIO = 'importacion-captura-v2' // ── Normalización de headers ───────────────────────────────────────── function norm(h) { return String(h ?? '') .trim() .toUpperCase() .replace(/\./g, ' ') .replace(/\s+/g, ' ') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') } // Header normalizado → campo de captura const FIELD_MAP = { // OBRA (checkbox; si la celda trae fecha se escribe `${campo}_fecha`) 'CERTIFICACION GAS': { dept: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean', obra: true }, 'TO Y C4-FIANZA': { dept: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean', obra: true }, 'TO Y C4 FIANZA': { dept: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean', obra: true }, 'REGIMEN INSCRITO': { dept: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean', obra: true }, 'NO OFICIAL': { dept: 'OBRA', campo: 'no_oficial', tipo: 'boolean', obra: true }, 'CLAVE CATASTRAL': { dept: 'OBRA', campo: 'clave_catastral', tipo: 'boolean', obra: true }, 'LISTAS PARA AVALUO': { dept: 'OBRA', campo: 'listas_avaluo', tipo: 'boolean', obra: true }, 'LISTA PARA AVALUO': { dept: 'OBRA', campo: 'listas_avaluo', tipo: 'boolean', obra: true }, 'HABITABILIDAD A PARTIR DE': { dept: 'OBRA', campo: 'habitabilidad', tipo: 'date' }, 'HABILITABILIDAD': { dept: 'OBRA', campo: 'habitabilidad', tipo: 'date' }, 'COMENTARIOS OBRA': { dept: 'OBRA', campo: 'comentarios_obra', tipo: 'text' }, // VENTAS 'CLIENTE': { dept: 'VENTAS', campo: 'cliente_id', tipo: 'text' }, 'NOMBRE': { dept: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' }, 'UBICACION': { dept: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, 'MODELO': { dept: 'VENTAS', campo: 'modelo', tipo: 'text' }, 'TIPO DE CREDITO': { dept: 'VENTAS', campo: 'tipo_credito', tipo: 'text' }, 'ESTATUS': { dept: 'VENTAS', campo: 'estatus', tipo: 'text' }, 'FECHA DE APARTADO': { dept: 'VENTAS', campo: 'fecha_apartado', tipo: 'date' }, 'FECHA DE VENTA': { dept: 'VENTAS', campo: 'fecha_venta', tipo: 'date' }, 'FECHA VENCIMIENTO DE CONTRATO': { dept: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, 'FECHA VENCIMIENTO': { dept: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, 'PRECIO DE VENTA': { dept: 'VENTAS', campo: 'precio_venta', tipo: 'number' }, // ESCRITURACION 'FECHA DE AUTORIZACION': { dept: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, 'FECHA AUTORIZACION': { dept: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, 'FECHA FIRMA': { dept: 'ESCRITURACION', campo: 'fecha_firma', tipo: 'date' }, 'NOTARIA': { dept: 'ESCRITURACION', campo: 'notaria', tipo: 'text' }, 'FECHA DE PAGO BANCO': { dept: 'ESCRITURACION', campo: 'fecha_pago_banco', tipo: 'date' }, 'FECHA DE PAGO INFONAVIT': { dept: 'ESCRITURACION', campo: 'fecha_pago_infonavit', tipo: 'date' }, 'FECHA DE PAGO FOVISSSTE': { dept: 'ESCRITURACION', campo: 'fecha_pago_fovissste', tipo: 'date' }, // Hojas DG: fechas de obra por unidad 'FECHA DE INICIO POR OBRA': { dept: 'ESCRITURACION', campo: 'inicio_obra_fecha', tipo: 'date' }, 'FECHA DE ENTREGA POR OBRA': { dept: 'ESCRITURACION', campo: 'entrega_programada_fecha', tipo: 'date' }, 'FECHA ESCRITURA': { dept: 'ESCRITURACION', campo: 'fecha_firma', tipo: 'date' }, // FINANZAS 'OTROS INGRESOS': { dept: 'FINANZAS', campo: 'otros_ingresos', tipo: 'number' }, 'PAGADO TRANSFERENCIA/CHEQUE/EFECTIVO MN': { dept: 'FINANZAS', campo: 'pagado_transferencia', tipo: 'number' }, 'PAGADO TRANSFERENCIA': { dept: 'FINANZAS', campo: 'pagado_transferencia', tipo: 'number' }, 'INFONAVIT': { dept: 'FINANZAS', campo: 'infonavit', tipo: 'number' }, 'BANCARIO': { dept: 'FINANZAS', campo: 'bancario', tipo: 'number' }, 'FOVISSSTE': { dept: 'FINANZAS', campo: 'fovissste', tipo: 'number' }, 'CREDITO PUENTE': { dept: 'FINANZAS', campo: 'credito_puente', tipo: 'number' }, 'SALDO A FAVOR DEL CLIENTE': { dept: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, 'SALDO A FAVOR': { dept: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, } // Headers que NO son campos (solo para armar la unidad / ignorados) const HEADERS_UNIDAD = new Set([ 'UNIDADES', 'UNIDAD', 'DEPTO', 'DEPARTAMENTO', 'UBICACION', 'MZA', 'MZ', 'LOTE', 'TORRE', 'NIVEL', 'PROTOTIPO', 'CONTRATISTA', 'ETAPA', 'CONDOMINIO', 'DUPLO', 'CASA', 'FLUJO', 'COMENTARIO', ]) // ── Parsers ────────────────────────────────────────────────────────── const MESES = { ENE: 1, FEB: 2, MAR: 3, ABR: 4, MAY: 5, JUN: 6, JUL: 7, AGO: 8, SEP: 9, OCT: 10, NOV: 11, DIC: 12 } function isoDeUTC(d) { const y = d.getUTCFullYear() const m = String(d.getUTCMonth() + 1).padStart(2, '0') const dia = String(d.getUTCDate()).padStart(2, '0') return `${y}-${m}-${dia}` } /** 'YYYY-MM-DD' desde Date | 'dd/mm/yy' | 'dd-mon.-yy' | 'lun dd/mm/yy'; null si inválida (fuera 2020-2040) */ function parseFecha(v) { if (v instanceof Date) return isNaN(v.getTime()) ? null : isoDeUTC(v) if (v == null) return null let s = String(v).trim() if (!s) return null // quitar prefijo de día de la semana: 'lun 14/12/26' s = s.replace(/^[a-zA-ZáéíóúÁÉÍÓÚ]{3,4}\.?\s+/, '') const dmy = s.match(/^(\d{1,2})[/\-](\d{1,2})[/\-](\d{2,4})$/) if (dmy) { const dia = parseInt(dmy[1], 10) const mes = parseInt(dmy[2], 10) let anio = parseInt(dmy[3], 10) if (anio < 100) anio += 2000 if (mes < 1 || mes > 12 || anio < 2020 || anio > 2040) return null return `${anio}-${String(mes).padStart(2, '0')}-${String(dia).padStart(2, '0')}` } const dMes = s.match(/^(\d{1,2})[-\s]([a-zA-Z]{3})\.?[-\s](\d{2,4})$/) if (dMes) { const mes = MESES[dMes[2].toUpperCase()] if (!mes) return null let anio = parseInt(dMes[3], 10) if (anio < 100) anio += 2000 if (anio < 2020 || anio > 2040) return null return `${anio}-${String(mes).padStart(2, '0')}-${String(parseInt(dMes[1], 10)).padStart(2, '0')}` } return null } function parseBool(v) { if (typeof v === 'boolean') return v if (v == null) return null const s = String(v).trim().toUpperCase() if (['TRUE', 'SI', 'X', '✓', '1'].includes(s)) return true if (['FALSE', 'NO', '0'].includes(s)) return false return null } function parseNumero(v) { if (v == null || v === '') return null if (typeof v === 'number') return String(v) const s = String(v).trim().replace(/[$,\s]/g, '') if (!s) return null const n = parseFloat(s) return isNaN(n) ? null : String(n) } function parseTexto(v) { if (v == null || v instanceof Date) return null const s = String(v).trim() return s === '' ? null : s.replace(/\s+/g, ' ') } /** Celda de columna OBRA: fecha → `${campo}_fecha`, True/False → checkbox, 'n/a' → nada */ function parseObra(v, dept, campo, out) { if (v == null || v === '') return const f = parseFecha(v) if (f) { out.push({ dept, campo: `${campo}_fecha`, valor: f, tipo: 'date' }); return } const b = parseBool(v) if (b !== null) { out.push({ dept, campo, valor: b ? 'true' : 'false', tipo: 'boolean' }); return } const s = String(v).trim().toUpperCase() if (s === 'N/A' || s === 'NA' || s === '-') return } // ── Config de hojas ────────────────────────────────────────────────── const HOJAS = [ { hoja: 'Solana I y II', headerRow: 4, desarrollo: 'SOLANA', unidad: { necesita: ['UNIDADES'], fmt: (v) => { const m = String(v).match(/(\d+)\s*SOL\s*(I{1,2})\b/i) if (m) return `SOLANA-${m[1]}${m[2].toUpperCase() === 'I' ? 'A' : 'B'}` const n = String(v).match(/\d+/) return n ? `SOLANA-${n[0]}` : null }, }, }, { hoja: 'Atelier', headerRow: 4, desarrollo: 'ATELIER', unidad: { necesita: ['UNIDADES'], fmt: (v) => { const m = String(v).trim().match(/^(\d+)/); return m ? `ATELIER-${m[1]}` : null }, }, }, { hoja: 'Estanza', headerRow: 4, desarrollo: 'ESTANZA', unidad: { necesita: ['UNIDADES'], fmt: (v) => { const m = String(v).trim().match(/^([A-Za-z0-9]+)/); return m ? `ESTANZA-${m[1].toUpperCase()}` : null }, }, }, { hoja: 'Senda', headerRow: 4, desarrollo: 'SENDA', unidad: { necesita: ['UBICACION', 'MZA', 'LOTE'], fmt: (v, idx, fila) => `SENDA-${String(fila[idx.MZA]).trim()}-${String(fila[idx.LOTE]).trim()}-${String(fila[idx.UBICACION]).trim()}`, }, }, { hoja: 'Sole', headerRow: 4, desarrollo: 'SOLE', unidad: { necesita: ['UNIDAD'], fmt: (v) => { const m = String(v).trim().match(/^(\d+)/); return m ? `SOLE-${m[1]}` : null }, }, }, { hoja: 'Diagonal 38', headerRow: 2, desarrollo: 'DIAGONAL', unidad: { necesita: ['UNIDAD'], fmt: (v) => { const m = String(v).trim().match(/^(\d+)/); return m ? `U-${m[1]}` : null }, }, }, { hoja: 'Mutuo', headerRow: 8, desarrollo: 'MUTUO', unidad: { necesita: ['UNIDADES'], fmt: (v) => { const m = String(v).trim().match(/^(\d+)/); return m ? `MUTUO-${m[1]}` : null }, }, }, { hoja: 'SENDA DG', headerRow: 2, desarrollo: 'SENDA', unidad: { necesita: ['UBICACION', 'MZ', 'LT'], fmt: (v, idx, fila) => `SENDA-${String(fila[idx.MZ]).trim()}-${String(fila[idx.LT]).trim()}-${String(fila[idx.UBICACION]).trim()}`, }, }, { hoja: 'SOLE DG', headerRow: 2, desarrollo: 'SOLE', unidad: { necesita: ['UBICACION'], fmt: (v) => { const m = String(v).trim().match(/^(\d+)/); return m ? `SOLE-${m[1]}` : null }, }, }, ] // ── Lectura y agregación ───────────────────────────────────────────── /** Devuelve el agregado: Map `${desarrollo}|${unidad}|${dept}|${campo}` → {valor, tipo} */ function leer() { console.log(`Leyendo: ${RUTA} ${DRY ? '(DRY-RUN, no escribe)' : '(BORRA y reimporta)'}`) const wb = XLSX.readFile(RUTA, { cellDates: true }) const agregado = new Map() for (const cfg of HOJAS) { const sheet = wb.Sheets[cfg.hoja] if (!sheet) { console.log(`⚠ Hoja "${cfg.hoja}" no existe, saltando`); continue } const filas = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null }) const headersNorm = (filas[cfg.headerRow - 1] || []).map(norm) // columnas campo (primera aparición gana dentro de la hoja) const colMap = [] const vistos = new Set() for (let c = 0; c < headersNorm.length; c++) { const h = headersNorm[c] if (!h || HEADERS_UNIDAD.has(h)) { colMap.push(null); continue } const m = FIELD_MAP[h] if (!m || vistos.has(`${m.dept}.${m.campo}`)) { colMap.push(null); continue } vistos.add(`${m.dept}.${m.campo}`) colMap.push(m) } const idx = {} let ok = true for (const nombre of cfg.unidad.necesita) { const i = headersNorm.indexOf(nombre) if (i === -1) { console.log(`⚠ "${cfg.hoja}": falta columna "${nombre}" para la unidad, saltando`); ok = false; break } idx[nombre] = i } if (!ok) continue let filasOk = 0, valores = 0 for (let r = cfg.headerRow; r < filas.length; r++) { const fila = filas[r] if (!fila) continue const raw = fila[idx[cfg.unidad.necesita[0]]] if (raw == null || String(raw).trim() === '') continue const unidad = cfg.unidad.fmt(raw, idx, fila) if (!unidad || /TOTAL|DDI/i.test(unidad)) continue const celdas = [] for (let c = 0; c < colMap.length; c++) { const m = colMap[c] if (!m) continue const v = fila[c] if (m.obra) { parseObra(v, m.dept, m.campo, celdas) } else if (m.campo === 'entrega_sac') { const f = parseFecha(v) const b = parseBool(v) if (f || b === true) celdas.push({ dept: m.dept, campo: m.campo, valor: 'true', tipo: 'boolean' }) else if (b === false) celdas.push({ dept: m.dept, campo: m.campo, valor: 'false', tipo: 'boolean' }) } else { let valor = null if (m.tipo === 'date') valor = parseFecha(v) else if (m.tipo === 'number') valor = parseNumero(v) else valor = parseTexto(v) if (valor != null) celdas.push({ dept: m.dept, campo: m.campo, valor, tipo: m.tipo }) } } if (celdas.length === 0) continue filasOk++ for (const c of celdas) { agregado.set(`${cfg.desarrollo}|${unidad}|${c.dept}|${c.campo}`, { valor: c.valor, tipo: c.tipo }) valores++ } } console.log(` ${cfg.hoja} → ${cfg.desarrollo}: ${filasOk} unidades, ${valores} valores`) } console.log(`\nTotal valores únicos: ${agregado.size}`) const porDesarrollo = {} const unidadesPorDesarrollo = new Set() for (const [key] of agregado) { const des = key.split('|')[0] porDesarrollo[des] = (porDesarrollo[des] || 0) + 1 unidadesPorDesarrollo.add(key.split('|').slice(0, 2).join('|')) } console.log('Valores por desarrollo:', Object.entries(porDesarrollo).map(([d, n]) => `${d}=${n}`).join(' ')) const uniPorDes = {} for (const k of unidadesPorDesarrollo) { const des = k.split('|')[0] uniPorDes[des] = (uniPorDes[des] || 0) + 1 } console.log('Unidades por desarrollo:', Object.entries(uniPorDes).map(([d, n]) => `${d}=${n}`).join(' ')) return agregado } // ── Complementos ───────────────────────────────────────────────────── /** Ubicación/Unidad siempre llena (código canónico) + modelo faltante desde el inventario */ async function complementar(agregado) { const clavesUnidad = new Set() for (const key of agregado.keys()) { const [des, uni] = key.split('|') clavesUnidad.add(`${des}|${uni}`) } // 1) ubicacion = código canónico de la unidad for (const k of clavesUnidad) { const [des, uni] = k.split('|') const key = `${des}|${uni}|VENTAS|ubicacion` if (!agregado.has(key)) agregado.set(key, { valor: uni, tipo: 'text' }) } // 2) modelo faltante: exacto desde el cotizador; fallback sin sufijo de letra const unidadesInv = await prisma.unidad.findMany({ select: { codigo: true, modelo: true } }) const porCodigo = new Map(unidadesInv.map((u) => [u.codigo, u.modelo])) let llenados = 0 const sinModelo = new Set() for (const k of clavesUnidad) { const [des, uni] = k.split('|') if (agregado.has(`${des}|${uni}|VENTAS|modelo`)) continue const modelo = porCodigo.get(uni) ?? porCodigo.get(uni.replace(/[A-Za-z]+$/, '')) if (modelo) { agregado.set(`${des}|${uni}|VENTAS|modelo`, { valor: modelo, tipo: 'text' }) llenados++ } else { sinModelo.add(uni) } } console.log(`Modelo rellenado desde inventario: ${llenados}`) if (sinModelo.size > 0) { console.log(`Unidades sin modelo en maestro ni inventario (${sinModelo.size}): ${[...sinModelo].slice(0, 15).join(', ')}${sinModelo.size > 15 ? '…' : ''}`) } } // ── Escritura y verificación ───────────────────────────────────────── async function escribir(agregado) { console.log('\nBorrando registros previos de captura…') const borrados = await prisma.capturaDepartamento.deleteMany({}) console.log(`Borrados: ${borrados.count}`) const registros = [...agregado.entries()].map(([key, v]) => { const [desarrollo, unidadCodigo, departamento, campo] = key.split('|') return { desarrollo, unidadCodigo, departamento, campo, valor: v.valor, tipo: v.tipo, usuarioId: USUARIO } }) const CHUNK = 500 for (let i = 0; i < registros.length; i += CHUNK) { await prisma.capturaDepartamento.createMany({ data: registros.slice(i, i + CHUNK) }) } console.log(`Insertados: ${registros.length}`) } async function verificar() { const porDept = await prisma.capturaDepartamento.groupBy({ by: ['desarrollo', 'departamento'], _count: true, orderBy: [{ desarrollo: 'asc' }, { departamento: 'asc' }] }) console.log('\nVerificación (desarrollo/depto = registros):') console.log(porDept.map((r) => `${r.desarrollo}/${r.departamento}=${r._count}`).join(' ')) const filasUnidad = await prisma.capturaDepartamento.findMany({ select: { desarrollo: true, unidadCodigo: true }, distinct: ['desarrollo', 'unidadCodigo'], }) const conteo = {} for (const u of filasUnidad) conteo[u.desarrollo] = (conteo[u.desarrollo] || 0) + 1 console.log('Unidades por desarrollo:', Object.entries(conteo).map(([d, n]) => `${d}=${n}`).join(' ')) const muestra = await prisma.capturaDepartamento.findMany({ where: { campo: { in: ['certificacion_gas_fecha', 'estatus', 'inicio_obra_fecha', 'entrega_programada_fecha', 'fecha_venta'] }, valor: { not: null } }, take: 10, orderBy: [{ desarrollo: 'asc' }, { unidadCodigo: 'asc' }], }) console.log('\nMuestra:') for (const m of muestra) console.log(` ${m.desarrollo} ${m.unidadCodigo} ${m.departamento}.${m.campo} = ${m.valor}`) } async function main() { const agregado = leer() await complementar(agregado) if (DRY) { console.log('\n(dry-run: nada escrito)') } else { await escribir(agregado) await verificar() } await prisma.$disconnect() } main().catch((err) => { console.error('Error:', err) process.exit(1) })