Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdd9319fd0 | ||
|
|
3f46d8a734 | ||
|
|
d20007d252 |
@@ -1,4 +1,4 @@
|
|||||||
import { crearCliente, modelo } from "@/lib/propuesta/cliente-ia";
|
import { crearCliente, modelo, normalizarRespuesta } from "@/lib/propuesta/cliente-ia";
|
||||||
|
|
||||||
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
|
// No llama a la API. Comprueba el contrato local y que el fallo sin clave sea claro.
|
||||||
|
|
||||||
@@ -47,5 +47,24 @@ else delete process.env.MINIMAX_API_KEY;
|
|||||||
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
|
if (guardadaBase) process.env.MINIMAX_BASE_URL = guardadaBase;
|
||||||
else delete process.env.MINIMAX_BASE_URL;
|
else delete process.env.MINIMAX_BASE_URL;
|
||||||
|
|
||||||
|
// ── Normalizacion de rarezas de MiniMax, observadas contra la API real ──
|
||||||
|
const igual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
check("desenvuelve {item: X} cuando item es la unica clave",
|
||||||
|
igual(normalizarRespuesta({ item: { a: 1 } }), { a: 1 }));
|
||||||
|
check("desenvuelve dentro de arrays",
|
||||||
|
igual(normalizarRespuesta([{ item: { a: 1 } }, { item: { a: 2 } }]), [{ a: 1 }, { a: 2 }]));
|
||||||
|
check("desenvuelve en profundidad",
|
||||||
|
igual(normalizarRespuesta({ lista: [{ item: { x: [{ item: 5 }] } }] }), { lista: [{ x: [5] }] }));
|
||||||
|
check("NO toca un objeto con item junto a otras claves",
|
||||||
|
igual(normalizarRespuesta({ item: 1, otro: 2 }), { item: 1, otro: 2 }));
|
||||||
|
check("deja intactos los valores simples",
|
||||||
|
normalizarRespuesta("texto") === "texto" && normalizarRespuesta(5) === 5 && normalizarRespuesta(null) === null);
|
||||||
|
check("no altera un objeto normal",
|
||||||
|
igual(normalizarRespuesta({ a: 1, b: [1, 2] }), { a: 1, b: [1, 2] }));
|
||||||
|
check("el caso real observado: alcance envuelto",
|
||||||
|
igual(normalizarRespuesta({ alcance: [{ item: { refPartida: "P01", descripcionResultado: "x" } }] }),
|
||||||
|
{ alcance: [{ refPartida: "P01", descripcionResultado: "x" }] }));
|
||||||
|
|
||||||
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`);
|
||||||
process.exit(fallas === 0 ? 0 : 1);
|
process.exit(fallas === 0 ? 0 : 1);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const econ: DatosEconomicos = {
|
|||||||
],
|
],
|
||||||
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
|
totales: { subtotalUnico: 12000, subtotalMensual: 8000, ivaUnico: 1920, ivaMensual: 1280, totalUnico: 13920, totalMensual: 9280, totalPrimerAnio: 125280, incluyeIva: true },
|
||||||
moneda: "MXN",
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const propuesta: PropuestaConsultiva = {
|
const propuesta: PropuestaConsultiva = {
|
||||||
@@ -127,15 +128,29 @@ async function main() {
|
|||||||
check("imprime el precio de P02", txt.includes("8,000"));
|
check("imprime el precio de P02", txt.includes("8,000"));
|
||||||
check("imprime el total unico con IVA", txt.includes("13,920"));
|
check("imprime el total unico con IVA", txt.includes("13,920"));
|
||||||
check("imprime la mensualidad con IVA", txt.includes("9,280"));
|
check("imprime la mensualidad con IVA", txt.includes("9,280"));
|
||||||
check("declara que los importes llevan IVA", txt.includes("IVA incluido"));
|
// El desglose debe cuadrar: lineas sin IVA + IVA como renglon propio.
|
||||||
|
check("imprime el subtotal sin IVA", txt.includes("Subtotal pago unico") && txt.includes("12,000"));
|
||||||
|
check("imprime el IVA como renglon propio", txt.includes("IVA 16%") && txt.includes("1,920"));
|
||||||
|
check("imprime el total con IVA", txt.includes("Total pago unico"));
|
||||||
|
check("la nota NO dice que las lineas incluyan IVA", !txt.includes("Importes con IVA incluido"));
|
||||||
|
check("la nota aclara que las partidas van sin IVA", txt.includes("no incluyen IVA"));
|
||||||
|
|
||||||
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
|
check("etiqueta el semaforo", txt.includes("Problema critico") && txt.includes("Ventaja existente"));
|
||||||
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
|
check("etiqueta la confianza", txt.includes("Confirmado") && txt.includes("Estimado"));
|
||||||
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
|
check("el backlog se marca como no comprometido", txt.includes("No comprometido"));
|
||||||
|
|
||||||
// Sin IVA la nota cambia.
|
// Sin IVA no debe aparecer el renglon de IVA.
|
||||||
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
|
const sinIva = await generarPropuestaPDF({ ...datos, economia: { ...econ, totales: { ...econ.totales, incluyeIva: false } } });
|
||||||
check("sin IVA la nota legal cambia", textoDePdf(sinIva).includes("sin IVA"));
|
const txtSinIva = textoDePdf(sinIva);
|
||||||
|
check("sin IVA no se imprime el renglon de IVA", !txtSinIva.includes("IVA 16%"));
|
||||||
|
check("sin IVA el total lo declara", txtSinIva.includes("sin IVA"));
|
||||||
|
|
||||||
|
// El plan Bucefalo tiene que aparecer: esta en la cotizacion y en los otros dos exportadores.
|
||||||
|
const conPlan = await generarPropuestaPDF({ ...datos, economia: { ...econ, planBucefalo: { nivel: "premium", precio: 4500 } } });
|
||||||
|
const txtPlan = textoDePdf(conPlan);
|
||||||
|
check("el plan Bucefalo aparece en el documento", txtPlan.includes("Bucefalo"));
|
||||||
|
check("el plan aparece con su nivel", txtPlan.includes("Premium"));
|
||||||
|
check("el plan aparece con su precio mensual", txtPlan.includes("4,500"));
|
||||||
|
|
||||||
// Una partida que la IA invento no se dibuja (R2 ya la marco).
|
// Una partida que la IA invento no se dibuja (R2 ya la marco).
|
||||||
const conFantasma = await generarPropuestaPDF({
|
const conFantasma = await generarPropuestaPDF({
|
||||||
@@ -144,6 +159,17 @@ async function main() {
|
|||||||
});
|
});
|
||||||
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
|
check("una partida inventada no se dibuja", !textoDePdf(conFantasma).includes("MARCADORFANTASMA"));
|
||||||
|
|
||||||
|
// Completitud: la cotizacion manda, no lo que la IA alcanzo a describir.
|
||||||
|
// Verificado con UJ2606UR001 en produccion: 58 partidas, la IA describio 40.
|
||||||
|
const econGrande = { ...econ, partidas: [...econ.partidas,
|
||||||
|
{ refPartida: "P03", servicioCotizadoId: "c3", nombre: "MARCADORNODESCRITA servicio extra", fase: 3, tipoPago: "unico" as const, precio: 5000, tiempoEntrega: "1 semana", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: ["Entregable A", "Entregable B"] },
|
||||||
|
] };
|
||||||
|
const conNoDescrita = await generarPropuestaPDF({ ...datos, economia: econGrande });
|
||||||
|
const txtND = textoDePdf(conNoDescrita);
|
||||||
|
check("una partida SIN descripcion de la IA igual aparece", txtND.includes("MARCADORNODESCRITA"));
|
||||||
|
check("y aparece con su precio", txtND.includes("5,000"));
|
||||||
|
check("y con su detalle del catalogo como respaldo", txtND.includes("Entregable A"));
|
||||||
|
|
||||||
// ── Anexo interno ──
|
// ── Anexo interno ──
|
||||||
const anexo = await generarAnexoInternoPDF(datos);
|
const anexo = await generarAnexoInternoPDF(datos);
|
||||||
const txtA = textoDePdf(anexo);
|
const txtA = textoDePdf(anexo);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const econ: DatosEconomicos = {
|
|||||||
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
partidas: [{ refPartida: "P01", servicioCotizadoId: "cuid-secreto-abc123", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12345, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
||||||
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
|
totales: { subtotalUnico: 12345, subtotalMensual: 0, ivaUnico: 1975.2, ivaMensual: 0, totalUnico: 14320.2, totalMensual: 0, totalPrimerAnio: 14320.2, incluyeIva: true },
|
||||||
moneda: "MXN",
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
};
|
};
|
||||||
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
|
const ctx = { transcripcion: "t", notas: "", observaciones: "", cliente: "Ana", empresa: "ACME", proyecto: "P" };
|
||||||
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
|
const hechos = { citas: [], hechos: [], materialesPendientes: [], decisionesPendientes: [], mencionesFueraDeAlcance: [], redFlags: [] } as Hechos;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const econ: DatosEconomicos = {
|
|||||||
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
partidas: [{ refPartida: "P01", servicioCotizadoId: "c1", nombre: "Sitio web", fase: 1, tipoPago: "unico", precio: 12000, tiempoEntrega: "2 semanas", modeloCobro: "fijo", horas: null, tarifaHora: null, entregables: [] }],
|
||||||
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
|
totales: { subtotalUnico: 12000, subtotalMensual: 0, ivaUnico: 1920, ivaMensual: 0, totalUnico: 13920, totalMensual: 0, totalPrimerAnio: 13920, incluyeIva: true },
|
||||||
moneda: "MXN",
|
moneda: "MXN",
|
||||||
|
planBucefalo: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const base = (): PropuestaConsultiva => ({
|
const base = (): PropuestaConsultiva => ({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getConfigBranding } from "@/lib/config-helpers";
|
|||||||
import { sanitizeFilename } from "@/lib/calculators";
|
import { sanitizeFilename } from "@/lib/calculators";
|
||||||
import { cargarEconomia } from "@/lib/propuesta/economia";
|
import { cargarEconomia } from "@/lib/propuesta/economia";
|
||||||
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
|
import { generarPropuestaPDF, generarAnexoInternoPDF } from "@/lib/propuesta/pdf";
|
||||||
|
import { hayBloqueantes, type Aviso } from "@/lib/propuesta/validacion";
|
||||||
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
import type { PropuestaConsultiva } from "@/lib/propuesta/schemas";
|
||||||
|
|
||||||
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
|
/** GET /api/propuesta-ia/:id/pdf -> documento del cliente
|
||||||
@@ -22,6 +23,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
if (!cot) return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||||
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
|
if (!fila) return NextResponse.json({ error: "Todavia no se ha generado la propuesta" }, { status: 404 });
|
||||||
|
|
||||||
|
// Los avisos bloqueantes tienen que bloquear DE VERDAD el documento del cliente.
|
||||||
|
// Antes esta ruta ni siquiera leia `avisos`: la severidad "bloqueante" era
|
||||||
|
// decorativa y el PDF se descargaba igual con una fuga de notas internas o un
|
||||||
|
// monto en USD dentro. El anexo interno SI se permite: es justamente el que el
|
||||||
|
// asesor necesita para entender que hay que corregir.
|
||||||
|
const avisos = Array.isArray(fila.avisos) ? (fila.avisos as unknown as Aviso[]) : [];
|
||||||
|
if (!anexo && hayBloqueantes(avisos)) {
|
||||||
|
const cuales = avisos
|
||||||
|
.filter((a) => a.severidad === "bloqueante")
|
||||||
|
.map((a) => `${a.regla}${a.ruta ? ` (${a.ruta})` : ""}: ${a.mensaje}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"La propuesta tiene avisos bloqueantes sin resolver. Corrigelos en el panel y guarda antes de descargar el documento del cliente.",
|
||||||
|
bloqueantes: cuales,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
|
const propuesta = (fila.contenidoEditado ?? fila.contenidoIA) as unknown as PropuestaConsultiva;
|
||||||
const economia = await cargarEconomia(id);
|
const economia = await cargarEconomia(id);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,30 @@ export interface UsoTokens {
|
|||||||
cacheEscritura: number;
|
cacheEscritura: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza rarezas observadas en las respuestas de MiniMax antes de validar.
|
||||||
|
*
|
||||||
|
* Observado contra la API real, sobre datos de produccion: a veces envuelve los
|
||||||
|
* elementos de un array en un objeto `{item: {...}}` en vez de emitir el objeto
|
||||||
|
* directamente. Zod lo rechaza con `Unrecognized keys: "item"` y se gasta un
|
||||||
|
* reintento en algo que se puede corregir aqui sin ambiguedad.
|
||||||
|
*
|
||||||
|
* Solo desenvuelve cuando `item` es la UNICA clave: si el objeto trae mas cosas,
|
||||||
|
* podria ser un campo legitimo y no se toca.
|
||||||
|
*/
|
||||||
|
export function normalizarRespuesta(v: unknown): unknown {
|
||||||
|
if (Array.isArray(v)) return v.map(normalizarRespuesta);
|
||||||
|
if (v && typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
const claves = Object.keys(o);
|
||||||
|
if (claves.length === 1 && claves[0] === "item") return normalizarRespuesta(o.item);
|
||||||
|
const salida: Record<string, unknown> = {};
|
||||||
|
for (const k of claves) salida[k] = normalizarRespuesta(o[k]);
|
||||||
|
return salida;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export function crearCliente(): Anthropic {
|
export function crearCliente(): Anthropic {
|
||||||
const apiKey = process.env.MINIMAX_API_KEY;
|
const apiKey = process.env.MINIMAX_API_KEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -45,6 +69,51 @@ export interface BloqueSystem {
|
|||||||
cachear: boolean;
|
cachear: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿El fallo es del proveedor y vale la pena reintentar?
|
||||||
|
*
|
||||||
|
* Motivacion: en produccion aparecio un "unexpected doc type" que NO se pudo reproducir
|
||||||
|
* en seis corridas completas del pipeline contra los mismos datos, y la generacion que
|
||||||
|
* lo siguio completo sin problema. Todo apunta a un fallo transitorio del proveedor.
|
||||||
|
* En vez de adivinar un arreglo para un error que no se puede reproducir, se reintenta
|
||||||
|
* esa clase de fallo con espera creciente.
|
||||||
|
*/
|
||||||
|
function esTransitorio(e: unknown): boolean {
|
||||||
|
const err = e as { status?: number; message?: string };
|
||||||
|
if (typeof err?.status === "number") {
|
||||||
|
// 408 timeout, 409 conflicto, 429 rate limit, 5xx y el 529 de sobrecarga.
|
||||||
|
if ([408, 409, 429].includes(err.status) || err.status >= 500) return true;
|
||||||
|
// 400 con mensaje que no describe un problema de nuestro payload: el proveedor
|
||||||
|
// devuelve errores de parseo internos con 400. Se reintenta una vez por si acaso.
|
||||||
|
if (err.status === 400 && /unexpected|internal|parse|unknown/i.test(err.message ?? "")) return true;
|
||||||
|
}
|
||||||
|
// Fallos de red sin status.
|
||||||
|
if (!err?.status && /ECONN|ETIMEDOUT|socket|network|fetch failed/i.test(err?.message ?? "")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const esperar = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Llama al proveedor reintentando SOLO los fallos transitorios. Los errores de
|
||||||
|
* nuestro payload no se reintentan aqui: los corrige el bucle de schema. */
|
||||||
|
async function crearMensajeConReintentos(
|
||||||
|
cliente: Anthropic,
|
||||||
|
cuerpo: Anthropic.MessageCreateParamsNonStreaming,
|
||||||
|
maxTransitorios = 3
|
||||||
|
): Promise<Anthropic.Message> {
|
||||||
|
let ultimo: unknown;
|
||||||
|
for (let i = 1; i <= maxTransitorios; i++) {
|
||||||
|
try {
|
||||||
|
return await cliente.messages.create(cuerpo);
|
||||||
|
} catch (e) {
|
||||||
|
ultimo = e;
|
||||||
|
if (!esTransitorio(e) || i === maxTransitorios) throw e;
|
||||||
|
await esperar(1000 * 2 ** (i - 1)); // 1s, 2s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ultimo;
|
||||||
|
}
|
||||||
|
|
||||||
interface OpcionesLlamada {
|
interface OpcionesLlamada {
|
||||||
system: BloqueSystem[];
|
system: BloqueSystem[];
|
||||||
mensajeUsuario: string;
|
mensajeUsuario: string;
|
||||||
@@ -77,7 +146,7 @@ export async function llamarConHerramienta<T>(
|
|||||||
let ultimoError = "";
|
let ultimoError = "";
|
||||||
|
|
||||||
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
for (let intento = 1; intento <= opts.maxIntentos; intento++) {
|
||||||
const res = await cliente.messages.create({
|
const res = await crearMensajeConReintentos(cliente, {
|
||||||
model: modelo(),
|
model: modelo(),
|
||||||
max_tokens: opts.maxTokens,
|
max_tokens: opts.maxTokens,
|
||||||
system,
|
system,
|
||||||
@@ -99,15 +168,25 @@ export async function llamarConHerramienta<T>(
|
|||||||
const bloquesTool = res.content.filter(
|
const bloquesTool = res.content.filter(
|
||||||
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
|
||||||
);
|
);
|
||||||
const correcto = bloquesTool.find((b) => b.name === opts.herramienta.nombre);
|
// El modelo a veces emite DOS tool_use en una misma respuesta (observado contra la
|
||||||
|
// API real). Se prueban todos los candidatos y gana el primero que valide, en vez
|
||||||
|
// de quedarse con el primero a secas y desperdiciar un reintento.
|
||||||
|
const candidatos = bloquesTool.filter((b) => b.name === opts.herramienta.nombre);
|
||||||
|
|
||||||
|
let correcto: Anthropic.ToolUseBlock | undefined;
|
||||||
|
for (const c of candidatos) {
|
||||||
|
const intentoParse = opts.herramienta.schema.safeParse(normalizarRespuesta(c.input));
|
||||||
|
if (intentoParse.success) {
|
||||||
|
return { datos: intentoParse.data as T, uso, intentos: intento };
|
||||||
|
}
|
||||||
|
// Se guarda el primero para reportar su error si ninguno valida.
|
||||||
|
if (!correcto) {
|
||||||
|
correcto = c;
|
||||||
|
ultimoError = z.prettifyError(intentoParse.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (correcto) {
|
if (correcto) {
|
||||||
const parsed = opts.herramienta.schema.safeParse(correcto.input);
|
|
||||||
if (parsed.success) {
|
|
||||||
return { datos: parsed.data as T, uso, intentos: intento };
|
|
||||||
}
|
|
||||||
ultimoError = z.prettifyError(parsed.error);
|
|
||||||
|
|
||||||
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
|
// Hubo tool_use: la API exige un tool_result por CADA uno antes de continuar.
|
||||||
// Un turno de usuario plano despues de un tool_use devuelve 400.
|
// Un turno de usuario plano despues de un tool_use devuelve 400.
|
||||||
mensajes.push(
|
mensajes.push(
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export interface DatosEconomicos {
|
|||||||
partidas: PartidaCanonica[];
|
partidas: PartidaCanonica[];
|
||||||
totales: TotalesCotizacion;
|
totales: TotalesCotizacion;
|
||||||
moneda: string;
|
moneda: string;
|
||||||
|
/** Plan Bucefalo contratado, si lo hay. Se cobra mensual y aparece en el PDF
|
||||||
|
* economico y en el Excel; tiene que aparecer tambien en el consultivo o el
|
||||||
|
* cliente recibe dos documentos con alcances distintos. */
|
||||||
|
planBucefalo: { nivel: string; precio: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
|
export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomicos> {
|
||||||
@@ -43,6 +47,7 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
|
|||||||
// correo y deben listar las partidas igual.
|
// correo y deben listar las partidas igual.
|
||||||
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||||
},
|
},
|
||||||
|
planBucefalo: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
|
if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`);
|
||||||
@@ -62,10 +67,22 @@ export async function cargarEconomia(cotizacionId: string): Promise<DatosEconomi
|
|||||||
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
|
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const plan =
|
||||||
|
cot.planBucefalo && cot.planBucefalo.seleccionado
|
||||||
|
? { nivel: cot.planBucefalo.nivel, precio: cot.planBucefalo.precio }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// El plan Bucefalo se cobra mensual: entra a los totales como una partida mensual
|
||||||
|
// mas, igual que en el PDF economico.
|
||||||
|
const paraTotales = plan
|
||||||
|
? [...activos, { tipoPago: "mensual", precio: plan.precio, seleccionado: true }]
|
||||||
|
: activos;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
partidas,
|
partidas,
|
||||||
totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }),
|
totales: calcularTotalesCotizacion(paraTotales, { incluirIva: cot.incluirIva }),
|
||||||
moneda: cot.moneda,
|
moneda: cot.moneda,
|
||||||
|
planBucefalo: plan,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-18
@@ -59,6 +59,16 @@ function hexSeguro(v: string | undefined, porDefecto: string): string {
|
|||||||
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
|
return v && /^#[0-9a-fA-F]{6}$/.test(v) ? v : porDefecto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Respaldo cuando la IA no describio una partida: se arma con los datos del
|
||||||
|
* catalogo, para que la partida nunca desaparezca del documento. */
|
||||||
|
function detalleDePartida(p: { tiempoEntrega: string; entregables: string[]; tipoPago: string }): string {
|
||||||
|
const trozos: string[] = [];
|
||||||
|
if (p.entregables.length) trozos.push(p.entregables.slice(0, 4).join(" · "));
|
||||||
|
if (p.tiempoEntrega) trozos.push(`Entrega: ${p.tiempoEntrega}`);
|
||||||
|
if (p.tipoPago === "mensual") trozos.push("Servicio mensual");
|
||||||
|
return trozos.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
interface Lienzo {
|
interface Lienzo {
|
||||||
doc: PDFKit.PDFDocument;
|
doc: PDFKit.PDFDocument;
|
||||||
W: number;
|
W: number;
|
||||||
@@ -206,14 +216,18 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
|||||||
y += 8;
|
y += 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ALCANCE: la IA pone la prosa, el codigo pone el dinero ──
|
// ── ALCANCE ──
|
||||||
|
// Se recorren las partidas de la COTIZACION, no las que la IA alcanzo a describir.
|
||||||
|
// Motivo: el schema acota `alcance` y una cotizacion con mas partidas que ese tope
|
||||||
|
// dejaria servicios cotizados fuera del documento del cliente, en silencio.
|
||||||
|
// Verificado con UJ2606UR001: 58 partidas, la IA describio 40, faltaban 18.
|
||||||
|
// La completitud la manda la base de datos; la IA solo aporta la prosa.
|
||||||
titulo("Alcance de la inversion");
|
titulo("Alcance de la inversion");
|
||||||
const porRef = new Map(d.economia.partidas.map((p) => [p.refPartida, p]));
|
const descPorRef = new Map(d.propuesta.redaccion.alcance.map((a) => [a.refPartida, a.descripcionResultado]));
|
||||||
for (const a of d.propuesta.redaccion.alcance) {
|
for (const p of d.economia.partidas) {
|
||||||
const p = porRef.get(a.refPartida);
|
const descripcion = descPorRef.get(p.refPartida);
|
||||||
if (!p) continue; // R2 ya lo marco como bloqueante; aqui simplemente no se dibuja
|
|
||||||
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
|
const hNombre = txtH(p.nombre, W * 0.68, 9.5);
|
||||||
const hDesc = txtH(a.descripcionResultado, W * 0.68, 8);
|
const hDesc = descripcion ? txtH(descripcion, W * 0.68, 8) : 0;
|
||||||
y = need(hNombre + hDesc + 14, y);
|
y = need(hNombre + hDesc + 14, y);
|
||||||
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
|
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(p.nombre, L, y, { width: W * 0.68 });
|
||||||
doc
|
doc
|
||||||
@@ -222,36 +236,94 @@ export async function generarPropuestaPDF(d: DatosPropuestaPDF): Promise<Buffer>
|
|||||||
.fillColor(PRIMARY)
|
.fillColor(PRIMARY)
|
||||||
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
.text(formatCurrency(p.precio), L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||||
y += hNombre + 2;
|
y += hNombre + 2;
|
||||||
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(a.descripcionResultado, L, y, { width: W * 0.68 });
|
if (descripcion) {
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(descripcion, L, y, { width: W * 0.68 });
|
||||||
y += hDesc + 10;
|
y += hDesc + 10;
|
||||||
|
} else {
|
||||||
|
// Sin prosa de la IA: la partida SI aparece, con su detalle del catalogo.
|
||||||
|
const respaldo = detalleDePartida(p);
|
||||||
|
if (respaldo) {
|
||||||
|
const hR = txtH(respaldo, W * 0.68, 8);
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(respaldo, L, y, { width: W * 0.68 });
|
||||||
|
y += hR + 10;
|
||||||
|
} else {
|
||||||
|
y += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// El plan Bucefalo es una partida mas del acuerdo: si no se imprime aqui, el
|
||||||
|
// cliente recibe un documento consultivo con menos alcance que su cotizacion.
|
||||||
|
if (d.economia.planBucefalo) {
|
||||||
|
const nivel = d.economia.planBucefalo.nivel;
|
||||||
|
const etiqueta = `CRM Bucefalo — plan ${nivel.charAt(0).toUpperCase() + nivel.slice(1)}`;
|
||||||
|
const hE = txtH(etiqueta, W * 0.68, 9.5);
|
||||||
|
y = need(hE + 22, y);
|
||||||
|
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text(etiqueta, L, y, { width: W * 0.68 });
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.fillColor(PRIMARY)
|
||||||
|
.text(`${formatCurrency(d.economia.planBucefalo.precio)} / mes`, L + W * 0.7, y, { width: W * 0.3, align: "right" });
|
||||||
|
y += hE + 2;
|
||||||
|
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text("Servicio mensual", L, y, { width: W * 0.68 });
|
||||||
|
y += 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
y = need(58, y);
|
y = need(58, y);
|
||||||
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||||
y += 10;
|
y += 10;
|
||||||
|
// ── CAJA DE TOTALES ──
|
||||||
|
// Desglose completo (subtotal / IVA / total), como la plantilla autorizada.
|
||||||
|
//
|
||||||
|
// Antes se imprimia cada partida a su precio SIN IVA y debajo un unico total CON
|
||||||
|
// IVA, rematado con la leyenda "Importes con IVA incluido": las lineas no sumaban
|
||||||
|
// el total, la leyenda contradecia a sus propias lineas, y el PDF economico que va
|
||||||
|
// en el MISMO correo dice "los precios no incluyen IVA" sobre las mismas cifras.
|
||||||
|
// Ahora las lineas siguen siendo sin IVA —igual que el otro documento— y el IVA
|
||||||
|
// aparece como renglon propio.
|
||||||
const t = d.economia.totales;
|
const t = d.economia.totales;
|
||||||
const filaTotal = (etiqueta: string, valor: string) => {
|
const fila = (etiqueta: string, valor: string, fuerte = false) => {
|
||||||
y = need(16, y);
|
y = need(fuerte ? 18 : 15, y);
|
||||||
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text(etiqueta, L, y);
|
doc
|
||||||
doc.font("Helvetica-Bold").fontSize(10).fillColor(PRIMARY).text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
.font(fuerte ? "Helvetica-Bold" : "Helvetica")
|
||||||
y += 16;
|
.fontSize(fuerte ? 10.5 : 9)
|
||||||
|
.fillColor(fuerte ? DARK : MUTED)
|
||||||
|
.text(etiqueta, L, y);
|
||||||
|
doc
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(fuerte ? 10.5 : 9)
|
||||||
|
.fillColor(fuerte ? PRIMARY : DARK)
|
||||||
|
.text(valor, L + W * 0.6, y, { width: W * 0.4, align: "right" });
|
||||||
|
y += fuerte ? 18 : 15;
|
||||||
};
|
};
|
||||||
if (t.subtotalUnico > 0) filaTotal("Pago unico", formatCurrency(t.totalUnico));
|
|
||||||
if (t.subtotalMensual > 0) filaTotal("Mensualidad", formatCurrency(t.totalMensual));
|
if (t.subtotalUnico > 0) {
|
||||||
y = need(14, y);
|
fila("Subtotal pago unico", formatCurrency(t.subtotalUnico));
|
||||||
|
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaUnico));
|
||||||
|
fila(t.incluyeIva ? "Total pago unico" : "Total pago unico (sin IVA)", formatCurrency(t.totalUnico), true);
|
||||||
|
}
|
||||||
|
if (t.subtotalMensual > 0) {
|
||||||
|
if (t.subtotalUnico > 0) y += 4;
|
||||||
|
fila("Subtotal mensual", formatCurrency(t.subtotalMensual));
|
||||||
|
if (t.incluyeIva) fila("IVA 16%", formatCurrency(t.ivaMensual));
|
||||||
|
fila(t.incluyeIva ? "Total mensual" : "Total mensual (sin IVA)", formatCurrency(t.totalMensual), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
y = need(16, y);
|
||||||
doc
|
doc
|
||||||
.font("Helvetica")
|
.font("Helvetica")
|
||||||
.fontSize(7.5)
|
.fontSize(7.5)
|
||||||
.fillColor(MUTED)
|
.fillColor(MUTED)
|
||||||
.text(
|
.text(
|
||||||
t.incluyeIva
|
t.incluyeIva
|
||||||
? "Importes con IVA incluido. Moneda nacional (MXN). Facturacion CFDI."
|
? "Los precios por partida son en Moneda Nacional (MXN) y no incluyen IVA; el IVA se desglosa arriba. Facturacion CFDI."
|
||||||
: "Importes sin IVA. Moneda nacional (MXN). Facturacion CFDI.",
|
: "Los precios son en Moneda Nacional (MXN) y no incluyen IVA. Facturacion CFDI.",
|
||||||
L,
|
L,
|
||||||
y,
|
y,
|
||||||
{ width: W }
|
{ width: W }
|
||||||
);
|
);
|
||||||
y += 22;
|
y += 24;
|
||||||
|
|
||||||
// ── BENEFICIOS ──
|
// ── BENEFICIOS ──
|
||||||
if (d.propuesta.redaccion.beneficios.length) {
|
if (d.propuesta.redaccion.beneficios.length) {
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export const diagnosticoSchema = z
|
|||||||
urgencia: z
|
urgencia: z
|
||||||
.enum(URGENCIA)
|
.enum(URGENCIA)
|
||||||
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
|
.describe("rojo=problema critico, ambar=area de mejora, azul=oportunidad, verde=ventaja que el cliente YA tiene"),
|
||||||
titulo: z.string().min(3).max(80),
|
// 140 y no 80: con 80 el modelo se pasaba y gastaba un reintento. Observado
|
||||||
|
// en 2 de 6 corridas contra datos reales.
|
||||||
|
titulo: z.string().min(3).max(140),
|
||||||
descripcion: z.string().min(20),
|
descripcion: z.string().min(20),
|
||||||
confianza: z.enum(CONFIANZA),
|
confianza: z.enum(CONFIANZA),
|
||||||
citas: z.array(z.string().regex(ID_CITA)),
|
citas: z.array(z.string().regex(ID_CITA)),
|
||||||
@@ -161,7 +163,12 @@ export const redaccionSchema = z
|
|||||||
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
|
.describe("La partida traducida a resultado de negocio. Sin jerga tecnica y SIN mencionar importes."),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.max(40),
|
// 120 y no 40: una cotizacion real (UJ2606UR001) tiene 58 partidas, asi que el
|
||||||
|
// tope de 40 garantizaba un rechazo de Zod y un reintento desperdiciado en cada
|
||||||
|
// corrida. Observado en 6 de 6. El tope solo esta para acotar una respuesta
|
||||||
|
// desbocada; la completitud del documento ya no depende de este array, porque el
|
||||||
|
// generador recorre las partidas de la cotizacion.
|
||||||
|
.max(120),
|
||||||
beneficios: z
|
beneficios: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -35,13 +35,29 @@ const PROMESA_GARANTIA = /\bgarantiza(mos|do|da|r|remos)?\b|\baseguramos\s+(vent
|
|||||||
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
|
const MONTO_USD = /\b(usd|d[oó]lares|dlls?)\b|\$\s*[\d,.]+\s*(usd|dls)\b/i;
|
||||||
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
|
const TEMA_SENSIBLE = /\b(sueldo|salario|n[oó]mina|despido|demanda laboral|incapacidad|embarazo|enfermedad)\b/i;
|
||||||
|
|
||||||
/** Todo el texto que termina impreso en el documento del cliente. */
|
/**
|
||||||
function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
|
* TODO el texto que termina impreso en el documento del cliente.
|
||||||
|
*
|
||||||
|
* Esta lista tiene que cubrir exactamente lo que dibuja `generarPropuestaPDF`. Es la
|
||||||
|
* unica fuente de los filtros de marca, moneda, garantias y PII: un campo que se
|
||||||
|
* imprime y no esta aqui llega al cliente sin que nadie lo mire.
|
||||||
|
*
|
||||||
|
* Faltaban seis, todos impresos: el texto y el autor de la cita destacada, la metrica
|
||||||
|
* y el periodo de cada resultado, quien decide cada pendiente, y el momento sugerido
|
||||||
|
* del backlog. Si agregas algo al PDF, agregalo aqui en el mismo cambio.
|
||||||
|
*/
|
||||||
|
export function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[] {
|
||||||
const out: { ruta: string; texto: string }[] = [
|
const out: { ruta: string; texto: string }[] = [
|
||||||
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
|
{ ruta: "hero.titulo", texto: p.redaccion.hero.titulo },
|
||||||
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
|
{ ruta: "hero.subtitulo", texto: p.redaccion.hero.subtitulo },
|
||||||
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
|
{ ruta: "valorProblema.notaMetodologia", texto: p.diagnostico.valorProblema.notaMetodologia },
|
||||||
];
|
];
|
||||||
|
// La cita destacada se imprime literal, con su autor (pdf.ts, seccion Diagnostico).
|
||||||
|
const destacada = p.hechos.citas.find((c) => c.id === p.redaccion.citaDestacadaId);
|
||||||
|
if (destacada) {
|
||||||
|
out.push({ ruta: `citas.${destacada.id}.textoLiteral`, texto: destacada.textoLiteral });
|
||||||
|
out.push({ ruta: `citas.${destacada.id}.quienLoDijo`, texto: destacada.quienLoDijo });
|
||||||
|
}
|
||||||
p.diagnostico.hallazgos.forEach((h, i) => {
|
p.diagnostico.hallazgos.forEach((h, i) => {
|
||||||
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
|
out.push({ ruta: `hallazgos[${i}].titulo`, texto: h.titulo });
|
||||||
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
|
out.push({ ruta: `hallazgos[${i}].descripcion`, texto: h.descripcion });
|
||||||
@@ -49,15 +65,25 @@ function textoVisible(p: PropuestaConsultiva): { ruta: string; texto: string }[]
|
|||||||
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
|
p.diagnostico.valorProblema.dimensiones.forEach((d, i) =>
|
||||||
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
|
out.push({ ruta: `valorProblema.dimensiones[${i}]`, texto: d.descripcion })
|
||||||
);
|
);
|
||||||
p.diagnostico.resultados.forEach((r, i) => out.push({ ruta: `resultados[${i}]`, texto: r.enunciado }));
|
p.diagnostico.resultados.forEach((r, i) => {
|
||||||
|
out.push({ ruta: `resultados[${i}].enunciado`, texto: r.enunciado });
|
||||||
|
out.push({ ruta: `resultados[${i}].metrica`, texto: r.metrica });
|
||||||
|
out.push({ ruta: `resultados[${i}].periodoMedicion`, texto: r.periodoMedicion });
|
||||||
|
});
|
||||||
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
|
p.redaccion.alcance.forEach((a, i) => out.push({ ruta: `alcance[${i}]`, texto: a.descripcionResultado }));
|
||||||
p.redaccion.beneficios.forEach((b, i) =>
|
p.redaccion.beneficios.forEach((b, i) =>
|
||||||
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
|
out.push({ ruta: `beneficios[${i}]`, texto: `${b.etiqueta} ${b.texto}` })
|
||||||
);
|
);
|
||||||
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
|
p.redaccion.exclusiones.forEach((e, i) => out.push({ ruta: `exclusiones[${i}]`, texto: e.texto }));
|
||||||
p.redaccion.backlogEvolucion.forEach((b, i) => out.push({ ruta: `backlog[${i}]`, texto: b.problema }));
|
p.redaccion.backlogEvolucion.forEach((b, i) => {
|
||||||
|
out.push({ ruta: `backlog[${i}].problema`, texto: b.problema });
|
||||||
|
out.push({ ruta: `backlog[${i}].momentoSugerido`, texto: b.momentoSugerido });
|
||||||
|
});
|
||||||
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
|
p.hechos.materialesPendientes.forEach((m, i) => out.push({ ruta: `materiales[${i}]`, texto: m.texto }));
|
||||||
p.hechos.decisionesPendientes.forEach((d, i) => out.push({ ruta: `decisiones[${i}]`, texto: d.texto }));
|
p.hechos.decisionesPendientes.forEach((d, i) => {
|
||||||
|
out.push({ ruta: `decisiones[${i}].texto`, texto: d.texto });
|
||||||
|
out.push({ ruta: `decisiones[${i}].quienDecide`, texto: d.quienDecide });
|
||||||
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user