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]>
This commit is contained in:
urieljareth
2026-07-28 14:50:03 -06:00
co-authored by Claude Opus 5
parent 0ff783fd65
commit 9023384de9
19 changed files with 322 additions and 54 deletions
+63
View File
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
};
}
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
//
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
export interface TotalesCotizacion {
subtotalUnico: number;
subtotalMensual: number;
ivaUnico: number;
ivaMensual: number;
totalUnico: number;
totalMensual: number;
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
* Es la base del ratio precio/valor de la propuesta consultiva. */
totalPrimerAnio: number;
incluyeIva: boolean;
}
export function calcularTotalesCotizacion(
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
opciones?: { incluirIva?: boolean }
): TotalesCotizacion {
const incluyeIva = opciones?.incluirIva !== false;
const activos = servicios.filter((s) => s.seleccionado !== false);
const suma = (tipo: string) =>
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
const subtotalUnico = suma("unico");
const subtotalMensual = suma("mensual");
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
const totalUnico = r2(subtotalUnico + ivaUnico);
const totalMensual = r2(subtotalMensual + ivaMensual);
return {
subtotalUnico,
subtotalMensual,
ivaUnico,
ivaMensual,
totalUnico,
totalMensual,
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
incluyeIva,
};
}
function r2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Aplica IVA a un monto respetando el flag de la cotizacion.
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
export function conIva(monto: number, incluirIva: boolean = true): number {
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
}
export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",