diff --git a/api-gateway/scripts/importar-captura-maestro.js b/api-gateway/scripts/importar-captura-maestro.js new file mode 100644 index 0000000..f84b87b --- /dev/null +++ b/api-gateway/scripts/importar-captura-maestro.js @@ -0,0 +1,416 @@ +#!/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) 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 +} + +// ── 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() + 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) +}) diff --git a/api-gateway/scripts/importar-maestro.js b/api-gateway/scripts/importar-maestro.js deleted file mode 100644 index 6a10ef3..0000000 --- a/api-gateway/scripts/importar-maestro.js +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env node -/** - * Importa el archivo maestro de estatus de venta (/root/Reporte_EstatusVenta.xlsx) - * a la tabla CapturaDepartamento. - * - * Uso: - * node scripts/importar-maestro.js [ruta.xlsx] [usuarioId] - * - * - Lee cada hoja de desarrollo (SENDA, ATELIER, ESTANZA, etc.) - * - Encuentra la fila de headers (la que tiene "CLIENTE" o "ESTATUS") - * - Mapea columnas a campos del config - * - Para cada fila de datos, crea registros via upsert (idempotente) - * - Log de progreso - */ - -import { PrismaClient } from '@prisma/client' -import XLSX from 'xlsx' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -const prisma = new PrismaClient() - -// ── Desarrollos y sus nombres de hoja esperados ────────────────────── - -const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] - -// Mapeo de headers del Excel → campo del config -// Normaliza: quita acentos, mayúsculas, espacios → nombre de campo -const HEADER_MAP = { - // OBRA - 'CERTIFICACIÓN GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' }, - 'CERTIFICACION GAS': { departamento: 'OBRA', campo: 'certificacion_gas', tipo: 'boolean' }, - 'TO Y C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' }, - 'TO C4-FIANZA': { departamento: 'OBRA', campo: 'to_c4_fianza', tipo: 'boolean' }, - 'RÉGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' }, - 'REGIMEN INSCRITO': { departamento: 'OBRA', campo: 'regimen_inscrito', tipo: 'boolean' }, - 'NO. OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' }, - 'NO OFICIAL': { departamento: 'OBRA', campo: 'no_oficial', tipo: 'boolean' }, - 'CLAVE CATASTRAL': { departamento: 'OBRA', campo: 'clave_catastral', tipo: 'boolean' }, - 'LISTAS PARA AVALUO': { departamento: 'OBRA', campo: 'listas_avaluo', tipo: 'boolean' }, - 'HABILITABILIDAD': { departamento: 'OBRA', campo: 'habitabilidad', tipo: 'date' }, - 'COMENTARIOS OBRA': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' }, - 'COMENTARIOS': { departamento: 'OBRA', campo: 'comentarios_obra', tipo: 'text' }, - - // VENTAS - 'CLIENTE': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' }, - 'CLIENTE ID': { departamento: 'VENTAS', campo: 'cliente_id', tipo: 'text' }, - 'NOMBRE CLIENTE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' }, - 'NOMBRE': { departamento: 'VENTAS', campo: 'nombre_cliente', tipo: 'text' }, - 'UBICACIÓN': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, - 'UBICACION': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, - 'UBICACIÓN/UNIDAD': { departamento: 'VENTAS', campo: 'ubicacion', tipo: 'text' }, - 'MODELO': { departamento: 'VENTAS', campo: 'modelo', tipo: 'text' }, - 'TIPO CRÉDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' }, - 'TIPO CREDITO': { departamento: 'VENTAS', campo: 'tipo_credito', tipo: 'select' }, - 'ESTATUS': { departamento: 'VENTAS', campo: 'estatus', tipo: 'select' }, - 'FECHA APARTADO': { departamento: 'VENTAS', campo: 'fecha_apartado', tipo: 'date' }, - 'FECHA VENTA': { departamento: 'VENTAS', campo: 'fecha_venta', tipo: 'date' }, - 'FECHA VENCIMIENTO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, - 'FECHA VENCIMIENTO CONTRATO': { departamento: 'VENTAS', campo: 'fecha_vencimiento', tipo: 'date' }, - 'PRECIO VENTA': { departamento: 'VENTAS', campo: 'precio_venta', tipo: 'number' }, - - // ESCRITURACION - 'FECHA AUTORIZACIÓN': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, - 'FECHA AUTORIZACION': { departamento: 'ESCRITURACION', campo: 'fecha_autorizacion', tipo: 'date' }, - 'FECHA FIRMA': { departamento: 'ESCRITURACION', campo: 'fecha_firma', tipo: 'date' }, - 'NOTARÍA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' }, - 'NOTARIA': { departamento: 'ESCRITURACION', campo: 'notaria', tipo: 'text' }, - 'FECHA PAGO BANCO': { departamento: 'ESCRITURACION', campo: 'fecha_pago_banco', tipo: 'date' }, - 'FECHA PAGO INFONAVIT': { departamento: 'ESCRITURACION', campo: 'fecha_pago_infonavit', tipo: 'date' }, - 'FECHA PAGO FOVISSSTE': { departamento: 'ESCRITURACION', campo: 'fecha_pago_fovissste', tipo: 'date' }, - - // FINANZAS - 'OTROS INGRESOS': { departamento: 'FINANZAS', campo: 'otros_ingresos', tipo: 'number' }, - 'PAGADO TRANSFERENCIA': { departamento: 'FINANZAS', campo: 'pagado_transferencia', tipo: 'number' }, - 'INFONAVIT': { departamento: 'FINANZAS', campo: 'infonavit', tipo: 'number' }, - 'BANCARIO': { departamento: 'FINANZAS', campo: 'bancario', tipo: 'number' }, - 'FOVISSSTE': { departamento: 'FINANZAS', campo: 'fovissste', tipo: 'number' }, - 'CRÉDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' }, - 'CREDITO PUENTE': { departamento: 'FINANZAS', campo: 'credito_puente', tipo: 'number' }, - 'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, - 'SALDO A FAVOR': { departamento: 'FINANZAS', campo: 'saldo_favor', tipo: 'number' }, - - // POSTVENTA - 'ENTREGA A SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' }, - 'ENTREGA SAC': { departamento: 'POSTVENTA', campo: 'entrega_sac', tipo: 'boolean' }, - 'COMENTARIOS ENTREGA': { departamento: 'POSTVENTA', campo: 'comentarios_entrega', tipo: 'text' }, -} - -// ── Helpers ────────────────────────────────────────────────────────── - -function normalizeHeader(h) { - return String(h ?? '') - .trim() - .toUpperCase() - .replace(/\s+/g, ' ') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') // quitar acentos -} - -function esBooleano(valor) { - if (valor == null) return false - const s = String(valor).trim().toUpperCase() - return s === 'TRUE' || s === 'SI' || s === 'SÍ' || s === '1' || s === 'X' || s === '✓' -} - -function parseFecha(valor) { - if (valor == null) return null - if (valor instanceof Date) return valor.toISOString() - const s = String(valor).trim() - if (!s) return null - // Intentar parsear como fecha - const d = new Date(s) - return isNaN(d.getTime()) ? null : d.toISOString() -} - -function parseNumero(valor) { - if (valor == null) return null - const s = String(valor).trim().replace(/[$,]/g, '') - if (!s) return null - const n = parseFloat(s) - return isNaN(n) ? null : String(n) -} - -function parseValor(valor, tipo) { - if (valor == null || String(valor).trim() === '') return null - switch (tipo) { - case 'boolean': - return esBooleano(valor) ? 'true' : 'false' - case 'date': - return parseFecha(valor) - case 'number': - return parseNumero(valor) - default: - return String(valor).trim() - } -} - -// ── Detectar nombre de desarrollo a partir del nombre de hoja ──────── - -function detectarDesarrollo(nombreHoja) { - const upper = nombreHoja.toUpperCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - for (const d of DESARROLLOS) { - if (upper.includes(d)) return d - } - return null -} - -// ── Main ───────────────────────────────────────────────────────────── - -async function main() { - const rutaExcel = process.argv[2] || '/root/Reporte_EstatusVenta.xlsx' - const usuarioId = process.argv[3] || 'importacion-maestro' - - console.log(`Leyendo archivo: ${rutaExcel}`) - const workbook = XLSX.readFile(rutaExcel) - - let totalRegistros = 0 - let totalCreados = 0 - let totalActualizados = 0 - - for (const nombreHoja of workbook.SheetNames) { - const desarrollo = detectarDesarrollo(nombreHoja) - if (!desarrollo) { - console.log(` Hoja "${nombreHoja}" → no se reconoce desarrollo, saltando`) - continue - } - - console.log(`\n Procesando hoja "${nombreHoja}" → desarrollo: ${desarrollo}`) - - const sheet = workbook.Sheets[nombreHoja] - const todasLasFilas = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null }) - - // Buscar fila de headers (la que contiene "CLIENTE" o "ESTATUS") - let headerIdx = -1 - for (let i = 0; i < Math.min(todasLasFilas.length, 20); i++) { - const fila = todasLasFilas[i] - const textos = fila.map((c) => normalizeHeader(c)) - if (textos.some((t) => t.includes('CLIENTE') || t.includes('ESTATUS'))) { - headerIdx = i - break - } - } - - if (headerIdx === -1) { - console.log(` No se encontró fila de headers, saltando`) - continue - } - - const headers = todasLasFilas[headerIdx] - const filasDatos = todasLasFilas.slice(headerIdx + 1) - - // Mapear columnas - const mapaColumnas = headers.map((h) => { - const norm = normalizeHeader(h) - return HEADER_MAP[norm] || null - }) - - let hojaCreados = 0 - let hojaActualizados = 0 - - for (const fila of filasDatos) { - // Detectar unidad: columna 1 (Depto) o combinación UBICACIÓN+MZA+LOTE - let unidadCodigo = String(fila[1] ?? '').trim() // columna 1 = Depto - if (!unidadCodigo) { - // Fallback: combinar UBICACIÓN (col 10) + MZA (col 11) + LOTE (col 12) - const ubic = String(fila[10] ?? '').trim() - const mza = String(fila[11] ?? '').trim() - const lote = String(fila[12] ?? '').trim() - if (ubic || mza || lote) unidadCodigo = [ubic, mza, lote].filter(Boolean).join('-') - } - if (!unidadCodigo) continue - - // Skip rows that are clearly headers/section markers - if (normalizeHeader(unidadCodigo).includes('TOTAL') || normalizeHeader(unidadCodigo).includes('DDI')) continue - - for (let col = 0; col < headers.length; col++) { - const mapeo = mapaColumnas[col] - if (!mapeo) continue - - const rawValor = fila[col] - const valor = parseValor(rawValor, mapeo.tipo) - - try { - const existente = await prisma.capturaDepartamento.findUnique({ - where: { - desarrollo_unidadCodigo_departamento_campo: { - desarrollo, - unidadCodigo, - departamento: mapeo.departamento, - campo: mapeo.campo, - }, - }, - }) - - await prisma.capturaDepartamento.upsert({ - where: { - desarrollo_unidadCodigo_departamento_campo: { - desarrollo, - unidadCodigo, - departamento: mapeo.departamento, - campo: mapeo.campo, - }, - }, - update: { valor, tipo: mapeo.tipo, usuarioId }, - create: { - desarrollo, - unidadCodigo, - departamento: mapeo.departamento, - campo: mapeo.campo, - valor, - tipo: mapeo.tipo, - usuarioId, - }, - }) - - if (existente) hojaActualizados++ - else hojaCreados++ - } catch (err) { - // Skip errors silently - } - } - } - - console.log(` Unidades: ${new Set(filasDatos.map((f) => String(f[0] ?? '').trim()).filter(Boolean)).size}`) - console.log(` Registros: ${hojaCreados} creados, ${hojaActualizados} actualizados`) - totalCreados += hojaCreados - totalActualizados += hojaActualizados - totalRegistros += hojaCreados + hojaActualizados - } - - console.log(`\n── Resumen ──`) - console.log(`Total: ${totalRegistros} registros (${totalCreados} creados, ${totalActualizados} actualizados)`) - - await prisma.$disconnect() -} - -main().catch((err) => { - console.error('Error:', err) - process.exit(1) -})