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:
Aduanasoft
2026-07-15 08:46:53 -06:00
parent 116d2e7f5a
commit e79705e6e3
26 changed files with 1407 additions and 68 deletions

View File

@@ -4,6 +4,12 @@ from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
class InvoiceClientReviewInput(BaseModel):
"""Resultado de la revisión de la factura por el cliente (R-F-06)."""
approved: bool
notes: str | None = None
class InvoiceItemBase(BaseModel):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
@@ -100,8 +106,13 @@ class InvoiceResponse(InvoiceBase):
total: Decimal
paid_amount: Decimal
balance: Decimal
ops_cost_total: Decimal | None = None
sent_at: datetime | None = None
paid_at: datetime | None = None
pdf_file_key: str | None = None
client_reviewed_at: datetime | None = None
client_approved: bool | None = None
review_notes: str | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -25,7 +25,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
# borrador | emitida | enviada | pagada | cancelada
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
@@ -35,10 +35,18 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
balance: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# Costos reales de la operación traídos de Operaciones al cierre (R-F-02)
ops_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
bank_info: Mapped[str | None] = mapped_column(Text, nullable=True) # datos bancarios
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# ----- Envío al cliente (R-F-05): PDF almacenado en MinIO -----
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ----- Revisión del cliente (R-F-06) -----
client_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
client_approved: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View 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)

View File

@@ -6,6 +6,7 @@ from core.security import get_current_user
from . import service
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemResponse,
@@ -62,7 +63,26 @@ def emit_invoice(invoice_id: int, company_id: int = Query(...), current_user: di
@router.patch("/invoices/{invoice_id}/send", response_model=InvoiceResponse)
def send_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.send_invoice(db, invoice_id, current_user["tenant_id"], company_id)
"""Genera el PDF, lo guarda en MinIO y marca la factura como enviada (R-F-05)."""
return service.send_invoice(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.get("/invoices/{invoice_id}/pdf-url")
def get_invoice_pdf_url(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""URL firmada fresca del PDF de la factura (R-F-05)."""
return {"url": service.get_invoice_pdf_url(db, invoice_id, current_user["tenant_id"], company_id)}
@router.patch("/invoices/{invoice_id}/client-review", response_model=InvoiceResponse)
def mark_client_review(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Marca la factura en revisión del cliente (R-F-06)."""
return service.mark_client_review(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/client-decision", response_model=InvoiceResponse)
def client_review_decision(invoice_id: int, payload: InvoiceClientReviewInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Registra la decisión del cliente sobre la factura: aprobada o con observaciones (R-F-06)."""
return service.client_review_decision(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/cancel", response_model=InvoiceResponse)

View File

@@ -9,8 +9,16 @@ from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
from .dto import InvoiceCreate, InvoiceItemCreate, InvoiceItemUpdate, InvoiceUpdate, PaymentCreate
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemUpdate,
InvoiceUpdate,
PaymentCreate,
)
from .models import Invoice, InvoiceItem, Payment
from .pdf import build_invoice_pdf
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
@@ -52,7 +60,7 @@ def _recompute(db: Session, invoice: Invoice) -> None:
invoice.paid_amount = paid
invoice.balance = total - paid
# Estado de cobranza (no toca borrador ni cancelada)
if invoice.status in ("emitida", "enviada", "pagada"):
if invoice.status in ("emitida", "enviada", "en_revision_cliente", "pagada"):
if total > 0 and invoice.balance <= 0:
invoice.status = "pagada"
invoice.paid_at = datetime.now(timezone.utc)
@@ -131,8 +139,108 @@ def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)
def send_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "enviada", set_issue=True)
def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
"""Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
items = get_items(db, invoice.id, tenant_id, company_id)
account_name = None
if invoice.account_id:
acc = db.query(Account).filter(Account.id == invoice.account_id).first()
account_name = acc.name if acc else None
return build_invoice_pdf(
folio=invoice.reference or f"FAC-{invoice.id}",
issue_date=str(invoice.issue_date or ""),
due_date=str(invoice.due_date or ""),
account_name=account_name or "Cliente",
currency=invoice.currency or "MXN",
items=[
{"concept": it.concept, "description": it.description, "quantity": it.quantity, "unit_amount": it.unit_amount}
for it in items
],
subtotal=invoice.subtotal,
tax_rate=invoice.tax_rate,
tax_amount=invoice.tax_amount,
total=invoice.total,
paid=invoice.paid_amount,
balance=invoice.balance,
bank_info=invoice.bank_info,
notes=invoice.notes,
)
def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Envía la factura al cliente: genera el PDF, lo guarda en MinIO y marca 'enviada' (R-F-05)."""
from core.storage_s3 import put_object_bytes # import diferido: evita conectar en tests
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status in ("borrador", "cancelada"):
# La factura debe estar emitida antes de enviarse al cliente
if obj.status == "cancelada":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada")
obj.status = "emitida"
if not obj.issue_date:
obj.issue_date = date.today()
db.flush()
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
obj.pdf_file_key = key
obj.status = "enviada"
obj.sent_at = datetime.now(timezone.utc)
if not obj.issue_date:
obj.issue_date = date.today()
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def get_invoice_pdf_url(db, invoice_id, tenant_id, company_id) -> str:
"""Devuelve una URL firmada fresca del PDF de la factura (las presignadas expiran)."""
from core.storage_s3 import presigned_get_url
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if not obj.pdf_file_key:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La factura aún no tiene PDF; envíala al cliente para generarlo",
)
return presigned_get_url(obj.pdf_file_key)
def mark_client_review(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Pone la factura en revisión del cliente (R-F-06)."""
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status not in ("enviada", "en_revision_cliente"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Solo una factura enviada puede pasar a revisión del cliente",
)
obj.status = "en_revision_cliente"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def client_review_decision(
db, invoice_id, payload: InvoiceClientReviewInput, tenant_id, company_id, user_id=None
) -> Invoice:
"""Registra la decisión de revisión del cliente: aprobada o con observaciones (R-F-06)."""
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status not in ("enviada", "en_revision_cliente"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La factura debe estar enviada o en revisión para registrar la decisión del cliente",
)
obj.client_reviewed_at = datetime.now(timezone.utc)
obj.client_approved = payload.approved
obj.review_notes = payload.notes
# Aprobada → lista para cobranza (enviada). Con observaciones → regresa a emitida para corregir.
obj.status = "enviada" if payload.approved else "emitida"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
@@ -140,12 +248,32 @@ def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Genera la factura de un embarque, tomando los conceptos (venta) de su cotización."""
"""Genera la factura de un embarque, tomando los conceptos (venta) de su cotización.
El disparador válido de la facturación es el cierre operativo del embarque
(R-F-01): solo se factura un embarque en estado 'cerrada'. Los costos reales de
la operación se arrastran a la factura (R-F-02).
"""
shipment = db.query(Shipment).filter(
Shipment.id == shipment_id, Shipment.tenant_id == tenant_id, Shipment.company_id == company_id, Shipment.deleted_at.is_(None)
).first()
if not shipment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
if shipment.status != "cerrada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El embarque debe estar cerrado (cierre operativo) para facturarse",
)
existing = db.query(Invoice).filter(
Invoice.shipment_id == shipment_id, Invoice.tenant_id == tenant_id,
Invoice.company_id == company_id, Invoice.deleted_at.is_(None),
Invoice.status != "cancelada",
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El embarque ya tiene una factura vigente",
)
quote = None
if shipment.quote_id:
@@ -156,7 +284,8 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
currency=quote.currency if quote else "MXN",
currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
ops_cost_total=shipment.actual_cost_total,
status="borrador",
tenant_id=tenant_id,
company_id=company_id,

View File

@@ -1,9 +1,12 @@
"""Router agregador del módulo Facturación (Diagrama 4). Prefijo ``/fin``."""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos)
from .invoices.routes import router as invoices_router
router = APIRouter()
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
router.include_router(invoices_router)