feat(captura): módulo de captura por departamentos
Backend: tabla CapturaDepartamento, 5 departamentos, 7 desarrollos, service CRUD + importador Excel + 6 endpoints. Frontend: tabla editable inline, tabs por dept, resumen avance, paginación, filtro estatus, permisos por rol, dark mode. 746 API tests + 42 landing tests.
This commit is contained in:
277
api-gateway/scripts/importar-maestro.js
Normal file
277
api-gateway/scripts/importar-maestro.js
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Importa el archivo maestro de estatus de venta (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] || join(__dirname, '..', '..', '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: primera columna con valor
|
||||
const unidadCodigo = String(fila[0] ?? '').trim()
|
||||
if (!unidadCodigo) continue
|
||||
|
||||
// Skip rows that are clearly headers/section markers
|
||||
if (normalizeHeader(unidadCodigo).includes('TOTAL')) 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)
|
||||
})
|
||||
Reference in New Issue
Block a user