- 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>
269 lines
9.8 KiB
Python
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()
|