fix(auditoria): afluencia (validacion, update sin perder datos ni retroceder etapa, dedup cruzado, notas), alta cliente (registro tras firma, documentos por cliente, aviso de fallos) y reporte de clientes aplanado

This commit is contained in:
2026-09-18 16:05:18 +00:00
parent 4965a4488a
commit 656116c4e5
13 changed files with 295 additions and 59 deletions

View File

@@ -202,14 +202,8 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
})
return
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día): no
// hay captura nueva que registrar
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
// La nota del alta de afluencia también vive en la bitácora (con clave de
// cliente para que siga al contacto en las fases siguientes).
// La nota del alta/actualización de afluencia vive en la bitácora (con clave
// de cliente para que siga al contacto en las fases siguientes).
if (body.notas !== undefined && body.notas.trim() !== '') {
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
titulo: body.notas,
@@ -218,6 +212,11 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
fechaRecordar: new Date().toISOString(),
})
}
// Actualización de oportunidad existente (botón "Afluencia" de Mi día)
if (resultado.actualizada === true) {
res.json({ id: resultado.id, estado: 'Afluencia', oportunidadId: resultado.oportunidadId, actualizada: true })
return
}
res.status(201).json({
id: resultado.id,
estado: 'Afluencia',

View File

@@ -51,6 +51,42 @@ const campoDesarrollo = (query: Record<string, unknown>): string | undefined =>
const ETAPAS_CANCELACION = ['Descartado', 'Perdido', 'Cancelacion Apartado', 'Cancelo']
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
/**
* Aplana el payload del alta de cliente, que viene anidado por secciones
* ({ datosGenerales, domicilio, empleo, economicos, coacreditado, … }) a un
* mapa plano de campos (usuarioEnkontrol, actividadOcupacion, compania, …).
*/
function perfilDeCliente(payloadJson: string | null): Record<string, string> {
if (!payloadJson) return {}
try {
const datos = JSON.parse(payloadJson) as Record<string, unknown>
const plano: Record<string, string> = {}
// Secciones que NO se aplanan: el co-acreditado repite los mismos campos
// (compania, ingresosMensuales, estadoCivil…) y, al venir después, vacíos,
// sobrescribía los datos reales del cliente. Documentos/firma/foto tampoco
// aportan datos de perfil.
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
for (const [k, v] of Object.entries(obj)) {
if (v == null) continue
if (typeof v === 'object' && !Array.isArray(v)) {
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
recorrer(v as Record<string, unknown>, false)
continue
}
if (typeof v === 'object') continue
const texto = String(v)
if (texto.trim() === '') continue
if (plano[k] === undefined) plano[k] = texto // gana el primer valor no vacío
}
}
recorrer(datos, true)
return plano
} catch {
return {}
}
}
function perfilDeDatos(datosJson: string | null): Record<string, string> {
if (!datosJson) return {}
try {
@@ -286,7 +322,9 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
const nombrePorId = new Map(usuarios.map((u) => [u.id, u.nombre]))
res.json({
filas: clientes.map((c) => {
const perfil = perfilDeDatos(c.payloadJson)
// El alta guarda el payload ANIDADO por secciones (datosGenerales,
// empleo, economicos…) — hay que aplanarlo para leer sus campos.
const perfil = perfilDeCliente(c.payloadJson)
return {
nombre: c.nombreCompleto,
curp: c.curp,
@@ -295,13 +333,14 @@ export async function reporteClientes(_req: Request, res: Response): Promise<voi
celular: c.celular ?? '',
proyecto: c.proyecto,
unidad: c.unidadCodigo ?? '',
ekontrol: perfil['ekontrol'] ?? perfil['usuarioEnkontrol'] ?? '',
ekontrol: perfil['usuarioEnkontrol'] ?? perfil['ekontrol'] ?? '',
estadoCivil: perfil['estadoCivil'] ?? '',
ocupacion: perfil['ocupacion'] ?? '',
ocupacion: perfil['actividadOcupacion'] ?? perfil['ocupacion'] ?? '',
rangoSalarial: perfil['rangoSalarial'] ?? '',
empresa: perfil['compania'] ?? perfil['empresa'] ?? '',
antiguedad: perfil['antiguedad'] ?? '',
ingresoMensual: perfil['ingresoMensual'] ?? perfil['ingresos'] ?? '',
antiguedad: [perfil['antiguedadAnios'], perfil['antiguedadMeses']].filter(Boolean).join(' años / ') ||
perfil['antiguedad'] || '',
ingresoMensual: perfil['ingresosMensuales'] ?? perfil['ingresoMensual'] ?? '',
capturadoPor: c.capturadoPorId ? (nombrePorId.get(c.capturadoPorId) ?? '') : '',
fecha: c.createdAt.toISOString(),
}