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:
@@ -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")
|
||||||
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal 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)}
|
||||||
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
110
backend/api/v1/modules/fin/invoices/dto.py
Normal file
110
backend/api/v1/modules/fin/invoices/dto.py
Normal 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
|
||||||
78
backend/api/v1/modules/fin/invoices/models.py
Normal file
78
backend/api/v1/modules/fin/invoices/models.py
Normal 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)
|
||||||
114
backend/api/v1/modules/fin/invoices/routes.py
Normal file
114
backend/api/v1/modules/fin/invoices/routes.py
Normal 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)
|
||||||
268
backend/api/v1/modules/fin/invoices/service.py
Normal file
268
backend/api/v1/modules/fin/invoices/service.py
Normal 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()
|
||||||
17
backend/api/v1/modules/fin/permissions.py
Normal file
17
backend/api/v1/modules/fin/permissions.py
Normal 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()
|
||||||
9
backend/api/v1/modules/fin/router.py
Normal file
9
backend/api/v1/modules/fin/router.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.)."""
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
52
backend/tests/test_invoices.py
Normal file
52
backend/tests/test_invoices.py
Normal 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
|
||||||
34
backend/tests/test_shipment_events.py
Normal file
34
backend/tests/test_shipment_events.py
Normal 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
|
||||||
Reference in New Issue
Block a user