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:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user