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>
This commit is contained in:
Aduanasoft
2026-07-15 07:18:11 -06:00
parent 6f200b4505
commit 0b12ad5354
20 changed files with 1167 additions and 3 deletions

View File

@@ -0,0 +1,63 @@
"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones.
Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo
guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se
pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
"""
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
router = APIRouter()
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_filename(name: str | None) -> str:
base = (name or "archivo").strip().replace(" ", "_")
base = _SAFE_NAME.sub("", base) or "archivo"
return base[:120]
@router.post("/uploads")
async def upload_file(
file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user["tenant_id"]
content = await file.read()
if len(content) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El archivo excede el tamaño máximo permitido (25 MB)",
)
filename = _safe_filename(file.filename)
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
return {
"file_key": key,
"file_url": presigned_get_url(key),
"name": file.filename,
"content_type": file.content_type,
"size_bytes": len(content),
}
@router.get("/uploads/url")
def get_upload_url(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user["tenant_id"]
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
return {"url": presigned_get_url(key)}

View File

View File

@@ -0,0 +1,110 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
class InvoiceItemBase(BaseModel):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
unit_amount: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
class InvoiceItemCreate(InvoiceItemBase):
invoice_id: int
class InvoiceItemUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
class InvoiceItemResponse(InvoiceItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
tenant_id: int
company_id: int
@computed_field
@property
def line_total(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_amount or Decimal(0))
class PaymentBase(BaseModel):
amount: Decimal = Field(..., gt=0, max_digits=14, decimal_places=2)
payment_date: date | None = None
method: str | None = Field(None, max_length=40)
reference: str | None = Field(None, max_length=120)
notes: str | None = None
class PaymentCreate(PaymentBase):
invoice_id: int
class PaymentResponse(PaymentBase):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
tenant_id: int
company_id: int
created_at: datetime
class InvoiceBase(BaseModel):
reference: str | None = Field(None, max_length=40)
shipment_id: int | None = None
quote_id: int | None = None
account_id: int | None = None
currency: str = Field("MXN", max_length=3)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal = Field(Decimal(0), ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class InvoiceCreate(InvoiceBase):
pass
class InvoiceUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
shipment_id: int | None = None
quote_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
subtotal: Decimal
tax_amount: Decimal
total: Decimal
paid_amount: Decimal
balance: Decimal
sent_at: datetime | None = None
paid_at: datetime | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,78 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Invoice(Base, TenantScopedMixin, TimestampMixin):
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
__tablename__ = "invoices"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
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
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)
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0")) # % IVA
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
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"))
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)
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)
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
"""Concepto de una factura (transporte, flete, despacho, gastos en destino, otros)."""
__tablename__ = "invoice_items"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
class Payment(Base, TenantScopedMixin, TimestampMixin):
"""Pago (cobranza) aplicado a una factura."""
__tablename__ = "payments"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# transferencia | efectivo | cheque | tarjeta | otro
method: Mapped[str | None] = mapped_column(String(40), nullable=True)
reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemResponse,
InvoiceItemUpdate,
InvoiceResponse,
InvoiceUpdate,
PaymentCreate,
PaymentResponse,
)
router = APIRouter()
def _uid(cu: dict) -> str | None:
return cu.get("sub") or cu.get("id")
@router.get("/invoices", response_model=list[InvoiceResponse])
def list_invoices(
company_id: int = Query(...),
search: str | None = Query(None),
inv_status: str | None = Query(None, alias="status"),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_invoices(db, current_user["tenant_id"], company_id, search, inv_status, account_id)
@router.get("/invoices/{invoice_id}", response_model=InvoiceResponse)
def get_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/invoices", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
def create_invoice(payload: InvoiceCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_invoice(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.post("/invoices/from-shipment", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
def generate_from_shipment(shipment_id: int = Query(...), company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.generate_from_shipment(db, shipment_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}", response_model=InvoiceResponse)
def update_invoice(invoice_id: int, payload: InvoiceUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.update_invoice(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/emit", response_model=InvoiceResponse)
def emit_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.emit_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@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)
@router.patch("/invoices/{invoice_id}/cancel", response_model=InvoiceResponse)
def cancel_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.cancel_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_invoice(db, invoice_id, current_user["tenant_id"], company_id)
# ----- Conceptos -----
@router.get("/invoices/{invoice_id}/items", response_model=list[InvoiceItemResponse])
def list_items(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_items(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/invoice-items", response_model=InvoiceItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(payload: InvoiceItemCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_item(db, payload, current_user["tenant_id"], company_id)
@router.patch("/invoice-items/{item_id}", response_model=InvoiceItemResponse)
def update_item(item_id: int, payload: InvoiceItemUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.update_item(db, item_id, payload, current_user["tenant_id"], company_id)
@router.delete("/invoice-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_item(db, item_id, current_user["tenant_id"], company_id)
# ----- Pagos (cobranza) -----
@router.get("/invoices/{invoice_id}/payments", response_model=list[PaymentResponse])
def list_payments(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_payments(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/payments", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
def create_payment(payload: PaymentCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_payment(db, payload, current_user["tenant_id"], company_id)
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,268 @@
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()

View File

@@ -0,0 +1,17 @@
"""Registro de permisos del módulo Facturación (fin)."""
from api.v1.modules.core.permissions.registry import registry
MODULE = "fin"
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
def register_permissions() -> None:
registry.register(code=f"{MODULE}.access", description="Acceso a Facturación", module=MODULE, action="access")
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
register_permissions()

View File

@@ -0,0 +1,9 @@
"""Router agregador del módulo Facturación (Diagrama 4). Prefijo ``/fin``."""
from fastapi import APIRouter
from . import permissions # noqa: F401 (side-effect: registra permisos)
from .invoices.routes import router as invoices_router
router = APIRouter()
router.include_router(invoices_router)

View File

@@ -67,6 +67,41 @@ class ShipmentResponse(ShipmentBase):
updated_at: datetime
class ShipmentEventBase(BaseModel):
shipment_id: int
event_type: str | None = Field(None, max_length=60)
title: str = Field(..., min_length=1, max_length=160)
status: str = Field("pendiente", max_length=20)
position: int = Field(0, ge=0)
planned_date: datetime | None = None
actual_date: datetime | None = None
notes: str | None = None
class ShipmentEventCreate(ShipmentEventBase):
pass
class ShipmentEventUpdate(BaseModel):
event_type: str | None = Field(None, max_length=60)
title: str | None = Field(None, min_length=1, max_length=160)
status: str | None = Field(None, max_length=20)
position: int | None = Field(None, ge=0)
planned_date: datetime | None = None
actual_date: datetime | None = None
notes: str | None = None
class ShipmentEventResponse(ShipmentEventBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class ShipmentDocumentBase(BaseModel):
shipment_id: int
doc_kind: str = Field("otro", max_length=10)

View File

@@ -53,6 +53,26 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
"""Hito / bitácora del embarque (Diagramas 2 y 3). Timeline de la operación."""
__tablename__ = "shipment_events"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
shipment_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
)
event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito
title: Mapped[str] = mapped_column(String(160), nullable=False)
# pendiente | completado | omitido
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'"))
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
planned_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
actual_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""

View File

@@ -10,6 +10,9 @@ from .dto import (
ShipmentDocumentCreate,
ShipmentDocumentResponse,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventResponse,
ShipmentEventUpdate,
ShipmentResponse,
ShipmentUpdate,
)
@@ -130,3 +133,67 @@ def delete_shipment_document(
db: Session = Depends(get_core_db),
):
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id)
# ----- Bitácora / hitos -----
@router.get("/shipments/{shipment_id}/events", response_model=list[ShipmentEventResponse])
def list_shipment_events(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
return service.get_shipment_events(db, current_user["tenant_id"], company_id, shipment_id)
@router.post("/shipments/{shipment_id}/events/seed", response_model=list[ShipmentEventResponse])
def seed_shipment_events(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.seed_default_milestones(db, shipment_id, current_user["tenant_id"], company_id)
@router.post("/shipment-events", response_model=ShipmentEventResponse, status_code=status.HTTP_201_CREATED)
def create_shipment_event(
payload: ShipmentEventCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_shipment_event(db, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-events/{event_id}", response_model=ShipmentEventResponse)
def update_shipment_event(
event_id: int,
payload: ShipmentEventUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-events/{event_id}/complete", response_model=ShipmentEventResponse)
def complete_shipment_event(
event_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.complete_shipment_event(db, event_id, current_user["tenant_id"], company_id)
@router.delete("/shipment-events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment_event(
event_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment_event(db, event_id, current_user["tenant_id"], company_id)

View File

@@ -8,8 +8,36 @@ from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
from .dto import ShipmentCreate, ShipmentDocumentCreate, ShipmentDocumentUpdate, ShipmentUpdate
from .models import Shipment, ShipmentDocument
from .dto import (
ShipmentCreate,
ShipmentDocumentCreate,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventUpdate,
ShipmentUpdate,
)
from .models import Shipment, ShipmentDocument, ShipmentEvent
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3)
_DEFAULT_MILESTONES = {
"exportacion": [
("recoleccion", "Recolección de mercancía"),
("despacho_exportacion", "Despacho de exportación"),
("embarque", "Embarque"),
("zarpe", "Zarpe / Salida del transporte"),
("arribo", "Arribo a destino"),
("entrega", "Entrega al consignatario"),
],
"importacion": [
("aviso_llegada", "Aviso de llegada"),
("recepcion_docs", "Recepción de documentos (MBL/MAWB)"),
("despacho_importacion", "Despacho de importación"),
("liberacion", "Liberación de mercancía"),
("retiro", "Retiro en puerto / aeropuerto"),
("traslado", "Traslado a bodega del importador"),
("entrega", "Entrega final al cliente"),
],
}
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
@@ -228,3 +256,91 @@ def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_i
obj = _get_document(db, doc_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Bitácora / hitos del embarque -----
def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]:
query = db.query(ShipmentEvent).filter(
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentEvent.shipment_id == shipment_id)
return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all()
def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = (
db.query(ShipmentEvent)
.filter(
ShipmentEvent.id == event_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado")
return obj
def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent:
get_shipment(db, payload.shipment_id, tenant_id, company_id)
obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
obj.status = "completado"
obj.actual_date = datetime.now(timezone.utc)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_event(db, event_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]:
"""Crea los hitos por defecto del embarque según su tipo de operación (import/export)."""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
existing = get_shipment_events(db, tenant_id, company_id, shipment_id)
if existing:
return existing
milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", [])
if not milestones:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
)
created = []
for position, (event_type, title) in enumerate(milestones):
ev = ShipmentEvent(
shipment_id=shipment_id, event_type=event_type, title=title, status="pendiente",
position=position, tenant_id=tenant_id, company_id=company_id,
)
db.add(ev)
created.append(ev)
db.commit()
for ev in created:
db.refresh(ev)
return created

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter
from .modules.core.router import router as core_router
from .modules.crm.router import router as crm_router
from .modules.ops.router import router as ops_router
from .modules.fin.router import router as fin_router
from .modules.example.routes import router as example_router
@@ -15,6 +16,7 @@ router = APIRouter()
router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"])
router.include_router(ops_router, prefix="/ops", tags=["ops"])
router.include_router(fin_router, prefix="/fin", tags=["fin"])
router.include_router(example_router, prefix="/example", tags=["example"])