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
+53 -328
View File
@@ -1,24 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import ExcelJS from "exceljs";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
function hexToArgb(hex: string): string {
const h = hex.replace("#", "");
return "FF" + h.toUpperCase();
}
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
const bc: Partial<ExcelJS.Border> = { style: "thin", color: { argb: color || "FFD1D5DB" } };
cell.border = { top: bc, left: bc, bottom: bc, right: bc };
}
function fillRow(ws: ExcelJS.Worksheet, row: number, startCol: string, endCol: string, argb: string) {
for (let c = ws.getColumn(startCol).number; c <= ws.getColumn(endCol).number; c++) {
ws.getCell(row, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
}
}
import { buildCotizacionExcel, type ExcelData } from "@/lib/excel-builder";
export async function GET(
request: NextRequest,
@@ -27,325 +11,66 @@ export async function GET(
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
const [cot, branding, bancaria] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
}),
getConfigBranding(),
getConfigBancaria(),
]);
if (!cot) {
return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
}
const empresa = cot.cliente.empresa || cot.cliente.nombre;
const branding = await getConfigBranding();
const bancaria = await getConfigBancaria();
const configColor = branding.colorPrimario || "#2563eb";
const PRIMARY = hexToArgb(configColor);
const PRIMARY_LIGHT = hexToArgb(configColor + "18");
const SECONDARY = hexToArgb(branding.colorSecundario || "#1e293b");
const WHITE = "FFFFFFFF";
const LIGHT_BG = "FFF8FAFC";
const MUTED = "FF64748B";
const BORDER_COLOR = "FFE2E8F0";
const workbook = new ExcelJS.Workbook();
workbook.creator = "Cotizador E3";
workbook.created = new Date();
const data: ExcelData = {
razonSocial: bancaria.razon_social || "Cotizador E3",
domicilioFiscal: bancaria.domicilio_fiscal || undefined,
numero: cot.numero,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa || "",
asesorNombre: cot.asesor.name,
fecha: cot.fecha,
vigencia: cot.vigencia,
moneda: cot.moneda,
tipoCambio: cot.tipoCambio,
proyecto: cot.proyecto,
esquemaPago: cot.esquemaPago,
servicios: cot.servicios
.filter((s) => s.seleccionado)
.map((s) => ({
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
modeloCobro: s.modeloCobro,
esPersonalizado: s.esPersonalizado,
horas: s.horas,
tarifaHora: s.tarifaHora,
montoMinimo: s.montoMinimo,
horasIncluidas: s.horasIncluidas,
opcion: s.opcion,
})),
esDoble: cot.esDoble,
opcionesMetadata: cot.opcionesMetadata as never,
planBucefaloNivel: cot.planBucefalo?.nivel ?? null,
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
colorPrimario: branding.colorPrimario || "#2563eb",
colorSecundario: branding.colorSecundario || "#1e293b",
};
const headerFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: WHITE } };
const labelFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: MUTED } };
const valueFont: Partial<ExcelJS.Font> = { size: 10, name: "Calibri" };
const boldFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri" };
const totalFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri" };
const smallFont: Partial<ExcelJS.Font> = { italic: true, size: 9, name: "Calibri", color: { argb: MUTED } };
const faseFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri", color: { argb: PRIMARY } };
const ws = workbook.addWorksheet("HOJA RESUMEN");
ws.columns = [
{ width: 3 },
{ width: 22 }, { width: 22 }, { width: 16 },
{ width: 38 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 18 },
];
ws.views = [{ showGridLines: false }];
// ── HEADER BANNER ──
ws.mergeCells("B2:K2");
ws.getCell("B2").value = bancaria.razon_social || "Cotizador E3";
ws.getCell("B2").font = { bold: true, size: 16, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B2").alignment = { vertical: "middle" };
fillRow(ws, 2, "A", "K", PRIMARY);
ws.mergeCells("B3:K3");
ws.getCell("B3").value = cot.numero;
ws.getCell("B3").font = { bold: true, size: 11, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B3").alignment = { vertical: "middle" };
fillRow(ws, 3, "A", "K", SECONDARY);
// ── CLIENTE / PROYECTO ──
const infoStart = 5;
const infoLabels: [string, string, string, string][] = [
["B", "En atencion a:", "C", cot.cliente.nombre],
["E", "Empresa:", "F", cot.cliente.empresa || "—"],
["B", "Proyecto:", "C", cot.proyecto],
["E", "Moneda:", "F", cot.moneda],
["B", "Asesor:", "C", cot.asesor.name],
["E", "Tipo de Cambio:", "F", cot.tipoCambio],
["B", "Fecha:", "C", ""],
["E", "Esquema de Pago:", "F", cot.esquemaPago],
];
for (let i = 0; i < infoLabels.length; i += 2) {
const r = infoStart + (i / 2);
const [lc1, lv1, vc1, vv1] = infoLabels[i];
const [lc2, lv2, vc2, vv2] = infoLabels[i + 1];
ws.getCell(`${lc1}${r}`).value = lv1;
ws.getCell(`${lc1}${r}`).font = labelFont;
ws.getCell(`${vc1}${r}`).value = vv1;
ws.getCell(`${vc1}${r}`).font = valueFont;
ws.getCell(`${lc2}${r}`).value = lv2;
ws.getCell(`${lc2}${r}`).font = labelFont;
ws.getCell(`${vc2}${r}`).value = vv2;
ws.getCell(`${vc2}${r}`).font = valueFont;
}
const fechaCell = ws.getCell(`C${infoStart + 6}`);
fechaCell.value = cot.fecha;
fechaCell.numFmt = "DD/MM/YYYY";
fechaCell.font = valueFont;
const vigenciaCell = ws.getCell(`C${infoStart + 7}`);
ws.getCell(`B${infoStart + 7}`).value = "Vigencia:";
ws.getCell(`B${infoStart + 7}`).font = labelFont;
vigenciaCell.value = cot.vigencia;
vigenciaCell.numFmt = "DD/MM/YYYY";
vigenciaCell.font = valueFont;
// ── TABLA SERVICIOS ──
const tableStart = infoStart + 9;
const cols = ["B", "C", "D", "E", "K"];
const headers = ["Fase", "Tipo de Pago", "Servicio", "", "Precio"];
ws.mergeCells(`D${tableStart}:J${tableStart}`);
for (let i = 0; i < cols.length; i++) {
const cell = ws.getCell(`${cols[i]}${tableStart}`);
cell.value = headers[i];
cell.font = headerFont;
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
cell.alignment = { horizontal: i === cols.length - 1 ? "right" : "left", vertical: "middle" };
applyThinBorder(cell, PRIMARY);
}
ws.getRow(tableStart).height = 24;
const fases: Record<number, string> = { 0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3" };
const serviciosUnicos = cot.servicios.filter(s => s.tipoPago === "unico" && s.seleccionado);
const serviciosMensuales = cot.servicios.filter(s => s.tipoPago === "mensual" && s.seleccionado);
let row = tableStart + 1;
let currentFase = -1;
for (const serv of [...serviciosUnicos, ...serviciosMensuales]) {
if (serv.fase !== currentFase) {
currentFase = serv.fase;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = fases[serv.fase] || `FASE ${serv.fase}`;
ws.getCell(`B${row}`).font = faseFont;
fillRow(ws, row, "B", "K", PRIMARY_LIGHT);
row++;
}
const isAlt = row % 2 === 0;
const rowBg = isAlt ? LIGHT_BG : WHITE;
ws.getCell(`B${row}`).value = "";
ws.getCell(`C${row}`).value = serv.tipoPago === "unico" ? "Unico" : "Mensual";
ws.getCell(`C${row}`).font = valueFont;
ws.mergeCells(`D${row}:J${row}`);
ws.getCell(`D${row}`).value = serv.servicioCatalogo?.nombre || "Servicio";
ws.getCell(`D${row}`).font = valueFont;
ws.getCell(`K${row}`).value = serv.precio;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = valueFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: rowBg } };
applyThinBorder(ws.getCell(`${c}${row}`), BORDER_COLOR);
}
row++;
}
// ── TOTALES ──
row++;
const totalUnico = serviciosUnicos.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Unico";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalUnico;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row++;
const totalMensual = serviciosMensuales.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Mensual";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalMensual;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row += 2;
if (cot.planBucefalo) {
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = `CRM Bucefalo - ${cot.planBucefalo.nivel.charAt(0).toUpperCase() + cot.planBucefalo.nivel.slice(1)}`;
ws.getCell(`B${row}`).font = boldFont;
ws.getCell(`K${row}`).value = cot.planBucefalo.precio;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = boldFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row += 2;
}
// ── NOTA AL PIE ──
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles.";
ws.getCell(`B${row}`).font = smallFont;
// ── HOJAS DETALLADAS POR SERVICIO ──
for (const serv of cot.servicios.filter(s => s.seleccionado)) {
const nombre = serv.servicioCatalogo?.nombre || "Servicio";
const safeName = nombre.substring(0, 31).replace(/[/\\*?[\]:]/g, "");
const dw = workbook.addWorksheet(safeName);
dw.columns = [
{ width: 3 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 }, { width: 18 },
];
dw.views = [{ showGridLines: false }];
// Banner
dw.mergeCells("B1:J1");
dw.getCell("B1").value = `${fases[serv.fase] || "FASE " + serv.fase}${nombre}`;
dw.getCell("B1").font = { bold: true, size: 14, name: "Calibri", color: { argb: WHITE } };
dw.getCell("B1").alignment = { vertical: "middle" };
fillRow(dw, 1, "A", "J", PRIMARY);
dw.getRow(1).height = 30;
// Info
let r = 3;
dw.getCell(`B${r}`).value = "Cliente:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = cot.cliente.empresa || cot.cliente.nombre;
dw.getCell(`C${r}`).font = boldFont;
dw.getCell(`F${r}`).value = "No. Cotizacion:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = cot.numero;
dw.getCell(`G${r}`).font = boldFont;
r += 2;
// Servicio header
dw.getCell(`B${r}`).value = "Servicio:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = nombre;
dw.getCell(`C${r}`).font = { ...boldFont, size: 12, color: { argb: PRIMARY } };
dw.getCell(`F${r}`).value = "Tiempo de Entrega:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = serv.tiempoEntrega;
dw.getCell(`G${r}`).font = valueFont;
r += 2;
// Tabla entregables
const tableCols = ["B", "C"];
const thCols = ["B", "I"];
for (const c of thCols) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
dw.getCell(`${c}${r}`).font = headerFont;
dw.getCell(`${c}${r}`).border = { bottom: { style: "thin", color: { argb: PRIMARY } } };
}
dw.getCell(`B${r}`).value = "Entregable";
dw.getCell(`I${r}`).value = "";
r++;
const entregables = Array.isArray(serv.entregables) ? serv.entregables as string[] : [];
for (let i = 0; i < entregables.length; i++) {
const isAlt = i % 2 === 1;
dw.getCell(`B${i + r}`).value = `${i + 1}. ${entregables[i]}`;
dw.getCell(`B${i + r}`).font = valueFont;
const bg = isAlt ? LIGHT_BG : WHITE;
for (const c of tableCols) {
dw.getCell(`${c}${i + r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } };
}
applyThinBorder(dw.getCell(`B${i + r}`), BORDER_COLOR);
}
r += entregables.length + 1;
// Precio
for (const c of ["B", "I"]) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
applyThinBorder(dw.getCell(`${c}${r}`), PRIMARY);
}
dw.getCell(`B${r}`).value = `Total Pago ${serv.tipoPago === "unico" ? "Unico" : "Mensual"}`;
dw.getCell(`B${r}`).font = totalFont;
dw.getCell(`I${r}`).value = serv.precio;
dw.getCell(`I${r}`).numFmt = '$#,##0.00';
dw.getCell(`I${r}`).font = totalFont;
dw.getCell(`I${r}`).alignment = { horizontal: "right" };
r++;
dw.getCell(`B${r}`).value = "(+ IVA)";
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
r += 2;
// Footer
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA.";
dw.getCell(`B${r}`).font = smallFont;
r += 2;
dw.getCell(`B${r}`).value = bancaria.razon_social || "Cotizador E3";
dw.getCell(`B${r}`).font = { ...boldFont, color: { argb: PRIMARY } };
if (bancaria.domicilio_fiscal) {
r++;
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = bancaria.domicilio_fiscal;
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
}
}
const buffer = await workbook.xlsx.writeBuffer();
return new NextResponse(buffer, {
const buffer = await buildCotizacionExcel(data);
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}.xlsx"`,