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:
2026-08-22 07:18:48 +00:00
parent afb454b54c
commit f0f923b2ef
22 changed files with 1178 additions and 13 deletions

View File

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