fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)

La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-30 10:33:14 -06:00
parent e09cb02b8b
commit 03055cd377
3 changed files with 18 additions and 9 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, File, Query, UploadFile, status from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from core.database import get_core_db from core.database import get_core_db
@@ -152,16 +152,23 @@ def reject_quote(
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id) return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.get("/quotes/{quote_id}/pdf-url") @router.get("/quotes/{quote_id}/pdf")
def quote_pdf_url( def quote_pdf(
quote_id: int, quote_id: int,
company_id: int = Query(..., description="Company ID"), company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
"""Genera el PDF de la cotización (formato maestro + marca) y devuelve una URL.""" """Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO)."""
url = pdf_service.get_pdf_url(db, quote_id, current_user["tenant_id"], company_id) tenant_id = current_user["tenant_id"]
return {"url": url} quote = service.get_quote(db, quote_id, tenant_id, company_id)
pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id)
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'},
)
@router.post("/quotes/{quote_id}/send-email") @router.post("/quotes/{quote_id}/send-email")

View File

@@ -158,7 +158,7 @@ export const quotesAPI = {
clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})), clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)), items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)),
pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/crm/quotes/${id}/pdf-url?${qp(companyId)}`)), pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise<Blob>,
sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) => sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) =>
unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body)) unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body))
}; };

View File

@@ -146,8 +146,10 @@
if (!companyId || !quote) return; if (!companyId || !quote) return;
busy = true; busy = true;
try { try {
const { url } = await quotesAPI.pdfUrl(quote.id, companyId); const blob = await quotesAPI.pdfBlob(quote.id, companyId);
if (url) window.open(url, '_blank', 'noopener'); const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo generar el PDF'); toast.error(e instanceof Error ? e.message : 'No se pudo generar el PDF');
} finally { } finally {