feat(cotizador): historial, compartir WhatsApp, folio+QR, comparador de unidades, alertas de inventario
- Mis cotizaciones (guardar con cliente, retomar, nueva) asociadas al vendedor del token - Compartir: resumen copiable + deep link wa.me; PDF con folio, vigencia y QR - GET /api/cotizaciones/:id/publico (público, datos mínimos sin PII del cliente) - Comparador lado a lado de 2 unidades (mismo o distinto desarrollo) - POST /api/cotizaciones/alertas-inventario: avisa al vendedor si una unidad cotizada cambia de estatus (dedup; wire con syncs documentado) - Fix: labels de propuesta mapeados a los reales del board Oportunidades - Tests: api 201, cotizador 82
This commit is contained in:
@@ -2,8 +2,13 @@ import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { mondayService } from '../services/monday.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),
|
||||
@@ -23,6 +28,15 @@ const hoyISO = (): string => {
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
// Labels reales de la columna status "Tipo de Propuesta" del tablero Oportunidades
|
||||
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)
|
||||
@@ -37,6 +51,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
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,
|
||||
@@ -59,7 +75,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
mondayItemId = await mondayService.crearOportunidad({
|
||||
clienteNombre: body.clienteNombre,
|
||||
unidadCodigo: unidad.codigo,
|
||||
tipoPropuestaLabel: body.tipoPropuesta,
|
||||
tipoPropuestaLabel: LABEL_PROPUESTA_MONDAY[body.tipoPropuesta] ?? body.tipoPropuesta,
|
||||
precioLista,
|
||||
descuentoPct: propuesta.descuento * 100,
|
||||
precioFinal: propuesta.precioFinal,
|
||||
@@ -74,7 +90,22 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
console.error('No se pudo crear la oportunidad en Monday:', err)
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, mondayItemId, propuesta })
|
||||
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 */
|
||||
@@ -89,3 +120,91 @@ export async function obtenerCotizacion(req: Request, res: Response): Promise<vo
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user