Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/invoices/pdf.py
Aduanasoft e79705e6e3 feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)
Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:46:53 -06:00

187 lines
6.3 KiB
Python

"""Generador de PDF de factura sin dependencias externas.
Se evita ``pdfkit`` (requiere el binario ``wkhtmltopdf``, ausente en el contenedor)
y librerías extra. Produce un PDF válido de una o varias páginas con la fuente
estándar Helvetica (no requiere incrustar fuentes). El texto se codifica en
WinAnsi/Latin-1; los caracteres fuera de ese rango se sustituyen para no romper
el flujo de contenido.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Sequence
_PAGE_W = 612 # carta (8.5in) en puntos
_PAGE_H = 792 # carta (11in)
_MARGIN = 56
_LINE_H = 16
_LINES_PER_PAGE = 42
def _esc(text: str) -> str:
"""Escapa y codifica una cadena para un literal de texto PDF (WinAnsi)."""
out = (text or "").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} {d:,.2f}"
def _wrap(text: str, width: int) -> list[str]:
text = text or ""
words = text.split()
if not words:
return [""]
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
if len(candidate) > width and current:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
return lines
def _build_lines(
*,
folio: str,
issue_date: str,
due_date: str,
account_name: str,
currency: str,
items: Sequence[dict],
subtotal,
tax_rate,
tax_amount,
total,
paid,
balance,
bank_info: str | None,
notes: str | None,
) -> list[tuple[str, int]]:
"""Devuelve una lista de (texto, tamaño_fuente) que compone el cuerpo."""
L: list[tuple[str, int]] = []
L.append(("FACTURA", 20))
L.append((f"Folio: {folio or 's/f'}", 11))
L.append((f"Fecha de emision: {issue_date or '-'} Vencimiento: {due_date or '-'}", 11))
L.append(("", 11))
L.append((f"Cliente: {account_name or '-'}", 12))
L.append(("", 11))
L.append(("Conceptos", 13))
L.append(("-" * 78, 10))
L.append(("Cant. Concepto P. unitario Importe", 10))
L.append(("-" * 78, 10))
for it in items:
concept = str(it.get("concept") or "")
desc = str(it.get("description") or "")
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_amount") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
label = concept if not desc else f"{concept}{desc}"
label = label[:42].ljust(42)
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
L.append((row, 10))
L.append(("-" * 78, 10))
L.append(("", 11))
L.append((f"Subtotal: {_money(subtotal, currency)}", 11))
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
L.append((f"Total: {_money(total, currency)}", 13))
L.append((f"Pagado: {_money(paid, currency)}", 11))
L.append((f"Saldo: {_money(balance, currency)}", 12))
if bank_info:
L.append(("", 11))
L.append(("Datos bancarios / de pago", 12))
for line in _wrap(bank_info, 90):
L.append((line, 10))
if notes:
L.append(("", 11))
L.append(("Notas", 12))
for line in _wrap(notes, 90):
L.append((line, 10))
return L
def build_invoice_pdf(**kwargs) -> bytes:
"""Construye el PDF de la factura y devuelve los bytes."""
lines = _build_lines(**kwargs)
# Paginar el cuerpo
pages: list[list[tuple[str, int]]] = []
for i in range(0, len(lines), _LINES_PER_PAGE):
pages.append(lines[i : i + _LINES_PER_PAGE])
if not pages:
pages = [[("FACTURA", 20)]]
# Un content stream por página
content_streams: list[bytes] = []
for page_lines in pages:
parts = ["BT", f"/F1 11 Tf", f"1 0 0 1 {_MARGIN} {_PAGE_H - _MARGIN} Tm", f"{_LINE_H} TL"]
first = True
for text, size in page_lines:
parts.append(f"/F1 {size} Tf")
if first:
parts.append(f"({_esc(text)}) Tj")
first = False
else:
parts.append(f"T* ({_esc(text)}) Tj")
parts.append("ET")
content_streams.append("\n".join(parts).encode("latin-1", "replace"))
# Ensamblado de objetos PDF
objects: list[bytes] = []
def add(obj: bytes) -> int:
objects.append(obj)
return len(objects) # número de objeto (1-indexado)
# Reservamos números: catalog(1), pages(2), font(3), luego páginas y streams
font_obj_num = 3
page_obj_nums: list[int] = []
content_obj_nums: list[int] = []
# Precalcular números de páginas y streams
next_num = 4
for _ in pages:
page_obj_nums.append(next_num)
next_num += 1
for _ in pages:
content_obj_nums.append(next_num)
next_num += 1
kids = " ".join(f"{n} 0 R" for n in page_obj_nums)
add(f"<< /Type /Catalog /Pages 2 0 R >>".encode("latin-1"))
add(f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
for i, _ in enumerate(pages):
page_dict = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] "
f"/Resources << /Font << /F1 {font_obj_num} 0 R >> >> "
f"/Contents {content_obj_nums[i]} 0 R >>"
)
add(page_dict.encode("latin-1"))
for stream in content_streams:
obj = b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream"
add(obj)
# Serialización con tabla xref
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)
n = len(objects) + 1
out += f"xref\n0 {n}\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 {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
return bytes(out)