feat(mi-dia): gate de afluencia para apartar, pestana Afluencia en bitacora, cita cliente con fecha en calendario, filtros por desarrollo/origen corregidos, rangos salariales nuevos y soporte como acceso directo
This commit is contained in:
@@ -60,8 +60,27 @@ export const VOCABULARIO = {
|
||||
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
|
||||
// agregados 2026-09 — existen en captura e inventario)
|
||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
||||
// Campos de calificación que hacen "completa" una afluencia: sin ellos la
|
||||
// oportunidad NO puede avanzar a 'Aparto' (regla de negocio del cliente)
|
||||
CAMPOS_AFLUENCIA_REQUERIDOS: ['estadoCivil', 'tipoEmpleo', 'rangoSalarial', 'tipoEconomia', 'califica', 'loQuierePara'],
|
||||
} as const
|
||||
|
||||
/** Campos de afluencia faltantes en un datosJson ('[]' = afluencia completa) */
|
||||
export function camposAfluenciaFaltantes(datosJson: string | null): string[] {
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (datosJson) {
|
||||
try { datos = JSON.parse(datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||
}
|
||||
return VOCABULARIO.CAMPOS_AFLUENCIA_REQUERIDOS.filter(
|
||||
(c) => typeof datos[c] !== 'string' || (datos[c] as string).trim() === '',
|
||||
)
|
||||
}
|
||||
|
||||
/** ¿La fila tiene la afluencia completa? (gate de 'Aparto' y flag de Mi día) */
|
||||
export function afluenciaCompleta(datosJson: string | null): boolean {
|
||||
return camposAfluenciaFaltantes(datosJson).length === 0
|
||||
}
|
||||
|
||||
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
||||
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
||||
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
|
||||
|
||||
@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
|
||||
motivo: z.string().optional(),
|
||||
subcategoria: z.string().optional(),
|
||||
comentario: z.string().optional(),
|
||||
// En 'Cita Clientes' puede setear la fecha y hora de la cita
|
||||
fecha: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -33,9 +35,32 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
||||
const body = cambiarEstadoSchema.parse(req.body)
|
||||
const itemId = String(req.params['itemId'])
|
||||
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
||||
fecha: body.fecha,
|
||||
usuarioId: req.usuarioToken?.sub,
|
||||
sombra: !esIdLocal(itemId),
|
||||
})
|
||||
// 'Cita Clientes' con fecha: agenda la cita en el calendario del vendedor del
|
||||
// apartado (ActividadLocal ligada a la captura; reaperturas reagendan: se
|
||||
// cierran las citas abiertas previas de ese apartado y se crea la nueva).
|
||||
if (body.estado === 'Cita Clientes' && body.fecha !== undefined && body.fecha !== '') {
|
||||
const fila = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
const nombreCliente = fila?.nombreLead ?? 'Cliente'
|
||||
const usuarioDestino = fila?.usuarioId ?? req.usuarioToken?.sub ?? 'desconocido'
|
||||
await prisma.actividadLocal.updateMany({
|
||||
where: { leadCapturaId: itemId, tipo: 'CITA', estado: 'ABIERTA' },
|
||||
data: { estado: 'CERRADA', fechaCierre: new Date() },
|
||||
})
|
||||
await prisma.actividadLocal.create({
|
||||
data: {
|
||||
titulo: `Cita cliente: ${nombreCliente}`,
|
||||
descripcion: `Cita de cliente del apartado (${itemId})`,
|
||||
usuarioId: usuarioDestino,
|
||||
leadCapturaId: itemId,
|
||||
fechaVencimiento: new Date(body.fecha),
|
||||
tipo: 'CITA',
|
||||
},
|
||||
})
|
||||
}
|
||||
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
|
||||
// para los reportes de cancelación (Cliente/Motivo).
|
||||
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
||||
|
||||
@@ -124,7 +124,15 @@ const oportunidadLocal = {
|
||||
email: null,
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
datosJson: null,
|
||||
// Afluencia completa: el gate de negocio exige el perfil para apartar
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado(a)',
|
||||
tipoEmpleo: 'Asalariado',
|
||||
rangoSalarial: '20,000-40,000',
|
||||
tipoEconomia: 'Economía mexicana',
|
||||
califica: 'Sí',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
}
|
||||
|
||||
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { VOCABULARIO } from '../config/vocabulario.js'
|
||||
import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
@@ -226,6 +226,26 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/afluencias/:leadId — perfil de afluencia capturado en el datosJson
|
||||
* de la fila (lead/oportunidad/apartado). Solo valores de texto no vacíos.
|
||||
* Alimenta la pestaña "Afluencia" de la bitácora del cliente en Mi día.
|
||||
*/
|
||||
export async function obtenerAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const leadId = String(req.params['leadId'])
|
||||
const fila = await prisma.leadCaptura.findUnique({ where: { leadId } })
|
||||
if (!fila) throw new NotFoundError('No existe el registro solicitado')
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (fila.datosJson) {
|
||||
try { datos = JSON.parse(fila.datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||
}
|
||||
const perfil: Record<string, string> = {}
|
||||
for (const [clave, valor] of Object.entries(datos)) {
|
||||
if (typeof valor === 'string' && valor.trim() !== '') perfil[clave] = valor
|
||||
}
|
||||
res.json({ leadId, nombre: fila.nombreLead, perfil, afluenciaCompleta: afluenciaCompleta(fila.datosJson) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
|
||||
* local. Con id heredado sin fila local (items viejos del CRM anterior)
|
||||
|
||||
@@ -155,6 +155,8 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
})
|
||||
|
||||
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
|
||||
// La oportunidad YA tiene afluencia completa (gate de negocio: sin
|
||||
// afluencia no se puede apartar)
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
@@ -163,7 +165,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
email: null,
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'U-101',
|
||||
}),
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
@@ -179,7 +189,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
board: 'APARTADO',
|
||||
etapa: 'Pago Apartado',
|
||||
nombreLead: 'Luis Demo',
|
||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'U-101',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
|
||||
@@ -190,6 +208,26 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
||||
})
|
||||
|
||||
it("'Aparto' SIN afluencia completa: se rechaza con 400 antes de crear nada", async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
usuarioId: 'u-1',
|
||||
nombreLead: 'Luis Demo',
|
||||
telefono: '6641112233',
|
||||
email: null,
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||
})
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res),
|
||||
).rejects.toThrow(/afluencia/i)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
leadId: 'loc_op',
|
||||
@@ -199,7 +237,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
email: null,
|
||||
desarrollo: 'SENDA',
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
})
|
||||
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
||||
const { res } = resFake()
|
||||
@@ -212,7 +257,17 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
select: { unidadCodigo: true },
|
||||
})
|
||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }),
|
||||
data: expect.objectContaining({
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
unidadCodigo: 'SENDA-411-1-7',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
||||
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
||||
@@ -229,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
||||
email: null,
|
||||
desarrollo: null,
|
||||
modelo: null,
|
||||
datosJson: null,
|
||||
datosJson: JSON.stringify({
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
})
|
||||
const { res } = resFake()
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
|
||||
import {
|
||||
cambiarEstadoLeadEndpoint,
|
||||
crearAfluenciaEndpoint,
|
||||
obtenerAfluenciaEndpoint,
|
||||
crearLeadEndpoint,
|
||||
buscarLeadsEndpoint,
|
||||
convertirLeadEndpoint,
|
||||
@@ -305,6 +306,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
|
||||
|
||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||
// Perfil de afluencia capturado de un lead/oportunidad/apartado (bitácora de Mi día)
|
||||
apiRouter.get('/afluencias/:leadId', obtenerAfluenciaEndpoint)
|
||||
|
||||
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||
|
||||
|
||||
@@ -234,9 +234,26 @@ describe('CrmLocalService.apartar', () => {
|
||||
email: 'luis@correo.com',
|
||||
desarrollo: 'ATELIER',
|
||||
modelo: 'SOHO',
|
||||
datosJson: '{"edad":35}',
|
||||
datosJson: JSON.stringify({
|
||||
edad: 35,
|
||||
estadoCivil: 'Casado',
|
||||
tipoEmpleo: 'Formal',
|
||||
rangoSalarial: 'B. 20,000-40,000',
|
||||
tipoEconomia: 'Formal',
|
||||
califica: 'Si',
|
||||
loQuierePara: 'Para vivir',
|
||||
}),
|
||||
}
|
||||
|
||||
it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||
...filaOportunidad,
|
||||
datosJson: '{"edad":35}',
|
||||
})
|
||||
await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i)
|
||||
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
|
||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
||||
const apartado = await svc.apartar('loc_op', 'u-1')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { afluenciaCompleta } from '../config/vocabulario.js'
|
||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
@@ -229,9 +230,10 @@ export class CrmLocalService {
|
||||
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
||||
): Promise<void> {
|
||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
|
||||
// Guardar fechaCita en datosJson (oportunidad y apartado) para que la
|
||||
// muestre Mi día y el calendario
|
||||
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
||||
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
|
||||
if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) {
|
||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
||||
let datos: Record<string, unknown> = {}
|
||||
if (existente?.datosJson) {
|
||||
@@ -347,6 +349,11 @@ export class CrmLocalService {
|
||||
!esIdLocal(oportunidadIdLocal),
|
||||
)
|
||||
|
||||
// Regla de negocio: no se aparta sin la afluencia completa del cliente
|
||||
if (!afluenciaCompleta(oportunidad.datosJson)) {
|
||||
throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar')
|
||||
}
|
||||
|
||||
const clave = claveApartadoCreado(oportunidadIdLocal)
|
||||
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||
if (previo !== null) {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* resuelve por el usuarioId de cada fila.
|
||||
*/
|
||||
|
||||
import { camposAfluenciaFaltantes } from '../config/vocabulario.js'
|
||||
|
||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||
|
||||
@@ -133,6 +135,8 @@ export interface OportunidadMiDia {
|
||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||
fechaCita?: string | undefined
|
||||
/** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */
|
||||
afluenciaCompleta: boolean
|
||||
}
|
||||
|
||||
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
||||
@@ -339,6 +343,7 @@ export function resumenMiDiaLocal(
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
fechaCita,
|
||||
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user