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>
This commit is contained in:
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal file
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user