Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/invoices/service.py
Aduanasoft 0b12ad5354 feat(fin,ops): Facturación y Cobranza (Diag. 4), bitácora de embarque (Diag. 3) y subida a MinIO
- schema fin: fin.invoices + fin.invoice_items + fin.payments; totales con IVA,
  estados borrador→emitida→enviada→pagada, cobranza (pagos) y saldo automático
- generar-factura-desde-embarque (toma conceptos de venta de la cotización)
- ops.shipment_events: bitácora/hitos del embarque con secuencia por defecto
  según operación (importación/exportación) — cubre Diagrama 3
- subida de documentos a MinIO: POST /crm/uploads (multipart) + URL firmada
- migración c4d5e6f7a8b9, routers/permisos (fin), seed del flujo hasta factura
- 58 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:18:11 -06:00

269 lines
9.8 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 InvoiceCreate, InvoiceItemCreate, InvoiceItemUpdate, InvoiceUpdate, PaymentCreate
from .models import Invoice, InvoiceItem, Payment
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", "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 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 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."""
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")
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=quote.currency if quote else "MXN",
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()