Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/quotes/pdf.py
Ernesto Herrera 206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00

283 lines
9.8 KiB
Python

"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema.
Compone un PDF 1.4 válido byte a byte (fuente Helvetica) e incrusta el logo como
imagen JPEG (XObject /DCTDecode) usando Pillow para normalizarlo. El branding del
emisor (nombre, RFC, dirección, contacto, color) viene de la configuración por
tenant.
"""
from __future__ import annotations
import io
from decimal import Decimal
_PAGE_W = 612
_PAGE_H = 792
_MARGIN = 50
CONCEPT_LABELS = {
"flete_internacional": "Flete internacional",
"transporte_terrestre": "Transporte terrestre",
"despacho_aduanal": "Despacho aduanal",
"gastos_destino": "Gastos en destino",
"otros": "Otros cargos",
}
def _esc(text: str) -> str:
out = (str(text) if text is not None else "").encode("latin-1", "replace").decode("latin-1")
return out.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
def _money(value, currency: str = "") -> str:
d = Decimal(str(value or 0)).quantize(Decimal("0.01"))
return (f"{currency} " if currency else "") + f"{d:,.2f}"
def _wrap(text: str, width: int) -> list[str]:
words = (text or "").split()
if not words:
return []
lines, cur = [], ""
for w in words:
cand = f"{cur} {w}".strip()
if len(cand) > width and cur:
lines.append(cur)
cur = w
else:
cur = cand
if cur:
lines.append(cur)
return lines
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
try:
h = (hexs or "#2f6bf0").lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
except Exception:
return (0.184, 0.42, 0.94)
def _prep_logo(logo_bytes: bytes | None):
"""Normaliza el logo a JPEG RGB. Devuelve (jpeg_bytes, w, h) o None."""
if not logo_bytes:
return None
try:
from PIL import Image
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
im.thumbnail((600, 300))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=85)
return buf.getvalue(), im.width, im.height
except Exception:
return None
def _kv_lines(pairs: list[tuple[str, str]]) -> list[tuple[str, int]]:
out: list[tuple[str, int]] = []
for k, v in pairs:
if v not in (None, "", "None"):
out.append((f"{k}: {v}", 10))
return out
def build_quote_pdf(
*,
emitter: dict,
head: dict,
client: dict,
cargo: list[tuple[str, str]],
route: list[tuple[str, str]],
items: list[dict],
currency: str,
subtotal,
terms: str | None,
footer: str | None,
logo_bytes: bytes | None = None,
accent: str | None = "#2f6bf0",
) -> bytes:
accent_rgb = _hex_rgb(accent)
logo = _prep_logo(logo_bytes)
# ---- Cuerpo (debajo del encabezado) ----
body: list[tuple[str, int]] = []
def section(title: str):
body.append(("", 6))
body.append((title.upper(), 11))
body.append(("_" * 92, 8))
# Cliente
section("Cliente")
for line in _kv_lines([
("Cliente", client.get("name")), ("RFC", client.get("rfc")),
("Correo", client.get("email")), ("Teléfono", client.get("phone")),
]):
body.append(line)
# Carga / Ruta
if cargo:
section("Información de la carga")
for line in _kv_lines(cargo):
body.append(line)
if route:
section("Ruta logística")
for line in _kv_lines(route):
body.append(line)
# Costos
section("Costos cotizados")
body.append(("Concepto Cant. Tarifa Importe", 9))
body.append(("-" * 92, 8))
for it in items:
code = str(it.get("concept") or "")
label = CONCEPT_LABELS.get(code, code)
desc = str(it.get("description") or "")
if desc:
label = f"{label}{desc}"
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_sale") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
row = f"{label[:40].ljust(40)} {qty:>6.2f} {unit:>14,.2f} {amount:>14,.2f}"
body.append((row, 9))
body.append(("-" * 92, 8))
body.append((f"Subtotal {currency}: {_money(subtotal)}", 11))
body.append(("IVA: según aplique", 9))
body.append((f"Total {currency}: {_money(subtotal)} + IVA", 12))
# Condiciones
if terms:
section("Condiciones comerciales")
for para in terms.splitlines():
for line in _wrap(para, 105) or [""]:
body.append((line, 9))
# ---- Paginación (página 1 con encabezado; siguientes solo cuerpo) ----
p1_top = _PAGE_H - 150 # y donde inicia el cuerpo en la página 1
pN_top = _PAGE_H - _MARGIN
line_h = 14
pages: list[list[tuple[float, tuple[str, int]]]] = []
cur: list[tuple[float, tuple[str, int]]] = []
y = p1_top
for item in body:
if y < _MARGIN + 40:
pages.append(cur)
cur = []
y = pN_top
cur.append((y, item))
y -= line_h
pages.append(cur)
# ---- Content streams ----
streams: list[bytes] = []
for pi, page in enumerate(pages):
parts: list[str] = []
if pi == 0:
# barra de acento arriba
r, g, b = accent_rgb
parts.append(f"{r:.3f} {g:.3f} {b:.3f} rg")
parts.append(f"0 {_PAGE_H - 8} {_PAGE_W} 8 re f")
# logo
logo_y = _PAGE_H - 30
if logo:
_, lw, lh = logo
dw = 150.0
dh = dw * lh / lw
if dh > 60:
dh = 60.0
dw = dh * lw / lh
parts.append(f"q {dw:.2f} 0 0 {dh:.2f} {_MARGIN} {logo_y - dh:.2f} cm /Im0 Do Q")
# emisor (columna derecha)
ex = 330
ey = logo_y - 6
parts.append("BT /F1 12 Tf 0.09 0.14 0.24 rg")
parts.append(f"1 0 0 1 {ex} {ey} Tm ({_esc(emitter.get('name') or 'Emisor')}) Tj")
parts.append("/F1 9 Tf 0.35 0.41 0.5 rg 13 TL")
em_lines = []
if emitter.get("rfc"):
em_lines.append(f"RFC: {emitter['rfc']}")
for a in (emitter.get("address") or "").splitlines():
if a.strip():
em_lines.append(a.strip())
contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x])
if contact:
em_lines.append(contact)
for ln in em_lines[:5]:
parts.append(f"T* ({_esc(ln)}) Tj")
parts.append("ET")
# título
parts.append("BT /F1 22 Tf 0.09 0.14 0.24 rg")
parts.append(f"1 0 0 1 {_MARGIN} {_PAGE_H - 120} Tm (COTIZACION) Tj ET")
# datos de cabecera (derecha del título)
parts.append("BT /F1 9 Tf 0.2 0.25 0.35 rg 12 TL")
parts.append(f"1 0 0 1 330 {_PAGE_H - 100} Tm ({_esc('No.: ' + str(head.get('reference') or '-'))}) Tj")
for hl in [
f"Fecha: {head.get('issue_date') or '-'}",
f"Vigencia: {head.get('valid_until') or '-'}",
f"Ejecutivo: {head.get('owner') or '-'} Estatus: {head.get('status') or '-'}",
]:
parts.append(f"T* ({_esc(hl)}) Tj")
parts.append("ET")
# cuerpo
for yy, (text, size) in page:
parts.append(f"BT /F1 {size} Tf 0 0 0 rg 1 0 0 1 {_MARGIN} {yy:.2f} Tm ({_esc(text)}) Tj ET")
# pie
if footer:
parts.append(f"BT /F1 8 Tf 0.5 0.5 0.5 rg 1 0 0 1 {_MARGIN} {_MARGIN - 20} Tm ({_esc(footer[:110])}) Tj ET")
streams.append("\n".join(parts).encode("latin-1", "replace"))
# ---- Ensamblado de objetos ----
objects: list[bytes] = []
def add(obj: bytes) -> int:
objects.append(obj)
return len(objects)
n_pages = len(pages)
has_img = 1 if logo else 0
font_num = 3
img_num = 4 if has_img else None
base = 5 if has_img else 4
page_nums = list(range(base, base + n_pages))
content_nums = list(range(base + n_pages, base + 2 * n_pages))
kids = " ".join(f"{n} 0 R" for n in page_nums)
add(b"<< /Type /Catalog /Pages 2 0 R >>")
add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
if logo:
jpeg, lw, lh = logo
img_obj = (
f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} "
f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n"
).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream"
add(img_obj)
for i in range(n_pages):
res = f"/Font << /F1 {font_num} 0 R >>"
if has_img and i == 0:
res += f" /XObject << /Im0 {img_num} 0 R >>"
page_dict = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] "
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>"
)
add(page_dict.encode("latin-1"))
for stream in streams:
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
out = bytearray()
out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
offsets: list[int] = []
for i, obj in enumerate(objects, start=1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
xref_pos = len(out)
total = len(objects) + 1
out += f"xref\n0 {total}\n".encode("latin-1")
out += b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode("latin-1")
out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
return bytes(out)