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]>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
export const IVA_RATE = 0.16;
|
|
|
|
// Tarifa por hora sugerida para partidas personalizadas (Hora Centinela). El asesor puede ajustarla por partida.
|
|
export const TARIFA_HORA_DEFAULT = 700;
|
|
|
|
export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
|
|
return Math.round((horas || 0) * (tarifaHora || 0) * 100) / 100;
|
|
}
|
|
|
|
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
|
|
// tarifa de horas adicionales (las horas extra se facturan aparte, no suman al total).
|
|
// "demanda" = tarifa por hora sin horas comprometidas; precio=0, no suma al total
|
|
// (se factura a fin de mes segun consumo). Solo muestra la tarifa y sus beneficios.
|
|
export const MODELOS_COBRO: Record<string, string> = {
|
|
fijo: "Precio fijo",
|
|
horas: "Por horas",
|
|
retainer: "Retainer (minimo + adicionales)",
|
|
demanda: "Bajo demanda / por hora",
|
|
};
|
|
|
|
// Sub-linea de desglose por modelo de cobro (horas / retainer / demanda) para PDF/Excel.
|
|
// Reutilizado por ambos backends para mantener paridad.
|
|
export function detalleModelo(serv: {
|
|
modeloCobro?: string | null;
|
|
esPersonalizado?: boolean | null;
|
|
horas?: number | null;
|
|
tarifaHora?: number | null;
|
|
montoMinimo?: number | null;
|
|
horasIncluidas?: number | null;
|
|
}): string {
|
|
if (serv.modeloCobro === "retainer") {
|
|
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
|
}
|
|
if (serv.modeloCobro === "demanda") {
|
|
return `${formatCurrency(serv.tarifaHora ?? 0)}/hr · segun consumo`;
|
|
}
|
|
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
|
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Texto a mostrar en la columna "Precio" de una partida. Para "demanda" muestra la
|
|
// tarifa por hora en vez de $0 (el precio real depende del consumo). Reutilizado por
|
|
// UI, PDF y Excel para mantener consistencia.
|
|
export function precioDisplay(serv: {
|
|
modeloCobro?: string | null;
|
|
tarifaHora?: number | null;
|
|
precio: number;
|
|
}): string {
|
|
if (serv.modeloCobro === "demanda") {
|
|
return `${formatCurrency(serv.tarifaHora || 0)}/hr`;
|
|
}
|
|
return formatCurrency(serv.precio);
|
|
}
|
|
|
|
// Nota al pie para las partidas "bajo demanda": no suman al total comprometido y se
|
|
// facturan segun consumo. Devuelve "" si no hay partidas demanda. Reutilizado por PDF y Excel.
|
|
export function notaDemanda(
|
|
servicios: Array<{ modeloCobro?: string | null; nombre?: string | null; tarifaHora?: number | null }>
|
|
): string {
|
|
const dem = servicios.filter((s) => s.modeloCobro === "demanda");
|
|
if (dem.length === 0) return "";
|
|
const detalle = dem
|
|
.map((s) => `${s.nombre || "Servicio"} a ${formatCurrency(s.tarifaHora || 0)}/hr`)
|
|
.join(", ");
|
|
return `+ Horas facturadas a fin de mes segun consumo (no incluidas en el total): ${detalle}.`;
|
|
}
|
|
|
|
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
|
|
export function describirRetainer(
|
|
montoMinimo: number,
|
|
horasIncluidas: number,
|
|
tarifaHora: number
|
|
): string {
|
|
const partes = [`${formatCurrency(montoMinimo || 0)}/mes`];
|
|
if (horasIncluidas) partes.push(`incluye ${horasIncluidas} hr`);
|
|
if (tarifaHora) partes.push(`adicional ${formatCurrency(tarifaHora)}/hr (se factura aparte)`);
|
|
return partes.join(" · ");
|
|
}
|
|
|
|
// Doble propuesta: cada partida puede ir en la Opcion 1, la Opcion 2 o en ambas.
|
|
// "ambas" aparece (y suma) en las dos opciones. null = cotizacion normal (sin doble propuesta).
|
|
export const OPCIONES = ["1", "2", "ambas"] as const;
|
|
export type OpcionPropuesta = (typeof OPCIONES)[number];
|
|
|
|
export interface MetaOpcion {
|
|
titulo?: string;
|
|
descripcion?: string;
|
|
noIncluye?: string;
|
|
}
|
|
|
|
// Totales de una opcion: suma las partidas de esa opcion + las marcadas "ambas".
|
|
// Respeta la regla de retainer: el total usa `precio`, no `horas x tarifa`. Las horas
|
|
// son informativas para la tabla comparativa.
|
|
export function calcularTotalesOpcion(
|
|
servicios: Array<{ opcion?: string | null; tipoPago: string; precio: number; horas?: number | null }>,
|
|
opcion: "1" | "2"
|
|
): { totalUnico: number; totalMensual: number; horas: number } {
|
|
const rel = servicios.filter((s) => s.opcion === "ambas" || s.opcion === opcion);
|
|
const totalUnico = rel
|
|
.filter((s) => s.tipoPago === "unico")
|
|
.reduce((a, s) => a + (s.precio || 0), 0);
|
|
const totalMensual = rel
|
|
.filter((s) => s.tipoPago === "mensual")
|
|
.reduce((a, s) => a + (s.precio || 0), 0);
|
|
const horas = rel.reduce((a, s) => a + (s.horas || 0), 0);
|
|
return {
|
|
totalUnico: Math.round(totalUnico * 100) / 100,
|
|
totalMensual: Math.round(totalMensual * 100) / 100,
|
|
horas: Math.round(horas * 100) / 100,
|
|
};
|
|
}
|
|
|
|
// ----- 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",
|
|
2: "FASE 2 - Publicidad y Manejo",
|
|
3: "FASE 3 - Contenido y SEO",
|
|
};
|
|
|
|
export const FASES_SHORT: Record<number, string> = {
|
|
0: "FASE 0 - Auditoria",
|
|
1: "FASE 1 - Setup e Infraestructura",
|
|
2: "FASE 2 - Publicidad y Manejo",
|
|
3: "FASE 3 - Contenido y SEO",
|
|
};
|
|
|
|
export const PLANES_BUCEFALO = [
|
|
{ nivel: "basico", label: "Basico", precio: 1000 },
|
|
{ nivel: "estandar", label: "Estandar", precio: 3500 },
|
|
{ nivel: "premium", label: "Premium", precio: 4500 },
|
|
{ nivel: "empresarial", label: "Empresarial", precio: 7500 },
|
|
] as const;
|
|
|
|
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
|
|
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
|
|
|
|
// ----- Registro de horas (notas de pago) -----
|
|
// Modelos de cobro que implican trabajo por tiempo: para estas cotizaciones se
|
|
// habilita el registro de horas trabajadas y la emision de notas de pago.
|
|
export const MODELOS_COBRO_TIEMPO = ["horas", "retainer", "demanda"] as const;
|
|
|
|
// Una cotizacion es "por tiempo" si al menos una de sus partidas se cobra por horas,
|
|
// retainer o demanda. Solo en ese caso tiene sentido registrar horas trabajadas.
|
|
export function esCotizacionPorTiempo(
|
|
servicios: Array<{ modeloCobro?: string | null }>
|
|
): boolean {
|
|
return servicios.some((s) =>
|
|
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "")
|
|
);
|
|
}
|
|
|
|
// Tarifa por hora a sugerir al registrar horas: la de la primera partida por tiempo
|
|
// con tarifa > 0; si no hay, TARIFA_HORA_DEFAULT.
|
|
export function tarifaHoraSugerida(
|
|
servicios: Array<{ modeloCobro?: string | null; tarifaHora?: number | null }>
|
|
): number {
|
|
const serv = servicios.find(
|
|
(s) =>
|
|
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "") &&
|
|
(s.tarifaHora ?? 0) > 0
|
|
);
|
|
return serv?.tarifaHora ?? TARIFA_HORA_DEFAULT;
|
|
}
|
|
|
|
// "09:00" + "13:30" => 4.5. Devuelve 0 si el formato es invalido o fin <= inicio.
|
|
// No cruza medianoche (un registro = un tramo dentro de un dia).
|
|
export function calcularHorasRango(horaInicio: string, horaFin: string): number {
|
|
const re = /^(\d{1,2}):(\d{2})$/;
|
|
const a = re.exec(horaInicio ?? "");
|
|
const b = re.exec(horaFin ?? "");
|
|
if (!a || !b) return 0;
|
|
const ini = parseInt(a[1], 10) * 60 + parseInt(a[2], 10);
|
|
const fin = parseInt(b[1], 10) * 60 + parseInt(b[2], 10);
|
|
if (fin <= ini) return 0;
|
|
return Math.round(((fin - ini) / 60) * 100) / 100;
|
|
}
|
|
|
|
export type ModoAgrupacion = "detalle" | "dia" | "semana" | "mes";
|
|
|
|
export const MODOS_AGRUPACION: { value: ModoAgrupacion; label: string }[] = [
|
|
{ value: "detalle", label: "Detalle" },
|
|
{ value: "dia", label: "Por dia" },
|
|
{ value: "semana", label: "Por semana" },
|
|
{ value: "mes", label: "Por mes" },
|
|
];
|
|
|
|
const MESES_LARGO = [
|
|
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
|
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
|
|
];
|
|
|
|
// La fecha de un registro es una fecha-calendario sin huso horario. Para que el dia
|
|
// no se corra entre servidor (UTC) y cliente (MX), se persiste a las 12:00 UTC y se
|
|
// lee SIEMPRE con getters UTC. Helper para construir esa fecha desde "YYYY-MM-DD".
|
|
export function fechaRegistroDesdeISO(fechaISO: string): Date {
|
|
return new Date(`${fechaISO}T12:00:00.000Z`);
|
|
}
|
|
|
|
// Formato corto de fecha de registro (DD/MM/YYYY) leyendo en UTC.
|
|
export function formatFechaRegistro(date: Date): string {
|
|
return new Intl.DateTimeFormat("es-MX", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
timeZone: "UTC",
|
|
}).format(date);
|
|
}
|
|
|
|
function lunesDeSemanaUTC(d: Date): Date {
|
|
const r = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
const dia = r.getUTCDay(); // 0 = domingo
|
|
const offset = dia === 0 ? -6 : 1 - dia;
|
|
r.setUTCDate(r.getUTCDate() + offset);
|
|
return r;
|
|
}
|
|
|
|
// Clave + etiqueta del periodo al que pertenece una fecha, segun el modo de agrupacion.
|
|
// La clave agrupa; la etiqueta se muestra. Todo en UTC para coherencia de calendario.
|
|
export function periodoAgrupacion(
|
|
fecha: Date,
|
|
modo: ModoAgrupacion
|
|
): { clave: string; etiqueta: string } {
|
|
const y = fecha.getUTCFullYear();
|
|
const m = fecha.getUTCMonth();
|
|
const d = fecha.getUTCDate();
|
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
|
|
|
if (modo === "mes") {
|
|
return { clave: `${y}-${p2(m + 1)}`, etiqueta: `${MESES_LARGO[m]} ${y}` };
|
|
}
|
|
if (modo === "semana") {
|
|
const lunes = lunesDeSemanaUTC(fecha);
|
|
const domingo = new Date(lunes);
|
|
domingo.setUTCDate(lunes.getUTCDate() + 6);
|
|
const fmt = (x: Date) => `${p2(x.getUTCDate())}/${p2(x.getUTCMonth() + 1)}`;
|
|
const clave = `${lunes.getUTCFullYear()}-${p2(lunes.getUTCMonth() + 1)}-${p2(lunes.getUTCDate())}`;
|
|
return { clave, etiqueta: `Semana ${fmt(lunes)} - ${fmt(domingo)}` };
|
|
}
|
|
// "dia" / "detalle"
|
|
return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
|
|
}
|
|
|
|
// Estado de cobro de una partida de horas registrada. "por_pagar" es pendiente de
|
|
// cobro (entra en la nota por pagar al cliente); "pagada" ya se cobro (se conserva
|
|
// como recibo/historial y NO se suma al pendiente).
|
|
export const ESTADOS_PAGO_HORAS = ["por_pagar", "pagada"] as const;
|
|
export type EstadoPagoHoras = (typeof ESTADOS_PAGO_HORAS)[number];
|
|
|
|
export const ESTADOS_PAGO_HORAS_LABEL: Record<EstadoPagoHoras, string> = {
|
|
por_pagar: "Por pagar",
|
|
pagada: "Pagada",
|
|
};
|
|
|
|
// Normaliza el estado de pago: cualquier valor distinto de "pagada" cuenta como pendiente.
|
|
export function esPagada(estadoPago?: string | null): boolean {
|
|
return estadoPago === "pagada";
|
|
}
|
|
|
|
// Suma horas e importe de un conjunto de registros separando por estado de pago, para
|
|
// que lo pagado y lo pendiente nunca se mezclen en un solo total.
|
|
export function resumenPagoHoras(
|
|
registros: Array<{ horas: number; tarifaHora: number; estadoPago?: string | null }>
|
|
): {
|
|
porPagar: { horas: number; importe: number; count: number };
|
|
pagada: { horas: number; importe: number; count: number };
|
|
total: { horas: number; importe: number; count: number };
|
|
} {
|
|
const acc = () => ({ horas: 0, importe: 0, count: 0 });
|
|
const r = { porPagar: acc(), pagada: acc(), total: acc() };
|
|
for (const x of registros) {
|
|
const importe = (x.horas || 0) * (x.tarifaHora || 0);
|
|
const bucket = esPagada(x.estadoPago) ? r.pagada : r.porPagar;
|
|
bucket.horas += x.horas || 0;
|
|
bucket.importe += importe;
|
|
bucket.count += 1;
|
|
r.total.horas += x.horas || 0;
|
|
r.total.importe += importe;
|
|
r.total.count += 1;
|
|
}
|
|
for (const k of ["porPagar", "pagada", "total"] as const) {
|
|
r[k].horas = Math.round(r[k].horas * 100) / 100;
|
|
r[k].importe = Math.round(r[k].importe * 100) / 100;
|
|
}
|
|
return r;
|
|
}
|
|
|
|
export function bucefaloPrecio(nivel: string): number {
|
|
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
|
}
|
|
|
|
export function calcularVigencia(fecha: Date): Date {
|
|
const vigencia = new Date(fecha);
|
|
let diasHabiles = 0;
|
|
while (diasHabiles < 15) {
|
|
vigencia.setDate(vigencia.getDate() + 1);
|
|
const dia = vigencia.getDay();
|
|
if (dia !== 0 && dia !== 6) diasHabiles++;
|
|
}
|
|
return vigencia;
|
|
}
|
|
|
|
export function calcularFinanciamiento(input: { monto: number; meses: number; tasa: number; comision: number; iva: number }) {
|
|
const { monto, meses, tasa, comision, iva } = input;
|
|
const comisionTotal = (monto * comision) / 100;
|
|
const montoConComision = monto + comisionTotal;
|
|
const pagoMensual = montoConComision * (1 + tasa) / meses;
|
|
const ivaMensual = pagoMensual * iva;
|
|
const totalMensual = pagoMensual + ivaMensual;
|
|
return {
|
|
pagoMensual: Math.round(pagoMensual * 100) / 100,
|
|
ivaMensual: Math.round(ivaMensual * 100) / 100,
|
|
totalMensual: Math.round(totalMensual * 100) / 100,
|
|
comisionTotal: Math.round(comisionTotal * 100) / 100,
|
|
granTotal: Math.round(totalMensual * meses * 100) / 100,
|
|
};
|
|
}
|
|
|
|
export function generarNumeroCotizacion(asesorIniciales: string, secuencia: number): string {
|
|
const now = new Date();
|
|
const yy = now.getFullYear().toString().slice(-2);
|
|
const mm = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
const seq = secuencia.toString().padStart(3, "0");
|
|
return `UJ${yy}${mm}${asesorIniciales}${seq}`;
|
|
}
|
|
|
|
export function sanitizeFilename(name: string): string {
|
|
return name.replace(/[^\w\s.\-]/g, "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export function formatCurrency(amount: number): string {
|
|
return new Intl.NumberFormat("es-MX", {
|
|
style: "currency",
|
|
currency: "MXN",
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
export function formatDate(date: Date): string {
|
|
return new Intl.DateTimeFormat("es-MX", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
}).format(date);
|
|
}
|