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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user