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:
2026-09-25 10:23:24 +00:00
parent 8a5db114ec
commit 7f663208a0
19 changed files with 381 additions and 501 deletions

View File

@@ -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'

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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()