feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API

- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor,
  column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes,
  webhook con handshake challenge, Prisma SQLite dev, 55 tests
- cotizador: inventario desde /api/unidades con fallback a seed
- alta-clientes: POST /api/clientes + upload multipart de documentos
- cobranza: persistencia via API con respaldo offline en localStorage
- .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

View File

@@ -0,0 +1,91 @@
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 { prisma } from '../services/prisma.js'
import { NotFoundError } from '../utils/errors.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', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
})
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}`
}
/** 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)
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const cotizacion = await prisma.cotizacion.create({
data: {
unidadCodigo: unidad.codigo,
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,
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 {
mondayItemId = await mondayService.crearOportunidad({
clienteNombre: body.clienteNombre,
unidadCodigo: unidad.codigo,
tipoPropuestaLabel: body.tipoPropuesta,
precioLista,
descuentoPct: propuesta.descuento * 100,
precioFinal: propuesta.precioFinal,
fechaCotizacion: hoyISO(),
estadoLabel: 'COTIZADA',
...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}),
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
})
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, mondayItemId, propuesta })
}
/** 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)
}