- Mis apartados (Mi día): botón Documentos con precarga del expediente — captura del cotizador y captura de pago apartado (obligatorios) más documentos generales; los archivos se guardan en el cliente ligado al apartado (se crea la ficha mínima si no existe). Endpoints GET/POST /apartados/:id/documentos (multipart, 10 MB, pdf/jpg/png) - Cotizador: se oculta el editor de descuento (usan los % del Excel); enganche seleccionable con mínimo 2.5% del valor de la propiedad, solo en esquemas con crédito (bancario/Infonavit); mensualidades se recalculan con el enganche elegido y nunca quedan en negativo - Cotizador: buscador de cliente en Monday (lead u oportunidad) que prellena nombre/contacto/desarrollo y vincula la cotización a la oportunidad al guardar - Alta Afluencia: buscador que jala nombre, contacto, origen, desarrollo y modelo desde leads y oportunidades existentes - buscarLeads devuelve desarrollo y modelo de interés - Landing: el módulo Clientes vuelve a llamarse Cobranza - Tests: 456 API + 94 cotizador + 14 mi-dia + 116 alta-clientes, verde
236 lines
9.2 KiB
TypeScript
236 lines
9.2 KiB
TypeScript
import type { Request, Response } from 'express'
|
|
import { z } from 'zod'
|
|
import { mondayService, type DatosOportunidad } from '../services/monday.service.js'
|
|
import { funnelService } from '../services/funnel.service.js'
|
|
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
|
import {
|
|
procesarAlertasInventario,
|
|
type ResultadoAlertas,
|
|
} from '../services/alertas-inventario.service.js'
|
|
import { prisma } from '../services/prisma.js'
|
|
import { NotFoundError } from '../utils/errors.js'
|
|
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
|
|
|
|
const crearCotizacionSchema = z.object({
|
|
unidadCodigo: z.string().min(1),
|
|
acabados: z.boolean(),
|
|
apartado: z.number().positive().optional(),
|
|
clienteNombre: z.string().min(1),
|
|
clienteEmail: z.string().email().optional(),
|
|
clienteTelefono: z.string().min(7).optional(),
|
|
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
|
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
|
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
|
|
// esquemas con crédito (bancario/Infonavit)
|
|
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
|
|
// Item del tablero Oportunidades al que queda vinculada la cotización
|
|
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
|
oportunidadId: z.string().trim().min(1).optional(),
|
|
})
|
|
|
|
const hoyISO = (): string => {
|
|
const d = new Date()
|
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
const dia = String(d.getDate()).padStart(2, '0')
|
|
return `${d.getFullYear()}-${m}-${dia}`
|
|
}
|
|
|
|
// Labels reales de la columna status "Tipo de Propuesta" del tablero
|
|
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
|
|
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
|
|
export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
|
BANCARIO: 'CREDITO BANCARIO',
|
|
INFONAVIT: 'INFONAVIT-COFINAVIT',
|
|
COFINAVIT: 'INFONAVIT-COFINAVIT',
|
|
CONTADO: 'CONTADO',
|
|
SEMI_CONTADO: 'SEMI CONTADO',
|
|
}
|
|
|
|
/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */
|
|
export async function crearCotizacion(req: Request, res: Response): Promise<void> {
|
|
const body = crearCotizacionSchema.parse(req.body)
|
|
|
|
const unidad = await mondayService.getUnidad(body.unidadCodigo)
|
|
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
|
|
|
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
|
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
|
|
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
|
|
|
const cotizacion = await prisma.cotizacion.create({
|
|
data: {
|
|
unidadCodigo: unidad.codigo,
|
|
desarrollo: unidad.desarrollo ?? 'DIAGONAL',
|
|
vendedorId: req.usuarioToken?.sub ?? null,
|
|
clienteNombre: body.clienteNombre,
|
|
clienteEmail: body.clienteEmail ?? null,
|
|
clienteTelefono: body.clienteTelefono ?? null,
|
|
tipoPropuesta: body.tipoPropuesta,
|
|
acabados: body.acabados,
|
|
apartado,
|
|
descuento: propuesta.descuento,
|
|
precioLista,
|
|
precioFinal: propuesta.precioFinal,
|
|
enganche: propuesta.enganche,
|
|
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega)
|
|
mensualidad: propuesta.mensualidad,
|
|
pagoFinal: propuesta.pagoFinal,
|
|
},
|
|
})
|
|
|
|
// Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida
|
|
let mondayItemId: string | null = null
|
|
try {
|
|
const datosOportunidad: DatosOportunidad = {
|
|
clienteNombre: body.clienteNombre,
|
|
unidadCodigo: unidad.codigo,
|
|
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
|
|
precioLista,
|
|
descuentoPct: propuesta.descuento * 100,
|
|
precioFinal: propuesta.precioFinal,
|
|
fechaCotizacion: hoyISO(),
|
|
estadoLabel: 'Cotizador', // etapa del funnel CRM al nacer la oportunidad
|
|
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
|
|
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
|
}
|
|
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
|
|
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
|
|
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
|
|
// item directamente y la cotización queda vinculada al cliente.
|
|
const opExistente =
|
|
body.oportunidadId !== undefined
|
|
? body.oportunidadId
|
|
: body.clienteTelefono !== undefined
|
|
? await funnelService.oportunidadPorTelefono(body.clienteTelefono)
|
|
: null
|
|
if (opExistente !== null) {
|
|
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
|
|
mondayItemId = opExistente
|
|
} else {
|
|
mondayItemId = await mondayService.crearOportunidad(datosOportunidad)
|
|
}
|
|
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('No se pudo crear la oportunidad en Monday:', err)
|
|
}
|
|
|
|
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
|
}
|
|
|
|
/** GET /api/cotizaciones/mias — últimas 50 del vendedor autenticado (sub del JWT) */
|
|
export async function listarMisCotizaciones(req: Request, res: Response): Promise<void> {
|
|
const vendedorId = req.usuarioToken?.sub
|
|
if (!vendedorId) {
|
|
res.json([])
|
|
return
|
|
}
|
|
const cotizaciones = await prisma.cotizacion.findMany({
|
|
where: { vendedorId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 50,
|
|
})
|
|
res.json(cotizaciones.map((c) => ({ ...c, folio: folioCotizacion(c.id) })))
|
|
}
|
|
|
|
/** GET /api/cotizaciones */
|
|
export async function listarCotizaciones(_req: Request, res: Response): Promise<void> {
|
|
const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } })
|
|
res.json(cotizaciones)
|
|
}
|
|
|
|
/** GET /api/cotizaciones/:id */
|
|
export async function obtenerCotizacion(req: Request, res: Response): Promise<void> {
|
|
const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } })
|
|
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
|
res.json(cotizacion)
|
|
}
|
|
|
|
/**
|
|
* GET /api/cotizaciones/:id/publico — PÚBLICO (sin auth; es el destino del QR
|
|
* del PDF). Devuelve SOLO datos de la propuesta comercial; nunca datos de
|
|
* contacto del cliente (nombre/teléfono/email).
|
|
*/
|
|
export async function obtenerCotizacionPublica(req: Request, res: Response): Promise<void> {
|
|
const cotizacion = await prisma.cotizacion.findUnique({
|
|
where: { id: String(req.params['id']) },
|
|
})
|
|
if (!cotizacion) throw new NotFoundError('Cotización no encontrada')
|
|
|
|
let vendedorNombre: string | null = null
|
|
if (cotizacion.vendedorId) {
|
|
const vendedor = await prisma.usuario.findUnique({
|
|
where: { id: cotizacion.vendedorId },
|
|
select: { nombre: true },
|
|
})
|
|
vendedorNombre = vendedor?.nombre ?? null
|
|
}
|
|
|
|
res.json({
|
|
folio: folioCotizacion(cotizacion.id),
|
|
desarrollo: cotizacion.desarrollo,
|
|
unidadCodigo: cotizacion.unidadCodigo,
|
|
tipoPropuesta: cotizacion.tipoPropuesta,
|
|
precioFinal: cotizacion.precioFinal,
|
|
enganche: cotizacion.enganche,
|
|
mensualidad: cotizacion.mensualidad,
|
|
pagoFinal: cotizacion.pagoFinal,
|
|
vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(),
|
|
vendedorNombre,
|
|
})
|
|
}
|
|
|
|
const alertasInventarioSchema = z.object({
|
|
cambios: z
|
|
.array(
|
|
z.object({
|
|
unidadCodigo: z.string().min(1),
|
|
statusNuevo: z.string().min(1),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(100),
|
|
})
|
|
|
|
/**
|
|
* POST /api/cotizaciones/alertas-inventario — lo invoca el sync de estatus al
|
|
* detectar cambios (ver README). Crea una Actividad en Monday al vendedor de
|
|
* cada cotización reciente (≤30 días) de la unidad afectada, con dedup
|
|
* persistente por unidad+cotización+estatus.
|
|
*/
|
|
export async function alertasInventarioEndpoint(req: Request, res: Response): Promise<void> {
|
|
const { cambios } = alertasInventarioSchema.parse(req.body)
|
|
|
|
const resultado: ResultadoAlertas = await procesarAlertasInventario(
|
|
{
|
|
buscarCotizacionesRecientes: (unidadCodigo, desde) =>
|
|
prisma.cotizacion.findMany({
|
|
where: { unidadCodigo, createdAt: { gte: desde } },
|
|
select: { id: true, vendedorId: true },
|
|
}),
|
|
mondayUserIdDeVendedor: async (vendedorId) => {
|
|
const usuario = await prisma.usuario.findUnique({
|
|
where: { id: vendedorId },
|
|
select: { mondayUserId: true },
|
|
})
|
|
return usuario?.mondayUserId ?? null
|
|
},
|
|
crearActividad: (datos) => mondayService.crearActividad(datos),
|
|
yaAlertada: async (clave) => {
|
|
const estado = await prisma.automatizacionState.findUnique({ where: { clave } })
|
|
return estado !== null
|
|
},
|
|
marcarAlertada: async (clave) => {
|
|
await prisma.automatizacionState.upsert({
|
|
where: { clave },
|
|
create: { clave, valor: new Date().toISOString() },
|
|
update: { valor: new Date().toISOString() },
|
|
})
|
|
},
|
|
},
|
|
cambios,
|
|
)
|
|
|
|
res.json(resultado)
|
|
}
|