diff --git a/backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py b/backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py new file mode 100644 index 0000000..03e70f7 --- /dev/null +++ b/backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py @@ -0,0 +1,232 @@ +"""crm commercial (service_requests, rate_requests, quotes, quote_items) + ops (shipments, documents) + +Revision ID: b3c4d5e6f7a8 +Revises: a7b8c9d0e1f2 +Create Date: 2026-07-14 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b3c4d5e6f7a8" +down_revision: Union[str, None] = "a7b8c9d0e1f2" +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: + # ---------- crm.service_requests ---------- + op.create_table( + "service_requests", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reference", sa.String(length=40), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("operation_type", sa.String(length=20), nullable=False), + sa.Column("transport_mode", sa.String(length=20), nullable=True), + sa.Column("service_type", sa.String(length=20), nullable=True), + sa.Column("incoterm", sa.String(length=10), nullable=True), + sa.Column("origin", sa.String(length=160), nullable=True), + sa.Column("destination", sa.String(length=160), nullable=True), + sa.Column("cargo_type", sa.String(length=120), nullable=True), + sa.Column("weight", sa.Numeric(precision=14, scale=3), nullable=True), + sa.Column("volume", sa.Numeric(precision=14, scale=3), nullable=True), + sa.Column("load_type", sa.String(length=10), nullable=True), + sa.Column("container_equipment", sa.String(length=120), nullable=True), + sa.Column("commodity", sa.Text(), nullable=True), + sa.Column("required_date", sa.Date(), nullable=True), + sa.Column("destination_agent_id", sa.Integer(), nullable=True), + sa.Column("requirements", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'nueva'")), + sa.Column("notes", sa.Text(), 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(["account_id"], ["crm.accounts.id"]), + sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]), + schema="crm", + ) + _scoped_indexes("service_requests", "crm") + op.create_index("ix_crm_service_requests_reference", "service_requests", ["reference"], schema="crm") + op.create_index("ix_crm_service_requests_account_id", "service_requests", ["account_id"], schema="crm") + op.create_index("ix_crm_service_requests_status", "service_requests", ["status"], schema="crm") + op.create_index("ix_crm_service_requests_owner_user_id", "service_requests", ["owner_user_id"], schema="crm") + + # ---------- crm.rate_requests ---------- + op.create_table( + "rate_requests", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_request_id", sa.Integer(), nullable=False), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("concept", sa.String(length=60), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'solicitada'")), + sa.Column("rate_amount", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=True), + sa.Column("valid_until", sa.Date(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]), + sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]), + schema="crm", + ) + _scoped_indexes("rate_requests", "crm") + op.create_index("ix_crm_rate_requests_service_request_id", "rate_requests", ["service_request_id"], schema="crm") + op.create_index("ix_crm_rate_requests_supplier_id", "rate_requests", ["supplier_id"], schema="crm") + + # ---------- crm.quotes ---------- + op.create_table( + "quotes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reference", sa.String(length=40), nullable=True), + sa.Column("service_request_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("'USD'")), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")), + sa.Column("issue_date", sa.Date(), nullable=True), + sa.Column("valid_until", sa.Date(), nullable=True), + sa.Column("total_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("total_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.Column("rejected_at", sa.DateTime(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("terms", sa.Text(), 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(["service_request_id"], ["crm.service_requests.id"]), + sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]), + schema="crm", + ) + _scoped_indexes("quotes", "crm") + op.create_index("ix_crm_quotes_reference", "quotes", ["reference"], schema="crm") + op.create_index("ix_crm_quotes_service_request_id", "quotes", ["service_request_id"], schema="crm") + op.create_index("ix_crm_quotes_account_id", "quotes", ["account_id"], schema="crm") + op.create_index("ix_crm_quotes_status", "quotes", ["status"], schema="crm") + op.create_index("ix_crm_quotes_owner_user_id", "quotes", ["owner_user_id"], schema="crm") + + # ---------- crm.quote_items ---------- + op.create_table( + "quote_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("quote_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("supplier_id", sa.Integer(), nullable=True), + sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")), + sa.Column("unit_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("unit_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("currency", sa.String(length=3), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]), + sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]), + schema="crm", + ) + _scoped_indexes("quote_items", "crm") + op.create_index("ix_crm_quote_items_quote_id", "quote_items", ["quote_id"], schema="crm") + + # ---------- schema ops ---------- + op.execute("CREATE SCHEMA IF NOT EXISTS ops") + + op.create_table( + "shipments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reference", sa.String(length=40), nullable=True), + sa.Column("quote_id", sa.Integer(), nullable=True), + sa.Column("service_request_id", sa.Integer(), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("operation_type", sa.String(length=20), nullable=True), + sa.Column("transport_mode", sa.String(length=20), nullable=True), + sa.Column("service_type", sa.String(length=20), nullable=True), + sa.Column("incoterm", sa.String(length=10), nullable=True), + sa.Column("origin", sa.String(length=160), nullable=True), + sa.Column("destination", sa.String(length=160), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierta'")), + sa.Column("booking_number", sa.String(length=60), nullable=True), + sa.Column("carrier_supplier_id", sa.Integer(), nullable=True), + sa.Column("customs_agent_id", sa.Integer(), nullable=True), + sa.Column("destination_agent_id", sa.Integer(), nullable=True), + sa.Column("cutoff_date", sa.DateTime(), nullable=True), + sa.Column("etd", sa.Date(), nullable=True), + sa.Column("eta", sa.Date(), nullable=True), + sa.Column("vessel_flight", sa.String(length=120), nullable=True), + sa.Column("container_number", sa.String(length=60), nullable=True), + sa.Column("notes", sa.Text(), 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(["quote_id"], ["crm.quotes.id"]), + sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]), + sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]), + sa.ForeignKeyConstraint(["carrier_supplier_id"], ["crm.suppliers.id"]), + sa.ForeignKeyConstraint(["customs_agent_id"], ["crm.suppliers.id"]), + sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]), + schema="ops", + ) + _scoped_indexes("shipments", "ops") + op.create_index("ix_ops_shipments_reference", "shipments", ["reference"], schema="ops") + op.create_index("ix_ops_shipments_quote_id", "shipments", ["quote_id"], schema="ops") + op.create_index("ix_ops_shipments_account_id", "shipments", ["account_id"], schema="ops") + op.create_index("ix_ops_shipments_status", "shipments", ["status"], schema="ops") + op.create_index("ix_ops_shipments_owner_user_id", "shipments", ["owner_user_id"], schema="ops") + + op.create_table( + "shipment_documents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("shipment_id", sa.Integer(), nullable=False), + sa.Column("doc_kind", sa.String(length=10), nullable=False, server_default=sa.text("'otro'")), + sa.Column("doc_type", sa.String(length=30), nullable=False), + sa.Column("number", sa.String(length=80), nullable=True), + sa.Column("issue_date", sa.Date(), nullable=True), + sa.Column("file_url", sa.String(length=1024), nullable=True), + sa.Column("file_key", sa.String(length=512), 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_documents", "ops") + op.create_index("ix_ops_shipment_documents_shipment_id", "shipment_documents", ["shipment_id"], schema="ops") + + +def downgrade() -> None: + op.drop_table("shipment_documents", schema="ops") + op.drop_table("shipments", schema="ops") + op.execute("DROP SCHEMA IF EXISTS ops") + op.drop_table("quote_items", schema="crm") + op.drop_table("quotes", schema="crm") + op.drop_table("rate_requests", schema="crm") + op.drop_table("service_requests", schema="crm") diff --git a/backend/api/v1/modules/crm/permissions.py b/backend/api/v1/modules/crm/permissions.py index 0825d06..d278b6a 100644 --- a/backend/api/v1/modules/crm/permissions.py +++ b/backend/api/v1/modules/crm/permissions.py @@ -16,6 +16,9 @@ _ENTITIES = [ ("contact", "contactos"), ("address", "direcciones"), ("document", "documentos"), + ("service_request", "solicitudes de servicio"), + ("rate_request", "solicitudes de tarifa"), + ("quote", "cotizaciones"), ("lead", "prospectos"), ("opportunity", "oportunidades"), ("pipeline", "embudos"), diff --git a/backend/api/v1/modules/crm/quotes/__init__.py b/backend/api/v1/modules/crm/quotes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py new file mode 100644 index 0000000..3ff1387 --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -0,0 +1,102 @@ +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field, computed_field + + +# ----- Quote items ----- + +class QuoteItemBase(BaseModel): + concept: str = Field(..., max_length=60) + description: str | None = Field(None, max_length=255) + supplier_id: int | None = None + quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2) + unit_cost: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2) + unit_sale: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, max_length=3) + + +class QuoteItemCreate(QuoteItemBase): + quote_id: int + + +class QuoteItemUpdate(BaseModel): + concept: str | None = Field(None, max_length=60) + description: str | None = Field(None, max_length=255) + supplier_id: int | None = None + quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) + unit_cost: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + unit_sale: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, max_length=3) + + +class QuoteItemResponse(QuoteItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + quote_id: int + tenant_id: int + company_id: int + + @computed_field + @property + def line_cost(self) -> Decimal: + return (self.quantity or Decimal(0)) * (self.unit_cost or Decimal(0)) + + @computed_field + @property + def line_sale(self) -> Decimal: + return (self.quantity or Decimal(0)) * (self.unit_sale or Decimal(0)) + + +# ----- Quotes ----- + +class QuoteBase(BaseModel): + reference: str | None = Field(None, max_length=40) + service_request_id: int | None = None + account_id: int | None = None + currency: str = Field("USD", max_length=3) + issue_date: date | None = None + valid_until: date | None = None + notes: str | None = None + terms: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class QuoteCreate(QuoteBase): + pass + + +class QuoteUpdate(BaseModel): + reference: str | None = Field(None, max_length=40) + service_request_id: int | None = None + account_id: int | None = None + currency: str | None = Field(None, max_length=3) + issue_date: date | None = None + valid_until: date | None = None + notes: str | None = None + terms: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class QuoteResponse(QuoteBase): + model_config = ConfigDict(from_attributes=True) + + id: int + status: str + total_cost: Decimal + total_sale: Decimal + sent_at: datetime | None = None + accepted_at: datetime | None = None + rejected_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 + + @computed_field + @property + def margin(self) -> Decimal: + return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0)) diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py new file mode 100644 index 0000000..ee3ff16 --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -0,0 +1,60 @@ +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 Quote(Base, TenantScopedMixin, TimestampMixin): + """Cotización (Diagrama 1, pasos 7-9). Integra los conceptos de costo/venta.""" + + __tablename__ = "quotes" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=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("'USD'")) + # borrador | enviada | aceptada | rechazada + 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) + valid_until: Mapped[date | None] = mapped_column(Date, nullable=True) + total_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + total_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + terms: Mapped[str | None] = mapped_column(Text, 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 QuoteItem(Base, TenantScopedMixin, TimestampMixin): + """Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros).""" + + __tablename__ = "quote_items" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + quote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("crm.quotes.id"), nullable=False, index=True + ) + # flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros + concept: Mapped[str] = mapped_column(String(60), nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + supplier_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True + ) + quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1")) + unit_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + unit_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + currency: Mapped[str | None] = mapped_column(String(3), nullable=True) diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py new file mode 100644 index 0000000..37ae7ff --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -0,0 +1,151 @@ +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 ( + QuoteCreate, + QuoteItemCreate, + QuoteItemResponse, + QuoteItemUpdate, + QuoteResponse, + QuoteUpdate, +) + +router = APIRouter() + + +def _user_id(current_user: dict) -> str | None: + return current_user.get("sub") or current_user.get("id") + + +@router.get("/quotes", response_model=list[QuoteResponse]) +def list_quotes( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None), + quote_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), +): + tenant_id = current_user["tenant_id"] + return service.get_quotes(db, tenant_id, company_id, search, quote_status, account_id) + + +@router.get("/quotes/{quote_id}", response_model=QuoteResponse) +def get_quote( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_quote(db, quote_id, tenant_id, company_id) + + +@router.post("/quotes", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED) +def create_quote( + payload: QuoteCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.patch("/quotes/{quote_id}", response_model=QuoteResponse) +def update_quote( + quote_id: int, + payload: QuoteUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.update_quote(db, quote_id, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.patch("/quotes/{quote_id}/send", response_model=QuoteResponse) +def send_quote( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.send_quote(db, quote_id, current_user["tenant_id"], company_id) + + +@router.patch("/quotes/{quote_id}/accept", response_model=QuoteResponse) +def accept_quote( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.accept_quote(db, quote_id, current_user["tenant_id"], company_id) + + +@router.patch("/quotes/{quote_id}/reject", response_model=QuoteResponse) +def reject_quote( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id) + + +@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_quote( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + service.delete_quote(db, quote_id, current_user["tenant_id"], company_id) + + +# ----- Conceptos de la cotización ----- + +@router.get("/quotes/{quote_id}/items", response_model=list[QuoteItemResponse]) +def list_quote_items( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_quote_items(db, quote_id, current_user["tenant_id"], company_id) + + +@router.post("/quote-items", response_model=QuoteItemResponse, status_code=status.HTTP_201_CREATED) +def create_quote_item( + payload: QuoteItemCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.create_quote_item(db, payload, current_user["tenant_id"], company_id) + + +@router.patch("/quote-items/{item_id}", response_model=QuoteItemResponse) +def update_quote_item( + item_id: int, + payload: QuoteItemUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.update_quote_item(db, item_id, payload, current_user["tenant_id"], company_id) + + +@router.delete("/quote-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_quote_item( + item_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + service.delete_quote_item(db, item_id, current_user["tenant_id"], company_id) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py new file mode 100644 index 0000000..a06733e --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -0,0 +1,231 @@ +from datetime import datetime, timezone +from decimal import Decimal + +from fastapi import HTTPException, status +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ..accounts.models import Account +from ..service_requests.models import ServiceRequest +from ..suppliers.models import Supplier +from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate +from .models import Quote, QuoteItem + + +def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> 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: + if not _exists(db, Account, data.get("account_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe") + if not _exists(db, ServiceRequest, data.get("service_request_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe") + + +def _recompute_totals(db: Session, quote: Quote) -> None: + """Recalcula total_cost/total_sale a partir de los conceptos vigentes.""" + cost, sale = ( + db.query( + func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0), + func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0), + ) + .filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)) + .one() + ) + quote.total_cost = Decimal(cost or 0) + quote.total_sale = Decimal(sale or 0) + + +# ----- Quotes ----- + +def get_quotes( + db: Session, + tenant_id: int, + company_id: int, + search: str | None = None, + quote_status: str | None = None, + account_id: int | None = None, +) -> list[Quote]: + query = db.query(Quote).filter( + Quote.tenant_id == tenant_id, + Quote.company_id == company_id, + Quote.deleted_at.is_(None), + ) + if quote_status: + query = query.filter(Quote.status == quote_status) + if account_id is not None: + query = query.filter(Quote.account_id == account_id) + if search: + query = query.filter(Quote.reference.ilike(f"%{search}%")) + return query.order_by(Quote.created_at.desc()).all() + + +def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: + obj = ( + db.query(Quote) + .filter( + Quote.id == quote_id, + Quote.tenant_id == tenant_id, + Quote.company_id == company_id, + Quote.deleted_at.is_(None), + ) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada") + return obj + + +def create_quote( + db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Quote: + data = payload.model_dump() + _validate_refs(db, data, tenant_id, company_id) + obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_quote( + db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Quote: + obj = get_quote(db, quote_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + _validate_refs(db, data, tenant_id, company_id) + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj + + +def delete_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None: + obj = get_quote(db, quote_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() + + +def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None: + if quote.service_request_id: + sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + if sr: + sr.status = new_status + + +def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: + quote = get_quote(db, quote_id, tenant_id, company_id) + quote.status = "enviada" + quote.sent_at = datetime.now(timezone.utc) + _set_service_request_status(db, quote, "cotizada") + db.commit() + db.refresh(quote) + return quote + + +def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: + quote = get_quote(db, quote_id, tenant_id, company_id) + quote.status = "aceptada" + quote.accepted_at = datetime.now(timezone.utc) + _set_service_request_status(db, quote, "aceptada") + db.commit() + db.refresh(quote) + return quote + + +def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: + quote = get_quote(db, quote_id, tenant_id, company_id) + quote.status = "rechazada" + quote.rejected_at = datetime.now(timezone.utc) + _set_service_request_status(db, quote, "rechazada") + db.commit() + db.refresh(quote) + return quote + + +# ----- Quote items ----- + +def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]: + get_quote(db, quote_id, tenant_id, company_id) # valida scope + return ( + db.query(QuoteItem) + .filter( + QuoteItem.quote_id == quote_id, + QuoteItem.tenant_id == tenant_id, + QuoteItem.company_id == company_id, + QuoteItem.deleted_at.is_(None), + ) + .order_by(QuoteItem.id.asc()) + .all() + ) + + +def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem: + item = ( + db.query(QuoteItem) + .filter( + QuoteItem.id == item_id, + QuoteItem.tenant_id == tenant_id, + QuoteItem.company_id == company_id, + QuoteItem.deleted_at.is_(None), + ) + .first() + ) + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") + return item + + +def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem: + quote = get_quote(db, payload.quote_id, tenant_id, company_id) + if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") + item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + db.add(item) + db.flush() + _recompute_totals(db, quote) + db.commit() + db.refresh(item) + return item + + +def update_quote_item( + db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int +) -> QuoteItem: + item = _get_item(db, item_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") + for field, value in data.items(): + setattr(item, field, value) + db.flush() + quote = get_quote(db, item.quote_id, tenant_id, company_id) + _recompute_totals(db, quote) + db.commit() + db.refresh(item) + return item + + +def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None: + item = _get_item(db, item_id, tenant_id, company_id) + quote_id = item.quote_id + item.deleted_at = datetime.now(timezone.utc) + db.flush() + quote = get_quote(db, quote_id, tenant_id, company_id) + _recompute_totals(db, quote) + db.commit() diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index 1250cf0..39988cf 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -17,6 +17,8 @@ from .leads.routes import router as leads_router from .metrics.routes import router as metrics_router from .opportunities.routes import router as opportunities_router from .pipelines.routes import router as pipelines_router +from .quotes.routes import router as quotes_router +from .service_requests.routes import router as service_requests_router from .suppliers.routes import router as suppliers_router router = APIRouter() @@ -26,6 +28,8 @@ router.include_router(suppliers_router) router.include_router(contacts_router) router.include_router(addresses_router) router.include_router(documents_router) +router.include_router(service_requests_router) +router.include_router(quotes_router) router.include_router(leads_router) router.include_router(pipelines_router) router.include_router(opportunities_router) diff --git a/backend/api/v1/modules/crm/service_requests/__init__.py b/backend/api/v1/modules/crm/service_requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py new file mode 100644 index 0000000..7f6a5a1 --- /dev/null +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -0,0 +1,103 @@ +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +class ServiceRequestBase(BaseModel): + reference: str | None = Field(None, max_length=40) + account_id: int | None = None + operation_type: str = Field(..., max_length=20) # importacion | exportacion + transport_mode: str | None = Field(None, max_length=20) + service_type: str | None = Field(None, max_length=20) + incoterm: str | None = Field(None, max_length=10) + origin: str | None = Field(None, max_length=160) + destination: str | None = Field(None, max_length=160) + cargo_type: str | None = Field(None, max_length=120) + weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + load_type: str | None = Field(None, max_length=10) + container_equipment: str | None = Field(None, max_length=120) + commodity: str | None = None + required_date: date | None = None + destination_agent_id: int | None = None + requirements: str | None = None + status: str = Field("nueva", max_length=20) + notes: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class ServiceRequestCreate(ServiceRequestBase): + pass + + +class ServiceRequestUpdate(BaseModel): + reference: str | None = Field(None, max_length=40) + account_id: int | None = None + operation_type: str | None = Field(None, max_length=20) + transport_mode: str | None = Field(None, max_length=20) + service_type: str | None = Field(None, max_length=20) + incoterm: str | None = Field(None, max_length=10) + origin: str | None = Field(None, max_length=160) + destination: str | None = Field(None, max_length=160) + cargo_type: str | None = Field(None, max_length=120) + weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + load_type: str | None = Field(None, max_length=10) + container_equipment: str | None = Field(None, max_length=120) + commodity: str | None = None + required_date: date | None = None + destination_agent_id: int | None = None + requirements: str | None = None + status: str | None = Field(None, max_length=20) + notes: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class ServiceRequestResponse(ServiceRequestBase): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + created_by: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime + + +class RateRequestBase(BaseModel): + service_request_id: int + supplier_id: int | None = None + concept: str = Field(..., max_length=60) + description: str | None = Field(None, max_length=255) + status: str = Field("solicitada", max_length=20) + rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, max_length=3) + valid_until: date | None = None + notes: str | None = None + + +class RateRequestCreate(RateRequestBase): + pass + + +class RateRequestUpdate(BaseModel): + supplier_id: int | None = None + concept: str | None = Field(None, max_length=60) + description: str | None = Field(None, max_length=255) + status: str | None = Field(None, max_length=20) + rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, max_length=3) + valid_until: date | None = None + notes: str | None = None + + +class RateRequestResponse(RateRequestBase): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py new file mode 100644 index 0000000..26d60ba --- /dev/null +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -0,0 +1,75 @@ +from datetime import date + +from sqlalchemy import Date, 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 ServiceRequest(Base, TenantScopedMixin, TimestampMixin): + """Solicitud de cotización / levantamiento de requerimientos (Diagrama 1, pasos 3-5). + + Captura los requerimientos logísticos de la operación que el cliente solicita + cotizar (tipo de operación, medio de transporte, ruta, carga, Incoterm, etc.). + """ + + __tablename__ = "service_requests" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio + account_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True + ) + # importacion | exportacion + operation_type: Mapped[str] = mapped_column(String(20), nullable=False) + # maritimo | aereo | terrestre | ferroviario | multimodal + transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True) + # puerto_puerto | puerto_puerta | puerta_puerto | puerta_puerta + service_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True) + origin: Mapped[str | None] = mapped_column(String(160), nullable=True) + destination: Mapped[str | None] = mapped_column(String(160), nullable=True) + cargo_type: Mapped[str | None] = mapped_column(String(120), nullable=True) + weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + volume: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # FCL | LCL + container_equipment: Mapped[str | None] = mapped_column(String(120), nullable=True) + commodity: Mapped[str | None] = mapped_column(Text, nullable=True) # mercancía + required_date: Mapped[date | None] = mapped_column(Date, nullable=True) + # Agente en destino / contraparte (proveedor) + destination_agent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True + ) + requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos + # nueva | en_analisis | cotizada | aceptada | rechazada | liberada + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=True) + notes: Mapped[str | None] = mapped_column(Text, 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 RateRequest(Base, TenantScopedMixin, TimestampMixin): + """Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6).""" + + __tablename__ = "rate_requests" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + service_request_id: Mapped[int] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=False, index=True + ) + supplier_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True + ) + # flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros + concept: Mapped[str] = mapped_column(String(60), nullable=False) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + # solicitada | recibida | declinada + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'solicitada'")) + rate_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) + currency: Mapped[str | None] = mapped_column(String(3), nullable=True) + valid_until: Mapped[date | None] = mapped_column(Date, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/api/v1/modules/crm/service_requests/routes.py b/backend/api/v1/modules/crm/service_requests/routes.py new file mode 100644 index 0000000..8e05ad5 --- /dev/null +++ b/backend/api/v1/modules/crm/service_requests/routes.py @@ -0,0 +1,129 @@ +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 ( + RateRequestCreate, + RateRequestResponse, + RateRequestUpdate, + ServiceRequestCreate, + ServiceRequestResponse, + ServiceRequestUpdate, +) + +router = APIRouter() + + +def _user_id(current_user: dict) -> str | None: + return current_user.get("sub") or current_user.get("id") + + +# ----- Solicitudes de servicio (RFQ) ----- + +@router.get("/service-requests", response_model=list[ServiceRequestResponse]) +def list_service_requests( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None), + req_status: str | None = Query(None, alias="status"), + operation_type: str | None = Query(None), + account_id: int | None = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_service_requests(db, tenant_id, company_id, search, req_status, operation_type, account_id) + + +@router.get("/service-requests/{request_id}", response_model=ServiceRequestResponse) +def get_service_request( + request_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_service_request(db, request_id, tenant_id, company_id) + + +@router.post("/service-requests", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED) +def create_service_request( + payload: ServiceRequestCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_service_request(db, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.patch("/service-requests/{request_id}", response_model=ServiceRequestResponse) +def update_service_request( + request_id: int, + payload: ServiceRequestUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.update_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_service_request( + request_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + service.delete_service_request(db, request_id, tenant_id, company_id) + + +# ----- Solicitudes de tarifa ----- + +@router.get("/rate-requests", response_model=list[RateRequestResponse]) +def list_rate_requests( + company_id: int = Query(..., description="Company ID"), + service_request_id: int | None = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.get_rate_requests(db, tenant_id, company_id, service_request_id) + + +@router.post("/rate-requests", response_model=RateRequestResponse, status_code=status.HTTP_201_CREATED) +def create_rate_request( + payload: RateRequestCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_rate_request(db, payload, tenant_id, company_id) + + +@router.patch("/rate-requests/{rate_id}", response_model=RateRequestResponse) +def update_rate_request( + rate_id: int, + payload: RateRequestUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.update_rate_request(db, rate_id, payload, tenant_id, company_id) + + +@router.delete("/rate-requests/{rate_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_rate_request( + rate_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + service.delete_rate_request(db, rate_id, tenant_id, company_id) diff --git a/backend/api/v1/modules/crm/service_requests/service.py b/backend/api/v1/modules/crm/service_requests/service.py new file mode 100644 index 0000000..4212b50 --- /dev/null +++ b/backend/api/v1/modules/crm/service_requests/service.py @@ -0,0 +1,176 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from ..accounts.models import Account +from ..suppliers.models import Supplier +from .dto import RateRequestCreate, RateRequestUpdate, ServiceRequestCreate, ServiceRequestUpdate +from .models import RateRequest, ServiceRequest + + +def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> 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_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: + if not _exists(db, Account, data.get("account_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe") + if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe") + + +# ----- Service requests (RFQ) ----- + +def get_service_requests( + db: Session, + tenant_id: int, + company_id: int, + search: str | None = None, + req_status: str | None = None, + operation_type: str | None = None, + account_id: int | None = None, +) -> list[ServiceRequest]: + query = db.query(ServiceRequest).filter( + ServiceRequest.tenant_id == tenant_id, + ServiceRequest.company_id == company_id, + ServiceRequest.deleted_at.is_(None), + ) + if req_status: + query = query.filter(ServiceRequest.status == req_status) + if operation_type: + query = query.filter(ServiceRequest.operation_type == operation_type) + if account_id is not None: + query = query.filter(ServiceRequest.account_id == account_id) + if search: + pattern = f"%{search}%" + query = query.filter( + ServiceRequest.reference.ilike(pattern) + | ServiceRequest.origin.ilike(pattern) + | ServiceRequest.destination.ilike(pattern) + ) + return query.order_by(ServiceRequest.created_at.desc()).all() + + +def get_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> ServiceRequest: + obj = ( + db.query(ServiceRequest) + .filter( + ServiceRequest.id == request_id, + ServiceRequest.tenant_id == tenant_id, + ServiceRequest.company_id == company_id, + ServiceRequest.deleted_at.is_(None), + ) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada") + return obj + + +def create_service_request( + db: Session, payload: ServiceRequestCreate, tenant_id: int, company_id: int, user_id: str | None = None +) -> ServiceRequest: + data = payload.model_dump() + _validate_request_refs(db, data, tenant_id, company_id) + obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_service_request( + db: Session, request_id: int, payload: ServiceRequestUpdate, tenant_id: int, company_id: int, user_id: str | None = None +) -> ServiceRequest: + obj = get_service_request(db, request_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + _validate_request_refs(db, data, tenant_id, company_id) + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj + + +def delete_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> None: + obj = get_service_request(db, request_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() + + +# ----- Rate requests ----- + +def get_rate_requests( + db: Session, tenant_id: int, company_id: int, service_request_id: int | None = None +) -> list[RateRequest]: + query = db.query(RateRequest).filter( + RateRequest.tenant_id == tenant_id, + RateRequest.company_id == company_id, + RateRequest.deleted_at.is_(None), + ) + if service_request_id is not None: + query = query.filter(RateRequest.service_request_id == service_request_id) + return query.order_by(RateRequest.id.asc()).all() + + +def get_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> RateRequest: + obj = ( + db.query(RateRequest) + .filter( + RateRequest.id == rate_id, + RateRequest.tenant_id == tenant_id, + RateRequest.company_id == company_id, + RateRequest.deleted_at.is_(None), + ) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud de tarifa no encontrada") + return obj + + +def create_rate_request(db: Session, payload: RateRequestCreate, tenant_id: int, company_id: int) -> RateRequest: + data = payload.model_dump() + # La solicitud de servicio debe existir en el tenant/company + get_service_request(db, data["service_request_id"], tenant_id, company_id) + if not _exists(db, Supplier, data.get("supplier_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") + obj = RateRequest(**data, tenant_id=tenant_id, company_id=company_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_rate_request( + db: Session, rate_id: int, payload: RateRequestUpdate, tenant_id: int, company_id: int +) -> RateRequest: + obj = get_rate_request(db, rate_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe") + for field, value in data.items(): + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +def delete_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> None: + obj = get_rate_request(db, rate_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/backend/api/v1/modules/ops/__init__.py b/backend/api/v1/modules/ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/ops/permissions.py b/backend/api/v1/modules/ops/permissions.py new file mode 100644 index 0000000..a32609d --- /dev/null +++ b/backend/api/v1/modules/ops/permissions.py @@ -0,0 +1,26 @@ +"""Registro de permisos del módulo Operaciones (ops).""" + +from api.v1.modules.core.permissions.registry import registry + +MODULE = "ops" + +_ENTITIES = [ + ("shipment", "embarques"), + ("document", "documentos de embarque"), +] +_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")] + + +def register_permissions() -> None: + registry.register(code=f"{MODULE}.access", description="Acceso a Operaciones", 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/ops/router.py b/backend/api/v1/modules/ops/router.py new file mode 100644 index 0000000..f64b4ba --- /dev/null +++ b/backend/api/v1/modules/ops/router.py @@ -0,0 +1,13 @@ +"""Router agregador del módulo Operaciones (Diagramas 2-4). + +Se monta bajo el prefijo ``/ops`` en ``api/v1/router.py``. +""" + +from fastapi import APIRouter + +from . import permissions # noqa: F401 (side-effect: registra permisos de ops) +from .shipments.routes import router as shipments_router + +router = APIRouter() + +router.include_router(shipments_router) diff --git a/backend/api/v1/modules/ops/shipments/__init__.py b/backend/api/v1/modules/ops/shipments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/ops/shipments/dto.py b/backend/api/v1/modules/ops/shipments/dto.py new file mode 100644 index 0000000..7b05ea4 --- /dev/null +++ b/backend/api/v1/modules/ops/shipments/dto.py @@ -0,0 +1,102 @@ +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ShipmentBase(BaseModel): + reference: str | None = Field(None, max_length=40) + quote_id: int | None = None + service_request_id: int | None = None + account_id: int | None = None + operation_type: str | None = Field(None, max_length=20) + transport_mode: str | None = Field(None, max_length=20) + service_type: str | None = Field(None, max_length=20) + incoterm: str | None = Field(None, max_length=10) + origin: str | None = Field(None, max_length=160) + destination: str | None = Field(None, max_length=160) + status: str = Field("abierta", max_length=20) + booking_number: str | None = Field(None, max_length=60) + carrier_supplier_id: int | None = None + customs_agent_id: int | None = None + destination_agent_id: int | None = None + cutoff_date: datetime | None = None + etd: date | None = None + eta: date | None = None + vessel_flight: str | None = Field(None, max_length=120) + container_number: str | None = Field(None, max_length=60) + notes: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class ShipmentCreate(ShipmentBase): + pass + + +class ShipmentUpdate(BaseModel): + reference: str | None = Field(None, max_length=40) + account_id: int | None = None + operation_type: str | None = Field(None, max_length=20) + transport_mode: str | None = Field(None, max_length=20) + service_type: str | None = Field(None, max_length=20) + incoterm: str | None = Field(None, max_length=10) + origin: str | None = Field(None, max_length=160) + destination: str | None = Field(None, max_length=160) + status: str | None = Field(None, max_length=20) + booking_number: str | None = Field(None, max_length=60) + carrier_supplier_id: int | None = None + customs_agent_id: int | None = None + destination_agent_id: int | None = None + cutoff_date: datetime | None = None + etd: date | None = None + eta: date | None = None + vessel_flight: str | None = Field(None, max_length=120) + container_number: str | None = Field(None, max_length=60) + notes: str | None = None + owner_user_id: str | None = Field(None, max_length=64) + + +class ShipmentResponse(ShipmentBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_by: str | None = None + updated_by: str | None = None + 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) + doc_type: str = Field(..., max_length=30) + number: str | None = Field(None, max_length=80) + issue_date: date | None = None + file_url: str | None = Field(None, max_length=1024) + file_key: str | None = Field(None, max_length=512) + notes: str | None = None + + +class ShipmentDocumentCreate(ShipmentDocumentBase): + pass + + +class ShipmentDocumentUpdate(BaseModel): + doc_kind: str | None = Field(None, max_length=10) + doc_type: str | None = Field(None, max_length=30) + number: str | None = Field(None, max_length=80) + issue_date: date | None = None + file_url: str | None = Field(None, max_length=1024) + file_key: str | None = Field(None, max_length=512) + notes: str | None = None + + +class ShipmentDocumentResponse(ShipmentDocumentBase): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/ops/shipments/models.py b/backend/api/v1/modules/ops/shipments/models.py new file mode 100644 index 0000000..ed4dc09 --- /dev/null +++ b/backend/api/v1/modules/ops/shipments/models.py @@ -0,0 +1,73 @@ +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, 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 Shipment(Base, TenantScopedMixin, TimestampMixin): + """Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada.""" + + __tablename__ = "shipments" + __table_args__ = {"schema": "ops"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque + quote_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True + ) + service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True + ) + account_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True + ) + operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True) + service_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True) + origin: Mapped[str | None] = mapped_column(String(160), nullable=True) + destination: Mapped[str | None] = mapped_column(String(160), nullable=True) + # abierta | booking | en_transito | arribado | entregada | cerrada | cancelada + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True) + booking_number: Mapped[str | None] = mapped_column(String(60), nullable=True) + carrier_supplier_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True + ) # naviera / aerolínea / transportista + customs_agent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True + ) # agente aduanal + destination_agent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True + ) # agente en destino + cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off + etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada + eta: Mapped[date | None] = mapped_column(Date, nullable=True) # llegada estimada + vessel_flight: Mapped[str | None] = mapped_column(String(120), nullable=True) # buque / vuelo + container_number: Mapped[str | None] = mapped_column(String(60), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, 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 ShipmentDocument(Base, TenantScopedMixin, TimestampMixin): + """Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).""" + + __tablename__ = "shipment_documents" + __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 + ) + doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro + # MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro + doc_type: Mapped[str] = mapped_column(String(30), nullable=False) + number: Mapped[str | None] = mapped_column(String(80), nullable=True) + issue_date: Mapped[date | None] = mapped_column(Date, nullable=True) + file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/api/v1/modules/ops/shipments/routes.py b/backend/api/v1/modules/ops/shipments/routes.py new file mode 100644 index 0000000..8571237 --- /dev/null +++ b/backend/api/v1/modules/ops/shipments/routes.py @@ -0,0 +1,132 @@ +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 ( + ShipmentCreate, + ShipmentDocumentCreate, + ShipmentDocumentResponse, + ShipmentDocumentUpdate, + ShipmentResponse, + ShipmentUpdate, +) + +router = APIRouter() + + +def _user_id(current_user: dict) -> str | None: + return current_user.get("sub") or current_user.get("id") + + +@router.get("/shipments", response_model=list[ShipmentResponse]) +def list_shipments( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None), + shipment_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), +): + tenant_id = current_user["tenant_id"] + return service.get_shipments(db, tenant_id, company_id, search, shipment_status, account_id) + + +@router.get("/shipments/{shipment_id}", response_model=ShipmentResponse) +def get_shipment( + 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.get_shipment(db, shipment_id, current_user["tenant_id"], company_id) + + +@router.post("/shipments", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED) +def create_shipment( + payload: ShipmentCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_shipment(db, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.post("/shipments/from-quote", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED) +def create_shipment_from_quote( + quote_id: int = Query(..., description="Cotización aceptada a liberar"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user)) + + +@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse) +def update_shipment( + shipment_id: int, + payload: ShipmentUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id = current_user["tenant_id"] + return service.update_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user)) + + +@router.delete("/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_shipment( + shipment_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(db, shipment_id, current_user["tenant_id"], company_id) + + +# ----- Documentos del embarque ----- + +@router.get("/shipments/{shipment_id}/documents", response_model=list[ShipmentDocumentResponse]) +def list_shipment_documents( + 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_documents(db, current_user["tenant_id"], company_id, shipment_id) + + +@router.post("/shipment-documents", response_model=ShipmentDocumentResponse, status_code=status.HTTP_201_CREATED) +def create_shipment_document( + payload: ShipmentDocumentCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.create_shipment_document(db, payload, current_user["tenant_id"], company_id) + + +@router.patch("/shipment-documents/{doc_id}", response_model=ShipmentDocumentResponse) +def update_shipment_document( + doc_id: int, + payload: ShipmentDocumentUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.update_shipment_document(db, doc_id, payload, current_user["tenant_id"], company_id) + + +@router.delete("/shipment-documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_shipment_document( + doc_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_document(db, doc_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 new file mode 100644 index 0000000..ea65e70 --- /dev/null +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -0,0 +1,230 @@ +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from api.v1.modules.crm.accounts.models import Account +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 + + +def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> 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: + checks = [ + ("account_id", Account, "El cliente asociado no existe"), + ("quote_id", Quote, "La cotización asociada no existe"), + ("service_request_id", ServiceRequest, "La solicitud asociada no existe"), + ("carrier_supplier_id", Supplier, "El transportista/naviera no existe"), + ("customs_agent_id", Supplier, "El agente aduanal no existe"), + ("destination_agent_id", Supplier, "El agente en destino no existe"), + ] + for field, model, msg in checks: + 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 get_shipments( + db: Session, + tenant_id: int, + company_id: int, + search: str | None = None, + shipment_status: str | None = None, + account_id: int | None = None, +) -> list[Shipment]: + query = db.query(Shipment).filter( + Shipment.tenant_id == tenant_id, + Shipment.company_id == company_id, + Shipment.deleted_at.is_(None), + ) + if shipment_status: + query = query.filter(Shipment.status == shipment_status) + if account_id is not None: + query = query.filter(Shipment.account_id == account_id) + if search: + pattern = f"%{search}%" + query = query.filter( + Shipment.reference.ilike(pattern) + | Shipment.booking_number.ilike(pattern) + | Shipment.origin.ilike(pattern) + | Shipment.destination.ilike(pattern) + ) + return query.order_by(Shipment.created_at.desc()).all() + + +def get_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> Shipment: + obj = ( + 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 obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado") + return obj + + +def create_shipment( + db: Session, payload: ShipmentCreate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Shipment: + data = payload.model_dump() + _validate_refs(db, data, tenant_id, company_id) + obj = Shipment(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_shipment( + db: Session, shipment_id: int, payload: ShipmentUpdate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Shipment: + obj = get_shipment(db, shipment_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + _validate_refs(db, data, tenant_id, company_id) + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj + + +def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> None: + obj = get_shipment(db, shipment_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() + + +def create_shipment_from_quote( + db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None +) -> Shipment: + """Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.""" + quote = ( + db.query(Quote) + .filter( + Quote.id == quote_id, + Quote.tenant_id == tenant_id, + Quote.company_id == company_id, + Quote.deleted_at.is_(None), + ) + .first() + ) + if not quote: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada") + if quote.status != "aceptada": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="La cotización debe estar aceptada para liberarse a Operaciones", + ) + + sr = None + if quote.service_request_id: + sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + + shipment = Shipment( + reference=quote.reference, + quote_id=quote.id, + service_request_id=quote.service_request_id, + account_id=quote.account_id, + operation_type=sr.operation_type if sr else None, + transport_mode=sr.transport_mode if sr else None, + service_type=sr.service_type if sr else None, + incoterm=sr.incoterm if sr else None, + origin=sr.origin if sr else None, + destination=sr.destination if sr else None, + destination_agent_id=sr.destination_agent_id if sr else None, + status="abierta", + owner_user_id=quote.owner_user_id, + tenant_id=tenant_id, + company_id=company_id, + created_by=user_id, + updated_by=user_id, + ) + db.add(shipment) + if sr: + sr.status = "liberada" + db.commit() + db.refresh(shipment) + return shipment + + +# ----- Documentos del embarque ----- + +def get_shipment_documents( + db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None +) -> list[ShipmentDocument]: + query = db.query(ShipmentDocument).filter( + ShipmentDocument.tenant_id == tenant_id, + ShipmentDocument.company_id == company_id, + ShipmentDocument.deleted_at.is_(None), + ) + if shipment_id is not None: + query = query.filter(ShipmentDocument.shipment_id == shipment_id) + return query.order_by(ShipmentDocument.id.asc()).all() + + +def _get_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> ShipmentDocument: + obj = ( + db.query(ShipmentDocument) + .filter( + ShipmentDocument.id == doc_id, + ShipmentDocument.tenant_id == tenant_id, + ShipmentDocument.company_id == company_id, + ShipmentDocument.deleted_at.is_(None), + ) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado") + return obj + + +def create_shipment_document( + db: Session, payload: ShipmentDocumentCreate, tenant_id: int, company_id: int +) -> ShipmentDocument: + get_shipment(db, payload.shipment_id, tenant_id, company_id) # valida scope + obj = ShipmentDocument(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_shipment_document( + db: Session, doc_id: int, payload: ShipmentDocumentUpdate, tenant_id: int, company_id: int +) -> ShipmentDocument: + obj = _get_document(db, doc_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 delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> None: + obj = _get_document(db, doc_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 687009c..7522e45 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -6,6 +6,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.example.routes import router as example_router @@ -13,6 +14,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(example_router, prefix="/example", tags=["example"]) diff --git a/backend/seed_crm.py b/backend/seed_crm.py index 2ad374e..758d4dd 100644 --- a/backend/seed_crm.py +++ b/backend/seed_crm.py @@ -22,7 +22,10 @@ from api.v1.modules.crm.documents.models import Document from api.v1.modules.crm.leads.models import Lead from api.v1.modules.crm.opportunities.models import Opportunity from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage +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 core.database import CoreSessionLocal # Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID @@ -254,6 +257,72 @@ def seed_suppliers_and_related(db) -> None: print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor") +def seed_commercial_and_ops(db) -> None: + """Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado.""" + if db.query(ServiceRequest).filter( + ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID, + ServiceRequest.deleted_at.is_(None), + ).first(): + print("• Ya existe flujo comercial; se omite") + return + + account = ( + db.query(Account) + .filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None)) + .order_by(Account.id.asc()) + .first() + ) + account_id = account.id if account else None + + # 1. Solicitud de servicio (RFQ) + sr = ServiceRequest( + reference="SOL-0001", account_id=account_id, operation_type="exportacion", + transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB", + origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general", + load_type="FCL", container_equipment="1x40'HC", status="cotizada", + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + db.add(sr) + db.flush() + + # 2. Cotización aceptada con conceptos + quote = Quote( + reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD", + status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + db.add(quote) + db.flush() + items = [ + ("flete_internacional", 1, 1800, 2200), + ("transporte_terrestre", 1, 350, 500), + ("despacho_aduanal", 1, 200, 320), + ] + total_cost = total_sale = 0 + for concept, qty, cost, sale in items: + db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost, + unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID)) + total_cost += qty * cost + total_sale += qty * sale + quote.total_cost = total_cost + quote.total_sale = total_sale + sr.status = "liberada" + + # 3. Embarque liberado a Operaciones + shipment = Shipment( + reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id, + operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type, + incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination, + status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + db.add(shipment) + db.flush() + db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL", + number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID)) + + db.commit() + print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL") + + def main() -> None: db = CoreSessionLocal() try: @@ -261,6 +330,7 @@ def main() -> None: pipeline, stages = ensure_pipeline(db) seed_sample_data(db, pipeline, stages) seed_suppliers_and_related(db) + seed_commercial_and_ops(db) print("\nSeed CRM completado.") finally: db.close() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f2ff212..2310ed9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -33,9 +33,12 @@ import api.v1.modules.crm.documents.models # noqa: E402,F401 import api.v1.modules.crm.leads.models # noqa: E402,F401 import api.v1.modules.crm.opportunities.models # noqa: E402,F401 import api.v1.modules.crm.pipelines.models # noqa: E402,F401 +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 -_SCHEMA_MAP = {"crm": None, "core": None} +_SCHEMA_MAP = {"crm": None, "core": None, "ops": 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_quotes.py b/backend/tests/test_quotes.py new file mode 100644 index 0000000..0d10639 --- /dev/null +++ b/backend/tests/test_quotes.py @@ -0,0 +1,46 @@ +from decimal import Decimal + +from api.v1.modules.crm.quotes import service +from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate + +T, C = 1, 1 + + +def test_quote_totals_recompute_on_items(db): + q = service.create_quote(db, QuoteCreate(reference="COT-001", currency="USD"), T, C) + service.create_quote_item( + db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=2, unit_cost=100, unit_sale=150), T, C + ) + service.create_quote_item( + db, QuoteItemCreate(quote_id=q.id, concept="despacho_aduanal", quantity=1, unit_cost=50, unit_sale=90), T, C + ) + q = service.get_quote(db, q.id, T, C) + assert float(q.total_cost) == 250.0 # 2*100 + 1*50 + assert float(q.total_sale) == 390.0 # 2*150 + 1*90 + + +def test_quote_totals_update_and_delete_item(db): + q = service.create_quote(db, QuoteCreate(reference="COT-002"), T, C) + item = service.create_quote_item( + db, QuoteItemCreate(quote_id=q.id, concept="otros", quantity=1, unit_cost=100, unit_sale=200), T, C + ) + service.update_quote_item(db, item.id, QuoteItemUpdate(unit_sale=Decimal("300")), T, C) + q = service.get_quote(db, q.id, T, C) + assert float(q.total_sale) == 300.0 + service.delete_quote_item(db, item.id, T, C) + q = service.get_quote(db, q.id, T, C) + assert float(q.total_sale) == 0.0 + + +def test_accept_quote_updates_service_request(db): + sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + q = service.create_quote(db, QuoteCreate(reference="COT-003", service_request_id=sr.id), T, C) + service.send_quote(db, q.id, T, C) + accepted = service.accept_quote(db, q.id, T, C) + assert accepted.status == "aceptada" + assert accepted.accepted_at is not None + # la solicitud asociada queda aceptada + sr = sr_service.get_service_request(db, sr.id, T, C) + assert sr.status == "aceptada" diff --git a/backend/tests/test_service_requests.py b/backend/tests/test_service_requests.py new file mode 100644 index 0000000..2e798d8 --- /dev/null +++ b/backend/tests/test_service_requests.py @@ -0,0 +1,66 @@ +import pytest +from fastapi import HTTPException + +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.service_requests import service +from api.v1.modules.crm.service_requests.dto import ( + RateRequestCreate, + ServiceRequestCreate, + ServiceRequestUpdate, +) + +T, C = 1, 1 + + +def test_create_service_request(db): + acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C) + sr = service.create_service_request( + db, + ServiceRequestCreate( + account_id=acc.id, operation_type="exportacion", transport_mode="maritimo", + service_type="puerta_puerta", origin="Manzanillo", destination="Long Beach", + incoterm="FOB", load_type="FCL", + ), + T, C, user_id="dev", + ) + assert sr.id is not None + assert sr.status == "nueva" + assert sr.created_by == "dev" + + +def test_service_request_rejects_unknown_account(db): + with pytest.raises(HTTPException) as exc: + service.create_service_request(db, ServiceRequestCreate(account_id=999, operation_type="importacion"), T, C) + assert exc.value.status_code == 422 + + +def test_filter_by_operation_and_status(db): + service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + service.create_service_request(db, ServiceRequestCreate(operation_type="importacion"), T, C) + exp = service.get_service_requests(db, T, C, operation_type="exportacion") + assert len(exp) == 1 and exp[0].operation_type == "exportacion" + + +def test_rate_request_requires_service_request(db): + with pytest.raises(HTTPException) as exc: + service.create_rate_request( + db, RateRequestCreate(service_request_id=999, concept="flete_internacional"), T, C + ) + assert exc.value.status_code == 404 + + +def test_rate_request_ok_and_listed(db): + sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + service.create_rate_request( + db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", rate_amount=1200, currency="USD"), + T, C, + ) + rates = service.get_rate_requests(db, T, C, service_request_id=sr.id) + assert len(rates) == 1 and rates[0].concept == "flete_internacional" + + +def test_update_service_request_status(db): + sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + upd = service.update_service_request(db, sr.id, ServiceRequestUpdate(status="en_analisis"), T, C) + assert upd.status == "en_analisis" diff --git a/backend/tests/test_shipments.py b/backend/tests/test_shipments.py new file mode 100644 index 0000000..b987b38 --- /dev/null +++ b/backend/tests/test_shipments.py @@ -0,0 +1,60 @@ +import pytest +from fastapi import HTTPException + +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 +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from api.v1.modules.ops.shipments import service +from api.v1.modules.ops.shipments.dto import ShipmentCreate, ShipmentDocumentCreate + +T, C = 1, 1 + + +def test_release_requires_accepted_quote(db): + q = quotes_service.create_quote(db, QuoteCreate(reference="COT-A"), T, C) + with pytest.raises(HTTPException) as exc: + service.create_shipment_from_quote(db, q.id, T, C) + assert exc.value.status_code == 422 # aún no aceptada + + +def test_release_from_accepted_quote_copies_data(db): + acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C) + sr = sr_service.create_service_request( + db, + ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", transport_mode="maritimo", + origin="Veracruz", destination="Rotterdam"), + T, C, + ) + q = quotes_service.create_quote(db, QuoteCreate(reference="COT-B", service_request_id=sr.id, account_id=acc.id), T, C) + quotes_service.accept_quote(db, q.id, T, C) + + shipment = service.create_shipment_from_quote(db, q.id, T, C, user_id="dev") + assert shipment.quote_id == q.id + assert shipment.account_id == acc.id + assert shipment.operation_type == "exportacion" + assert shipment.transport_mode == "maritimo" + assert shipment.origin == "Veracruz" + assert shipment.status == "abierta" + # la solicitud queda liberada + sr = sr_service.get_service_request(db, sr.id, T, C) + assert sr.status == "liberada" + + +def test_shipment_crud_and_documents(db): + shipment = service.create_shipment(db, ShipmentCreate(reference="EMB-001", status="abierta", origin="MX"), T, C) + assert shipment.id is not None + doc = service.create_shipment_document( + db, ShipmentDocumentCreate(shipment_id=shipment.id, doc_kind="master", doc_type="MBL", number="MBL123"), T, C + ) + assert doc.doc_type == "MBL" + docs = service.get_shipment_documents(db, T, C, shipment_id=shipment.id) + assert len(docs) == 1 + + +def test_shipment_rejects_unknown_quote(db): + with pytest.raises(HTTPException) as exc: + service.create_shipment(db, ShipmentCreate(quote_id=999), T, C) + assert exc.value.status_code == 422