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,140 @@
"""fin (invoices, invoice_items, payments) + ops.shipment_events
Revision ID: c4d5e6f7a8b9
Revises: b3c4d5e6f7a8
Create Date: 2026-07-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c4d5e6f7a8b9"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _scoped_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
]
def _scoped_indexes(table: str, schema: str) -> None:
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
def upgrade() -> None:
# ---------- schema fin ----------
op.execute("CREATE SCHEMA IF NOT EXISTS fin")
op.create_table(
"invoices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("shipment_id", sa.Integer(), nullable=True),
sa.Column("quote_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("subtotal", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("tax_rate", sa.Numeric(precision=5, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("tax_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("total", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("paid_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("balance", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("bank_info", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="fin",
)
_scoped_indexes("invoices", "fin")
op.create_index("ix_fin_invoices_reference", "invoices", ["reference"], schema="fin")
op.create_index("ix_fin_invoices_shipment_id", "invoices", ["shipment_id"], schema="fin")
op.create_index("ix_fin_invoices_account_id", "invoices", ["account_id"], schema="fin")
op.create_index("ix_fin_invoices_status", "invoices", ["status"], schema="fin")
op.create_index("ix_fin_invoices_owner_user_id", "invoices", ["owner_user_id"], schema="fin")
op.create_table(
"invoice_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
sa.Column("unit_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
schema="fin",
)
_scoped_indexes("invoice_items", "fin")
op.create_index("ix_fin_invoice_items_invoice_id", "invoice_items", ["invoice_id"], schema="fin")
op.create_table(
"payments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("payment_date", sa.Date(), nullable=True),
sa.Column("method", sa.String(length=40), nullable=True),
sa.Column("reference", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
schema="fin",
)
_scoped_indexes("payments", "fin")
op.create_index("ix_fin_payments_invoice_id", "payments", ["invoice_id"], schema="fin")
# ---------- ops.shipment_events ----------
op.create_table(
"shipment_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("shipment_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=60), nullable=True),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pendiente'")),
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("planned_date", sa.DateTime(), nullable=True),
sa.Column("actual_date", sa.DateTime(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
schema="ops",
)
_scoped_indexes("shipment_events", "ops")
op.create_index("ix_ops_shipment_events_shipment_id", "shipment_events", ["shipment_id"], schema="ops")
def downgrade() -> None:
op.drop_table("shipment_events", schema="ops")
op.drop_table("payments", schema="fin")
op.drop_table("invoice_items", schema="fin")
op.drop_table("invoices", schema="fin")
op.execute("DROP SCHEMA IF EXISTS fin")

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 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): class ShipmentDocumentBase(BaseModel):
shipment_id: int shipment_id: int
doc_kind: str = Field("otro", max_length=10) 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) 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): class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).""" """Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""

View File

@@ -10,6 +10,9 @@ from .dto import (
ShipmentDocumentCreate, ShipmentDocumentCreate,
ShipmentDocumentResponse, ShipmentDocumentResponse,
ShipmentDocumentUpdate, ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventResponse,
ShipmentEventUpdate,
ShipmentResponse, ShipmentResponse,
ShipmentUpdate, ShipmentUpdate,
) )
@@ -130,3 +133,67 @@ def delete_shipment_document(
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id) 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.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier from api.v1.modules.crm.suppliers.models import Supplier
from .dto import ShipmentCreate, ShipmentDocumentCreate, ShipmentDocumentUpdate, ShipmentUpdate from .dto import (
from .models import Shipment, ShipmentDocument 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: 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 = _get_document(db, doc_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc) obj.deleted_at = datetime.now(timezone.utc)
db.commit() 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.core.router import router as core_router
from .modules.crm.router import router as crm_router from .modules.crm.router import router as crm_router
from .modules.ops.router import router as ops_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 from .modules.example.routes import router as example_router
@@ -15,6 +16,7 @@ router = APIRouter()
router.include_router(core_router) router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"]) router.include_router(crm_router, prefix="/crm", tags=["crm"])
router.include_router(ops_router, prefix="/ops", tags=["ops"]) 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"]) router.include_router(example_router, prefix="/example", tags=["example"])

View File

@@ -26,6 +26,10 @@ from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.crm.service_requests.models import ServiceRequest from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier from api.v1.modules.crm.suppliers.models import Supplier
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import PaymentCreate
from core.database import CoreSessionLocal from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID # Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -323,6 +327,39 @@ def seed_commercial_and_ops(db) -> None:
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL") print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
def seed_invoicing_and_events(db) -> None:
"""Factura desde el embarque (con pago parcial) + hitos de la bitácora."""
from decimal import Decimal
if db.query(Invoice).filter(
Invoice.tenant_id == TENANT_ID, Invoice.company_id == COMPANY_ID, Invoice.deleted_at.is_(None)
).first():
print("• Ya existe facturación; se omite")
return
shipment = (
db.query(Shipment)
.filter(Shipment.tenant_id == TENANT_ID, Shipment.company_id == COMPANY_ID, Shipment.deleted_at.is_(None))
.order_by(Shipment.id.asc())
.first()
)
if not shipment:
print("• Sin embarque; se omite facturación")
return
# Bitácora de hitos (según tipo de operación)
shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID)
# Factura generada desde el embarque (toma conceptos de la cotización)
invoice = invoices_service.generate_from_shipment(db, shipment.id, TENANT_ID, COMPANY_ID)
invoices_service.emit_invoice(db, invoice.id, TENANT_ID, COMPANY_ID)
invoices_service.create_payment(
db, PaymentCreate(invoice_id=invoice.id, amount=Decimal("1000"), method="transferencia", reference="SPEI-001"),
TENANT_ID, COMPANY_ID,
)
print("✓ Factura emitida desde el embarque con pago parcial + hitos de la bitácora")
def main() -> None: def main() -> None:
db = CoreSessionLocal() db = CoreSessionLocal()
try: try:
@@ -331,6 +368,7 @@ def main() -> None:
seed_sample_data(db, pipeline, stages) seed_sample_data(db, pipeline, stages)
seed_suppliers_and_related(db) seed_suppliers_and_related(db)
seed_commercial_and_ops(db) seed_commercial_and_ops(db)
seed_invoicing_and_events(db)
print("\nSeed CRM completado.") print("\nSeed CRM completado.")
finally: finally:
db.close() db.close()

View File

@@ -37,8 +37,9 @@ import api.v1.modules.crm.quotes.models # noqa: E402,F401
import api.v1.modules.crm.service_requests.models # noqa: E402,F401 import api.v1.modules.crm.service_requests.models # noqa: E402,F401
import api.v1.modules.crm.suppliers.models # noqa: E402,F401 import api.v1.modules.crm.suppliers.models # noqa: E402,F401
import api.v1.modules.ops.shipments.models # noqa: E402,F401 import api.v1.modules.ops.shipments.models # noqa: E402,F401
import api.v1.modules.fin.invoices.models # noqa: E402,F401
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None} _SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm. # Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core. # En CI (PostgreSQL) la tabla real la crea la migración inicial del core.

View File

@@ -0,0 +1,52 @@
from decimal import Decimal
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.fin.invoices import service
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, PaymentCreate
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCreate
T, C = 1, 1
def test_invoice_totals_with_tax(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 240.0 # 16% de 1500
assert float(inv.total) == 1740.0
assert float(inv.balance) == 1740.0
def test_payment_marks_paid(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
service.emit_invoice(db, inv.id, T, C)
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("400"), method="transferencia"), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.paid_amount) == 400.0 and float(inv.balance) == 600.0
assert inv.status == "emitida"
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("600")), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.balance) == 0.0 and inv.status == "pagada" and inv.paid_at is not None
def test_generate_from_shipment_copies_quote_items(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
quote = quotes_service.create_quote(db, QuoteCreate(reference="COT-9", account_id=acc.id, currency="USD"), T, C)
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500), T, C)
quotes_service.accept_quote(db, quote.id, T, C)
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
inv = service.generate_from_shipment(db, shipment.id, T, C)
assert inv.shipment_id == shipment.id
assert inv.account_id == acc.id
assert inv.currency == "USD"
items = service.get_items(db, inv.id, T, C)
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
assert float(inv.subtotal) == 1500.0

View File

@@ -0,0 +1,34 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.ops.shipments import service
from api.v1.modules.ops.shipments.dto import ShipmentCreate
T, C = 1, 1
def test_seed_import_milestones(db):
s = service.create_shipment(db, ShipmentCreate(reference="IMP-1", operation_type="importacion"), T, C)
events = service.seed_default_milestones(db, s.id, T, C)
titles = [e.event_type for e in events]
assert "aviso_llegada" in titles
assert "despacho_importacion" in titles
assert "entrega" in titles
# idempotente: no duplica
again = service.seed_default_milestones(db, s.id, T, C)
assert len(again) == len(events)
def test_seed_export_milestones_and_complete(db):
s = service.create_shipment(db, ShipmentCreate(reference="EXP-1", operation_type="exportacion"), T, C)
events = service.seed_default_milestones(db, s.id, T, C)
assert any(e.event_type == "embarque" for e in events)
done = service.complete_shipment_event(db, events[0].id, T, C)
assert done.status == "completado" and done.actual_date is not None
def test_seed_requires_operation_type(db):
s = service.create_shipment(db, ShipmentCreate(reference="X-1"), T, C) # sin operation_type
with pytest.raises(HTTPException) as exc:
service.seed_default_milestones(db, s.id, T, C)
assert exc.value.status_code == 422