feat(crm,ops): proceso comercial (solicitudes/RFQ, tarifas, cotizaciones) + Operaciones (embarques)
Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga: - crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores) - crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos, estados borrador→enviada→aceptada/rechazada - schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino) y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…) - liberar-a-operaciones: crea el embarque desde la cotización aceptada - migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo - 52 tests pytest en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
@@ -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")
|
||||||
@@ -16,6 +16,9 @@ _ENTITIES = [
|
|||||||
("contact", "contactos"),
|
("contact", "contactos"),
|
||||||
("address", "direcciones"),
|
("address", "direcciones"),
|
||||||
("document", "documentos"),
|
("document", "documentos"),
|
||||||
|
("service_request", "solicitudes de servicio"),
|
||||||
|
("rate_request", "solicitudes de tarifa"),
|
||||||
|
("quote", "cotizaciones"),
|
||||||
("lead", "prospectos"),
|
("lead", "prospectos"),
|
||||||
("opportunity", "oportunidades"),
|
("opportunity", "oportunidades"),
|
||||||
("pipeline", "embudos"),
|
("pipeline", "embudos"),
|
||||||
|
|||||||
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal file
@@ -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))
|
||||||
60
backend/api/v1/modules/crm/quotes/models.py
Normal file
60
backend/api/v1/modules/crm/quotes/models.py
Normal file
@@ -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)
|
||||||
151
backend/api/v1/modules/crm/quotes/routes.py
Normal file
151
backend/api/v1/modules/crm/quotes/routes.py
Normal file
@@ -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)
|
||||||
231
backend/api/v1/modules/crm/quotes/service.py
Normal file
231
backend/api/v1/modules/crm/quotes/service.py
Normal file
@@ -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()
|
||||||
@@ -17,6 +17,8 @@ from .leads.routes import router as leads_router
|
|||||||
from .metrics.routes import router as metrics_router
|
from .metrics.routes import router as metrics_router
|
||||||
from .opportunities.routes import router as opportunities_router
|
from .opportunities.routes import router as opportunities_router
|
||||||
from .pipelines.routes import router as pipelines_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
|
from .suppliers.routes import router as suppliers_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,6 +28,8 @@ router.include_router(suppliers_router)
|
|||||||
router.include_router(contacts_router)
|
router.include_router(contacts_router)
|
||||||
router.include_router(addresses_router)
|
router.include_router(addresses_router)
|
||||||
router.include_router(documents_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(leads_router)
|
||||||
router.include_router(pipelines_router)
|
router.include_router(pipelines_router)
|
||||||
router.include_router(opportunities_router)
|
router.include_router(opportunities_router)
|
||||||
|
|||||||
103
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
103
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
@@ -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
|
||||||
75
backend/api/v1/modules/crm/service_requests/models.py
Normal file
75
backend/api/v1/modules/crm/service_requests/models.py
Normal file
@@ -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)
|
||||||
129
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
129
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
@@ -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)
|
||||||
176
backend/api/v1/modules/crm/service_requests/service.py
Normal file
176
backend/api/v1/modules/crm/service_requests/service.py
Normal file
@@ -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()
|
||||||
0
backend/api/v1/modules/ops/__init__.py
Normal file
0
backend/api/v1/modules/ops/__init__.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
@@ -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()
|
||||||
13
backend/api/v1/modules/ops/router.py
Normal file
13
backend/api/v1/modules/ops/router.py
Normal file
@@ -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)
|
||||||
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal file
@@ -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
|
||||||
73
backend/api/v1/modules/ops/shipments/models.py
Normal file
73
backend/api/v1/modules/ops/shipments/models.py
Normal file
@@ -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)
|
||||||
132
backend/api/v1/modules/ops/shipments/routes.py
Normal file
132
backend/api/v1/modules/ops/shipments/routes.py
Normal file
@@ -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)
|
||||||
230
backend/api/v1/modules/ops/shipments/service.py
Normal file
230
backend/api/v1/modules/ops/shipments/service.py
Normal file
@@ -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()
|
||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from .modules.core.router import router as core_router
|
from .modules.core.router import router as core_router
|
||||||
from .modules.crm.router import router as crm_router
|
from .modules.crm.router import router as crm_router
|
||||||
|
from .modules.ops.router import router as ops_router
|
||||||
from .modules.example.routes import router as example_router
|
from .modules.example.routes import router as example_router
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
router.include_router(core_router)
|
router.include_router(core_router)
|
||||||
router.include_router(crm_router, prefix="/crm", tags=["crm"])
|
router.include_router(crm_router, prefix="/crm", tags=["crm"])
|
||||||
|
router.include_router(ops_router, prefix="/ops", tags=["ops"])
|
||||||
router.include_router(example_router, prefix="/example", tags=["example"])
|
router.include_router(example_router, prefix="/example", tags=["example"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.leads.models import Lead
|
||||||
from api.v1.modules.crm.opportunities.models import Opportunity
|
from api.v1.modules.crm.opportunities.models import Opportunity
|
||||||
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
|
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.crm.suppliers.models import Supplier
|
||||||
|
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
|
||||||
from core.database import CoreSessionLocal
|
from core.database import CoreSessionLocal
|
||||||
|
|
||||||
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
|
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
|
||||||
@@ -254,6 +257,72 @@ def seed_suppliers_and_related(db) -> None:
|
|||||||
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
|
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:
|
def main() -> None:
|
||||||
db = CoreSessionLocal()
|
db = CoreSessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -261,6 +330,7 @@ def main() -> None:
|
|||||||
pipeline, stages = ensure_pipeline(db)
|
pipeline, stages = ensure_pipeline(db)
|
||||||
seed_sample_data(db, pipeline, stages)
|
seed_sample_data(db, pipeline, stages)
|
||||||
seed_suppliers_and_related(db)
|
seed_suppliers_and_related(db)
|
||||||
|
seed_commercial_and_ops(db)
|
||||||
print("\nSeed CRM completado.")
|
print("\nSeed CRM completado.")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -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.leads.models # noqa: E402,F401
|
||||||
import api.v1.modules.crm.opportunities.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.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.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.
|
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
||||||
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
||||||
|
|||||||
46
backend/tests/test_quotes.py
Normal file
46
backend/tests/test_quotes.py
Normal file
@@ -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"
|
||||||
66
backend/tests/test_service_requests.py
Normal file
66
backend/tests/test_service_requests.py
Normal file
@@ -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"
|
||||||
60
backend/tests/test_shipments.py
Normal file
60
backend/tests/test_shipments.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user