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>
187 lines
6.3 KiB
Python
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)
|