Files
FRASA-CAS/api-gateway/src/controllers/clientes.controller.ts

709 lines
26 KiB
TypeScript

import type { Request, Response } from 'express'
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'
import { crmLocal } from '../services/crm-local.service.js'
import { NotFoundError, BadRequestError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
// ---------- Alta de clientes ----------
export const crearClienteSchema = z
.object({
datosGenerales: z
.object({
nombreCompleto: z.string().min(1),
curp: z.string().min(1),
rfc: z.string().min(1),
email: 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(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
tipoCompra: z.string().optional(),
usuarioEnkontrol: z.string().optional(),
})
.passthrough(),
empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(),
viviendaCredito: z
.object({
proyecto: z.string().min(1),
ubicacion: z.string().optional(),
valorVenta: z.union([z.string(), z.number()]).optional(),
enganche: z.union([z.string(), z.number()]).optional(),
})
.passthrough(),
firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(),
})
.passthrough() // el resto del payload pasa tal cual a payloadJson
/**
* Checkbox que corresponde a un tipo de documento en el expediente del
* apartado. INE y el resto no tienen checkbox (devuelve null).
*/
export function checkboxParaTipoDocumento(tipo: string): string | null {
switch (tipo.trim().toUpperCase()) {
case 'CURP':
return 'CURP'
case 'RFC':
case 'CONSTANCIA_RFC':
return 'RFC'
case 'COMPROBANTE':
case 'COMPROBANTE_DOMICILIO':
return 'Comprobante'
default:
return null
}
}
const aNumero = (v: string | number | undefined): number => {
if (v === undefined) return 0
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
return Number.isFinite(n) ? n : 0
}
const hoyMas = (dias: number): string => {
const d = new Date()
d.setDate(d.getDate() + dias)
const m = String(d.getMonth() + 1).padStart(2, '0')
const dia = String(d.getDate()).padStart(2, '0')
return `${d.getFullYear()}-${m}-${dia}`
}
/** Label de "Forma de Pago" a partir del enum del formato de alta */
const FORMA_PAGO_LABEL: Record<string, string> = {
INFONAVIT: 'Infonavit',
APOYO_INFONAVIT: 'Apoyo Infonavit',
COFINAVIT: 'Cofinavit',
SEGUNDO_CREDITO: 'Segundo crédito',
CONTADO: 'Contado',
FOVISSSTE: 'Fovissste',
COFI_FOVISSSTE: 'Cofi-Fovissste',
BANCARIO: 'Bancario',
}
/**
* "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo del
* wizard; se toma el PRIMERO reconocido). undefined si no hay ninguno.
*/
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
if (!Array.isArray(tiposCredito)) return undefined
for (const t of tiposCredito) {
const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()]
if (label !== undefined) return label
}
return undefined
}
// ---------- Helpers locales compartidos (alta directa y alta progresiva) ----------
const apartadoLigadoDeBody = (body: Record<string, unknown>): string | null => {
const id = body['apartadoMondayId']
return typeof id === 'string' && id !== '' ? id : null
}
/**
* Si el alta viene de una afluencia del funnel (la app alta-clientes manda
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto' y se
* crea el apartado local (crmLocal.apartar; idempotente por dedup). El id
* puede ser una oportunidad directa (modelo nuevo: la afluencia ES la
* oportunidad, fuente 'oportunidad' del picker) o un lead histórico: se
* resuelve ambos vía LeadOportunidad, como hacía moverAApartoConDedup.
* Devuelve el id de la fila de apartado local creada (null si no aplica).
* Un fallo aquí no rompe el alta.
*/
async function moverLeadAApartoSiAplica(
body: Record<string, unknown>,
capturadoPorId: string | null,
): Promise<string | null> {
const leadId = body['leadMondayId']
if (typeof leadId !== 'string' || leadId === '') return null
try {
const comoOportunidad = await prisma.leadOportunidad.findUnique({
where: { oportunidadId: leadId },
})
const mapping =
comoOportunidad ??
(await prisma.leadOportunidad.findUnique({ where: { leadId } }))
const oportunidadId = comoOportunidad?.oportunidadId ?? mapping?.oportunidadId ?? null
if (oportunidadId === null) return null
const apartado = await crmLocal.apartar(oportunidadId, capturadoPorId ?? undefined)
return apartado.leadId
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadId}:`, err)
return null
}
}
/**
* Enriquece la fila de apartado local con lo capturado en el alta (unidad,
* precio, enganche, forma de pago, contacto): el "Alta Enkontrol" desde un
* apartado encuentra así la ficha completa al prellenar. Best-effort.
*/
async function enriquecerApartadoLocal(
apartadoId: string,
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
vc: {
ubicacion?: string | undefined
valorVenta?: string | number | undefined
enganche?: string | number | undefined
tiposCredito?: unknown
},
): Promise<void> {
try {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: apartadoId } })
if (!captura) return
let datos: Record<string, unknown> = {}
if (captura.datosJson) {
try { datos = JSON.parse(captura.datosJson) as Record<string, unknown> } catch { /* ignorar */ }
}
const valorVenta = aNumero(vc.valorVenta)
const enganche = aNumero(vc.enganche)
datos['unidadCodigo'] = vc.ubicacion ?? datos['unidadCodigo'] ?? ''
if (valorVenta > 0) datos['precioFinal'] = valorVenta
if (enganche > 0) {
datos['enganche'] = enganche
datos['montoApartado'] = enganche
}
datos['estadoLabel'] = 'Pago Apartado'
datos['fechaApartado'] = datos['fechaApartado'] ?? hoyMas(0)
datos['fechaLimite'] = datos['fechaLimite'] ?? hoyMas(15)
datos['fechaCompra'] = hoyMas(0)
const formaPago = formaPagoLabel(vc.tiposCredito)
if (formaPago !== undefined) datos['formaPagoLabel'] = formaPago
if (dg.celular !== undefined && dg.celular !== '') datos['telefono'] = dg.celular
if (dg.email !== undefined && dg.email !== '') datos['email'] = dg.email
if (dg.curp !== undefined && dg.curp !== '') datos['curp'] = dg.curp
await prisma.leadCaptura.update({
where: { leadId: apartadoId },
data: {
datosJson: JSON.stringify(datos),
...(dg.celular !== undefined && dg.celular !== '' && captura.telefono === null ? { telefono: dg.celular } : {}),
...(dg.email !== undefined && dg.email !== '' && captura.email === null ? { email: dg.email } : {}),
},
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo enriquecer el apartado local ${apartadoId}:`, err)
}
}
/**
* Liga el apartado del alta a la ficha del Cliente (mondayApartadoId conserva
* el nombre histórico: es el id de la fila de apartado, local o heredado).
* Best-effort: un fallo no rompe el alta.
*/
async function ligarApartadoACliente(clienteId: string, apartadoId: string): Promise<void> {
try {
await prisma.cliente.update({ where: { id: clienteId }, data: { mondayApartadoId: apartadoId } })
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo ligar el apartado ${apartadoId} al cliente ${clienteId}:`, err)
}
}
/** POST /api/clientes — persiste el alta en SQLite y liga el apartado del funnel */
export async function crearCliente(req: Request, res: Response): Promise<void> {
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
const vc = body.viviendaCredito
const cliente = await prisma.cliente.create({
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp,
rfc: dg.rfc,
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion ?? '', // la "ubicación" del formato es el código de unidad (U-101, etc.)
capturadoPorId: req.usuarioToken?.sub ?? null,
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
})
// Apartado del alta: el existente (botón "Alta Enkontrol" desde Mis
// apartados) se LIGA tal cual; si el alta viene del funnel se mueve la
// oportunidad a 'Aparto' y se crea/enriquece el apartado local.
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, req.usuarioToken?.sub ?? null)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(cliente.id, mondayApartadoId)
}
res.status(201).json({ ...cliente, mondayContactoId: null, mondayApartadoId })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id)
}
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------
/**
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
* opcionales porque se completan en los pasos siguientes.
*/
export const iniciarProgresoSchema = z
.object({
datosGenerales: z
.object({
nombreCompleto: z.string().min(1),
curp: z.string().optional(),
rfc: z.string().optional(),
email: z.string().optional(),
celular: z.string().optional(),
direccion: z.string().optional(),
fechaNacimiento: z.string().optional(),
estadoCivil: z.string().optional(),
tipoVivienda: z.string().optional(),
tipoCompra: z.string().optional(),
usuarioEnkontrol: z.string().optional(),
})
.passthrough(),
leadMondayId: z.string().optional(),
})
.passthrough()
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
const actualizarProgresoSchema = z.object({}).passthrough()
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso */
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
const body = iniciarProgresoSchema.parse(req.body)
const dg = body.datosGenerales
const cliente = await prisma.cliente.create({
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp ?? '',
rfc: dg.rfc ?? '',
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: '', // se define al completar el alta
capturadoPorId: req.usuarioToken?.sub ?? null,
payloadJson: JSON.stringify(body),
},
})
res.status(201).json({ id: cliente.id, mondayContactoId: null })
}
/** PATCH /api/clientes/progreso/:id — guarda el avance del wizard (local) */
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = actualizarProgresoSchema.parse(req.body)
const cliente = await prisma.cliente.findUnique({ where: { id } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
res.json({ id, mondaySync: false })
}
/**
* POST /api/clientes/progreso/:id/completar — cierra el alta iniciada con el
* guardado progresivo. Misma validación y shape de respuesta que POST /clientes.
*/
export async function completarProgreso(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const body = crearClienteSchema.parse(req.body)
const dg = body.datosGenerales
const vc = body.viviendaCredito
const cliente = await prisma.cliente.findUnique({ where: { id } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const actualizado = await prisma.cliente.update({
where: { id },
data: {
nombreCompleto: dg.nombreCompleto,
curp: dg.curp,
rfc: dg.rfc,
email: dg.email ?? null,
celular: dg.celular ?? null,
proyecto: vc.proyecto,
unidadCodigo: vc.ubicacion ?? '',
payloadJson: JSON.stringify(body),
firmaPng: body.firma?.firmaDataUrl ?? null,
},
})
// Apartado del alta (misma lógica que el alta directa)
let mondayApartadoId = apartadoLigadoDeBody(body as Record<string, unknown>)
if (mondayApartadoId === null) {
const apartadoFunnelId = await moverLeadAApartoSiAplica(body as Record<string, unknown>, cliente.capturadoPorId)
if (apartadoFunnelId !== null) {
mondayApartadoId = apartadoFunnelId
await enriquecerApartadoLocal(apartadoFunnelId, dg, vc)
}
}
if (mondayApartadoId !== null) {
await ligarApartadoACliente(id, mondayApartadoId)
}
res.status(201).json({ ...actualizado, mondayContactoId: null, mondayApartadoId })
}
/** GET /api/clientes/:id */
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
const cliente = await prisma.cliente.findUnique({
where: { id: String(req.params['id']) },
include: { documentos: true },
})
if (!cliente) throw new NotFoundError('Cliente no encontrado')
res.json(cliente)
}
// ---------- Documentos ----------
const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = path.join(config.uploadsDir, String(req.params['id']))
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (req, file, cb) => {
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const ext = EXT_POR_MIME[file.mimetype] ?? 'bin'
cb(null, `${tipo}_${randomUUID()}.${ext}`)
},
})
export const uploadDocumentos = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
fileFilter: (_req, file, cb) => {
if (!MIME_PERMITIDOS.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`))
return
}
cb(null, true)
},
})
/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */
export async function subirDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const archivos = (req.files ?? []) as Express.Multer.File[]
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
const documentos = await Promise.all(
archivos.map((f) =>
prisma.documento.create({
data: {
clienteId,
tipo,
nombreArchivo: f.originalname,
ruta: path.relative(config.uploadsDir, f.path),
mime: f.mimetype,
tamano: f.size,
},
}),
),
)
res.status(201).json(documentos)
}
/** GET /api/clientes/:id/documentos */
export async function listarDocumentos(req: Request, res: Response): Promise<void> {
const clienteId = String(req.params['id'])
const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } })
if (!cliente) throw new NotFoundError('Cliente no encontrado')
const documentos = await prisma.documento.findMany({
where: { clienteId },
orderBy: { createdAt: 'desc' },
})
res.json(documentos)
}
/**
* GET /api/altas/prefill-apartado/:itemId — todo lo ya recolectado de un
* apartado para prellenar el alta de cliente (botón "Alta Enkontrol" de Mis
* apartados): ficha del apartado (fila LeadCaptura board=APARTADO), contacto
* relacionado (ficha del Cliente), oportunidad relacionada (perfil de la
* afluencia) y la cotización más reciente vinculada. Las relaciones se arman
* por teléfono (últimos 10 dígitos) o email. 404 si no existe el apartado.
*/
export async function prefillApartadoEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
res.json(await prefillApartadoLocal(itemId))
}
/** Shape de la cotización en la respuesta del prefill */
function cotizacionPrefill(cotizacion: {
tipoPropuesta: string
acabados: boolean
apartado: number
precioLista: number
precioFinal: number
enganche: number
numMensualidades: number
mensualidad: number | null
pagoFinal: number
}) {
return {
tipoPropuesta: cotizacion.tipoPropuesta,
acabados: cotizacion.acabados,
apartado: cotizacion.apartado,
precioLista: cotizacion.precioLista,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
}
}
/**
* Prefill local: el apartado es la fila LeadCaptura board=APARTADO (id loc_* o
* sombra); el contacto sale del Cliente ligado, la oportunidad de la captura
* board=OPORTUNIDADES con el mismo teléfono (su perfil va en datosJson) y la
* cotización de la tabla local.
*/
async function prefillApartadoLocal(itemId: string) {
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
if (!captura || captura.board !== 'APARTADO') throw new NotFoundError('Apartado no encontrado')
const tel = (captura.telefono ?? '').replace(/\D/g, '').slice(-10)
const cliente = await prisma.cliente.findFirst({
where: {
OR: [{ mondayApartadoId: itemId }, ...(tel !== '' ? [{ celular: { contains: tel } }] : [])],
},
orderBy: { createdAt: 'desc' },
})
const oportunidadLocal = await prisma.leadCaptura.findFirst({
where: {
board: 'OPORTUNIDADES',
...(tel !== '' ? { telefono: { contains: tel } } : { nombreLead: captura.nombreLead }),
},
orderBy: { updatedAt: 'desc' },
})
// Perfil de la afluencia (datosJson): solo los campos que el front consume
let credito = ''
if (oportunidadLocal?.datosJson) {
try {
const datos = JSON.parse(oportunidadLocal.datosJson) as Record<string, unknown>
if (typeof datos['credito'] === 'string') credito = datos['credito']
} catch {
// datosJson inválido: se ignora, el prefill sale igual
}
}
// Cotización local más reciente: vinculada a la oportunidad local o por teléfono
let cotizacion = oportunidadLocal
? await prisma.cotizacion.findFirst({
where: { mondayItemId: oportunidadLocal.leadId },
orderBy: { createdAt: 'desc' },
})
: null
if (!cotizacion && tel !== '') {
cotizacion = await prisma.cotizacion.findFirst({
where: { clienteTelefono: { contains: tel } },
orderBy: { createdAt: 'desc' },
})
}
// Datos del alta (payloadJson) que el prefill del front consume con claves
// legacy de Monday: dirección, estado civil y fecha de nacimiento
const contactoExtra: Record<string, string> = {}
if (cliente?.payloadJson) {
try {
const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record<string, unknown> }
const g = payload.datosGenerales ?? {}
const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '')
if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion'])
if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil'])
if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento'])
} catch {
// payload inválido: el prefill sale parcial, como antes
}
}
return {
apartado: {
id: captura.leadId,
nombre: captura.nombreLead,
unidadCodigo: cliente?.unidadCodigo ?? cotizacion?.unidadCodigo ?? '',
fechaLimite: '',
estado: captura.etapa,
vendedorIds: [] as number[],
telefono: captura.telefono ?? '',
email: captura.email ?? '',
precioFinal: cotizacion?.precioFinal ?? 0,
enganche: cotizacion?.enganche ?? 0,
},
contacto: cliente
? {
nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '',
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
...contactoExtra,
}
: null,
oportunidad: oportunidadLocal
? {
id: oportunidadLocal.leadId,
desarrollo: oportunidadLocal.desarrollo ?? '',
modeloInteres: oportunidadLocal.modelo ?? '',
credito,
}
: null,
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`,
)
}