feat(apartado): ficha completa en Monday + estado visible en la app

- El alta (directa y progresiva) llena el Apartado con telefono, correo,
  curp, Precio Final, Enganche, Fecha Compra, Vendedor (el capturista
  con Monday vinculado) y Forma de Pago; el path webhook/cron hereda
  contacto, precio y asesor de la oportunidad.
- Bidireccional: Mis clientes en /hoy muestra el estado actual del
  Apartado leyendolo de Monday (si lo mueven en Monday, la app lo
  refleja en el siguiente refresh).
- getApartados con la correccion value-null al leer Estado.
- Test del mapa de Tipo propuesta del cotizador (los 502 del log eran
  de un proceso previo al fix 4f31d1c; queda cubierto).
This commit is contained in:
2026-09-04 07:34:57 +00:00
parent d81797c610
commit 42cfe937cf
13 changed files with 428 additions and 12 deletions

View File

@@ -632,6 +632,25 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
})
it('el Apartado hereda de la oportunidad: contacto, Precio Final y Vendedor (primer asesor)', async () => {
const monday = mondayMock([], [
oportunidad('10', { etapa: 'Aparto', telefono: '6641112233', email: 'c@c.com', asesorIds: [101, 102] }),
])
const state = stateEnMemoria()
const svc = crearServicio(monday, state)
const r = await svc.escaneoOportunidades()
expect(r.apartadosCreados).toHaveLength(1)
expect(monday.apartados[0]).toMatchObject({
clienteNombre: 'Cliente 10',
unidadCodigo: 'U-101',
telefono: '6641112233',
email: 'c@c.com',
precioFinal: 2500000,
vendedorId: 101,
})
})
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
const state = stateEnMemoria()

View File

@@ -541,12 +541,18 @@ export class AutomatizacionesService {
for (const op of oportunidades) {
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
try {
// El Apartado hereda lo que la oportunidad ya trae (contacto, precio,
// asesor como Vendedor); lo que no exista se omite
const apartadoItemId = await this.monday.crearApartado({
clienteNombre: op.nombre,
unidadCodigo: op.unidadCodigo,
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
estadoLabel: 'Pago Apartado',
telefono: op.telefono !== '' ? op.telefono : undefined,
email: op.email !== '' ? op.email : undefined,
precioFinal: op.precioFinal > 0 ? op.precioFinal : undefined,
vendedorId: op.asesorIds[0],
})
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })

View File

@@ -310,6 +310,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
nombreCompleto: 'Ana En Captura',
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-26T09:00:00Z'),
},
{
@@ -317,6 +318,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
nombreCompleto: 'Luis Completado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
mondayApartadoId: null,
createdAt: new Date('2026-08-27T09:00:00Z'),
},
]
@@ -329,6 +331,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
unidadCodigo: 'U-101',
completado: true,
capturadoEn: '2026-08-27T09:00:00.000Z',
estadoApartado: null,
},
{
id: 'c1',
@@ -337,13 +340,57 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
unidadCodigo: '',
completado: false,
capturadoEn: '2026-08-26T09:00:00.000Z',
estadoApartado: null,
},
])
})
it('mis clientes: estadoApartado cruza mondayApartadoId contra el tablero Apartado', async () => {
const clientes = [
{
id: 'c1',
nombreCompleto: 'Con Apartado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-101',
mondayApartadoId: 'ap-1',
createdAt: new Date('2026-08-27T09:00:00Z'),
},
{
id: 'c2',
nombreCompleto: 'Apartado Borrado',
proyecto: 'DIAGONAL',
unidadCodigo: 'U-102',
mondayApartadoId: 'ap-fantasma', // ya no existe en Monday
createdAt: new Date('2026-08-26T09:00:00Z'),
},
{
id: 'c3',
nombreCompleto: 'Sin Apartado',
proyecto: '',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-25T09:00:00Z'),
},
]
const monday = mondayMock({ apartados: [apartado({ id: 'ap-1', estado: 'Alta Enkontrol' })] })
const r = await resumenMiDia(monday, vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes.map((c) => [c.id, c.estadoApartado])).toEqual([
['c1', 'Alta Enkontrol'], // estado real del item en Monday
['c2', null], // el apartado ya no existe
['c3', null], // sin apartado ligado
])
})
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
const clientes = [
{ id: 'c1', nombreCompleto: 'Espacios', proyecto: ' ', unidadCodigo: null, createdAt: new Date('2026-08-26T09:00:00Z') },
{
id: 'c1',
nombreCompleto: 'Espacios',
proyecto: ' ',
unidadCodigo: null,
mondayApartadoId: null,
createdAt: new Date('2026-08-26T09:00:00Z'),
},
]
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
expect(r.misClientes[0]?.completado).toBe(false)

View File

@@ -74,6 +74,8 @@ export interface ClienteMiDia {
unidadCodigo: string
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
capturadoEn: string // ISO
/** Estado actual del item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */
estadoApartado: string | null
}
export interface ResumenMiDia {
@@ -136,7 +138,14 @@ export async function resumenMiDia(
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: { id: string; nombreCompleto: string; proyecto: string; unidadCodigo: string | null; createdAt: Date }[] = [],
clientes: {
id: string
nombreCompleto: string
proyecto: string
unidadCodigo: string | null
mondayApartadoId: string | null
createdAt: Date
}[] = [],
ahora: Date = new Date(),
): Promise<ResumenMiDia> {
const miId = usuario.mondayUserId
@@ -217,8 +226,11 @@ export async function resumenMiDia(
})
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
// "Mis clientes" es 100% local (no requiere Monday): el alta en progreso
// lleva proyecto '', el alta completada lleva el proyecto lleno
// "Mis clientes" es local + cruce con Monday: el alta en progreso lleva
// proyecto '', el alta completada lleva el proyecto lleno. estadoApartado
// cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay
// apartado ligado o ya no existe en Monday)
const apartadosPorId = new Map(apartados.map((a) => [a.id, a]))
const misClientes: ClienteMiDia[] = clientes
.map((c) => ({
id: c.id,
@@ -227,6 +239,8 @@ export async function resumenMiDia(
unidadCodigo: c.unidadCodigo ?? '',
completado: c.proyecto.trim() !== '',
capturadoEn: c.createdAt.toISOString(),
estadoApartado:
c.mondayApartadoId != null ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null,
}))
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))

View File

@@ -743,4 +743,99 @@ describe('MondayService', () => {
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
})
})
describe('crearApartado (ficha completa del tablero Apartado)', () => {
// Columnas reales del board (verificadas en Monday 2026-09-04)
const COLUMNAS_APARTADO = [
{ id: 'text_u', title: 'Unidad', type: 'text' },
{ id: 'numeric_ma', title: 'Monto apartado', type: 'numbers' },
{ id: 'date_fa', title: 'Fecha apartado', type: 'date' },
{ id: 'date_fl', title: 'Fecha límite', type: 'date' },
{ id: 'color_e', title: 'Estado', type: 'status' },
{ id: 'phone_t', title: 'Teléfono', type: 'phone' },
{ id: 'email_c', title: 'Correo electrónico', type: 'email' },
{ id: 'text_curp', title: 'curp', type: 'text' }, // minúscula en el board
{ id: 'numeric_pf', title: 'Precio Final', type: 'numbers' },
{ id: 'numeric_en', title: 'Enganche', type: 'numbers' },
{ id: 'date_fc', title: 'Fecha Compra', type: 'date' },
{ id: 'people_v', title: 'Vendedor', type: 'people' },
{ id: 'dropdown_fp', title: 'Forma de Pago', type: 'dropdown' },
{ id: 'long_obs', title: 'Observaciones', type: 'long_text' },
]
it('serializa cada columna según su tipo (phone, email, numbers, date, people, dropdown, long_text)', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [
{
columns: [
{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' },
{ id: 'dropdown_fp', title: 'Forma de Pago', settings_str: '{"labels":[{"name":"Infonavit"},{"name":"Contado"}]}' },
],
},
],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
return respuestaOk({ create_item: { id: 'ap-1' } })
})
const id = await crearServicio().crearApartado({
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
montoApartado: 20000,
fechaApartado: '2026-09-04',
fechaLimite: '2026-09-19',
estadoLabel: 'Pago Apartado',
telefono: '6641112233',
email: 'cli@correo.com',
curp: 'CURP123',
precioFinal: 1500000,
enganche: 20000,
fechaCompra: '2026-09-04',
vendedorId: 12345,
formaPagoLabel: 'INFONAVIT', // el resolver lo ajusta al label real
observaciones: 'Venta con descuento autorizado',
})
expect(id).toBe('ap-1')
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
expect(mutacion?.body.variables['boardId']).toBe('666') // tablero Apartado
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(parsed['phone_t']).toEqual({ phone: '6641112233', countryShortName: 'MX' })
expect(parsed['email_c']).toEqual({ email: 'cli@correo.com', text: 'cli@correo.com' })
expect(parsed['text_curp']).toBe('CURP123')
expect(parsed['numeric_pf']).toBe(1500000)
expect(parsed['numeric_en']).toBe(20000)
expect(parsed['date_fc']).toEqual({ date: '2026-09-04' })
expect(parsed['people_v']).toEqual({ personsAndTeams: [{ id: 12345, kind: 'person' }] })
expect(parsed['dropdown_fp']).toEqual({ labels: ['Infonavit'] }) // dropdown resuelto case-insensitive
expect(parsed['long_obs']).toEqual({ text: 'Venta con descuento autorizado' })
})
it('omite las columnas opcionales no capturadas', async () => {
const llamadas = mockearFetch((body) => {
if (body.query.includes('settings_str')) {
return respuestaOk({
boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' }] }],
})
}
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
return respuestaOk({ create_item: { id: 'ap-2' } })
})
await crearServicio().crearApartado({
clienteNombre: 'Cliente Demo',
unidadCodigo: 'U-101',
fechaApartado: '2026-09-04',
fechaLimite: '2026-09-19',
estadoLabel: 'Pago Apartado',
})
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u'])
})
})
})

View File

@@ -557,12 +557,25 @@ export class MondayService {
async crearApartado(datos: DatosApartado): Promise<string> {
const boardId = config.monday.boards.apartado
// Mapeo a las columnas reales del tablero Apartado (verificadas en Monday):
// contacto (Teléfono/Correo/curp), operación (Precio Final/Enganche/
// Fecha Compra/Forma de Pago), asignación (Vendedor) y Observaciones.
// Todo lo opcional se omite si viene undefined (mismo patrón de siempre).
const columnValues = await this.construirColumnValues(boardId, {
Unidad: datos.unidadCodigo,
'Monto apartado': datos.montoApartado,
'Fecha apartado': datos.fechaApartado,
'Fecha límite': datos.fechaLimite,
Estado: datos.estadoLabel,
Teléfono: datos.telefono,
'Correo electrónico': datos.email,
curp: datos.curp, // el título de la columna es minúscula en el board
'Precio Final': datos.precioFinal,
Enganche: datos.enganche,
'Fecha Compra': datos.fechaCompra,
Vendedor: datos.vendedorId,
'Forma de Pago': datos.formaPagoLabel, // dropdown; el resolver ajusta case
Observaciones: datos.observaciones,
})
return this.crearItem(boardId, datos.clienteNombre, columnValues)
}
@@ -1001,12 +1014,16 @@ export class MondayService {
return items.map((item) => {
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
// Misma corrección que mapearLead: status nunca escrito → value null y
// `text` muestra el default del tablero; el estado real es vacío
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
return {
id: item.id,
nombre: item.name,
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
estado: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
estado,
}
})
}
@@ -1262,6 +1279,16 @@ export interface DatosApartado {
fechaApartado: string // 'YYYY-MM-DD'
fechaLimite: string // 'YYYY-MM-DD'
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
// Contacto y operación (opcionales; columnas reales del tablero Apartado)
telefono?: string | undefined // phone 'Teléfono'
email?: string | undefined // email 'Correo electrónico'
curp?: string | undefined // text 'curp' (título minúscula en el board)
precioFinal?: number | undefined // numbers 'Precio Final'
enganche?: number | undefined // numbers 'Enganche'
fechaCompra?: string | undefined // date 'Fecha Compra' ('YYYY-MM-DD')
vendedorId?: number | undefined // people 'Vendedor' (user ID de Monday)
formaPagoLabel?: string | undefined // dropdown 'Forma de Pago' (labels ya sembrados)
observaciones?: string | undefined // long_text 'Observaciones'
}
export interface DatosVenta {