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>
398 lines
15 KiB
Python
398 lines
15 KiB
Python
from datetime import date, datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
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 (
|
|
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:
|
|
if _id is None:
|
|
return True
|
|
return (
|
|
db.query(model.id)
|
|
.filter(model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None))
|
|
.first()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
|
for field, model, msg in [
|
|
("account_id", Account, "El cliente asociado no existe"),
|
|
("shipment_id", Shipment, "El embarque asociado no existe"),
|
|
("quote_id", Quote, "La cotización asociada no existe"),
|
|
]:
|
|
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
|
|
|
|
|
def _recompute(db: Session, invoice: Invoice) -> None:
|
|
subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
|
|
InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
|
|
).scalar()
|
|
paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
|
|
Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
|
|
).scalar()
|
|
subtotal = Decimal(subtotal or 0)
|
|
rate = Decimal(invoice.tax_rate or 0)
|
|
tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
|
|
total = subtotal + tax
|
|
paid = Decimal(paid or 0)
|
|
invoice.subtotal = subtotal
|
|
invoice.tax_amount = tax
|
|
invoice.total = total
|
|
invoice.paid_amount = paid
|
|
invoice.balance = total - paid
|
|
# Estado de cobranza (no toca borrador ni cancelada)
|
|
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)
|
|
elif invoice.status == "pagada" and invoice.balance > 0:
|
|
invoice.status = "enviada"
|
|
invoice.paid_at = None
|
|
|
|
|
|
# ----- Invoices -----
|
|
|
|
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
|
|
q = db.query(Invoice).filter(Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None))
|
|
if inv_status:
|
|
q = q.filter(Invoice.status == inv_status)
|
|
if account_id is not None:
|
|
q = q.filter(Invoice.account_id == account_id)
|
|
if search:
|
|
q = q.filter(Invoice.reference.ilike(f"%{search}%"))
|
|
return q.order_by(Invoice.created_at.desc()).all()
|
|
|
|
|
|
def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
|
obj = db.query(Invoice).filter(
|
|
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None)
|
|
).first()
|
|
if not obj:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
|
|
return obj
|
|
|
|
|
|
def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
|
|
data = payload.model_dump()
|
|
_validate_refs(db, data, tenant_id, company_id)
|
|
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
|
db.add(obj)
|
|
db.flush()
|
|
_recompute(db, obj)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id, user_id=None) -> Invoice:
|
|
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
|
data = payload.model_dump(exclude_unset=True)
|
|
_validate_refs(db, data, tenant_id, company_id)
|
|
for f, v in data.items():
|
|
setattr(obj, f, v)
|
|
obj.updated_by = user_id
|
|
db.flush()
|
|
_recompute(db, obj) # tax_rate pudo cambiar
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
|
|
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
|
obj.deleted_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
|
|
def _set_status(db, invoice_id, tenant_id, company_id, new_status, set_issue=False) -> Invoice:
|
|
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
|
obj.status = new_status
|
|
if set_issue and not obj.issue_date:
|
|
obj.issue_date = date.today()
|
|
if new_status == "enviada":
|
|
obj.sent_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
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 _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:
|
|
return _set_status(db, invoice_id, tenant_id, company_id, "cancelada")
|
|
|
|
|
|
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.
|
|
|
|
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:
|
|
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
|
|
|
|
invoice = Invoice(
|
|
reference=shipment.reference,
|
|
shipment_id=shipment.id,
|
|
quote_id=shipment.quote_id,
|
|
account_id=shipment.account_id,
|
|
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,
|
|
created_by=user_id,
|
|
updated_by=user_id,
|
|
)
|
|
db.add(invoice)
|
|
db.flush()
|
|
|
|
if quote:
|
|
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
|
|
for qi in q_items:
|
|
db.add(InvoiceItem(
|
|
invoice_id=invoice.id, concept=qi.concept, description=qi.description,
|
|
quantity=qi.quantity, unit_amount=qi.unit_sale,
|
|
tenant_id=tenant_id, company_id=company_id,
|
|
))
|
|
db.flush()
|
|
|
|
_recompute(db, invoice)
|
|
db.commit()
|
|
db.refresh(invoice)
|
|
return invoice
|
|
|
|
|
|
# ----- Items -----
|
|
|
|
def get_items(db, invoice_id, tenant_id, company_id) -> list[InvoiceItem]:
|
|
get_invoice(db, invoice_id, tenant_id, company_id)
|
|
return db.query(InvoiceItem).filter(
|
|
InvoiceItem.invoice_id == invoice_id, InvoiceItem.tenant_id == tenant_id,
|
|
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
|
|
).order_by(InvoiceItem.id.asc()).all()
|
|
|
|
|
|
def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
|
obj = db.query(InvoiceItem).filter(
|
|
InvoiceItem.id == item_id, InvoiceItem.tenant_id == tenant_id,
|
|
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
|
|
).first()
|
|
if not obj:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
|
return obj
|
|
|
|
|
|
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
|
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
|
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
|
db.add(item)
|
|
db.flush()
|
|
_recompute(db, invoice)
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
|
|
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
|
item = _get_item(db, item_id, tenant_id, company_id)
|
|
for f, v in payload.model_dump(exclude_unset=True).items():
|
|
setattr(item, f, v)
|
|
db.flush()
|
|
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
|
db.commit()
|
|
db.refresh(item)
|
|
return item
|
|
|
|
|
|
def delete_item(db, item_id, tenant_id, company_id) -> None:
|
|
item = _get_item(db, item_id, tenant_id, company_id)
|
|
invoice_id = item.invoice_id
|
|
item.deleted_at = datetime.now(timezone.utc)
|
|
db.flush()
|
|
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
|
|
db.commit()
|
|
|
|
|
|
# ----- Payments -----
|
|
|
|
def get_payments(db, invoice_id, tenant_id, company_id) -> list[Payment]:
|
|
get_invoice(db, invoice_id, tenant_id, company_id)
|
|
return db.query(Payment).filter(
|
|
Payment.invoice_id == invoice_id, Payment.tenant_id == tenant_id,
|
|
Payment.company_id == company_id, Payment.deleted_at.is_(None)
|
|
).order_by(Payment.id.asc()).all()
|
|
|
|
|
|
def create_payment(db, payload: PaymentCreate, tenant_id, company_id) -> Payment:
|
|
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
|
pay = Payment(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
|
db.add(pay)
|
|
db.flush()
|
|
_recompute(db, invoice)
|
|
db.commit()
|
|
db.refresh(pay)
|
|
return pay
|
|
|
|
|
|
def delete_payment(db, payment_id, tenant_id, company_id) -> None:
|
|
pay = db.query(Payment).filter(
|
|
Payment.id == payment_id, Payment.tenant_id == tenant_id,
|
|
Payment.company_id == company_id, Payment.deleted_at.is_(None)
|
|
).first()
|
|
if not pay:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pago no encontrado")
|
|
invoice_id = pay.invoice_id
|
|
pay.deleted_at = datetime.now(timezone.utc)
|
|
db.flush()
|
|
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
|
|
db.commit()
|