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