feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial

Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
  mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).

Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.

Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).

Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).

Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-03 17:51:40 -06:00
parent 87d23b3d23
commit 915bdd19fe
34 changed files with 1364 additions and 100 deletions

View File

@@ -0,0 +1,158 @@
"""Campos del documento maestro de cotización en la solicitud + folios del ciclo comercial
Revision ID: b1c2d3e4f5a6
Revises: a0b1c2d3e4f5
Create Date: 2026-08-03 00:00:00.000000
Amplía crm.service_requests con los campos que exige el documento maestro de
cotización, agrega los back-links y la dirección impo/expo del ciclo
Oportunidad→Solicitud→Cotización→Operación, y crea crm.folio_counters para los
folios auto-generados ({LETRA}{AAAA}-{MM}-{NNN}-{DIR}).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b1c2d3e4f5a6"
down_revision: Union[str, None] = "a0b1c2d3e4f5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
# Columnas nuevas de crm.service_requests (nombre, tipo, kwargs).
_SR_COLUMNS = [
("contact_id", sa.Integer(), {}),
("request_date", sa.Date(), {}),
("currency", sa.String(length=3), {}),
("priority", sa.String(length=20), {}),
("origin_country", sa.String(length=3), {}),
("origin_city", sa.String(length=120), {}),
("origin_port", sa.String(length=20), {}),
("destination_country", sa.String(length=3), {}),
("destination_city", sa.String(length=120), {}),
("destination_port", sa.String(length=20), {}),
("pickup_location", sa.String(length=255), {}),
("delivery_location", sa.String(length=255), {}),
("estimated_shipment_date", sa.Date(), {}),
("cargo_value", sa.Numeric(14, 2), {}),
("insurance_required", sa.Boolean(), {"server_default": sa.text("false")}),
("hs_code", sa.String(length=20), {}),
("goods_origin_country", sa.String(length=3), {}),
("hazardous_imo", sa.Boolean(), {"server_default": sa.text("false")}),
("refrigerated", sa.Boolean(), {"server_default": sa.text("false")}),
("stackable", sa.Boolean(), {"server_default": sa.text("false")}),
("pieces_count", sa.Integer(), {}),
("boxes_count", sa.Integer(), {}),
("pallets_count", sa.Integer(), {}),
("net_weight", sa.Numeric(14, 3), {}),
("length_cm", sa.Numeric(10, 2), {}),
("width_cm", sa.Numeric(10, 2), {}),
("height_cm", sa.Numeric(10, 2), {}),
("measurement_unit", sa.String(length=20), {}),
("container_count", sa.Integer(), {}),
("packaging_type", sa.String(length=20), {}),
("oversized", sa.Boolean(), {"server_default": sa.text("false")}),
("weight_per_pallet", sa.Numeric(14, 3), {}),
("volume_per_pallet", sa.Numeric(14, 3), {}),
("additional_services", sa.JSON(), {}),
("payment_method", sa.String(length=20), {}),
("client_notes", sa.Text(), {}),
("internal_notes", sa.Text(), {}),
]
def upgrade() -> None:
# ----- crm.service_requests: campos del documento maestro de cotización -----
for name, col_type, kwargs in _SR_COLUMNS:
nullable = "server_default" not in kwargs # los boolean quedan NOT NULL con default false
op.add_column(
"service_requests",
sa.Column(name, col_type, nullable=nullable, **kwargs),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_service_requests_contact_id", "service_requests", "contacts",
["contact_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_service_requests_contact_id", "service_requests", ["contact_id"], schema=SCHEMA
)
# ----- crm.documents: adjuntos de una solicitud -----
op.add_column(
"documents", sa.Column("service_request_id", sa.Integer(), nullable=True), schema=SCHEMA
)
op.create_foreign_key(
"fk_crm_documents_service_request_id", "documents", "service_requests",
["service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_documents_service_request_id", "documents", ["service_request_id"], schema=SCHEMA
)
# ----- crm.opportunities: dirección impo/expo + folio + back-link a la solicitud -----
op.add_column("opportunities", sa.Column("operation_type", sa.String(length=20), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("reference", sa.String(length=40), nullable=True), schema=SCHEMA)
op.add_column(
"opportunities",
sa.Column("converted_service_request_id", sa.Integer(), nullable=True),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_opportunities_converted_sr", "opportunities", "service_requests",
["converted_service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_opportunities_reference", "opportunities", ["reference"], schema=SCHEMA
)
# ----- crm.quotes: variante FCL/LCL para la comparación "Ambas" -----
op.add_column("quotes", sa.Column("load_type", sa.String(length=10), nullable=True), schema=SCHEMA)
# ----- crm.folio_counters: consecutivo mensual por compañía y entidad -----
op.create_table(
"folio_counters",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity", sa.String(length=4), nullable=False),
sa.Column("period", sa.String(length=7), nullable=False),
sa.Column("last_number", sa.Integer(), nullable=False, server_default=sa.text("0")),
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.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
schema=SCHEMA,
)
op.create_index("ix_crm_folio_counters_id", "folio_counters", ["id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_tenant_id", "folio_counters", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_company_id", "folio_counters", ["company_id"], schema=SCHEMA)
def downgrade() -> None:
op.drop_index("ix_crm_folio_counters_company_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_tenant_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_id", table_name="folio_counters", schema=SCHEMA)
op.drop_table("folio_counters", schema=SCHEMA)
op.drop_column("quotes", "load_type", schema=SCHEMA)
op.drop_index("ix_crm_opportunities_reference", table_name="opportunities", schema=SCHEMA)
op.drop_constraint("fk_crm_opportunities_converted_sr", "opportunities", schema=SCHEMA, type_="foreignkey")
op.drop_column("opportunities", "converted_service_request_id", schema=SCHEMA)
op.drop_column("opportunities", "reference", schema=SCHEMA)
op.drop_column("opportunities", "operation_type", schema=SCHEMA)
op.drop_index("ix_crm_documents_service_request_id", table_name="documents", schema=SCHEMA)
op.drop_constraint("fk_crm_documents_service_request_id", "documents", schema=SCHEMA, type_="foreignkey")
op.drop_column("documents", "service_request_id", schema=SCHEMA)
op.drop_index("ix_crm_service_requests_contact_id", table_name="service_requests", schema=SCHEMA)
op.drop_constraint("fk_crm_service_requests_contact_id", "service_requests", schema=SCHEMA, type_="foreignkey")
for name, _col_type, _kwargs in reversed(_SR_COLUMNS):
op.drop_column("service_requests", name, schema=SCHEMA)

View File

@@ -766,3 +766,85 @@ TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
'puerto': 'Puertos donde opera',
'aeropuerto': 'Aeropuertos donde opera',
'aduana': 'Aduanas donde opera'}
# ---------------------------------------------------------------------------
# Catálogos del proceso comercial (Solicitud de servicio → Cotización).
# Alimentan los selects de la solicitud y del ciclo Oportunidad→Cotización.
# is_system = catálogos base que el cliente no puede borrar (sólo activar/desactivar).
# ---------------------------------------------------------------------------
GLOBAL_CATALOGS.update({
'tipo_operacion': {'label': 'Tipo de operación',
'is_system': True,
'items': [{'code': 'importacion', 'label': 'Importación'},
{'code': 'exportacion', 'label': 'Exportación'}]},
'medio_transporte': {'label': 'Medio de transporte',
'is_system': True,
'items': [{'code': 'maritimo', 'label': 'Marítimo'},
{'code': 'aereo', 'label': 'Aéreo'},
{'code': 'terrestre', 'label': 'Terrestre'},
{'code': 'ferroviario', 'label': 'Ferroviario'},
{'code': 'multimodal', 'label': 'Multimodal'}]},
'tipo_servicio': {'label': 'Tipo de servicio',
'is_system': True,
'items': [{'code': 'puerto_puerto', 'label': 'Puerto a puerto'},
{'code': 'puerto_puerta', 'label': 'Puerto a puerta'},
{'code': 'puerta_puerto', 'label': 'Puerta a puerto'},
{'code': 'puerta_puerta', 'label': 'Puerta a puerta'}]},
'prioridad': {'label': 'Prioridad',
'is_system': False,
'items': [{'code': 'baja', 'label': 'Baja'},
{'code': 'normal', 'label': 'Normal'},
{'code': 'alta', 'label': 'Alta'},
{'code': 'urgente', 'label': 'Urgente'}]},
'tipo_mercancia': {'label': 'Tipo de mercancía',
'is_system': False,
'items': [{'code': 'general', 'label': 'Carga general'},
{'code': 'perecedera', 'label': 'Perecedera'},
{'code': 'peligrosa', 'label': 'Peligrosa (IMO)'},
{'code': 'refrigerada', 'label': 'Refrigerada'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'sobredimensionada', 'label': 'Sobredimensionada'},
{'code': 'valiosa', 'label': 'Valiosa'},
{'code': 'otro', 'label': 'Otro'}]},
'unidad_medida': {'label': 'Unidad de medida',
'is_system': False,
'items': [{'code': 'cm', 'label': 'Centímetros (cm)'},
{'code': 'm', 'label': 'Metros (m)'},
{'code': 'in', 'label': 'Pulgadas (in)'},
{'code': 'ft', 'label': 'Pies (ft)'},
{'code': 'kg', 'label': 'Kilogramos (kg)'},
{'code': 'lb', 'label': 'Libras (lb)'},
{'code': 'm3', 'label': 'Metros cúbicos (m³)'}]},
'tipo_embalaje': {'label': 'Tipo de embalaje',
'is_system': False,
'items': [{'code': 'caja', 'label': 'Caja'},
{'code': 'pallet', 'label': 'Pallet'},
{'code': 'tarima', 'label': 'Tarima'},
{'code': 'huacal', 'label': 'Huacal'},
{'code': 'saco', 'label': 'Saco'},
{'code': 'tambor', 'label': 'Tambor'},
{'code': 'rollo', 'label': 'Rollo'},
{'code': 'atado', 'label': 'Atado'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'otro', 'label': 'Otro'}]},
'servicio_adicional': {'label': 'Servicios adicionales',
'is_system': False,
'items': [{'code': 'seguro', 'label': 'Seguro de la mercancía'},
{'code': 'despacho_aduanal', 'label': 'Despacho aduanal'},
{'code': 'transporte_terrestre', 'label': 'Transporte terrestre'},
{'code': 'almacenaje', 'label': 'Almacenaje'},
{'code': 'maniobras', 'label': 'Maniobras'},
{'code': 'custodia', 'label': 'Custodia'},
{'code': 'revalidacion', 'label': 'Revalidación'},
{'code': 'inspeccion', 'label': 'Inspección'},
{'code': 'otro', 'label': 'Otro'}]},
'tipo_documento': {'label': 'Tipo de documento',
'is_system': False,
'items': [{'code': 'factura_comercial', 'label': 'Factura comercial'},
{'code': 'packing_list', 'label': 'Packing list'},
{'code': 'certificado_origen', 'label': 'Certificado de origen'},
{'code': 'hoja_seguridad_msds', 'label': 'Hoja de seguridad (MSDS)'},
{'code': 'ficha_tecnica', 'label': 'Ficha técnica'},
{'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'},
{'code': 'otro', 'label': 'Otro'}]},
})

View File

@@ -0,0 +1,92 @@
"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación).
Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``):
- LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque.
- ``AAAA-MM``: año-mes de creación.
- ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes).
- ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección).
El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar
duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error;
la unicidad la garantiza el índice único (tenant, company, entity, period).
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin
from core.database import Base
# Entidades válidas y su letra de folio.
ENTITIES = ("O", "S", "C", "OP")
# Mapa dirección de operación → sufijo del folio.
_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"}
class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin):
"""Consecutivo mensual por compañía y entidad para armar los folios del ciclo."""
__tablename__ = "folio_counters"
__table_args__ = (
UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP
period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM'
last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
def direction_suffix(direction: str | None) -> str:
"""Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida."""
return _DIRECTION_SUFFIX.get(direction or "", "X")
def next_folio(
db,
tenant_id: int,
company_id: int,
entity: str,
direction: str | None,
on_date: date | None = None,
) -> str:
"""Genera el siguiente folio de una entidad, incrementando su consecutivo mensual.
Reserva el número dentro de la transacción activa (no hace commit): el ``create_*``
que lo invoca es quien confirma junto con la fila recién creada.
"""
if entity not in ENTITIES:
raise ValueError(f"Entidad de folio inválida: {entity!r}")
on_date = on_date or date.today()
period = on_date.strftime("%Y-%m")
counter = (
db.query(FolioCounter)
.filter(
FolioCounter.tenant_id == tenant_id,
FolioCounter.company_id == company_id,
FolioCounter.entity == entity,
FolioCounter.period == period,
)
.with_for_update()
.first()
)
if counter is None:
counter = FolioCounter(
tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0
)
db.add(counter)
db.flush()
counter.last_number = (counter.last_number or 0) + 1
db.flush()
sequence = f"{counter.last_number:03d}"
return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"

View File

@@ -22,6 +22,10 @@ class Document(Base, TenantScopedMixin, TimestampMixin):
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# Documento adjunto a una solicitud de servicio (factura, packing list, MSDS, etc.)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
# constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio |
# contrato | presentacion | certificacion | licencia | convenio | tarifario | otro
doc_type: Mapped[str] = mapped_column(String(60), nullable=False)

View File

@@ -17,6 +17,7 @@ class OpportunityCreate(BaseModel):
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
class OpportunityUpdate(BaseModel):
@@ -34,6 +35,7 @@ class OpportunityUpdate(BaseModel):
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20)
class OpportunityMove(BaseModel):
@@ -61,6 +63,9 @@ class OpportunityResponse(BaseModel):
source: str | None
owner_user_id: str | None
notes: str | None
operation_type: str | None = None
reference: str | None = None
converted_service_request_id: int | None = None
tenant_id: int
company_id: int
created_at: datetime

View File

@@ -38,3 +38,10 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O...
# Solicitud generada al convertir la oportunidad (back-link idempotente)
converted_service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True
)

View File

@@ -4,6 +4,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..pipelines.models import Pipeline, PipelineStage
from .dto import OpportunityCreate, OpportunityUpdate
@@ -149,6 +150,9 @@ def create_opportunity(
if opportunity.stage_id is not None:
stage = _get_scoped_stage(db, opportunity.stage_id, tenant_id, company_id)
_apply_stage_state(opportunity, stage)
# Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo.
if not opportunity.reference:
opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type)
db.add(opportunity)
db.commit()
db.refresh(opportunity)

View File

@@ -56,6 +56,7 @@ class QuoteBase(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str = Field("USD", max_length=3)
load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas")
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
@@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
load_type: str | None = Field(None, max_length=10)
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None

View File

@@ -22,6 +22,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'"))
# Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica)
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
# 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)

View File

@@ -59,6 +59,14 @@ def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) ->
return obj
def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None:
"""Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada)."""
head = ", ".join(p for p in (city, country) if p)
if port:
head = f"{head} ({port})" if head else port
return head or None
def _company_row(db: Session, company_id: int) -> dict:
try:
row = db.execute(
@@ -139,7 +147,8 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int)
route = [
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
("Origen", sr.origin), ("Destino", sr.destination),
("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)),
("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)),
("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None),
]

View File

@@ -110,6 +110,24 @@ def create_quote(
return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user))
@router.post(
"/quotes/from-service-request",
response_model=list[QuoteResponse],
status_code=status.HTTP_201_CREATED,
)
def create_quotes_from_service_request(
service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL)."""
tenant_id = current_user["tenant_id"]
return service.create_quotes_from_service_request(
db, service_request_id, tenant_id, company_id, _user_id(current_user)
)
@router.patch("/quotes/{quote_id}", response_model=QuoteResponse)
def update_quote(
quote_id: int,

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
@@ -6,7 +6,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..common.folios import next_folio
from ..service_requests.models import RateRequest, ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
from .models import Quote, QuoteItem
@@ -89,18 +90,104 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu
return obj
def _sr_direction(db: Session, service_request_id: int | None) -> str | None:
"""Dirección impo/expo heredada de la solicitud asociada (para el folio)."""
if not service_request_id:
return None
sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first()
return sr.operation_type if sr else None
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)
# Fecha de la cotización: por defecto hoy si no se capturó
if obj.issue_date is None:
obj.issue_date = date.today()
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def create_quotes_from_service_request(
db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> list[Quote]:
"""Genera cotización(es) a partir de una solicitud de servicio.
Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por
variante) para comparar. Cada cotización toma su propio folio C... y hereda la
dirección impo/expo de la solicitud. Los conceptos se siembran desde las
solicitudes de tarifa (RateRequest) capturadas en la solicitud.
"""
sr = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == service_request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None]
rate_requests = (
db.query(RateRequest)
.filter(
RateRequest.service_request_id == sr.id,
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
.all()
)
created: list[Quote] = []
for variant in variants:
quote = Quote(
account_id=sr.account_id,
service_request_id=sr.id,
currency=sr.currency or "USD",
load_type=variant,
status="borrador",
issue_date=date.today(),
notes=sr.client_notes or sr.notes,
owner_user_id=sr.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type),
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(quote)
db.flush()
for rr in rate_requests:
amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0)
db.add(QuoteItem(
quote_id=quote.id, concept=rr.concept, description=rr.description,
supplier_id=rr.supplier_id, quantity=Decimal(1),
unit_cost=amount, unit_sale=amount, currency=rr.currency,
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, quote)
created.append(quote)
db.commit()
for quote in created:
db.refresh(quote)
return created
def update_quote(
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:

View File

@@ -7,22 +7,65 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_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)
# Ruta legada (texto libre) — se conserva por compatibilidad
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
# Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS
container_equipment: str | None = Field(None, max_length=120)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
# Mercancía
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool = False
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool = False
refrigerated: bool = False
stackable: bool = False
# Dimensiones y bultos
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
# LCL
packaging_type: str | None = Field(None, max_length=20)
oversized: bool = False
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
# Servicios adicionales (códigos del catálogo servicio_adicional) y pago
additional_services: list[str] | None = None
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: str | None = None
status: str = Field("nueva", max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
@@ -38,8 +81,12 @@ class ServiceRequestContactInput(BaseModel):
class ServiceRequestFromOpportunityInput(BaseModel):
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
operation_type: str = Field(..., max_length=20) # importacion | exportacion
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).
La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es
solo un respaldo para oportunidades antiguas que no la tengan capturada.
"""
operation_type: str | None = Field(None, 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)
@@ -51,6 +98,7 @@ class ServiceRequestFromOpportunityInput(BaseModel):
class ServiceRequestUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
@@ -58,15 +106,51 @@ class ServiceRequestUpdate(BaseModel):
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
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)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool | None = None
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool | None = None
refrigerated: bool | None = None
stackable: bool | None = None
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
packaging_type: str | None = Field(None, max_length=20)
oversized: bool | None = None
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
additional_services: list[str] | None = None
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: 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)

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -57,6 +57,55 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ----- Campos del documento maestro de cotización (T2026-08) -----
# Datos generales
contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
)
request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente
# Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre)
origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Mercancía
cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria
goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía
hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
# Dimensiones y bultos
pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto)
length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
# FCL
container_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# LCL
packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
# Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago
additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True)
payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Notas
client_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class RateRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6)."""

View File

@@ -5,6 +5,8 @@ from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..catalogs.data import INCOTERM_CODES
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..opportunities.models import Opportunity
from ..suppliers.models import Supplier
from .dto import (
@@ -37,6 +39,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int
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, Contact, data.get("contact_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto 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")
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
@@ -103,6 +107,9 @@ def create_service_request(
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)
# Folio S... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type)
db.add(obj)
db.commit()
db.refresh(obj)
@@ -166,10 +173,24 @@ def create_from_opportunity(
)
if not opp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
# Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud
if opp.converted_service_request_id:
existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id)
return existing
# La dirección impo/expo se hereda de la oportunidad (respaldo: el payload)
operation_type = opp.operation_type or payload.operation_type
if not operation_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define la dirección (importación/exportación) en la oportunidad para convertirla",
)
obj = ServiceRequest(
account_id=opp.account_id,
contact_id=opp.contact_id,
opportunity_id=opp.id,
operation_type=payload.operation_type,
operation_type=operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
@@ -178,12 +199,16 @@ def create_from_opportunity(
status="nueva",
notes=payload.notes,
owner_user_id=opp.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "S", operation_type),
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(obj)
db.flush()
# Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia)
opp.converted_service_request_id = obj.id
db.commit()
db.refresh(obj)
return obj

View File

@@ -65,11 +65,14 @@ def create_shipment(
def create_shipment_from_quote(
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
company_id: int = Query(..., description="Company ID"),
operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"),
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))
return service.create_shipment_from_quote(
db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type
)
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)

View File

@@ -5,10 +5,14 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.common.folios import next_folio
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
# Direcciones válidas de la operación (para validar y sembrar hitos).
_OPERATION_TYPES = ("importacion", "exportacion")
from .dto import (
ShipmentCloseInput,
ShipmentCreate,
@@ -173,9 +177,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i
def create_shipment_from_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None,
operation_type: str | None = None,
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.
La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se
envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio
``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3).
"""
if operation_type is not None and operation_type not in _OPERATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'",
)
quote = (
db.query(Quote)
.filter(
@@ -198,12 +213,15 @@ def create_shipment_from_quote(
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
# La dirección enviada al liberar manda; si no viene, se hereda de la solicitud
resolved = operation_type or (sr.operation_type if sr else None)
shipment = Shipment(
reference=quote.reference,
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
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,
operation_type=resolved,
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,
@@ -220,6 +238,13 @@ def create_shipment_from_quote(
db.add(shipment)
if sr:
sr.status = "liberada"
db.flush()
# Siembra automática de hitos si ya se conoce la dirección de la operación
for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])):
db.add(ShipmentEvent(
shipment_id=shipment.id, event_type=event_type, title=title, kind=kind,
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
))
db.commit()
db.refresh(shipment)
return shipment

View File

@@ -28,6 +28,8 @@ from core.database import Base # noqa: E402
import api.v1.modules.crm.accounts.models # noqa: E402,F401
import api.v1.modules.crm.activities.models # noqa: E402,F401
import api.v1.modules.crm.addresses.models # noqa: E402,F401
import api.v1.modules.crm.catalogs.models # noqa: E402,F401
import api.v1.modules.crm.common.folios # noqa: E402,F401
import api.v1.modules.crm.contacts.models # noqa: E402,F401
import api.v1.modules.crm.documents.models # noqa: E402,F401
import api.v1.modules.crm.leads.models # noqa: E402,F401

View File

@@ -0,0 +1,49 @@
"""Pruebas de la siembra idempotente de catálogos globales del CRM."""
from api.v1.modules.crm.catalogs.models import CatalogItem
from api.v1.modules.crm.catalogs.seed import seed_global_catalogs
# Catálogos nuevos del proceso comercial y una clave base que debe existir en cada uno.
NEW_CATALOGS = {
"tipo_operacion": "importacion",
"medio_transporte": "maritimo",
"tipo_servicio": "puerto_puerto",
"prioridad": "urgente",
"tipo_mercancia": "peligrosa",
"unidad_medida": "kg",
"tipo_embalaje": "pallet",
"servicio_adicional": "seguro",
"tipo_documento": "factura_comercial",
}
def _codes(db, catalog: str) -> set[str]:
return {
row.code
for row in db.query(CatalogItem.code).filter(
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
)
}
def test_seed_creates_new_catalogs(db):
seed_global_catalogs(db)
for catalog, base_code in NEW_CATALOGS.items():
codes = _codes(db, catalog)
assert codes, f"El catálogo {catalog} quedó vacío"
assert base_code in codes, f"Falta la clave base {base_code} en {catalog}"
def test_seed_is_idempotent(db):
first = seed_global_catalogs(db)
assert first, "La primera corrida debió sembrar filas"
second = seed_global_catalogs(db)
assert second == {}, "La segunda corrida no debe agregar filas nuevas"
def test_pais_catalog_populated(db):
"""El catálogo pais alimenta Origen/Destino de la solicitud (decisión 6)."""
seed_global_catalogs(db)
codes = _codes(db, "pais")
assert len(codes) > 100
assert "MEX" in codes

View File

@@ -0,0 +1,43 @@
"""Pruebas del generador de folios del ciclo comercial (next_folio)."""
from datetime import date
from api.v1.modules.crm.common.folios import next_folio
T, C = 1, 1
def test_folio_format_and_direction(db):
folio = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 15))
assert folio == "O2025-08-001-E"
imp = next_folio(db, T, C, "S", "importacion", on_date=date(2025, 8, 15))
assert imp == "S2025-08-001-I"
sin_dir = next_folio(db, T, C, "C", None, on_date=date(2025, 8, 15))
assert sin_dir == "C2025-08-001-X"
def test_folio_monthly_consecutive_per_entity(db):
a = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 1))
b = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20))
assert a == "O2025-08-001-E"
assert b == "O2025-08-002-E" # mismo mes, mismo entity → +1
def test_folio_resets_on_month_change(db):
next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20))
sep = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 9, 1))
assert sep == "O2025-09-001-E" # nuevo mes → reinicia consecutivo
def test_folio_entities_do_not_share_counter(db):
o = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20))
s = next_folio(db, T, C, "S", "exportacion", on_date=date(2025, 8, 20))
op = next_folio(db, T, C, "OP", "importacion", on_date=date(2025, 8, 20))
assert o == "O2025-08-001-E"
assert s == "S2025-08-001-E" # entity distinto → su propio consecutivo
assert op == "OP2025-08-001-I"
def test_folio_unique_across_many(db):
folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)}
assert len(folios) == 25 # sin duplicados

View File

@@ -1,9 +1,13 @@
from datetime import date
from decimal import Decimal
import pytest
from fastapi import HTTPException
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
from api.v1.modules.crm.service_requests.dto import RateRequestCreate, ServiceRequestCreate
T, C = 1, 1
@@ -44,3 +48,70 @@ def test_accept_quote_updates_service_request(db):
# la solicitud asociada queda aceptada
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "aceptada"
# ----- Solicitud → Cotización -----
def _sr_with_rates(db, load_type="FCL"):
sr = sr_service.create_service_request(
db, ServiceRequestCreate(operation_type="importacion", load_type=load_type, currency="USD"), T, C
)
sr_service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional",
rate_amount=1200, currency="USD"), T, C
)
sr_service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="despacho_aduanal",
rate_amount=300, currency="USD"), T, C
)
return sr
def test_quote_from_service_request_seeds_items(db):
sr = _sr_with_rates(db)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C, user_id="dev")
assert len(quotes) == 1
q = quotes[0]
assert q.service_request_id == sr.id
assert q.reference.startswith("C") and q.reference.endswith("-I")
items = service.get_quote_items(db, q.id, T, C)
assert len(items) == 2
assert float(q.total_sale) == 1500.0 # 1200 + 300
def test_quote_from_service_request_without_rates(db):
sr = sr_service.create_service_request(
db, ServiceRequestCreate(operation_type="exportacion", load_type="FCL"), T, C
)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 1
assert service.get_quote_items(db, quotes[0].id, T, C) == []
def test_quote_from_service_request_not_found(db):
with pytest.raises(HTTPException) as exc:
service.create_quotes_from_service_request(db, 999, T, C)
assert exc.value.status_code == 404
def test_quote_from_service_request_ambas_genera_dos(db):
sr = _sr_with_rates(db, load_type="AMBAS")
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 2
variants = {q.load_type for q in quotes}
assert variants == {"FCL", "LCL"}
# cada variante siembra sus propios conceptos y toma su propio folio
assert quotes[0].reference != quotes[1].reference
for q in quotes:
assert len(service.get_quote_items(db, q.id, T, C)) == 2
def test_quote_from_service_request_sets_issue_date_today(db):
sr = _sr_with_rates(db)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert quotes[0].issue_date == date.today()
def test_create_quote_sets_issue_date_today(db):
q = service.create_quote(db, QuoteCreate(reference="COT-DATE"), T, C)
assert q.issue_date == date.today()

View File

@@ -3,10 +3,15 @@ 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.contacts import service as contacts_service
from api.v1.modules.crm.contacts.dto import ContactCreate
from api.v1.modules.crm.opportunities import service as opp_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.service_requests import service
from api.v1.modules.crm.service_requests.dto import (
RateRequestCreate,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestUpdate,
)
@@ -64,3 +69,88 @@ 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"
# ----- Campos del documento maestro de cotización -----
def test_create_service_request_new_fields(db):
sr = service.create_service_request(
db,
ServiceRequestCreate(
operation_type="importacion", load_type="LCL", priority="alta",
origin_country="CHN", origin_city="Shanghai",
destination_country="MEX", destination_city="Manzanillo",
cargo_value=15000, insurance_required=True, hazardous_imo=True,
pieces_count=12, net_weight=800, measurement_unit="kg",
additional_services=["seguro", "despacho_aduanal"],
payment_method="99", client_notes="Manejo con cuidado",
),
T, C,
)
assert sr.origin_country == "CHN"
assert sr.insurance_required is True
assert sr.hazardous_imo is True
assert sr.additional_services == ["seguro", "despacho_aduanal"]
assert sr.pieces_count == 12
def test_service_request_rejects_unknown_contact(db):
with pytest.raises(HTTPException) as exc:
service.create_service_request(
db, ServiceRequestCreate(operation_type="importacion", contact_id=999), T, C
)
assert exc.value.status_code == 422
def test_service_request_generates_folio(db):
sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
assert sr.reference is not None
assert sr.reference.startswith("S")
assert sr.reference.endswith("-E")
def test_service_request_accepts_ambas(db):
sr = service.create_service_request(
db, ServiceRequestCreate(operation_type="exportacion", load_type="AMBAS"), T, C
)
assert sr.load_type == "AMBAS"
def test_from_opportunity_inherits_operation_type_and_backlink(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
contact = contacts_service.create_contact(
db, ContactCreate(account_id=acc.id, first_name="Ana"), T, C
)
opp = opp_service.create_opportunity(
db,
OpportunityCreate(name="Negocio", account_id=acc.id, contact_id=contact.id,
operation_type="importacion"),
T, C,
)
sr = service.create_from_opportunity(
db, opp.id, ServiceRequestFromOpportunityInput(transport_mode="aereo"), T, C, user_id="dev"
)
# Hereda dirección y contacto de la oportunidad
assert sr.operation_type == "importacion"
assert sr.contact_id == contact.id
assert sr.opportunity_id == opp.id
assert sr.reference.startswith("S") and sr.reference.endswith("-I")
# Back-link en la oportunidad
refreshed = opp_service.get_opportunity(db, opp.id, T, C)
assert refreshed.converted_service_request_id == sr.id
def test_from_opportunity_idempotent(db):
opp = opp_service.create_opportunity(
db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C
)
first = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
second = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
assert first.id == second.id # no crea una segunda solicitud
def test_from_opportunity_without_direction_fails(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Sin dirección"), T, C)
with pytest.raises(HTTPException) as exc:
service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
assert exc.value.status_code == 422

View File

@@ -58,3 +58,47 @@ 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
# ----- Cotización → Operación: dirección IMPO/EXPO + auto-hitos + folio OP -----
def _accepted_quote(db, sr=None):
kwargs = {"reference": "COT-Z"}
if sr is not None:
kwargs["service_request_id"] = sr.id
q = quotes_service.create_quote(db, QuoteCreate(**kwargs), T, C)
quotes_service.accept_quote(db, q.id, T, C)
return q
def test_from_quote_explicit_operation_type_generates_milestones(db):
q = _accepted_quote(db)
shipment = service.create_shipment_from_quote(db, q.id, T, C, operation_type="importacion")
assert shipment.operation_type == "importacion"
assert shipment.reference.startswith("OP") and shipment.reference.endswith("-I")
events = service.get_shipment_events(db, T, C, shipment.id)
assert len(events) == 11 # hitos de importación (Diagrama 3)
def test_from_quote_inherits_sr_operation_type(db):
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
q = _accepted_quote(db, sr=sr)
shipment = service.create_shipment_from_quote(db, q.id, T, C) # sin operation_type explícito
assert shipment.operation_type == "exportacion"
events = service.get_shipment_events(db, T, C, shipment.id)
assert len(events) == 19 # hitos de exportación (Diagrama 2)
def test_from_quote_no_operation_type_no_milestones(db):
q = _accepted_quote(db) # sin solicitud → sin dirección
shipment = service.create_shipment_from_quote(db, q.id, T, C)
assert shipment.operation_type is None
assert service.get_shipment_events(db, T, C, shipment.id) == [] # sin hitos, sin excepción
assert shipment.reference.endswith("-X")
def test_from_quote_invalid_operation_type(db):
q = _accepted_quote(db)
with pytest.raises(HTTPException) as exc:
service.create_shipment_from_quote(db, q.id, T, C, operation_type="foo")
assert exc.value.status_code == 422