Files
cotizador/src/app/api/cotizaciones/[id]/route.ts
T
urieljarethandClaude Opus 5 9023384de9 Fase 0: cerrar el MCP, unificar totales y separar el contexto interno del cliente
Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.

Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
  el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
  Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
  require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
  cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
  CotizacionResponse, excluyendo observacionesInternas.

Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
  unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
  excel-builder, que ignoraban IVA_RATE y el flag incluirIva.

Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
  ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
  unica fila de produccion con observaciones ya contiene texto dirigido al
  cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
  viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
  campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
  puede filtrarlo aunque alguien lo intente.

Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
  agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
  los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
  salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
  texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
  o sea que salia como dos espacios. Sustituida por una vineta.

Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.

Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:50:03 -06:00

243 lines
8.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { ESTADOS_COTIZACION } from "@/lib/calculators";
import { cotizacionPutSchema } from "@/lib/schemas";
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({ where: { id } });
if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
await prisma.$transaction([
prisma.cotizacion.delete({ where: { id } }),
]);
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
return NextResponse.json(cot);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
// Camino rápido: cambio de estado puntual (CambiarEstadoButtons envía solo
// { estado }). El schema completo exige todos los campos de la cotización,
// así que un PUT con solo el estado fallaría la validación; lo tratamos
// como un patch parcial.
const bodyKeys = Object.keys(body);
if (bodyKeys.length === 1 && bodyKeys[0] === "estado") {
const nuevoEstado = body.estado;
if (!ESTADOS_COTIZACION.includes(nuevoEstado as typeof ESTADOS_COTIZACION[number])) {
return NextResponse.json({ error: "Estado invalido" }, { status: 400 });
}
const existing = await prisma.cotizacion.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
const updated = await prisma.cotizacion.update({
where: { id },
data: { estado: nuevoEstado },
});
return NextResponse.json(updated);
}
const parsed = cotizacionPutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const {
fecha,
vigencia,
moneda,
tipoCambio,
proyecto,
esquemaPago,
incluirBonos,
incluirFinanciamiento,
incluirIva,
esDoble,
opciones,
observaciones,
observacionesInternas,
cliente,
servicios,
planBucefalo,
} = parsed.data;
const estado = body.estado;
const existing = await prisma.cotizacion.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
let clienteIdFinal: string | undefined;
if (cliente) {
const existingCliente = await prisma.cliente.findFirst({
where: { nombre: cliente.nombre, empresa: cliente.empresa || null },
});
if (existingCliente) {
clienteIdFinal = existingCliente.id;
await prisma.cliente.update({
where: { id: clienteIdFinal },
data: {
email: cliente.email || existingCliente.email,
telefono: cliente.telefono || existingCliente.telefono,
rfc: cliente.rfc || existingCliente.rfc,
},
});
} else {
const newCliente = await prisma.cliente.create({
data: {
nombre: cliente.nombre,
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
rfc: cliente.rfc || null,
},
});
clienteIdFinal = newCliente.id;
}
}
const esDobleFinal = esDoble ?? existing.esDoble;
await prisma.cotizacion.update({
where: { id },
data: {
...(clienteIdFinal && { clienteId: clienteIdFinal }),
...(fecha && { fecha: new Date(fecha) }),
...(vigencia && { vigencia: new Date(vigencia) }),
...(moneda && { moneda }),
...(tipoCambio !== undefined && { tipoCambio }),
...(proyecto && { proyecto }),
...(esquemaPago && { esquemaPago }),
...(incluirBonos !== undefined && { incluirBonos }),
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
...(incluirIva !== undefined && { incluirIva }),
...(esDoble !== undefined && { esDoble }),
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
...(observaciones !== undefined && { observaciones }),
...(observacionesInternas !== undefined && { observacionesInternas }),
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
},
});
if (servicios && Array.isArray(servicios)) {
// El servicio Bucéfalo del catálogo se resuelve una sola vez, no por partida.
const necesitaBucefalo = servicios.some(
(s) => !s.esPersonalizado && s.catalogoId?.startsWith("bucefalo-")
);
const bucefaloCatalogoId = necesitaBucefalo
? (await prisma.servicioCatalogo.findFirst({
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" },
select: { id: true },
}))?.id ?? null
: null;
await prisma.$transaction(async (tx) => {
await tx.servicioCotizado.deleteMany({ where: { cotizacionId: id } });
await tx.servicioCotizado.createMany({
data: servicios.map((serv) => {
let catalogoId: string | null;
if (serv.esPersonalizado) {
catalogoId = null;
} else if (serv.catalogoId?.startsWith("bucefalo-")) {
catalogoId = bucefaloCatalogoId;
} else {
catalogoId = serv.catalogoId;
}
return {
cotizacionId: id,
servicioCatalogoId: catalogoId,
nombre: serv.esPersonalizado ? serv.nombre : null,
esPersonalizado: serv.esPersonalizado ?? false,
horas: serv.esPersonalizado ? serv.horas ?? null : null,
tarifaHora: serv.esPersonalizado ? serv.tarifaHora ?? null : null,
modeloCobro: serv.modeloCobro ?? (serv.esPersonalizado ? "horas" : "fijo"),
montoMinimo: serv.esPersonalizado ? serv.montoMinimo ?? null : null,
horasIncluidas: serv.esPersonalizado ? serv.horasIncluidas ?? null : null,
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
fase: serv.fase,
tipoPago: serv.tipoPago,
// "demanda": tarifa por hora sin compromiso; nunca suma al total.
precio: serv.modeloCobro === "demanda" ? 0 : serv.precio,
tiempoEntrega: serv.tiempoEntrega,
entregables: serv.entregables,
beneficios: serv.beneficios ?? [],
seleccionado: true,
};
}),
});
});
}
if (planBucefalo) {
await prisma.planBucefaloCotizacion.upsert({
where: { cotizacionId: id },
update: { nivel: planBucefalo.nivel, precio: planBucefalo.precio },
create: {
cotizacionId: id,
nivel: planBucefalo.nivel,
precio: planBucefalo.precio,
},
});
} else {
await prisma.planBucefaloCotizacion.deleteMany({ where: { cotizacionId: id } });
}
const updated = await prisma.cotizacion.findUnique({
where: { id },
include: { cliente: true, asesor: true, servicios: true },
});
return NextResponse.json(updated);
} catch (error: unknown) {
console.error("Error updating cotizacion:", error);
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}