feat(monday): alinear Frasa-CAS con CRM real del cliente (7 mejoras)
- Vocabulario funnel: labels Intento de contacto/Abandono en Leads, etapas completas en Oportunidades (Cotizador, Ganado, Perdido...) - Apartados: pipeline Enkontrol (8 estados) + checkboxes CURP/RFC/Comprobante que la app de alta prende al subir documentos - Motivos No Compra: board catálogo + dropdown + endpoint /oportunidades/:id/perder - Perfil demográfico en Contactos mapeado desde el alta (estado civil, vivienda, propósito, fecha nac., rango de edad calculado) - Desarrollos: catálogo maestro sincronizado (precios, m2, tipo, entrega) - Actividades: board nuevo + creación automática en round-robin e inactividad - Cobranza: plan liquidado mueve el apartado a 'X Escriturar' en Monday - Scripts: setup:crm y sync:desarrollos (idempotentes) - Tests 119/119 verdes
This commit is contained in:
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal file
75
api-gateway/src/controllers/clientes.controller.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
actividadEconomicaLabel,
|
||||
checkboxParaTipoDocumento,
|
||||
perfilDemografico,
|
||||
rangoEdadLabel,
|
||||
} from './clientes.controller.js'
|
||||
|
||||
describe('checkboxParaTipoDocumento (Apartado → checkboxes de documentos)', () => {
|
||||
it('mapea los tipos con checkbox al título de la columna', () => {
|
||||
expect(checkboxParaTipoDocumento('CURP')).toBe('CURP')
|
||||
expect(checkboxParaTipoDocumento('RFC')).toBe('RFC')
|
||||
expect(checkboxParaTipoDocumento('CONSTANCIA_RFC')).toBe('RFC')
|
||||
expect(checkboxParaTipoDocumento('COMPROBANTE')).toBe('Comprobante')
|
||||
expect(checkboxParaTipoDocumento('COMPROBANTE_DOMICILIO')).toBe('Comprobante')
|
||||
})
|
||||
|
||||
it('normaliza mayúsculas y espacios', () => {
|
||||
expect(checkboxParaTipoDocumento(' curp ')).toBe('CURP')
|
||||
expect(checkboxParaTipoDocumento('comprobante_domicilio')).toBe('Comprobante')
|
||||
})
|
||||
|
||||
it('INE y otros tipos no tienen checkbox (null)', () => {
|
||||
expect(checkboxParaTipoDocumento('INE')).toBeNull()
|
||||
expect(checkboxParaTipoDocumento('ACTA_NACIMIENTO')).toBeNull()
|
||||
expect(checkboxParaTipoDocumento('OTRO')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rangoEdadLabel', () => {
|
||||
const hoy = new Date('2026-08-22T12:00:00')
|
||||
|
||||
it('deriva el rango del CRM a partir de la fecha de nacimiento', () => {
|
||||
expect(rangoEdadLabel('2003-01-01', hoy)).toBe('Menor de 24') // 23 años
|
||||
expect(rangoEdadLabel('2002-08-22', hoy)).toBe('Entre 24 y 40') // cumple 24 hoy
|
||||
expect(rangoEdadLabel('1990-05-10', hoy)).toBe('Entre 24 y 40')
|
||||
expect(rangoEdadLabel('1986-08-21', hoy)).toBe('Entre 24 y 40') // 40 años: límite inclusivo
|
||||
expect(rangoEdadLabel('1985-01-01', hoy)).toBe('Mayor de 40')
|
||||
})
|
||||
|
||||
it('devuelve undefined con fechas inválidas o ausentes', () => {
|
||||
expect(rangoEdadLabel(undefined, hoy)).toBeUndefined()
|
||||
expect(rangoEdadLabel('no-es-fecha', hoy)).toBeUndefined()
|
||||
expect(rangoEdadLabel('2999-01-01', hoy)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('perfilDemografico (alta → Contacto Monday)', () => {
|
||||
it('mapea los valores del formato a los labels del CRM', () => {
|
||||
const perfil = perfilDemografico({
|
||||
estadoCivil: 'UNION_LIBRE',
|
||||
tipoVivienda: 'FAMILIAR',
|
||||
tipoCompra: 'VIVIR_AHI',
|
||||
fechaNacimiento: '1990-05-10',
|
||||
})
|
||||
expect(perfil.estadoCivilLabel).toBe('Union Libre')
|
||||
expect(perfil.viviendaActualLabel).toBe('Comparte con familia')
|
||||
expect(perfil.propositoCompraLabel).toBe('Habitarla')
|
||||
expect(perfil.rangoEdadLabel).toBe('Entre 24 y 40')
|
||||
expect(perfil.fechaNacimiento).toBe('1990-05-10')
|
||||
})
|
||||
|
||||
it('omite campos vacíos o desconocidos (no inventa datos)', () => {
|
||||
expect(perfilDemografico({})).toEqual({})
|
||||
expect(perfilDemografico({ estadoCivil: '', tipoVivienda: 'OTRA_COSA' })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('actividadEconomicaLabel', () => {
|
||||
it('solo ASALARIADO tiene equivalencia inequívoca (Empleado)', () => {
|
||||
expect(actividadEconomicaLabel('ASALARIADO')).toBe('Empleado')
|
||||
expect(actividadEconomicaLabel('INDEPENDIENTE')).toBeUndefined() // ambiguo: no se adivina
|
||||
expect(actividadEconomicaLabel(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -21,8 +21,14 @@ const crearClienteSchema = z
|
||||
email: z.string().optional(),
|
||||
celular: z.string().optional(),
|
||||
direccion: z.string().optional(),
|
||||
// Perfil demográfico (opcionales; se replican al Contacto en Monday)
|
||||
fechaNacimiento: z.string().optional(),
|
||||
estadoCivil: z.string().optional(),
|
||||
tipoVivienda: z.string().optional(),
|
||||
tipoCompra: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
empleo: z.object({ tipoEmpleo: z.string().optional() }).passthrough().optional(),
|
||||
viviendaCredito: z
|
||||
.object({
|
||||
proyecto: z.string().min(1),
|
||||
@@ -35,6 +41,106 @@ const crearClienteSchema = z
|
||||
})
|
||||
.passthrough() // el resto del payload pasa tal cual a payloadJson
|
||||
|
||||
// ---------- Mapeos al vocabulario del CRM FRASA (lógica pura, testeable) ----------
|
||||
|
||||
const ESTADO_CIVIL_LABEL: Record<string, string> = {
|
||||
SOLTERO: 'Soltero',
|
||||
CASADO: 'Casado',
|
||||
UNION_LIBRE: 'Union Libre',
|
||||
DIVORCIADO: 'Divorciado',
|
||||
VIUDO: 'Viudo',
|
||||
}
|
||||
|
||||
const VIVIENDA_ACTUAL_LABEL: Record<string, string> = {
|
||||
PRESTADA: 'Prestada',
|
||||
RENTADA: 'Rentada',
|
||||
FAMILIAR: 'Comparte con familia',
|
||||
PROPIA: 'Propia',
|
||||
}
|
||||
|
||||
const PROPOSITO_COMPRA_LABEL: Record<string, string> = {
|
||||
INVERSION: 'Inversion',
|
||||
VIVIR_AHI: 'Habitarla',
|
||||
}
|
||||
|
||||
// El formato de alta solo permite inferir 'Empleado' (ASALARIADO); un
|
||||
// INDEPENDIENTE puede ser Comerciante o Empresario: no se adivina.
|
||||
const ACTIVIDAD_ECONOMICA_LABEL: Record<string, string> = {
|
||||
ASALARIADO: 'Empleado',
|
||||
}
|
||||
|
||||
/** Label del dropdown "Rango de Edad" derivado de la fecha de nacimiento */
|
||||
export function rangoEdadLabel(fechaNacimiento: string | undefined, hoy: Date = new Date()): string | undefined {
|
||||
if (!fechaNacimiento) return undefined
|
||||
const nacimiento = new Date(`${fechaNacimiento.slice(0, 10)}T00:00:00`)
|
||||
if (Number.isNaN(nacimiento.getTime())) return undefined
|
||||
let edad = hoy.getFullYear() - nacimiento.getFullYear()
|
||||
const aunNoCumple =
|
||||
hoy.getMonth() < nacimiento.getMonth() ||
|
||||
(hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate())
|
||||
if (aunNoCumple) edad--
|
||||
if (edad < 0 || edad > 120) return undefined
|
||||
if (edad < 24) return 'Menor de 24'
|
||||
if (edad <= 40) return 'Entre 24 y 40'
|
||||
return 'Mayor de 40'
|
||||
}
|
||||
|
||||
/** Labels demográficos del Contacto a partir del payload del formato de alta */
|
||||
export function perfilDemografico(dg: {
|
||||
estadoCivil?: string | undefined
|
||||
tipoVivienda?: string | undefined
|
||||
tipoCompra?: string | undefined
|
||||
fechaNacimiento?: string | undefined
|
||||
}): {
|
||||
estadoCivilLabel?: string | undefined
|
||||
viviendaActualLabel?: string | undefined
|
||||
propositoCompraLabel?: string | undefined
|
||||
rangoEdadLabel?: string | undefined
|
||||
fechaNacimiento?: string | undefined
|
||||
} {
|
||||
const limpio = (v: string | undefined): string => (v ?? '').trim().toUpperCase()
|
||||
const fecha = /^\d{4}-\d{2}-\d{2}/.test(dg.fechaNacimiento ?? '')
|
||||
? dg.fechaNacimiento!.slice(0, 10)
|
||||
: undefined
|
||||
return {
|
||||
...(ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] !== undefined
|
||||
? { estadoCivilLabel: ESTADO_CIVIL_LABEL[limpio(dg.estadoCivil)] }
|
||||
: {}),
|
||||
...(VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] !== undefined
|
||||
? { viviendaActualLabel: VIVIENDA_ACTUAL_LABEL[limpio(dg.tipoVivienda)] }
|
||||
: {}),
|
||||
...(PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] !== undefined
|
||||
? { propositoCompraLabel: PROPOSITO_COMPRA_LABEL[limpio(dg.tipoCompra)] }
|
||||
: {}),
|
||||
...(rangoEdadLabel(fecha) !== undefined ? { rangoEdadLabel: rangoEdadLabel(fecha) } : {}),
|
||||
...(fecha !== undefined ? { fechaNacimiento: fecha } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Label de "Actividad Economica" derivado del tipo de empleo del formato */
|
||||
export function actividadEconomicaLabel(tipoEmpleo: string | undefined): string | undefined {
|
||||
return ACTIVIDAD_ECONOMICA_LABEL[(tipoEmpleo ?? '').trim().toUpperCase()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox del tablero Apartado que corresponde a un tipo de documento.
|
||||
* INE y el resto no tienen checkbox (devuelve null).
|
||||
*/
|
||||
export function checkboxParaTipoDocumento(tipo: string): string | null {
|
||||
switch (tipo.trim().toUpperCase()) {
|
||||
case 'CURP':
|
||||
return 'CURP'
|
||||
case 'RFC':
|
||||
case 'CONSTANCIA_RFC':
|
||||
return 'RFC'
|
||||
case 'COMPROBANTE':
|
||||
case 'COMPROBANTE_DOMICILIO':
|
||||
return 'Comprobante'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const aNumero = (v: string | number | undefined): number => {
|
||||
if (v === undefined) return 0
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''))
|
||||
@@ -81,6 +187,11 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
curp: dg.curp,
|
||||
rfc: dg.rfc,
|
||||
...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}),
|
||||
// Perfil demográfico: solo lo que el formato de alta captura.
|
||||
// Sin fuente en el formulario: Sexo (no se pregunta) y Actividad
|
||||
// Economica distinta de 'Empleado' (INDEPENDIENTE es ambiguo).
|
||||
...perfilDemografico(dg),
|
||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
||||
})
|
||||
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
@@ -92,7 +203,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'VIGENTE',
|
||||
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,6 +286,23 @@ export async function subirDocumentos(req: Request, res: Response): Promise<void
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Prende el checkbox del documento en el item de Apartado del cliente
|
||||
// (pipeline Enkontrol). Un fallo de Monday no rompe la subida.
|
||||
const checkbox = checkboxParaTipoDocumento(tipo)
|
||||
if (checkbox !== null && cliente.mondayApartadoId !== null) {
|
||||
try {
|
||||
await mondayService.cambiarValorColumna(
|
||||
config.monday.boards.apartado,
|
||||
cliente.mondayApartadoId,
|
||||
checkbox,
|
||||
true,
|
||||
)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${cliente.mondayApartadoId}:`, err)
|
||||
}
|
||||
}
|
||||
res.status(201).json(documentos)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
fechaCotizacion: hoyISO(),
|
||||
estadoLabel: 'COTIZADA',
|
||||
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
|
||||
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||
})
|
||||
|
||||
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal file
52
api-gateway/src/controllers/oportunidades.controller.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { perderOportunidad } from './oportunidades.controller.js'
|
||||
|
||||
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
||||
function mondayMock() {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
motivos: [] as { nombre: string; categoria: string; descripcion?: string }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
async obtenerNombreItem(_itemId: string) {
|
||||
return 'Juan Pérez'
|
||||
},
|
||||
async crearMotivoNoCompra(datos: { nombre: string; categoria: string; descripcion?: string }) {
|
||||
this.motivos.push(datos)
|
||||
return 'motivo-item-1'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
|
||||
it('pone etapa Perdido + dropdown motivo y registra en el tablero Motivos No Compra', async () => {
|
||||
const monday = mondayMock()
|
||||
const motivoItemId = await perderOportunidad(monday, 'opp-123', 'Precio', 'Se le hizo caro')
|
||||
|
||||
expect(motivoItemId).toBe('motivo-item-1')
|
||||
// Etapa y motivo sobre la oportunidad (board 555 = MONDAY_BOARD_OPORTUNIDADES de test)
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '555', itemId: 'opp-123', columna: 'Estado', valor: 'Perdido' },
|
||||
{ boardId: '555', itemId: 'opp-123', columna: 'Motivo No Compra', valor: 'Precio' },
|
||||
])
|
||||
// Item en el catálogo: nombre = motivo + item de origen
|
||||
expect(monday.motivos).toEqual([
|
||||
{ nombre: 'Precio — Juan Pérez', categoria: 'Precio', descripcion: 'Se le hizo caro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('el comentario es opcional', async () => {
|
||||
const monday = mondayMock()
|
||||
await perderOportunidad(monday, 'opp-9', 'Competencia')
|
||||
expect(monday.motivos[0]).toEqual({ nombre: 'Competencia — Juan Pérez', categoria: 'Competencia' })
|
||||
})
|
||||
|
||||
it('propaga el error si Monday falla (el endpoint responde 5xx)', async () => {
|
||||
const monday = mondayMock()
|
||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
await expect(perderOportunidad(monday, 'opp-1', 'Timing')).rejects.toThrow('Monday caído')
|
||||
// No se registró motivo si falló el cambio de etapa
|
||||
expect(monday.motivos).toEqual([])
|
||||
})
|
||||
})
|
||||
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal file
55
api-gateway/src/controllers/oportunidades.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
|
||||
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
'Precio',
|
||||
'Producto',
|
||||
'Ubicacion',
|
||||
'Financiamiento',
|
||||
'Competencia',
|
||||
'Cliente',
|
||||
'Timing',
|
||||
] as const
|
||||
|
||||
const perderSchema = z.object({
|
||||
motivo: z.enum(MOTIVOS_NO_COMPRA),
|
||||
comentario: z.string().optional(),
|
||||
})
|
||||
|
||||
type MondayOportunidades = Pick<
|
||||
MondayService,
|
||||
'cambiarValorColumna' | 'crearMotivoNoCompra' | 'obtenerNombreItem'
|
||||
>
|
||||
|
||||
/**
|
||||
* Marca una oportunidad como perdida: etapa 'Perdido' + dropdown motivo, y
|
||||
* registra el motivo en el tablero catálogo Motivos No Compra.
|
||||
* Devuelve el ID del item creado en Motivos No Compra.
|
||||
*/
|
||||
export async function perderOportunidad(
|
||||
monday: MondayOportunidades,
|
||||
mondayItemId: string,
|
||||
motivo: string,
|
||||
comentario?: string,
|
||||
): Promise<string> {
|
||||
const boardOportunidades = config.monday.boards.oportunidades
|
||||
const nombreItem = await monday.obtenerNombreItem(mondayItemId)
|
||||
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Estado', 'Perdido')
|
||||
await monday.cambiarValorColumna(boardOportunidades, mondayItemId, 'Motivo No Compra', motivo)
|
||||
return monday.crearMotivoNoCompra({
|
||||
nombre: `${motivo} — ${nombreItem}`,
|
||||
categoria: motivo,
|
||||
...(comentario !== undefined ? { descripcion: comentario } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /api/oportunidades/:mondayItemId/perder { motivo, comentario? } */
|
||||
export async function perderOportunidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = perderSchema.parse(req.body)
|
||||
const mondayItemId = String(req.params['mondayItemId'])
|
||||
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
|
||||
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
}
|
||||
42
api-gateway/src/controllers/planes.controller.test.ts
Normal file
42
api-gateway/src/controllers/planes.controller.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { notificarEscrituracionSiLiquidado } from './planes.controller.js'
|
||||
|
||||
/** Monday mockeado: registra cambios de columna */
|
||||
function mondayMock() {
|
||||
return {
|
||||
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||
this.cambios.push({ boardId, itemId, columna, valor })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('notificarEscrituracionSiLiquidado (puente cobranza → postventa)', () => {
|
||||
it('plan LIQUIDADO con mondayItemId: mueve el apartado a "X Escriturar"', async () => {
|
||||
const monday = mondayMock()
|
||||
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
|
||||
expect(ok).toBe(true)
|
||||
// board 666 = MONDAY_BOARD_APARTADO de test
|
||||
expect(monday.cambios).toEqual([
|
||||
{ boardId: '666', itemId: 'apt-55', columna: 'Estado', valor: 'X Escriturar' },
|
||||
])
|
||||
})
|
||||
|
||||
it('no hace nada si el plan no está liquidado o no tiene item ligado', async () => {
|
||||
const monday = mondayMock()
|
||||
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, false, monday)).toBe(false)
|
||||
expect(await notificarEscrituracionSiLiquidado({ mondayItemId: null }, true, monday)).toBe(false)
|
||||
expect(await notificarEscrituracionSiLiquidado({}, true, monday)).toBe(false)
|
||||
expect(monday.cambios).toEqual([])
|
||||
})
|
||||
|
||||
it('un fallo de Monday no rompe el abono: loguea y devuelve false', async () => {
|
||||
const monday = mondayMock()
|
||||
vi.spyOn(monday, 'cambiarValorColumna').mockRejectedValueOnce(new Error('Monday caído'))
|
||||
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const ok = await notificarEscrituracionSiLiquidado({ mondayItemId: 'apt-55' }, true, monday)
|
||||
expect(ok).toBe(false)
|
||||
expect(spyError).toHaveBeenCalled()
|
||||
spyError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type PlanPago as PlanPagoDominio,
|
||||
} from '../services/cobranza.service.js'
|
||||
import { BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
|
||||
|
||||
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
|
||||
@@ -48,6 +50,7 @@ function aDominio(plan: PlanCompleto): PlanPagoDominio {
|
||||
mensualidad: plan.mensualidad,
|
||||
tasaMoraMensual: plan.tasaMoraMensual,
|
||||
status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO',
|
||||
mondayItemId: plan.mondayItemId,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
pagos: plan.pagos
|
||||
.slice()
|
||||
@@ -105,6 +108,8 @@ const crearPlanSchema = z.object({
|
||||
engancheTotal: z.number().positive(),
|
||||
numPagos: z.number().int().min(1),
|
||||
tasaMoraMensual: z.number().min(0).optional(),
|
||||
// Item del tablero Apartado (Monday) ligado al plan; también ligable después con PATCH
|
||||
mondayItemId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
/** POST /api/planes — genera el calendario de pagos y lo persiste */
|
||||
@@ -129,6 +134,7 @@ export async function crearPlan(req: Request, res: Response): Promise<void> {
|
||||
numPagos: body.numPagos,
|
||||
mensualidad: calendario.mensualidad,
|
||||
tasaMoraMensual: body.tasaMoraMensual ?? 0,
|
||||
mondayItemId: body.mondayItemId ?? null,
|
||||
pagos: {
|
||||
create: calendario.pagos.map((p) => ({
|
||||
numPago: p.numPago,
|
||||
@@ -176,6 +182,29 @@ const abonoSchema = z.object({
|
||||
referencia: z.string().min(1),
|
||||
})
|
||||
|
||||
type MondayApartado = Pick<MondayService, 'cambiarValorColumna'>
|
||||
|
||||
/**
|
||||
* Puente cobranza → postventa: cuando el plan queda LIQUIDADO y está ligado a
|
||||
* un item del tablero Apartado, ese item pasa a 'X Escriturar'.
|
||||
* Un fallo de Monday NUNCA rompe el abono: se loguea y devuelve false.
|
||||
*/
|
||||
export async function notificarEscrituracionSiLiquidado(
|
||||
plan: { mondayItemId?: string | null },
|
||||
liquidado: boolean,
|
||||
monday: MondayApartado = mondayService,
|
||||
): Promise<boolean> {
|
||||
if (!liquidado || plan.mondayItemId == null || plan.mondayItemId === '') return false
|
||||
try {
|
||||
await monday.cambiarValorColumna(config.monday.boards.apartado, plan.mondayItemId, 'Estado', 'X Escriturar')
|
||||
return true
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo mover el apartado ${plan.mondayItemId} a 'X Escriturar':`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */
|
||||
export async function registrarAbonoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = abonoSchema.parse(req.body)
|
||||
@@ -206,7 +235,24 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
|
||||
])
|
||||
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.status(201).json(conCalculados(aDominio(actualizado)))
|
||||
const dominioActualizado = aDominio(actualizado)
|
||||
const calculado = conCalculados(dominioActualizado)
|
||||
|
||||
// Si el abono liquidó el plan, avisar a postventa (no bloquea la respuesta)
|
||||
await notificarEscrituracionSiLiquidado(dominioActualizado, calculado.status === 'LIQUIDADO')
|
||||
|
||||
res.status(201).json(calculado)
|
||||
}
|
||||
|
||||
const ligarMondaySchema = z.object({ mondayItemId: z.string().min(1).nullable() })
|
||||
|
||||
/** PATCH /api/planes/:id/monday — liga (o desliga con null) el item de Apartado en Monday */
|
||||
export async function ligarMondayItem(req: Request, res: Response): Promise<void> {
|
||||
const body = ligarMondaySchema.parse(req.body)
|
||||
const plan = await cargarPlan(String(req.params['id']))
|
||||
await prisma.planPago.update({ where: { id: plan.id }, data: { mondayItemId: body.mondayItemId } })
|
||||
const actualizado = await cargarPlan(plan.id)
|
||||
res.json(conCalculados(aDominio(actualizado)))
|
||||
}
|
||||
|
||||
const notaSchema = z.object({ texto: z.string() })
|
||||
|
||||
Reference in New Issue
Block a user