|
|
|
|
@@ -3,6 +3,7 @@ import path from 'node:path'
|
|
|
|
|
import fs from 'node:fs'
|
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
|
import multer from 'multer'
|
|
|
|
|
import * as XLSX from 'xlsx'
|
|
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { config } from '../config/index.js'
|
|
|
|
|
import { prisma } from '../services/prisma.js'
|
|
|
|
|
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
|
|
|
|
|
curp: z.string().min(1),
|
|
|
|
|
rfc: z.string().min(1),
|
|
|
|
|
email: z.string().optional(),
|
|
|
|
|
celular: z.string().optional(),
|
|
|
|
|
// Teléfono obligatorio (regla de negocio: sin celular no hay alta)
|
|
|
|
|
celular: z.string().trim().min(8, 'El celular es obligatorio'),
|
|
|
|
|
direccion: z.string().optional(),
|
|
|
|
|
// Perfil demográfico (opcionales; viajan en el payload del alta)
|
|
|
|
|
fechaNacimiento: z.string().optional(),
|
|
|
|
|
@@ -575,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
|
|
|
|
|
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- Importación de información desde Enkontrol ----------
|
|
|
|
|
|
|
|
|
|
/** Upload en memoria del documento de Enkontrol (Excel o CSV, máx 10 MB) */
|
|
|
|
|
export const uploadImportacionEnkontrol = multer({
|
|
|
|
|
storage: multer.memoryStorage(),
|
|
|
|
|
limits: { fileSize: 10 * 1024 * 1024 },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/** Normaliza un encabezado para compararlo: minúsculas, sin acentos, espacios→' ' */
|
|
|
|
|
function normalizarEncabezado(h: string): string {
|
|
|
|
|
return h
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.normalize('NFD')
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
|
|
|
.replace(/[_-]+/g, ' ')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Encabezados aceptados como ID de Enkontrol (el campo determinante del documento) */
|
|
|
|
|
const COLUMNAS_ID_ENKONTROL = new Set([
|
|
|
|
|
'id enkontrol',
|
|
|
|
|
'cliente enkontrol',
|
|
|
|
|
'usuario enkontrol',
|
|
|
|
|
'enkontrol id',
|
|
|
|
|
'id de enkontrol',
|
|
|
|
|
'cliente enkontrol id',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
/** Encabezado normalizado → columna del Cliente que actualiza */
|
|
|
|
|
const COLUMNAS_CLIENTE: Record<string, 'nombreCompleto' | 'curp' | 'rfc' | 'email' | 'celular' | 'proyecto' | 'unidadCodigo'> = {
|
|
|
|
|
'nombre': 'nombreCompleto',
|
|
|
|
|
'nombre completo': 'nombreCompleto',
|
|
|
|
|
'cliente': 'nombreCompleto',
|
|
|
|
|
'curp': 'curp',
|
|
|
|
|
'rfc': 'rfc',
|
|
|
|
|
'email': 'email',
|
|
|
|
|
'correo': 'email',
|
|
|
|
|
'correo electronico': 'email',
|
|
|
|
|
'celular': 'celular',
|
|
|
|
|
'telefono': 'celular',
|
|
|
|
|
'telefono celular': 'celular',
|
|
|
|
|
'proyecto': 'proyecto',
|
|
|
|
|
'desarrollo': 'proyecto',
|
|
|
|
|
'unidad': 'unidadCodigo',
|
|
|
|
|
'ubicacion': 'unidadCodigo',
|
|
|
|
|
'unidad codigo': 'unidadCodigo',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/clientes/importar-enkontrol — importa un documento (Excel/CSV) de
|
|
|
|
|
* Enkontrol usando el ID de Enkontrol como campo determinante de cada cliente:
|
|
|
|
|
* cada fila se empareja con la ficha local cuyo alta tenga ese usuarioEnkontrol
|
|
|
|
|
* y su información completa se guarda en el payload de la ficha. Las columnas
|
|
|
|
|
* conocidas (nombre, CURP, RFC, email, celular, proyecto, unidad) actualizan
|
|
|
|
|
* también los campos directos del Cliente.
|
|
|
|
|
*/
|
|
|
|
|
export async function importarEnkontrolEndpoint(req: Request, res: Response): Promise<void> {
|
|
|
|
|
const archivo = (req as Request & { file?: Express.Multer.File }).file
|
|
|
|
|
if (!archivo) throw new BadRequestError('Adjunta el archivo Excel o CSV de Enkontrol')
|
|
|
|
|
|
|
|
|
|
let filas: Record<string, unknown>[]
|
|
|
|
|
try {
|
|
|
|
|
const libro = XLSX.read(archivo.buffer, { type: 'buffer' })
|
|
|
|
|
const hoja = libro.Sheets[libro.SheetNames[0] ?? '']
|
|
|
|
|
if (!hoja) throw new Error('sin hojas')
|
|
|
|
|
filas = XLSX.utils.sheet_to_json<Record<string, unknown>>(hoja, { defval: '' })
|
|
|
|
|
} catch {
|
|
|
|
|
throw new BadRequestError('No se pudo leer el documento: usa un Excel (.xlsx) o CSV válido')
|
|
|
|
|
}
|
|
|
|
|
if (filas.length === 0) throw new BadRequestError('El documento no contiene filas de datos')
|
|
|
|
|
|
|
|
|
|
const encabezados = Object.keys(filas[0]!)
|
|
|
|
|
const columnaId = encabezados.find((h) => COLUMNAS_ID_ENKONTROL.has(normalizarEncabezado(h)))
|
|
|
|
|
if (!columnaId) {
|
|
|
|
|
throw new BadRequestError(
|
|
|
|
|
`No encontré la columna del ID de Enkontrol (encabezados: ${encabezados.slice(0, 8).join(', ')})`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Índice de clientes locales por ID de Enkontrol (payloadJson.datosGenerales.usuarioEnkontrol)
|
|
|
|
|
const clientes = await prisma.cliente.findMany({ select: { id: true, nombreCompleto: true, payloadJson: true } })
|
|
|
|
|
const porEnkontrol = new Map<string, { id: string; nombreCompleto: string; payloadJson: string }>()
|
|
|
|
|
for (const c of clientes) {
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(c.payloadJson) as { datosGenerales?: { usuarioEnkontrol?: unknown } }
|
|
|
|
|
const idE = payload.datosGenerales?.usuarioEnkontrol
|
|
|
|
|
if (typeof idE === 'string' && idE.trim() !== '') {
|
|
|
|
|
porEnkontrol.set(idE.trim().toUpperCase(), c)
|
|
|
|
|
}
|
|
|
|
|
} catch { /* payload ilegible: se omite del índice */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let actualizados = 0
|
|
|
|
|
const noEncontrados: string[] = []
|
|
|
|
|
const ahora = new Date().toISOString()
|
|
|
|
|
for (const fila of filas) {
|
|
|
|
|
const idE = String(fila[columnaId] ?? '').trim()
|
|
|
|
|
if (idE === '') continue
|
|
|
|
|
const cliente = porEnkontrol.get(idE.toUpperCase())
|
|
|
|
|
if (!cliente) {
|
|
|
|
|
noEncontrados.push(idE)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
let payload: Record<string, unknown> = {}
|
|
|
|
|
try { payload = JSON.parse(cliente.payloadJson) as Record<string, unknown> } catch { payload = {} }
|
|
|
|
|
const data: Record<string, unknown> = {
|
|
|
|
|
payloadJson: JSON.stringify({ ...payload, importacionEnkontrol: { fecha: ahora, datos: fila } }),
|
|
|
|
|
}
|
|
|
|
|
for (const [encabezado, valor] of Object.entries(fila)) {
|
|
|
|
|
const columna = COLUMNAS_CLIENTE[normalizarEncabezado(encabezado)]
|
|
|
|
|
const texto = String(valor ?? '').trim()
|
|
|
|
|
if (columna !== undefined && texto !== '') data[columna] = texto
|
|
|
|
|
}
|
|
|
|
|
await prisma.cliente.update({ where: { id: cliente.id }, data })
|
|
|
|
|
actualizados++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ totalFilas: filas.length, actualizados, noEncontrados })
|
|
|
|
|
auditoria.registrar(
|
|
|
|
|
req.usuarioToken?.sub ?? 'sistema',
|
|
|
|
|
'IMPORTAR_ENKONTROL',
|
|
|
|
|
'cliente',
|
|
|
|
|
archivo.originalname,
|
|
|
|
|
`${actualizados} actualizados, ${noEncontrados.length} sin ficha local`,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|