Restaurar funcionalidades desde stash + setup de despliegue Coolify

- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop
- Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED)
- docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_*
- Endpoint público /api/health (verifica BD) para healthchecks
- seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas)
- .env.example, .dockerignore, uvicorn con --proxy-headers
- Limpieza: .xlsx y __pycache__ fuera del repo

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-06-10 02:21:18 -06:00
co-authored by Claude Fable 5
parent c3f4449296
commit 69c74faabb
43 changed files with 3536 additions and 1139 deletions
+60
View File
@@ -1,5 +1,65 @@
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).
export const MODELOS_COBRO: Record<string, string> = {
fijo: "Precio fijo",
horas: "Por horas",
retainer: "Retainer (minimo + adicionales)",
};
// 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,
};
}
export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
+116 -12
View File
@@ -1,5 +1,5 @@
import PDFDocument from "pdfkit";
import { FASES_SHORT as FASES } from "./calculators";
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
interface ServicioPDF {
nombre: string;
@@ -8,6 +8,24 @@ interface ServicioPDF {
precio: number;
tiempoEntrega: string;
entregables: string[];
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
opcion?: string | null;
}
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
function detalleModeloPDF(serv: ServicioPDF): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
}
interface CotizacionPDFData {
@@ -22,6 +40,8 @@ interface CotizacionPDFData {
proyecto: string;
esquemaPago: string;
servicios: ServicioPDF[];
esDoble?: boolean;
opcionesMetadata?: { "1"?: MetaOpcion; "2"?: MetaOpcion } | null;
planBucefaloNivel: string | null;
planBucefaloPrecio: number;
incluirBonos: boolean;
@@ -180,13 +200,15 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
const colTiempo = L + W - 120;
const colPrecio = L + W - 60;
rowBg(y, 16, LIGHT_BG);
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
doc.text("Servicio", colNombre + 6, y + 4);
doc.text("Tipo", colTipo + 4, y + 4);
doc.text("Entrega", colTiempo + 4, y + 4);
doc.text("Precio", colPrecio, y + 4, { width: 60, align: "right" });
y += 18;
function drawTableHeader() {
rowBg(y, 16, LIGHT_BG);
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
doc.text("Servicio", colNombre + 6, y + 4);
doc.text("Tipo", colTipo + 4, y + 4);
doc.text("Entrega", colTiempo + 4, y + 4);
doc.text("Precio", colPrecio, y + 4, { width: 60, align: "right" });
y += 18;
}
const unicos = data.servicios.filter((s) => s.tipoPago === "unico");
const mensuales = data.servicios.filter((s) => s.tipoPago === "mensual");
@@ -205,7 +227,8 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
y += 16;
}
const servH = 13 + (serv.entregables ? Math.ceil(serv.entregables.length / 2) * 8 : 0) + 5;
const detalleModelo = detalleModeloPDF(serv);
const servH = 13 + (detalleModelo ? 9 : 0) + (serv.entregables ? Math.ceil(serv.entregables.length / 2) * 8 : 0) + 5;
y = need(servH, y);
doc.font("Helvetica").fontSize(8).fillColor(DARK);
@@ -217,6 +240,12 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text(fmt(serv.precio), colPrecio, y, { width: 60, align: "right" });
y += 13;
if (detalleModelo) {
doc.font("Helvetica-Oblique").fontSize(6.5).fillColor(MUTED);
doc.text(detalleModelo, colNombre + 10, y, { width: colTipo - colNombre - 20 });
y += 9;
}
if (serv.entregables && serv.entregables.length > 0) {
const col1X = colNombre + 10;
const col2X = colNombre + W * 0.48;
@@ -251,8 +280,84 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
return y;
}
if (unicos.length > 0) y = drawSection(unicos, "Unico", "Total Pago Unico");
if (mensuales.length > 0) y = drawSection(mensuales, "Mensual", "Total Pago Mensual");
// Encabezado de una opcion (titulo, descripcion, exclusiones) en doble propuesta.
function drawOpcionHeader(op: "1" | "2") {
const meta = data.opcionesMetadata?.[op] || {};
y = need(30, y);
y += 4;
doc.rect(L, y, W, 18).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(WHITE)
.text(`OPCION ${op}${meta.titulo ? ": " + meta.titulo : ""}`, L + 8, y + 4, { width: W - 16 });
y += 22;
if (meta.descripcion) {
const h = txtHeight(meta.descripcion, W - 12, 7.5);
y = need(h + 4, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(meta.descripcion, L + 6, y, { width: W - 12 });
y += h + 4;
}
if (meta.noIncluye) {
const label = `No incluye: ${meta.noIncluye}`;
const h = txtHeight(label, W - 12, 7);
y = need(h + 4, y);
doc.font("Helvetica-Oblique").fontSize(7).fillColor(MUTED).text(label, L + 6, y, { width: W - 12 });
y += h + 6;
}
}
// Tabla comparativa final: totales (con IVA) y horas por opcion.
function drawComparativa() {
const t1 = calcularTotalesOpcion(data.servicios, "1");
const t2 = calcularTotalesOpcion(data.servicios, "2");
y = need(90, y);
y += 6;
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text("Comparativa de opciones", L + 10, y);
y += 18;
const cLabel = L;
const cOp1 = L + W * 0.45;
const cOp2 = L + W * 0.72;
const t1Tit = data.opcionesMetadata?.["1"]?.titulo;
const t2Tit = data.opcionesMetadata?.["2"]?.titulo;
rowBg(y, 16, LIGHT_BG);
doc.font("Helvetica-Bold").fontSize(7.5).fillColor(DARK);
doc.text("Concepto", cLabel + 6, y + 4);
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
y += 18;
const filas: [string, string, string][] = [
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
];
for (const [lab, v1, v2] of filas) {
y = need(14, y);
doc.font("Helvetica").fontSize(8).fillColor(DARK).text(lab, cLabel + 6, y);
doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text(v1, cOp1, y, { width: W * 0.27 - 4 });
doc.text(v2, cOp2, y, { width: W * 0.28 - 4 });
doc.save();
doc.moveTo(cLabel + 6, y + 12).lineTo(L + W, y + 12).strokeColor("#e5e7eb").lineWidth(0.2).stroke();
doc.restore();
y += 14;
}
y += 8;
}
if (data.esDoble) {
for (const op of ["1", "2"] as const) {
const inOp = (s: ServicioPDF) => s.opcion === op || s.opcion === "ambas";
drawOpcionHeader(op);
drawTableHeader();
const u = unicos.filter(inOp);
const me = mensuales.filter(inOp);
if (u.length > 0) y = drawSection(u, "Unico", `Total Pago Unico - Opcion ${op}`);
if (me.length > 0) y = drawSection(me, "Mensual", `Total Pago Mensual - Opcion ${op}`);
}
drawComparativa();
} else {
drawTableHeader();
if (unicos.length > 0) y = drawSection(unicos, "Unico", "Total Pago Unico");
if (mensuales.length > 0) y = drawSection(mensuales, "Mensual", "Total Pago Mensual");
}
if (data.planBucefaloNivel) {
y = need(20, y);
@@ -350,7 +455,6 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
const clabeNac = cfg.clabe_interbancaria || cfg.cuenta_nacional || "";
const cuentaNac = cfg.cuenta_nacional || "";
const bancoNac = cfg.cuenta_nacional ? "BBVA" : "";
const cuentaInt = (cfg.cuenta_internacional_swift || cfg.cuenta_internacional || "").split("\n").filter(Boolean);
const swiftMatch = cfg.cuenta_internacional_swift?.match(/SWIFT[^:]*:\s*(\S+)/i);
const clabeIntMatch = cfg.cuenta_internacional_swift?.match(/CLABE[^:]*:\s*(\S+)/i);
const swift = swiftMatch?.[1] || "BCMRMXMMPYM";
+23
View File
@@ -8,8 +8,26 @@ const servicioSchema = z.object({
precio: z.number().min(0),
tiempoEntrega: z.string().min(1),
entregables: z.array(z.string()),
esPersonalizado: z.boolean().optional(),
horas: z.number().min(0).optional(),
tarifaHora: z.number().min(0).optional(),
modeloCobro: z.enum(["fijo", "horas", "retainer"]).optional(),
montoMinimo: z.number().min(0).optional(),
horasIncluidas: z.number().min(0).optional(),
opcion: z.enum(["1", "2", "ambas"]).optional(),
});
const metaOpcionSchema = z.object({
titulo: z.string().optional(),
descripcion: z.string().optional(),
noIncluye: z.string().optional(),
});
// { "1": { titulo, descripcion, noIncluye }, "2": {...} }
const opcionesSchema = z
.object({ "1": metaOpcionSchema.optional(), "2": metaOpcionSchema.optional() })
.optional();
export const cotizacionPostSchema = z.object({
numero: z.string().min(1),
fecha: z.coerce.date(),
@@ -20,6 +38,8 @@ export const cotizacionPostSchema = z.object({
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
asesorId: z.string().min(1),
cliente: z.object({
@@ -46,6 +66,8 @@ export const cotizacionPutSchema = z.object({
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"),
@@ -77,6 +99,7 @@ export const servicioCatalogoSchema = z.object({
entregablesDefault: z.array(z.string()),
categoriaId: z.string().min(1, "Categoria es requerida"),
variante: z.string().nullable(),
nivel: z.string().nullable().optional(),
orden: z.number().int().min(0),
});
+30
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import type { MetaOpcion } from "./calculators";
export interface ServicioSeleccionado {
catalogoId: string;
@@ -9,6 +10,18 @@ export interface ServicioSeleccionado {
tiempoEntrega: string;
entregables: string[];
notas?: string;
// Doble propuesta: "1" | "2" | "ambas". undefined = cotizacion normal.
opcion?: string;
// Partidas personalizadas (sin catalogo). modeloCobro define como se calcula el precio:
// - "fijo": precio editable / precioBase (servicios de catalogo)
// - "horas": precio = horas * tarifaHora
// - "retainer": precio = montoMinimo (mensual); tarifaHora y horasIncluidas son informativos
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
}
export interface CotizacionDraft {
@@ -29,6 +42,9 @@ export interface CotizacionDraft {
planBucefaloNivel: string | null;
servicios: ServicioSeleccionado[];
observaciones: string;
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
esDoble: boolean;
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
}
interface CotizacionStore {
@@ -41,6 +57,7 @@ interface CotizacionStore {
updateServicio: (catalogoId: string, updates: Partial<ServicioSeleccionado>) => void;
removeServicio: (catalogoId: string) => void;
setServicios: (servicios: ServicioSeleccionado[]) => void;
updateOpcionMeta: (opcion: "1" | "2", parcial: Partial<MetaOpcion>) => void;
resetDraft: () => void;
}
@@ -62,6 +79,8 @@ const initialDraft: CotizacionDraft = {
planBucefaloNivel: null,
servicios: [],
observaciones: "",
esDoble: false,
opciones: {},
};
export const useCotizacionStore = create<CotizacionStore>((set) => ({
@@ -116,5 +135,16 @@ export const useCotizacionStore = create<CotizacionStore>((set) => ({
setServicios: (servicios) =>
set((state) => ({ draft: { ...state.draft, servicios } })),
updateOpcionMeta: (opcion, parcial) =>
set((state) => ({
draft: {
...state.draft,
opciones: {
...state.draft.opciones,
[opcion]: { ...state.draft.opciones[opcion], ...parcial },
},
},
})),
resetDraft: () => set({ draft: { ...initialDraft } }),
}));