diff --git a/backend/api/v1/modules/crm/quotes/pdf.py b/backend/api/v1/modules/crm/quotes/pdf.py index 7caebfc..9887673 100644 --- a/backend/api/v1/modules/crm/quotes/pdf.py +++ b/backend/api/v1/modules/crm/quotes/pdf.py @@ -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 -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. +Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección, +tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado +(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant. """ from __future__ import annotations @@ -11,9 +10,10 @@ from __future__ import annotations import io from decimal import Decimal -_PAGE_W = 612 -_PAGE_H = 792 -_MARGIN = 50 +_W = 612 +_H = 792 +_ML = 50 # margen izquierdo +_MR = 562 # margen derecho (x) CONCEPT_LABELS = { "flete_internacional": "Flete internacional", @@ -23,44 +23,63 @@ CONCEPT_LABELS = { "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"\)") +_TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "}) -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 _esc(text) -> str: + s = ("" if text is None else str(text)).translate(_TRANSLATE) + 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() if not words: return [] - lines, cur = [], "" + out, cur = [], "" for w in words: cand = f"{cur} {w}".strip() - if len(cand) > width and cur: - lines.append(cur) + if len(cand) > width_chars and cur: + out.append(cur) cur = w else: cur = cand if cur: - lines.append(cur) - return lines + out.append(cur) + return out def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: 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] except Exception: - return (0.184, 0.42, 0.94) + return (0.07, 0.16, 0.30) 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: @@ -69,18 +88,44 @@ def _prep_logo(logo_bytes: bytes | None): im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") im.thumbnail((600, 300)) buf = io.BytesIO() - im.save(buf, format="JPEG", quality=85) + im.save(buf, format="JPEG", quality=88) 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 +class _Canvas: + """Acumula operadores de contenido con paginación simple.""" + + def __init__(self): + self.pages: list[list[str]] = [[]] + 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( @@ -96,150 +141,198 @@ def build_quote_pdf( terms: str | None, footer: str | None, logo_bytes: bytes | None = None, - accent: str | None = "#2f6bf0", + accent: str | None = "#12294c", ) -> 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) - # ---- Cuerpo (debajo del encabezado) ---- - body: list[tuple[str, int]] = [] + c = _Canvas() + # ---------------- 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): - body.append(("", 6)) - body.append((title.upper(), 11)) - body.append(("_" * 92, 8)) + c.ensure(30) + c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC) + 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") - for line in _kv_lines([ + if not kv_block([ ("Cliente", client.get("name")), ("RFC", client.get("rfc")), ("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 - if cargo: + # ---------------- Carga / Ruta (solo si hay datos) ---------------- + if [v for _, v in cargo if v not in (None, "", "None")]: section("Información de la carga") - for line in _kv_lines(cargo): - body.append(line) - if route: + kv_block(cargo) + if [v for _, v in route if v not in (None, "", "None")]: section("Ruta logística") - for line in _kv_lines(route): - body.append(line) + kv_block(route) - # Costos + # ---------------- Costos ---------------- section("Costos cotizados") - body.append(("Concepto Cant. Tarifa Importe", 9)) - body.append(("-" * 92, 8)) + x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6 + 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: code = str(it.get("concept") or "") label = CONCEPT_LABELS.get(code, code) desc = str(it.get("description") or "") if desc: - label = f"{label} — {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)) + c.ensure(row_h) + if z: + c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA) + c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK) + c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True) + 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: section("Condiciones comerciales") for para in terms.splitlines(): - for line in _wrap(para, 105) or [""]: - body.append((line, 9)) + for ln in (_wrap(para, 108) or [""]): + 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) ---- - 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 + # pie en todas las páginas + for ops in c.pages: 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")) + r, g, b = GRAY + 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] = [] - def add(obj: bytes) -> int: + def add(obj: bytes): objects.append(obj) - return len(objects) - n_pages = len(pages) + n_pages = len(c.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 + # numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams + img_num = 5 if has_img else None + base = 6 if has_img else 5 page_nums = list(range(base, base + 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(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-Bold /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) + add( + ( + 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" + ) 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: 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( + ( + 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: 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] = [] + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [] 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" + out += f"xref\n0 {total}\n".encode("latin-1") + 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") diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py index 4fc9026..4ed0678 100644 --- a/backend/api/v1/modules/crm/quotes/pdf_service.py +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -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, "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" terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS footer = settings.footer_note if settings else None