feat(alta-clientes): telefono obligatorio en alta y afluencia, importacion de informacion Enkontrol por ID en pantalla final y campos de obra movidos a la seccion Obra en captura

This commit is contained in:
2026-09-25 21:41:59 +00:00
parent 2400ecf6ff
commit 6435086181
11 changed files with 269 additions and 24 deletions

View File

@@ -16,6 +16,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
// Fechas de obra (las llena el área de obra; rigen el arranque y la entrega)
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
{ campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' },
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
],
VENTAS: [
@@ -31,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
],
ESCRITURACION: [
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },

View File

@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
describe('crearClienteSchema', () => {
const base = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' },
}
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
// ---------- Alta local (POST /api/clientes) ----------
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' },
}
@@ -360,7 +360,7 @@ describe('alta progresiva — local', () => {
mondayApartadoId: null,
})
const altaCompleta = {
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
leadMondayId: 'loc_op_9',
}

View File

@@ -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`,
)
}

View File

@@ -212,6 +212,7 @@ describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la o
reqCon({
body: {
nombre: 'Lead Prueba',
telefono: '4771234567',
oportunidadId: 'loc_op_3',
desarrollo: 'SENDA',
modelo: 'SOHO',

View File

@@ -60,7 +60,8 @@ const crearLeadSchema = z.object({
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
const crearAfluenciaSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
// Teléfono obligatorio (regla de negocio: sin teléfono no hay afluencia)
telefono: z.string().trim().min(7, 'El teléfono es obligatorio'),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),

View File

@@ -15,12 +15,14 @@ import {
actualizarProgreso,
completarProgreso,
crearCliente,
importarEnkontrolEndpoint,
iniciarProgreso,
listarDocumentos,
obtenerCliente,
prefillApartadoEndpoint,
subirDocumentos,
uploadDocumentos,
uploadImportacionEnkontrol,
} from '../controllers/clientes.controller.js'
import {
eliminarBorrador,
@@ -208,6 +210,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
// Importación de información desde un documento de Enkontrol (ID Enkontrol =
// campo determinante). Solo administración.
apiRouter.post(
'/clientes/importar-enkontrol',
requireAdminOManager,
uploadImportacionEnkontrol.single('archivo'),
importarEnkontrolEndpoint,
)
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)

View File

@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => {
it('OBRA: no-admin no puede modificar la fecha de entrega programada ya establecida', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
])
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
guardarCampo(
'SENDA',
'U-101',
'ESCRITURACION',
'OBRA',
'entrega_programada_fecha',
'2026-10-15',
'user-1',
'escrituracion',
'obra',
),
).rejects.toThrow('solo puede modificarlas un administrador')
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()