From 0b12ad535417dd0cd733d37397f23637837e2c59 Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Wed, 15 Jul 2026 07:18:11 -0600 Subject: [PATCH] =?UTF-8?q?feat(fin,ops):=20Facturaci=C3=B3n=20y=20Cobranz?= =?UTF-8?q?a=20(Diag.=204),=20bit=C3=A1cora=20de=20embarque=20(Diag.=203)?= =?UTF-8?q?=20y=20subida=20a=20MinIO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...7a8b9_fin_invoicing_and_shipment_events.py | 140 +++++++++ .../api/v1/modules/crm/uploads/__init__.py | 0 backend/api/v1/modules/crm/uploads/routes.py | 63 ++++ backend/api/v1/modules/fin/__init__.py | 0 .../api/v1/modules/fin/invoices/__init__.py | 0 backend/api/v1/modules/fin/invoices/dto.py | 110 +++++++ backend/api/v1/modules/fin/invoices/models.py | 78 +++++ backend/api/v1/modules/fin/invoices/routes.py | 114 ++++++++ .../api/v1/modules/fin/invoices/service.py | 268 ++++++++++++++++++ backend/api/v1/modules/fin/permissions.py | 17 ++ backend/api/v1/modules/fin/router.py | 9 + backend/api/v1/modules/ops/shipments/dto.py | 35 +++ .../api/v1/modules/ops/shipments/models.py | 20 ++ .../api/v1/modules/ops/shipments/routes.py | 67 +++++ .../api/v1/modules/ops/shipments/service.py | 120 +++++++- backend/api/v1/router.py | 2 + backend/seed_crm.py | 38 +++ backend/tests/conftest.py | 3 +- backend/tests/test_invoices.py | 52 ++++ backend/tests/test_shipment_events.py | 34 +++ 20 files changed, 1167 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/c4d5e6f7a8b9_fin_invoicing_and_shipment_events.py create mode 100644 backend/api/v1/modules/crm/uploads/__init__.py create mode 100644 backend/api/v1/modules/crm/uploads/routes.py create mode 100644 backend/api/v1/modules/fin/__init__.py create mode 100644 backend/api/v1/modules/fin/invoices/__init__.py create mode 100644 backend/api/v1/modules/fin/invoices/dto.py create mode 100644 backend/api/v1/modules/fin/invoices/models.py create mode 100644 backend/api/v1/modules/fin/invoices/routes.py create mode 100644 backend/api/v1/modules/fin/invoices/service.py create mode 100644 backend/api/v1/modules/fin/permissions.py create mode 100644 backend/api/v1/modules/fin/router.py create mode 100644 backend/tests/test_invoices.py create mode 100644 backend/tests/test_shipment_events.py diff --git a/backend/alembic/versions/c4d5e6f7a8b9_fin_invoicing_and_shipment_events.py b/backend/alembic/versions/c4d5e6f7a8b9_fin_invoicing_and_shipment_events.py new file mode 100644 index 0000000..8c8bffc --- /dev/null +++ b/backend/alembic/versions/c4d5e6f7a8b9_fin_invoicing_and_shipment_events.py @@ -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") diff --git a/backend/api/v1/modules/crm/uploads/__init__.py b/backend/api/v1/modules/crm/uploads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py new file mode 100644 index 0000000..63757dd --- /dev/null +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -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)} diff --git a/backend/api/v1/modules/fin/__init__.py b/backend/api/v1/modules/fin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/fin/invoices/__init__.py b/backend/api/v1/modules/fin/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py new file mode 100644 index 0000000..ff87f18 --- /dev/null +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -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 diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py new file mode 100644 index 0000000..b1cdc6d --- /dev/null +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -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) diff --git a/backend/api/v1/modules/fin/invoices/routes.py b/backend/api/v1/modules/fin/invoices/routes.py new file mode 100644 index 0000000..5596a7e --- /dev/null +++ b/backend/api/v1/modules/fin/invoices/routes.py @@ -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) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py new file mode 100644 index 0000000..999ae4e --- /dev/null +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -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() diff --git a/backend/api/v1/modules/fin/permissions.py b/backend/api/v1/modules/fin/permissions.py new file mode 100644 index 0000000..068fb98 --- /dev/null +++ b/backend/api/v1/modules/fin/permissions.py @@ -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() diff --git a/backend/api/v1/modules/fin/router.py b/backend/api/v1/modules/fin/router.py new file mode 100644 index 0000000..b5dda5e --- /dev/null +++ b/backend/api/v1/modules/fin/router.py @@ -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) diff --git a/backend/api/v1/modules/ops/shipments/dto.py b/backend/api/v1/modules/ops/shipments/dto.py index 7b05ea4..d32348a 100644 --- a/backend/api/v1/modules/ops/shipments/dto.py +++ b/backend/api/v1/modules/ops/shipments/dto.py @@ -67,6 +67,41 @@ class ShipmentResponse(ShipmentBase): updated_at: datetime +class ShipmentEventBase(BaseModel): + shipment_id: int + event_type: str | None = Field(None, max_length=60) + title: str = Field(..., min_length=1, max_length=160) + status: str = Field("pendiente", max_length=20) + position: int = Field(0, ge=0) + planned_date: datetime | None = None + actual_date: datetime | None = None + notes: str | None = None + + +class ShipmentEventCreate(ShipmentEventBase): + pass + + +class ShipmentEventUpdate(BaseModel): + event_type: str | None = Field(None, max_length=60) + title: str | None = Field(None, min_length=1, max_length=160) + status: str | None = Field(None, max_length=20) + position: int | None = Field(None, ge=0) + planned_date: datetime | None = None + actual_date: datetime | None = None + notes: str | None = None + + +class ShipmentEventResponse(ShipmentEventBase): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + created_at: datetime + updated_at: datetime + + class ShipmentDocumentBase(BaseModel): shipment_id: int doc_kind: str = Field("otro", max_length=10) diff --git a/backend/api/v1/modules/ops/shipments/models.py b/backend/api/v1/modules/ops/shipments/models.py index ed4dc09..635160e 100644 --- a/backend/api/v1/modules/ops/shipments/models.py +++ b/backend/api/v1/modules/ops/shipments/models.py @@ -53,6 +53,26 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin): updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) +class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin): + """Hito / bitácora del embarque (Diagramas 2 y 3). Timeline de la operación.""" + + __tablename__ = "shipment_events" + __table_args__ = {"schema": "ops"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + shipment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True + ) + event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito + title: Mapped[str] = mapped_column(String(160), nullable=False) + # pendiente | completado | omitido + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'")) + position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + planned_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + actual_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin): """Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).""" diff --git a/backend/api/v1/modules/ops/shipments/routes.py b/backend/api/v1/modules/ops/shipments/routes.py index 8571237..8d6b256 100644 --- a/backend/api/v1/modules/ops/shipments/routes.py +++ b/backend/api/v1/modules/ops/shipments/routes.py @@ -10,6 +10,9 @@ from .dto import ( ShipmentDocumentCreate, ShipmentDocumentResponse, ShipmentDocumentUpdate, + ShipmentEventCreate, + ShipmentEventResponse, + ShipmentEventUpdate, ShipmentResponse, ShipmentUpdate, ) @@ -130,3 +133,67 @@ def delete_shipment_document( db: Session = Depends(get_core_db), ): service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id) + + +# ----- Bitácora / hitos ----- + +@router.get("/shipments/{shipment_id}/events", response_model=list[ShipmentEventResponse]) +def list_shipment_events( + shipment_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id) + return service.get_shipment_events(db, current_user["tenant_id"], company_id, shipment_id) + + +@router.post("/shipments/{shipment_id}/events/seed", response_model=list[ShipmentEventResponse]) +def seed_shipment_events( + shipment_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.seed_default_milestones(db, shipment_id, current_user["tenant_id"], company_id) + + +@router.post("/shipment-events", response_model=ShipmentEventResponse, status_code=status.HTTP_201_CREATED) +def create_shipment_event( + payload: ShipmentEventCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.create_shipment_event(db, payload, current_user["tenant_id"], company_id) + + +@router.patch("/shipment-events/{event_id}", response_model=ShipmentEventResponse) +def update_shipment_event( + event_id: int, + payload: ShipmentEventUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.update_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id) + + +@router.patch("/shipment-events/{event_id}/complete", response_model=ShipmentEventResponse) +def complete_shipment_event( + event_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.complete_shipment_event(db, event_id, current_user["tenant_id"], company_id) + + +@router.delete("/shipment-events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_shipment_event( + event_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + service.delete_shipment_event(db, event_id, current_user["tenant_id"], company_id) diff --git a/backend/api/v1/modules/ops/shipments/service.py b/backend/api/v1/modules/ops/shipments/service.py index ea65e70..e91c32b 100644 --- a/backend/api/v1/modules/ops/shipments/service.py +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -8,8 +8,36 @@ from api.v1.modules.crm.quotes.models import Quote from api.v1.modules.crm.service_requests.models import ServiceRequest from api.v1.modules.crm.suppliers.models import Supplier -from .dto import ShipmentCreate, ShipmentDocumentCreate, ShipmentDocumentUpdate, ShipmentUpdate -from .models import Shipment, ShipmentDocument +from .dto import ( + ShipmentCreate, + ShipmentDocumentCreate, + ShipmentDocumentUpdate, + ShipmentEventCreate, + ShipmentEventUpdate, + ShipmentUpdate, +) +from .models import Shipment, ShipmentDocument, ShipmentEvent + +# Hitos por defecto según el tipo de operación (Diagramas 2 y 3) +_DEFAULT_MILESTONES = { + "exportacion": [ + ("recoleccion", "Recolección de mercancía"), + ("despacho_exportacion", "Despacho de exportación"), + ("embarque", "Embarque"), + ("zarpe", "Zarpe / Salida del transporte"), + ("arribo", "Arribo a destino"), + ("entrega", "Entrega al consignatario"), + ], + "importacion": [ + ("aviso_llegada", "Aviso de llegada"), + ("recepcion_docs", "Recepción de documentos (MBL/MAWB)"), + ("despacho_importacion", "Despacho de importación"), + ("liberacion", "Liberación de mercancía"), + ("retiro", "Retiro en puerto / aeropuerto"), + ("traslado", "Traslado a bodega del importador"), + ("entrega", "Entrega final al cliente"), + ], +} def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool: @@ -228,3 +256,91 @@ def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_i obj = _get_document(db, doc_id, tenant_id, company_id) obj.deleted_at = datetime.now(timezone.utc) db.commit() + + +# ----- Bitácora / hitos del embarque ----- + +def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]: + query = db.query(ShipmentEvent).filter( + ShipmentEvent.tenant_id == tenant_id, + ShipmentEvent.company_id == company_id, + ShipmentEvent.deleted_at.is_(None), + ) + if shipment_id is not None: + query = query.filter(ShipmentEvent.shipment_id == shipment_id) + return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all() + + +def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent: + obj = ( + db.query(ShipmentEvent) + .filter( + ShipmentEvent.id == event_id, + ShipmentEvent.tenant_id == tenant_id, + ShipmentEvent.company_id == company_id, + ShipmentEvent.deleted_at.is_(None), + ) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado") + return obj + + +def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent: + get_shipment(db, payload.shipment_id, tenant_id, company_id) + obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent: + obj = _get_event(db, event_id, tenant_id, company_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent: + obj = _get_event(db, event_id, tenant_id, company_id) + obj.status = "completado" + obj.actual_date = datetime.now(timezone.utc) + db.commit() + db.refresh(obj) + return obj + + +def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None: + obj = _get_event(db, event_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() + + +def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]: + """Crea los hitos por defecto del embarque según su tipo de operación (import/export).""" + shipment = get_shipment(db, shipment_id, tenant_id, company_id) + existing = get_shipment_events(db, tenant_id, company_id, shipment_id) + if existing: + return existing + milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", []) + if not milestones: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Define el tipo de operación (importación/exportación) para generar los hitos", + ) + created = [] + for position, (event_type, title) in enumerate(milestones): + ev = ShipmentEvent( + shipment_id=shipment_id, event_type=event_type, title=title, status="pendiente", + position=position, tenant_id=tenant_id, company_id=company_id, + ) + db.add(ev) + created.append(ev) + db.commit() + for ev in created: + db.refresh(ev) + return created diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 7522e45..243ef64 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -7,6 +7,7 @@ from fastapi import APIRouter from .modules.core.router import router as core_router from .modules.crm.router import router as crm_router from .modules.ops.router import router as ops_router +from .modules.fin.router import router as fin_router from .modules.example.routes import router as example_router @@ -15,6 +16,7 @@ router = APIRouter() router.include_router(core_router) router.include_router(crm_router, prefix="/crm", tags=["crm"]) router.include_router(ops_router, prefix="/ops", tags=["ops"]) +router.include_router(fin_router, prefix="/fin", tags=["fin"]) router.include_router(example_router, prefix="/example", tags=["example"]) diff --git a/backend/seed_crm.py b/backend/seed_crm.py index 758d4dd..a6889b5 100644 --- a/backend/seed_crm.py +++ b/backend/seed_crm.py @@ -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.suppliers.models import Supplier 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 # 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") +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: db = CoreSessionLocal() try: @@ -331,6 +368,7 @@ def main() -> None: seed_sample_data(db, pipeline, stages) seed_suppliers_and_related(db) seed_commercial_and_ops(db) + seed_invoicing_and_events(db) print("\nSeed CRM completado.") finally: db.close() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2310ed9..70d2eaa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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.suppliers.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. # En CI (PostgreSQL) la tabla real la crea la migración inicial del core. diff --git a/backend/tests/test_invoices.py b/backend/tests/test_invoices.py new file mode 100644 index 0000000..44e4f44 --- /dev/null +++ b/backend/tests/test_invoices.py @@ -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 diff --git a/backend/tests/test_shipment_events.py b/backend/tests/test_shipment_events.py new file mode 100644 index 0000000..3314250 --- /dev/null +++ b/backend/tests/test_shipment_events.py @@ -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