feat(crm): rediseño profesional del PDF de cotización

Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de
sección en color, tabla de costos con encabezado y filas alternadas, caja de
totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1);
oculta secciones sin datos; color por defecto navy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-30 11:06:16 -06:00
parent e269e46d88
commit 87d23b3d23
2 changed files with 250 additions and 156 deletions

View File

@@ -1,9 +1,8 @@
"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema. """Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema.
Compone un PDF 1.4 válido byte a byte (fuente Helvetica) e incrusta el logo como Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección,
imagen JPEG (XObject /DCTDecode) usando Pillow para normalizarlo. El branding del tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado
emisor (nombre, RFC, dirección, contacto, color) viene de la configuración por (JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant.
tenant.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,9 +10,10 @@ from __future__ import annotations
import io import io
from decimal import Decimal from decimal import Decimal
_PAGE_W = 612 _W = 612
_PAGE_H = 792 _H = 792
_MARGIN = 50 _ML = 50 # margen izquierdo
_MR = 562 # margen derecho (x)
CONCEPT_LABELS = { CONCEPT_LABELS = {
"flete_internacional": "Flete internacional", "flete_internacional": "Flete internacional",
@@ -23,44 +23,63 @@ CONCEPT_LABELS = {
"otros": "Otros cargos", "otros": "Otros cargos",
} }
_TRANSLATE = str.maketrans({"": "-", "": "-", "": '"', "": '"', "": "'", "": "'", "": "-", "": "...", "\t": " "})
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: def _esc(text) -> str:
d = Decimal(str(value or 0)).quantize(Decimal("0.01")) s = ("" if text is None else str(text)).translate(_TRANSLATE)
return (f"{currency} " if currency else "") + f"{d:,.2f}" s = s.encode("latin-1", "replace").decode("latin-1")
return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
def _wrap(text: str, width: int) -> list[str]: def _money(value) -> str:
return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}"
def _num(value) -> str:
return f"{Decimal(str(value or 0)):,.2f}"
# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar)
def _text_w(s: str, size: float, bold: bool = False) -> float:
return len(s) * size * (0.56 if bold else 0.52)
def _fit(s: str, size: float, max_w: float) -> str:
s = s or ""
if _text_w(s, size) <= max_w:
return s
while s and _text_w(s + "", size) > max_w:
s = s[:-1]
return s + ""
def _wrap(text: str, width_chars: int) -> list[str]:
words = (text or "").split() words = (text or "").split()
if not words: if not words:
return [] return []
lines, cur = [], "" out, cur = [], ""
for w in words: for w in words:
cand = f"{cur} {w}".strip() cand = f"{cur} {w}".strip()
if len(cand) > width and cur: if len(cand) > width_chars and cur:
lines.append(cur) out.append(cur)
cur = w cur = w
else: else:
cur = cand cur = cand
if cur: if cur:
lines.append(cur) out.append(cur)
return lines return out
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
try: try:
h = (hexs or "#2f6bf0").lstrip("#") h = (hexs or "#12294c").lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
except Exception: except Exception:
return (0.184, 0.42, 0.94) return (0.07, 0.16, 0.30)
def _prep_logo(logo_bytes: bytes | None): def _prep_logo(logo_bytes: bytes | None):
"""Normaliza el logo a JPEG RGB. Devuelve (jpeg_bytes, w, h) o None."""
if not logo_bytes: if not logo_bytes:
return None return None
try: try:
@@ -69,18 +88,44 @@ def _prep_logo(logo_bytes: bytes | None):
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
im.thumbnail((600, 300)) im.thumbnail((600, 300))
buf = io.BytesIO() buf = io.BytesIO()
im.save(buf, format="JPEG", quality=85) im.save(buf, format="JPEG", quality=88)
return buf.getvalue(), im.width, im.height return buf.getvalue(), im.width, im.height
except Exception: except Exception:
return None return None
def _kv_lines(pairs: list[tuple[str, str]]) -> list[tuple[str, int]]: class _Canvas:
out: list[tuple[str, int]] = [] """Acumula operadores de contenido con paginación simple."""
for k, v in pairs:
if v not in (None, "", "None"): def __init__(self):
out.append((f"{k}: {v}", 10)) self.pages: list[list[str]] = [[]]
return out self.y = _H
@property
def ops(self) -> list[str]:
return self.pages[-1]
def new_page(self):
self.pages.append([])
self.y = _H - 50
def ensure(self, needed: float):
if self.y - needed < 50:
self.new_page()
def rect(self, x, y, w, h, rgb):
r, g, b = rgb
self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f")
def line(self, x1, y1, x2, y2, rgb, width=0.6):
r, g, b = rgb
self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S")
def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False):
font = "F2" if bold else "F1"
r, g, b = rgb
tx = x - _text_w(str(s), size, bold) if right else x
self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET")
def build_quote_pdf( def build_quote_pdf(
@@ -96,150 +141,198 @@ def build_quote_pdf(
terms: str | None, terms: str | None,
footer: str | None, footer: str | None,
logo_bytes: bytes | None = None, logo_bytes: bytes | None = None,
accent: str | None = "#2f6bf0", accent: str | None = "#12294c",
) -> bytes: ) -> bytes:
accent_rgb = _hex_rgb(accent) ACC = _hex_rgb(accent)
INK = (0.10, 0.15, 0.24)
GRAY = (0.42, 0.47, 0.55)
LINE = (0.80, 0.84, 0.90)
ZEBRA = (0.955, 0.965, 0.980)
logo = _prep_logo(logo_bytes) logo = _prep_logo(logo_bytes)
# ---- Cuerpo (debajo del encabezado) ---- c = _Canvas()
body: list[tuple[str, int]] = []
# ---------------- Encabezado ----------------
c.rect(0, _H - 12, _W, 12, ACC) # banda superior
logo_bottom = _H - 95
if logo:
_, lw, lh = logo
dw, dh = 150.0, 150.0 * lh / lw
if dh > 55:
dh, dw = 55.0, 55.0 * lw / lh
c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q")
else:
c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True)
# Emisor (derecha)
ex, ey = 320, _H - 42
c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True)
ey -= 14
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]:
c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY)
ey -= 11
# Título + regla
c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True)
c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2)
# Panel de datos (derecha)
px, pw = 320, _MR - 320
py_top = _H - 128
ph = 74
c.rect(px, py_top - ph, pw, ph, ZEBRA)
c.line(px, py_top, px, py_top - ph, LINE)
hy = py_top - 15
info = [
("No.", head.get("reference") or "-"),
("Fecha", head.get("issue_date") or "-"),
("Vigencia", head.get("valid_until") or "-"),
("Ejecutivo", head.get("owner") or "-"),
("Estatus", str(head.get("status") or "-").capitalize()),
]
for k, v in info:
c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True)
c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK)
hy -= 12.5
c.y = _H - 215
# ---------------- Helpers de sección ----------------
def section(title: str): def section(title: str):
body.append(("", 6)) c.ensure(30)
body.append((title.upper(), 11)) c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC)
body.append(("_" * 92, 8)) c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True)
c.y -= 26
# Cliente def kv_block(pairs: list[tuple[str, str]]):
rows = [(k, v) for k, v in pairs if v not in (None, "", "None")]
if not rows:
return False
col_w = (_MR - _ML) / 2
i = 0
while i < len(rows):
c.ensure(16)
for col in range(2):
if i + col < len(rows):
k, v = rows[i + col]
x = _ML + 6 + col * col_w
c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True)
c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK)
c.y -= 16
i += 2
c.y -= 4
return True
# ---------------- Cliente ----------------
section("Cliente") section("Cliente")
for line in _kv_lines([ if not kv_block([
("Cliente", client.get("name")), ("RFC", client.get("rfc")), ("Cliente", client.get("name")), ("RFC", client.get("rfc")),
("Correo", client.get("email")), ("Teléfono", client.get("phone")), ("Correo", client.get("email")), ("Teléfono", client.get("phone")),
]): ]):
body.append(line) c.text(_ML + 6, c.y - 11, "", 9, GRAY)
c.y -= 16
# Carga / Ruta # ---------------- Carga / Ruta (solo si hay datos) ----------------
if cargo: if [v for _, v in cargo if v not in (None, "", "None")]:
section("Información de la carga") section("Información de la carga")
for line in _kv_lines(cargo): kv_block(cargo)
body.append(line) if [v for _, v in route if v not in (None, "", "None")]:
if route:
section("Ruta logística") section("Ruta logística")
for line in _kv_lines(route): kv_block(route)
body.append(line)
# Costos # ---------------- Costos ----------------
section("Costos cotizados") section("Costos cotizados")
body.append(("Concepto Cant. Tarifa Importe", 9)) x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6
body.append(("-" * 92, 8)) row_h = 18
# encabezado de tabla
c.ensure(row_h)
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC)
c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True)
c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True)
c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True)
c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True)
c.y -= row_h
z = False
for it in items: for it in items:
code = str(it.get("concept") or "") code = str(it.get("concept") or "")
label = CONCEPT_LABELS.get(code, code) label = CONCEPT_LABELS.get(code, code)
desc = str(it.get("description") or "") desc = str(it.get("description") or "")
if desc: if desc:
label = f"{label} {desc}" label = f"{label} - {desc}"
qty = Decimal(str(it.get("quantity") or 0)) qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_sale") or 0)) unit = Decimal(str(it.get("unit_sale") or 0))
amount = (qty * unit).quantize(Decimal("0.01")) amount = (qty * unit).quantize(Decimal("0.01"))
row = f"{label[:40].ljust(40)} {qty:>6.2f} {unit:>14,.2f} {amount:>14,.2f}" c.ensure(row_h)
body.append((row, 9)) if z:
body.append(("-" * 92, 8)) c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA)
body.append((f"Subtotal {currency}: {_money(subtotal)}", 11)) c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK)
body.append(("IVA: según aplique", 9)) c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True)
body.append((f"Total {currency}: {_money(subtotal)} + IVA", 12)) c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True)
c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True)
c.y -= row_h
z = not z
if not items:
c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY)
c.y -= row_h
# borde de la tabla
c.line(_ML, c.y, _MR, c.y, LINE)
c.y -= 12
# Condiciones # ---------------- Totales (caja derecha) ----------------
tb_x, tb_w = 360, _MR - 360
c.ensure(58)
c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA)
c.line(tb_x, c.y, tb_x, c.y - 58, LINE)
ty = c.y - 16
c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True)
ty -= 15
c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True)
ty -= 6
c.rect(tb_x, ty - 20, tb_w, 20, ACC)
c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True)
c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True)
c.y -= 70
# ---------------- Condiciones ----------------
if terms: if terms:
section("Condiciones comerciales") section("Condiciones comerciales")
for para in terms.splitlines(): for para in terms.splitlines():
for line in _wrap(para, 105) or [""]: for ln in (_wrap(para, 108) or [""]):
body.append((line, 9)) c.ensure(13)
c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY)
c.y -= 12
c.y -= 4
# ---- Paginación (página 1 con encabezado; siguientes solo cuerpo) ---- # pie en todas las páginas
p1_top = _PAGE_H - 150 # y donde inicia el cuerpo en la página 1 for ops in c.pages:
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: 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") r, g, b = GRAY
streams.append("\n".join(parts).encode("latin-1", "replace")) ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET")
ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f")
# ---- Ensamblado de objetos ---- # ---------------- Ensamblado ----------------
streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages]
objects: list[bytes] = [] objects: list[bytes] = []
def add(obj: bytes) -> int: def add(obj: bytes):
objects.append(obj) objects.append(obj)
return len(objects)
n_pages = len(pages) n_pages = len(c.pages)
has_img = 1 if logo else 0 has_img = 1 if logo else 0
font_num = 3 # numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams
img_num = 4 if has_img else None img_num = 5 if has_img else None
base = 5 if has_img else 4 base = 6 if has_img else 5
page_nums = list(range(base, base + n_pages)) page_nums = list(range(base, base + n_pages))
content_nums = list(range(base + n_pages, base + 2 * n_pages)) content_nums = list(range(base + n_pages, base + 2 * n_pages))
@@ -247,35 +340,36 @@ def build_quote_pdf(
add(b"<< /Type /Catalog /Pages 2 0 R >>") add(b"<< /Type /Catalog /Pages 2 0 R >>")
add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1")) add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
if logo: if logo:
jpeg, lw, lh = logo jpeg, lw, lh = logo
img_obj = ( add(
f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " (
f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} "
).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n"
add(img_obj) ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream"
)
for i in range(n_pages): for i in range(n_pages):
res = f"/Font << /F1 {font_num} 0 R >>" res = "/Font << /F1 3 0 R /F2 4 0 R >>"
if has_img and i == 0: if has_img and i == 0:
res += f" /XObject << /Im0 {img_num} 0 R >>" res += f" /XObject << /Im0 {img_num} 0 R >>"
page_dict = ( add(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " (
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] "
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>"
).encode("latin-1")
) )
add(page_dict.encode("latin-1"))
for stream in streams: for stream in streams:
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
out = bytearray() out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" offsets = []
offsets: list[int] = []
for i, obj in enumerate(objects, start=1): for i, obj in enumerate(objects, start=1):
offsets.append(len(out)) offsets.append(len(out))
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n" out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
xref_pos = len(out) xref_pos = len(out)
total = len(objects) + 1 total = len(objects) + 1
out += f"xref\n0 {total}\n".encode("latin-1") out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n"
out += b"0000000000 65535 f \n"
for off in offsets: for off in offsets:
out += f"{off:010d} 00000 n \n".encode("latin-1") 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") out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")

View File

@@ -98,7 +98,7 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int)
"email": settings.emitter_email if settings else None, "email": settings.emitter_email if settings else None,
"website": settings.emitter_website if settings else None, "website": settings.emitter_website if settings else None,
} }
accent = (settings.accent_color if settings else None) or "#2f6bf0" accent = (settings.accent_color if settings else None) or "#12294c"
prefix = (settings.quote_prefix if settings else None) or "COT" prefix = (settings.quote_prefix if settings else None) or "COT"
terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS
footer = settings.footer_note if settings else None footer = settings.footer_note if settings else None