fix(mi-dia): admin reasigna/mueve leads, capturista real en cards, datos en apartados, INE obligatoria y bitácora persistente
This commit is contained in:
@@ -115,8 +115,8 @@ describe('cambiarEstadoApartadoEndpoint (POST /api/apartados/:id/estado) — loc
|
||||
])
|
||||
})
|
||||
|
||||
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
|
||||
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
|
||||
it('los tipos de documento de la precarga son capturas obligatorias + INE + generales', () => {
|
||||
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
||||
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
||||
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
||||
*/
|
||||
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const
|
||||
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'INE', 'OTRO'] as const
|
||||
|
||||
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js'
|
||||
import { recordatorioVendedorService, calcularClienteClave } from '../services/recordatorio-vendedor.service.js'
|
||||
|
||||
/**
|
||||
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
|
||||
@@ -163,11 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
})
|
||||
return
|
||||
}
|
||||
// La nota del alta se persiste como recordatorio en la bitácora del lead
|
||||
// La nota del alta se persiste como recordatorio en la bitácora del lead;
|
||||
// con clienteClave (nombre+teléfono) la nota sigue al lead en las fases
|
||||
// posteriores (oportunidad → apartado → escrituración).
|
||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||
titulo: body.notas,
|
||||
leadId: resultado.id,
|
||||
clienteClave: calcularClienteClave(body.nombre, body.telefono),
|
||||
fechaRecordar: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
@@ -205,6 +208,16 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
||||
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).
|
||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||
titulo: body.notas,
|
||||
leadId: resultado.id,
|
||||
clienteClave: calcularClienteClave(body.nombre, body.telefono),
|
||||
fechaRecordar: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
res.status(201).json({
|
||||
id: resultado.id,
|
||||
estado: 'Afluencia',
|
||||
|
||||
@@ -80,15 +80,21 @@ describe('resumenMiDiaLocal', () => {
|
||||
expect(r.equipo).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ultimoCambio viene del updatedAt local y el quién es el capturista ("app" si no hay nombre)', () => {
|
||||
it('ultimoCambio viene del updatedAt local y el quién es el capturista REAL de la fila (no el que ve)', () => {
|
||||
const capturas = [fila({ leadId: 'loc_l1', board: 'LEADS', updatedAt: new Date('2026-09-05T08:00:00') })]
|
||||
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
|
||||
// El capturista de la fila (u1) es otro usuario distinto del que consulta
|
||||
const usuarios = [{ id: 'u1', nombre: 'Martín Zavala', rol: 'vendedor', activo: true }]
|
||||
const r = resumenMiDiaLocal(vendedor, capturas, [], AHORA, [], usuarios)
|
||||
expect(r.misLeads[0]?.ultimoCambio).toEqual({
|
||||
fechaISO: '2026-09-05T08:00:00.000Z',
|
||||
usuario: 'Martin Zavala',
|
||||
usuario: 'Martín Zavala',
|
||||
detalle: 'Actualización',
|
||||
})
|
||||
|
||||
// Sin la lista de usuarios (o el capturista fuera de ella) → 'app'
|
||||
const rSinUsuarios = resumenMiDiaLocal(vendedor, capturas, [], AHORA)
|
||||
expect(rSinUsuarios.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
|
||||
|
||||
const rSinNombre = resumenMiDiaLocal(sinNombre, capturas, [], AHORA)
|
||||
expect(rSinNombre.misLeads[0]?.ultimoCambio?.usuario).toBe('app')
|
||||
})
|
||||
|
||||
@@ -135,6 +135,10 @@ export interface OportunidadMiDia {
|
||||
export interface ApartadoMiDia {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono: string
|
||||
email: string
|
||||
origen: string
|
||||
desarrollo: string
|
||||
unidadCodigo: string
|
||||
estado: string
|
||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||
@@ -268,6 +272,9 @@ export function resumenMiDiaLocal(
|
||||
): ResumenMiDia {
|
||||
const hace7DiasMs = ahora.getTime() - 7 * MS_DIA
|
||||
|
||||
// Resolución del capturista real de cada fila (no el usuario que ve la pantalla)
|
||||
const nombrePorUsuarioId = new Map(usuarios.map((u) => [u.id, u.nombre]))
|
||||
|
||||
const leadsLocales = capturas.filter((c) => c.board === 'LEADS')
|
||||
const oportunidadesLocales = capturas.filter((c) => c.board === 'OPORTUNIDADES')
|
||||
const apartadosLocales = capturas.filter((c) => c.board === 'APARTADO')
|
||||
@@ -285,7 +292,7 @@ export function resumenMiDiaLocal(
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
fuente: 'lead' as const,
|
||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
@@ -321,7 +328,7 @@ export function resumenMiDiaLocal(
|
||||
modeloInteres: c.modelo ?? '',
|
||||
unidadCodigo,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
fechaCita,
|
||||
pendienteSync: c.pendienteSync,
|
||||
}
|
||||
@@ -339,10 +346,14 @@ export function resumenMiDiaLocal(
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: ultimoCambioLocal(c, usuario.nombre),
|
||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||
pendienteSync: c.pendienteSync,
|
||||
}))
|
||||
|
||||
@@ -492,6 +503,10 @@ export function resumenMiDiaLocal(
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
telefono: c.telefono ?? '',
|
||||
email: c.email ?? '',
|
||||
origen: c.origen ?? '',
|
||||
desarrollo: c.desarrollo ?? '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
|
||||
Reference in New Issue
Block a user