3 Commits

Author SHA1 Message Date
Ernesto Herrera
f1e6fba75d feat(crm): cotización aérea con peso/volumen (P/Vol) operacional
- Modalidad AÉREO (4ª opción en "¿Cómo desea cotizar?"): en la solicitud
  muestra la sección aérea con el cálculo en vivo P/Vol = (L×A×H cm × bultos)/6000
  y el peso a cobrar = max(peso bruto, P/Vol). Fija el transporte en aéreo.
- Utilidad compartida crm/common/pricing.py (air_volumetric_kg / air_chargeable_kg,
  factor internacional 6000).
- Motor de costeo (rates): la rama aérea usa el P/Vol por dimensiones si vienen
  (CostRequest ahora acepta length/width/height_cm); respaldo m³×167 cuando no.
- Cotizador: captura por dimensiones (L×A×H + bultos) en modo aéreo y muestra el P/Vol.
- Solicitud→Cotización: si es AÉREO, siembra el concepto de flete con cantidad =
  peso a cobrar (P/Vol) para capturar la tarifa por kg.
- Pruebas: test_pricing (ejemplo del doc → 720; max bruto/volumétrico) + cotización
  aérea desde solicitud. Suite en verde (108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:30:25 -06:00
Ernesto Herrera
36e98ee976 feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo
- Solicitud: al marcar un servicio adicional se habilita su costo estimado
  (columna JSON additional_service_costs). Al cotizar, cada servicio marcado se
  siembra como concepto de la cotización con ese costo de partida (costo=venta).
- Folios visibles: se muestran en la tarjeta de Oportunidad del kanban y se aclara
  en el formulario que el folio se asigna al guardar (las listas ya lo mostraban).
- Sidebar CRM reordenado por flujo comercial (captación → embudo → solicitud →
  cotización → catálogos de apoyo).
- Migración c2d3e4f5a6b7 aditiva y reversible. Suite backend en verde (102).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:00:42 -06:00
Ernesto Herrera
915bdd19fe 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>
2026-08-03 17:51:40 -06:00
42 changed files with 1635 additions and 107 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

@@ -0,0 +1,33 @@
"""Costo estimado por servicio adicional en la solicitud de servicio
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-08-04 00:00:00.000000
Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo})
para capturar el costo estimado de cada servicio adicional marcado; ese costo se
usa como punto de partida al sembrar los conceptos de la cotización.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c2d3e4f5a6b7"
down_revision: Union[str, None] = "b1c2d3e4f5a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column(
"service_requests",
sa.Column("additional_service_costs", sa.JSON(), nullable=True),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_column("service_requests", "additional_service_costs", 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

@@ -0,0 +1,37 @@
"""Cálculos de precio compartidos del proceso comercial.
Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización):
P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000
El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo).
6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³).
"""
from __future__ import annotations
from decimal import Decimal
# Factor internacional de peso volumétrico aéreo (cm³ por kg).
AIR_VOLUMETRIC_DIVISOR = Decimal("6000")
def _d(value) -> Decimal:
if value is None:
return Decimal(0)
return value if isinstance(value, Decimal) else Decimal(str(value))
def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos.
Devuelve 0 si falta alguna dimensión (no se puede calcular).
"""
length, width, height = _d(length_cm), _d(width_cm), _d(height_cm)
if length <= 0 or width <= 0 or height <= 0:
return Decimal(0)
quantity = _d(qty) if _d(qty) > 0 else Decimal(1)
return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR
def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones)."""
return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty))

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,10 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..catalogs.models import CatalogItem
from ..common.folios import next_folio
from ..common.pricing import air_chargeable_kg
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 +92,134 @@ 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()
)
# Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos
service_labels = {
code: label
for code, label in db.query(CatalogItem.code, CatalogItem.label).filter(
CatalogItem.catalog == "servicio_adicional"
)
}
service_costs = sr.additional_service_costs or {}
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,
))
# Servicios adicionales marcados en la solicitud → conceptos con su costo estimado
for code in (sr.additional_services or []):
amount = Decimal(str(service_costs.get(code) or 0))
db.add(QuoteItem(
quote_id=quote.id, concept=code[:60],
description=service_labels.get(code, "Servicio adicional"),
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
# para que el ejecutivo capture la tarifa por kg.
if (variant or "").upper() == "AEREO":
chargeable = air_chargeable_kg(
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
sr.pallets_count or sr.pieces_count or 1,
)
db.add(QuoteItem(
quote_id=quote.id, concept="flete_internacional",
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
currency=sr.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

@@ -146,6 +146,10 @@ class CostRequest(BaseModel):
on_date: date | None = None
gross_weight_kg: Decimal | None = None
volume_m3: Decimal | None = None
# Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000)
length_cm: Decimal | None = None
width_cm: Decimal | None = None
height_cm: Decimal | None = None
equipment_type: str | None = None
quantity: int = 1
dangerous: bool = False

View File

@@ -20,9 +20,11 @@ from .dto import (
RateSheetCreate,
RateSheetUpdate,
)
from ..common.pricing import air_volumetric_kg
from .models import RateBreak, RateCharge, RateLane, RateSheet
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm).
AIR_VOLUMETRIC_FACTOR = Decimal("167")
@@ -518,11 +520,15 @@ def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -
base = max(base, lane.min_charge or Decimal(0))
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
else: # aereo
chargeable = max(gross, _volumetric_kg(req.volume_m3))
# P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones,
# respaldo con el volumen en m³ × 167.
vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity)
volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3)
chargeable = max(gross, volumetric)
brks = breaks_of(db, lane.id)
base = _best_break_cost(brks, chargeable)
base = max(base, lane.min_charge or Decimal(0))
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg"
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)"
charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous)
total = base + sum((c.amount for c in charge_lines), Decimal(0))

View File

@@ -7,22 +7,66 @@ 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
additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado}
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 +82,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 +99,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 +107,52 @@ 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
additional_service_costs: dict[str, float] | 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,57 @@ 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)
# Costo estimado por servicio adicional marcado: {codigo: costo}
additional_service_costs: Mapped[dict | 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

@@ -0,0 +1,29 @@
"""Pruebas del cálculo de peso/volumen (P/Vol) aéreo."""
from decimal import Decimal
from api.v1.modules.crm.common.pricing import air_chargeable_kg, air_volumetric_kg
def test_air_volumetric_doc_example():
# 3 pallets 120×120×100 cm → (120*120*100*3)/6000 = 720 (ejemplo del documento)
assert air_volumetric_kg(120, 120, 100, 3) == Decimal(720)
def test_air_volumetric_zero_without_dimensions():
assert air_volumetric_kg(None, 120, 100, 3) == Decimal(0)
assert air_volumetric_kg(0, 120, 100, 3) == Decimal(0)
def test_air_qty_defaults_to_one():
assert air_volumetric_kg(100, 100, 100, 0) == air_volumetric_kg(100, 100, 100, 1)
def test_air_chargeable_takes_gross_when_larger():
# bruto 800 > volumétrico 720 → se cobra 800
assert air_chargeable_kg(800, 120, 120, 100, 3) == Decimal(800)
def test_air_chargeable_takes_volumetric_when_larger():
# bruto 200 < volumétrico 720 → se cobra 720
assert air_chargeable_kg(200, 120, 120, 100, 3) == Decimal(720)

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,107 @@ 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_seeds_additional_services(db):
sr = sr_service.create_service_request(
db,
ServiceRequestCreate(
operation_type="importacion", load_type="FCL", currency="USD",
additional_services=["seguro", "despacho_aduanal"],
additional_service_costs={"seguro": 500, "despacho_aduanal": 300},
),
T, C,
)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
items = service.get_quote_items(db, quotes[0].id, T, C)
costs = {i.concept: float(i.unit_cost) for i in items}
assert costs.get("seguro") == 500.0
assert costs.get("despacho_aduanal") == 300.0
# el costo estimado de la solicitud es el punto de partida (costo=venta)
assert float(quotes[0].total_sale) == 800.0
def test_quote_from_service_request_aereo_seeds_pvol_concept(db):
sr = sr_service.create_service_request(
db,
ServiceRequestCreate(
operation_type="exportacion", load_type="AEREO", currency="USD",
weight=200, length_cm=120, width_cm=120, height_cm=100, pallets_count=3,
),
T, C,
)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 1
assert quotes[0].load_type == "AEREO"
flete = [i for i in service.get_quote_items(db, quotes[0].id, T, C) if i.concept == "flete_internacional"]
assert len(flete) == 1
# cantidad del flete = peso a cobrar (P/Vol 720 > bruto 200)
assert float(flete[0].quantity) == 720.0
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

View File

@@ -11,6 +11,7 @@ export interface ServiceRequest {
id: number;
reference: string | null;
account_id: number | null;
contact_id: number | null;
opportunity_id: number | null;
operation_type: string;
transport_mode: string | null;
@@ -18,15 +19,52 @@ export interface ServiceRequest {
incoterm: string | null;
origin: string | null;
destination: string | null;
origin_country: string | null;
origin_city: string | null;
origin_port: string | null;
destination_country: string | null;
destination_city: string | null;
destination_port: string | null;
pickup_location: string | null;
delivery_location: string | null;
cargo_type: string | null;
weight: number | null;
volume: number | null;
load_type: string | null;
container_equipment: string | null;
container_count: number | null;
commodity: string | null;
required_date: string | null;
request_date: string | null;
estimated_shipment_date: string | null;
currency: string | null;
priority: string | null;
cargo_value: number | null;
insurance_required: boolean;
hs_code: string | null;
goods_origin_country: string | null;
hazardous_imo: boolean;
refrigerated: boolean;
stackable: boolean;
pieces_count: number | null;
boxes_count: number | null;
pallets_count: number | null;
net_weight: number | null;
length_cm: number | null;
width_cm: number | null;
height_cm: number | null;
measurement_unit: string | null;
packaging_type: string | null;
oversized: boolean;
weight_per_pallet: number | null;
volume_per_pallet: number | null;
additional_services: string[] | null;
additional_service_costs: Record<string, number> | null;
payment_method: string | null;
destination_agent_id: number | null;
requirements: string | null;
client_notes: string | null;
internal_notes: string | null;
first_contact_at: string | null;
first_contact_notes: string | null;
status: ServiceRequestStatus;
@@ -70,6 +108,7 @@ export interface Quote {
service_request_id: number | null;
account_id: number | null;
currency: string;
load_type: string | null;
status: QuoteStatus;
issue_date: string | null;
valid_until: string | null;
@@ -133,7 +172,7 @@ export const serviceRequestsAPI = {
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })),
requote: (id: number, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/requote?${qp(companyId)}`, {})),
fromOpportunity: (opportunityId: number, data: { operation_type: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) =>
fromOpportunity: (opportunityId: number, data: { operation_type?: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/from-opportunity?${qp(companyId, { opportunity_id: opportunityId })}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`))
};
@@ -156,6 +195,8 @@ export const quotesAPI = {
accept: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})),
reject: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})),
clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})),
fromServiceRequest: (serviceRequestId: number, companyId: number) =>
unwrap<Quote[]>(api.post(`/v1/crm/quotes/from-service-request?${qp(companyId, { service_request_id: serviceRequestId })}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)),
pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise<Blob>,

View File

@@ -35,8 +35,9 @@ export interface ImportPreview { mode: RateMode; total: number; valid: number; r
export interface CostRequest {
mode: RateMode; origin?: string | null; destination?: string | null; on_date?: string | null;
gross_weight_kg?: number | null; volume_m3?: number | null; equipment_type?: string | null;
quantity?: number; dangerous?: boolean;
gross_weight_kg?: number | null; volume_m3?: number | null;
length_cm?: number | null; width_cm?: number | null; height_cm?: number | null;
equipment_type?: string | null; quantity?: number; dangerous?: boolean;
}
export interface CostChargeLine { concept: string; amount: number; }
export interface CostOption {

View File

@@ -265,6 +265,9 @@ export interface Opportunity {
source: string | null;
owner_user_id: string | null;
notes: string | null;
operation_type: string | null;
reference: string | null;
converted_service_request_id: number | null;
tenant_id: number;
company_id: number;
created_at: string;

View File

@@ -106,8 +106,8 @@ export const shipmentsAPI = {
unwrap<Shipment[]>(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Shipment>(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
create: (data: ShipmentInput, companyId: number) => unwrap<Shipment>(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)),
createFromQuote: (quoteId: number, companyId: number) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})),
createFromQuote: (quoteId: number, companyId: number, operationType?: string) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId, operation_type: operationType })}`, {})),
update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)),
reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)),

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ServiceRequestInput, Account, Contact, Supplier } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES, PRIORITIES, SR_STATUS } from '$lib/components/crm/format';
let {
form = $bindable(),
tab,
accounts = [],
contacts = [],
suppliers = []
}: {
form: ServiceRequestInput;
tab: string;
accounts?: Account[];
contacts?: Contact[];
suppliers?: Supplier[];
} = $props();
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS');
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS');
const isAir = $derived(form.load_type === 'AEREO');
// Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const airVolumetric = $derived(
Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0
? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000
: 0
);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// La modalidad aérea fija el medio de transporte en "aéreo"
$effect(() => {
if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo';
});
// Contactos del cliente seleccionado (o todos si no hay cliente)
const clientContacts = $derived(
form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts
);
function contactName(c: Contact): string {
return [c.first_name, c.last_name].filter(Boolean).join(' ');
}
// Costo estimado por servicio adicional (se traspasa a la cotización)
function serviceCost(code: string): number | undefined {
return form.additional_service_costs?.[code];
}
function setServiceCost(code: string, value: string) {
const map = { ...(form.additional_service_costs ?? {}) };
const n = value === '' ? NaN : Number(value);
if (Number.isNaN(n)) delete map[code];
else map[code] = n;
form.additional_service_costs = map;
}
onMount(() => {
void crmCatalogs.preload([
'pais', 'moneda', 'prioridad', 'tipo_mercancia', 'unidad_medida',
'tipo_embalaje', 'servicio_adicional', 'forma_pago', 'tipo_equipo',
'puerto', 'aeropuerto'
]);
if (!form.additional_services) form.additional_services = [];
if (!form.additional_service_costs) form.additional_service_costs = {};
});
</script>
{#if tab === 'datos'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class="{inputCls} bg-muted/40" bind:value={form.reference} readonly placeholder="Se asigna automáticamente al guardar (ej. S2026-08-001-E)" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación *</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contacto</span><select class={inputCls} bind:value={form.contact_id}><option value={undefined}>—</option>{#each clientContacts as c (c.id)}<option value={c.id}>{contactName(c)}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la solicitud</span><input type="date" class={inputCls} bind:value={form.request_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Prioridad</span><select class={inputCls} bind:value={form.priority}><option value={undefined}>—</option>{#each (crmCatalogs.options('prioridad').length ? crmCatalogs.options('prioridad') : PRIORITIES) as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ejecutivo (responsable)</span><input class={inputCls} bind:value={form.owner_user_id} placeholder="Usuario responsable" /></label>
{#if form.status}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
{/if}
</div>
{:else if tab === 'ruta'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} placeholder="FOB, CIF…" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad de carga</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Origen</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen</span><select class={inputCls} bind:value={form.origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><input class={inputCls} bind:value={form.origin_city} /></label>
{#if crmCatalogs.options('puerto').length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><select class={inputCls} bind:value={form.origin_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><input class={inputCls} bind:value={form.origin_port} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Lugar de recolección</span><input class={inputCls} bind:value={form.pickup_location} /></label>
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Destino</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de destino</span><select class={inputCls} bind:value={form.destination_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><input class={inputCls} bind:value={form.destination_city} /></label>
{#if crmCatalogs.options('puerto').length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><select class={inputCls} bind:value={form.destination_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><input class={inputCls} bind:value={form.destination_port} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Lugar de entrega</span><input class={inputCls} bind:value={form.delivery_location} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha estimada de embarque</span><input type="date" class={inputCls} bind:value={form.estimated_shipment_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
</div>
{:else if tab === 'mercancia'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de mercancía</span><select class={inputCls} bind:value={form.cargo_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_mercancia') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fracción arancelaria (HS)</span><input class="font-mono {inputCls}" maxlength="20" bind:value={form.hs_code} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen de la mercancía</span><select class={inputCls} bind:value={form.goods_origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor de la mercancía</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.cargo_value} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Descripción de la mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<div class="flex flex-wrap gap-5 sm:col-span-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.insurance_required} /><span>Requiere seguro</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.hazardous_imo} /><span>Mercancía peligrosa (IMO)</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.refrigerated} /><span>Refrigerada</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.stackable} /><span>Estibable</span></label>
</div>
</div>
{:else if tab === 'dimensiones'}
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Piezas</span><input type="number" min="0" class={inputCls} bind:value={form.pieces_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cajas</span><input type="number" min="0" class={inputCls} bind:value={form.boxes_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Pallets</span><input type="number" min="0" class={inputCls} bind:value={form.pallets_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso neto (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.net_weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Largo</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.length_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ancho</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.width_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Alto</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.height_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Unidad de medida</span><select class={inputCls} bind:value={form.measurement_unit}><option value={undefined}>—</option>{#each crmCatalogs.options('unidad_medida') as u (u.value)}<option value={u.value}>{u.label}</option>{/each}</select></label>
</div>
{#if isFcl}
<div class="mt-5 grid gap-4 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">FCL — Contenedor completo</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de contenedor / equipo</span>
{#if crmCatalogs.options('tipo_equipo').length}
<select class={inputCls} bind:value={form.container_equipment}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_equipo') as e (e.value)}<option value={e.value}>{e.label}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={form.container_equipment} placeholder="40'HC, 20'DV…" />
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad de contenedores</span><input type="number" min="0" class={inputCls} bind:value={form.container_count} /></label>
</div>
{/if}
{#if isLcl}
<div class="mt-4 grid gap-4 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">LCL — Carga consolidada</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de embalaje</span><select class={inputCls} bind:value={form.packaging_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_embalaje') as e (e.value)}<option value={e.value}>{e.label}</option>{/each}</select></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.oversized} /><span>Sobredimensionada</span></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso por pallet (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight_per_pallet} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen por pallet (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume_per_pallet} /></label>
</div>
{/if}
{#if isAir}
<div class="mt-4 grid gap-3 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">Aéreo — Peso / Volumen (P/Vol)</p>
<p class="text-xs text-muted-foreground sm:col-span-2">P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.</p>
<div class="rounded-md bg-muted/40 p-3 text-sm sm:col-span-2">
<div class="flex justify-between"><span class="text-muted-foreground">Cantidad de bultos</span><span class="font-medium">{airQty}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso bruto</span><span class="font-medium">{(Number(form.weight) || 0).toFixed(2)} kg</span></div>
<div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div>
</div>
</div>
{/if}
{:else if tab === 'servicios'}
<p class="mb-1 text-sm font-medium">Servicios adicionales</p>
<p class="mb-3 text-xs text-muted-foreground">Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.</p>
<div class="space-y-2">
{#each crmCatalogs.options('servicio_adicional') as s (s.value)}
{@const checked = (form.additional_services ?? []).includes(s.value)}
<div class="flex flex-wrap items-center gap-3">
<label class="flex w-60 items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" value={s.value} bind:group={form.additional_services} /><span>{s.label}</span></label>
{#if checked}
<div class="flex items-center gap-1">
<input type="number" min="0" step="0.01" class="{inputCls} w-40" placeholder="Costo estimado" value={serviceCost(s.value) ?? ''} oninput={(e) => setServiceCost(s.value, e.currentTarget.value)} />
<span class="text-xs text-muted-foreground">{form.currency ?? ''}</span>
</div>
{/if}
</div>
{/each}
</div>
<label class="mt-5 flex max-w-md flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><select class={inputCls} bind:value={form.payment_method}><option value={undefined}>—</option>{#each crmCatalogs.options('forma_pago') as f (f.value)}<option value={f.value}>{f.value} {f.label}</option>{/each}</select></label>
{:else if tab === 'notas'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas del cliente</span><textarea rows="4" class={inputCls} bind:value={form.client_notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="3" class={inputCls} bind:value={form.requirements}></textarea></label>
</div>
{/if}

View File

@@ -181,7 +181,26 @@ export const SERVICE_TYPES: Option[] = [
export const LOAD_TYPES: Option[] = [
{ value: 'FCL', label: 'FCL (contenedor completo)' },
{ value: 'LCL', label: 'LCL (carga consolidada)' }
{ value: 'LCL', label: 'LCL (carga consolidada)' },
{ value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' },
{ value: 'AEREO', label: 'Aéreo (carga aérea)' }
];
export const PRIORITIES: Option[] = [
{ value: 'baja', label: 'Baja' },
{ value: 'normal', label: 'Normal' },
{ value: 'alta', label: 'Alta' },
{ value: 'urgente', label: 'Urgente' }
];
// Pestañas del formulario de solicitud de servicio (documento maestro de cotización)
export const SR_FORM_TABS: Option[] = [
{ value: 'datos', label: 'Datos' },
{ value: 'ruta', label: 'Servicio y ruta' },
{ value: 'mercancia', label: 'Mercancía' },
{ value: 'dimensiones', label: 'Dimensiones' },
{ value: 'servicios', label: 'Servicios' },
{ value: 'notas', label: 'Notas' }
];
export const SR_STATUS: Option[] = [

View File

@@ -42,17 +42,18 @@ export function getNavMain(): NavMainItem[] {
title: 'CRM',
url: '/dashboard/crm',
icon: Briefcase,
// Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' },
{ title: 'Tarifarios', url: '/dashboard/crm/tarifarios' },
{ title: 'Cotizador', url: '/dashboard/crm/cotizador' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
{ title: 'Catálogos', url: '/dashboard/crm/catalogos' },
],

View File

@@ -113,13 +113,24 @@
}
}
async function release() {
let showRelease = $state(false);
let releaseDir = $state<'importacion' | 'exportacion'>('exportacion');
function openRelease() {
if (!quote) return;
// Prefija la dirección desde la solicitud asociada (si la hay)
const sr = requests.find((r) => r.id === quote?.service_request_id);
releaseDir = (sr?.operation_type as 'importacion' | 'exportacion') ?? 'exportacion';
showRelease = true;
}
async function confirmRelease() {
if (!companyId || !quote) return;
if (!confirm('¿Liberar esta cotización a Operaciones (crear embarque)?')) return;
busy = true;
try {
const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId);
const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId, releaseDir);
toast.success('Embarque creado');
showRelease = false;
await goto(`/dashboard/ops/embarques/${shipment.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo liberar');
@@ -222,7 +233,7 @@
<Button size="sm" variant="outline" onclick={() => doAction('reject')} disabled={busy}><X class="mr-1 h-4 w-4" /> Rechazar</Button>
{/if}
{#if quote.status === 'aceptada'}
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
<Button size="sm" onclick={openRelease} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
{/if}
{#if quote.status === 'rechazada'}
<Button size="sm" variant="outline" onclick={clone} disabled={busy}><Plus class="mr-1 h-4 w-4" /> Re-cotizar (clonar)</Button>
@@ -284,7 +295,9 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la cotización</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
{#if quote.load_type}<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Variante</span><input class="{inputCls} bg-muted/40" value={quote.load_type} readonly /></label>{/if}
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Términos y condiciones</span><textarea rows="2" class={inputCls} bind:value={form.terms}></textarea></label>
</div>
@@ -312,3 +325,26 @@
</div>
</div>
{/if}
{#if showRelease && quote}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showRelease = false)}>
<div class="w-full max-w-md rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-1 flex items-center gap-2 text-base font-semibold"><Ship class="h-4 w-4" /> Liberar a Operaciones</h3>
<p class="mb-4 text-sm text-muted-foreground">Confirma la dirección de la operación; se creará el embarque con sus hitos.</p>
<div class="grid gap-3">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tipo de operación</span>
<select class={inputCls} bind:value={releaseDir}>
<option value="importacion">Importación</option>
<option value="exportacion">Exportación</option>
</select>
<span class="text-xs text-muted-foreground">Prefijada desde la solicitud; los hitos se generan según esta dirección.</span>
</label>
</div>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showRelease = false)}>Cancelar</Button>
<Button onclick={confirmRelease} disabled={busy}>{busy ? 'Liberando…' : 'Liberar'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -7,7 +7,7 @@
import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let form = $state<QuoteInput>({ currency: 'USD' });
let form = $state<QuoteInput>({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) });
let accounts = $state<Account[]>([]);
let requests = $state<ServiceRequest[]>([]);
let saving = $state(false);
@@ -50,6 +50,7 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la cotización</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>

View File

@@ -16,6 +16,7 @@
let f = $state({
mode: 'aereo' as RateMode, origin: '', destination: '', on_date: '',
gross_weight_kg: null as number | null, volume_m3: null as number | null,
length_cm: null as number | null, width_cm: null as number | null, height_cm: null as number | null,
equipment_type: '', quantity: 1, dangerous: false
});
let options = $state<CostOption[]>([]);
@@ -23,6 +24,15 @@
let working = $state(false);
const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre');
const isAir = $derived(f.mode === 'aereo');
// P/Vol aéreo en vivo: (L×A×H cm × cantidad) / 6000; a cobrar = max(bruto, P/Vol)
const airVolumetric = $derived(
Number(f.length_cm) > 0 && Number(f.width_cm) > 0 && Number(f.height_cm) > 0
? (Number(f.length_cm) * Number(f.width_cm) * Number(f.height_cm) * (Number(f.quantity) || 1)) / 6000
: 0
);
const airChargeable = $derived(Math.max(Number(f.gross_weight_kg) || 0, airVolumetric));
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
@@ -34,6 +44,7 @@
const res = await rateSheetsAPI.quote({
mode: f.mode, origin: f.origin || null, destination: f.destination || null,
on_date: f.on_date || null, gross_weight_kg: f.gross_weight_kg, volume_m3: f.volume_m3,
length_cm: f.length_cm, width_cm: f.width_cm, height_cm: f.height_cm,
equipment_type: f.equipment_type || null, quantity: f.quantity || 1, dangerous: f.dangerous
}, companyId);
options = res.options; calculated = true;
@@ -70,11 +81,25 @@
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.gross_weight_kg} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={f.volume_m3} /></label>
{#if isAir}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Largo (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.length_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ancho (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.width_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Alto (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.height_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad de bultos</span><input type="number" min="1" class={inputCls} bind:value={f.quantity} /></label>
{/if}
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha embarque</span><input type="date" class={inputCls} bind:value={f.on_date} /></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={f.dangerous} /><span>Mercancía peligrosa (DGR)</span></label>
</div>
{#if isAir && airVolumetric > 0}
<div class="mt-3 flex flex-wrap gap-6 rounded-md bg-muted/40 p-3 text-sm">
<span>P/Vol (volumétrico): <b>{airVolumetric.toFixed(2)}</b></span>
<span>Peso bruto: <b>{(Number(f.gross_weight_kg) || 0).toFixed(2)} kg</b></span>
<span>Peso a cobrar: <b>{airChargeable.toFixed(2)} kg</b></span>
<span class="text-xs text-muted-foreground">P/Vol = (L×A×H) × bultos ÷ 6000</span>
</div>
{/if}
<div class="mt-4 flex justify-end"><Button onclick={calc} disabled={working}>{working ? 'Calculando…' : 'Calcular costo'}</Button></div>
</Card.Content>
</Card.Root>

View File

@@ -16,7 +16,7 @@
type Stage,
type Account
} from '$lib/api/crm';
import { formatMoney } from '$lib/components/crm/format';
import { formatMoney, OPERATION_TYPES, TRANSPORT_MODES } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let pipelines = $state<Pipeline[]>([]);
@@ -32,6 +32,12 @@
let saving = $state(false);
let form = $state<OpportunityInput>({ name: '' });
// Convertir oportunidad → solicitud (la dirección se hereda de la oportunidad)
let convertOpen = $state(false);
let converting = $state(false);
let convertOpp = $state<Opportunity | null>(null);
let convertForm = $state<{ operation_type: string; transport_mode?: string; incoterm?: string; origin?: string; destination?: string; notes?: string }>({ operation_type: 'exportacion' });
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const currentStages = $derived(
@@ -124,7 +130,8 @@
form = {
name: '',
pipeline_id: selectedPipelineId ?? undefined,
stage_id: currentStages[0]?.id
stage_id: currentStages[0]?.id,
operation_type: 'exportacion'
};
modalOpen = true;
}
@@ -152,17 +159,35 @@
}
}
async function convertToRequest(opp: Opportunity) {
if (!companyId) return;
const op = window.prompt('Convertir a solicitud — tipo de operación (importacion / exportacion):', 'exportacion');
if (!op) return;
const operation_type = op.trim().toLowerCase() === 'importacion' ? 'importacion' : 'exportacion';
function openConvert(opp: Opportunity) {
convertOpp = opp;
convertForm = { operation_type: opp.operation_type ?? 'exportacion' };
convertOpen = true;
}
async function confirmConvert() {
if (!companyId || !convertOpp) return;
converting = true;
try {
const sr = await serviceRequestsAPI.fromOpportunity(opp.id, { operation_type }, companyId);
const sr = await serviceRequestsAPI.fromOpportunity(
convertOpp.id,
{
operation_type: convertForm.operation_type,
transport_mode: convertForm.transport_mode || undefined,
incoterm: convertForm.incoterm || undefined,
origin: convertForm.origin || undefined,
destination: convertForm.destination || undefined,
notes: convertForm.notes || undefined
},
companyId
);
toast.success('Solicitud creada desde la oportunidad');
convertOpen = false;
await goto(`/dashboard/crm/solicitudes/${sr.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo convertir');
} finally {
converting = false;
}
}
@@ -251,6 +276,9 @@
ondragstart={(e) => onDragStart(e, opp.id)}
>
<p class="text-sm font-medium">{opp.name}</p>
{#if opp.reference}
<p class="font-mono text-[11px] text-muted-foreground">{opp.reference}</p>
{/if}
{#if accountName(opp.account_id)}
<p class="text-xs text-muted-foreground">{accountName(opp.account_id)}</p>
{/if}
@@ -264,7 +292,7 @@
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
{/if}
</div>
<button type="button" class="mt-2 inline-flex items-center gap-1 text-[11px] text-primary hover:underline" onclick={() => convertToRequest(opp)}>
<button type="button" class="mt-2 inline-flex items-center gap-1 text-[11px] text-primary hover:underline" onclick={() => openConvert(opp)}>
<FileOutput class="h-3 w-3" /> Convertir a solicitud
</button>
</div>
@@ -292,6 +320,13 @@
{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Dirección de la operación</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.operation_type}>
{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">Importación/Exportación fluye a Solicitud, Cotización y Embarque.</span>
</label>
<div class="grid grid-cols-2 gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Etapa</span>
@@ -316,3 +351,36 @@
</div>
</div>
{/if}
{#if convertOpen && convertOpp}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (convertOpen = false)}>
<div class="w-full max-w-lg overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h2 class="mb-1 flex items-center gap-2 text-lg font-semibold"><FileOutput class="h-5 w-5" /> Convertir a solicitud</h2>
<p class="mb-4 text-sm text-muted-foreground">{convertOpp.name}</p>
<div class="grid gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Dirección de la operación</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.operation_type}>
{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
{#if convertOpp.operation_type}<span class="text-xs text-muted-foreground">Heredada de la oportunidad.</span>{/if}
</label>
<div class="grid grid-cols-2 gap-4">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.transport_mode}>
<option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="10" bind:value={convertForm.incoterm} placeholder="FOB, CIF…" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.destination} /></label>
</div>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><textarea rows="2" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={convertForm.notes}></textarea></label>
</div>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (convertOpen = false)}>Cancelar</Button>
<Button onclick={confirmConvert} disabled={converting}>{converting ? 'Convirtiendo…' : 'Convertir a solicitud'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -1,30 +1,36 @@
<script lang="ts">
import { ArrowLeft, FileText, Plus, Trash2 } from '@lucide/svelte';
import { ArrowLeft, FileText, Plus, Trash2, Receipt } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
serviceRequestsAPI, rateRequestsAPI, accountsAPI, suppliersAPI,
serviceRequestsAPI, rateRequestsAPI, quotesAPI, accountsAPI, suppliersAPI, contactsAPI,
type ServiceRequest, type ServiceRequestInput, type RateRequest, type RateRequestInput,
type Account, type Supplier
type Account, type Supplier, type Contact
} from '$lib/api/crm';
import {
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES, SR_STATUS,
OPERATION_TYPES, SR_STATUS, SR_FORM_TABS,
QUOTE_CONCEPTS, RATE_STATUS, labelOf
} from '$lib/components/crm/format';
import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte';
import { toast } from 'svelte-sonner';
const srId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
// Pestañas del formulario + la de tarifas
const TABS = [...SR_FORM_TABS, { value: 'tarifas', label: 'Tarifas' }];
let sr = $state<ServiceRequest | null>(null);
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion' });
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', additional_services: [] });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let contacts = $state<Contact[]>([]);
let rates = $state<RateRequest[]>([]);
let tab = $state('requerimientos');
let tab = $state('datos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
@@ -41,13 +47,14 @@
async function load(cid: number, id: number) {
loading = true;
try {
[sr, accounts, suppliers, rates] = await Promise.all([
[sr, accounts, suppliers, contacts, rates] = await Promise.all([
serviceRequestsAPI.get(id, cid),
accountsAPI.list(cid),
suppliersAPI.list(cid),
contactsAPI.list(cid),
rateRequestsAPI.list(cid, id)
]);
form = { ...sr };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
} finally {
@@ -60,7 +67,7 @@
saving = true;
try {
sr = await serviceRequestsAPI.update(sr.id, form, companyId);
form = { ...sr };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
@@ -75,7 +82,7 @@
busy = true;
try {
sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes);
form = { ...sr };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Contacto registrado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto');
@@ -89,7 +96,7 @@
busy = true;
try {
sr = await serviceRequestsAPI.requote(sr.id, companyId);
form = { ...sr };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Solicitud reabierta para re-cotizar');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reabrir');
@@ -98,6 +105,21 @@
}
}
async function cotizar() {
if (!companyId || !sr) return;
if (!sr.account_id) { toast.error('Asigna un cliente antes de cotizar'); return; }
busy = true;
try {
const quotes = await quotesAPI.fromServiceRequest(sr.id, companyId);
toast.success(quotes.length > 1 ? `${quotes.length} cotizaciones generadas (FCL y LCL)` : 'Cotización generada');
await goto(`/dashboard/crm/cotizaciones/${quotes[0].id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cotizar');
} finally {
busy = false;
}
}
function startAdd() {
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
adding = true;
@@ -142,6 +164,7 @@
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}
{#if sr.status === 'rechazada' || sr.status === 'cotizada'}<Button size="sm" variant="outline" onclick={requote} disabled={busy}>Re-cotizar</Button>{/if}
{#if sr.account_id && sr.status !== 'liberada'}<Button size="sm" onclick={cotizar} disabled={busy}><Receipt class="mr-1 h-4 w-4" /> Cotizar</Button>{/if}
</div>
</div>
{#if sr.first_contact_at}<p class="text-xs text-muted-foreground">Contacto registrado{#if sr.first_contact_notes}: {sr.first_contact_notes}{/if}</p>{/if}
@@ -149,33 +172,12 @@
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'requerimientos', label: 'Requerimientos' }, { id: 'tarifas', label: 'Tarifas' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{#each TABS as t (t.value)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.value ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.value)}>{t.label}</button>
{/each}
</div>
{#if tab === 'requerimientos'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{:else}
{#if tab === 'tarifas'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar tarifa</Button></div>
{#if adding}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
@@ -189,7 +191,7 @@
</div>
{/if}
{#if rates.length === 0}
<p class="text-sm text-muted-foreground">Sin solicitudes de tarifa.</p>
<p class="text-sm text-muted-foreground">Sin solicitudes de tarifa. Captúralas para sembrar los conceptos de la cotización.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Proveedor</Table.Head><Table.Head class="text-right">Tarifa</Table.Head><Table.Head>Estatus</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
@@ -206,6 +208,9 @@
</Table.Body>
</Table.Root>
{/if}
{:else}
<ServiceRequestFields bind:form {tab} {accounts} {contacts} {suppliers} />
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -4,13 +4,16 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, accountsAPI, suppliersAPI, type ServiceRequestInput, type Account, type Supplier } from '$lib/api/crm';
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES } from '$lib/components/crm/format';
import { serviceRequestsAPI, accountsAPI, suppliersAPI, contactsAPI, type ServiceRequestInput, type Account, type Supplier, type Contact } from '$lib/api/crm';
import { SR_FORM_TABS } from '$lib/components/crm/format';
import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte';
import { toast } from 'svelte-sonner';
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva' });
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let contacts = $state<Contact[]>([]);
let tab = $state('datos');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
@@ -19,7 +22,9 @@
const cid = companyId;
if (!cid) return;
void (async () => {
[accounts, suppliers] = await Promise.all([accountsAPI.list(cid), suppliersAPI.list(cid)]);
[accounts, suppliers, contacts] = await Promise.all([
accountsAPI.list(cid), suppliersAPI.list(cid), contactsAPI.list(cid)
]);
})();
});
@@ -37,8 +42,6 @@
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
@@ -46,33 +49,16 @@
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Nueva solicitud de servicio</h1>
<Card.Root>
<Card.Content class="space-y-5 pt-6">
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Generales</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación *</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} placeholder="FOB, CIF…" /></label>
</fieldset>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each SR_FORM_TABS as t (t.value)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.value ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.value)}>{t.label}</button>
{/each}
</div>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Logística y carga</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} placeholder="1x40'HC" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
</fieldset>
<ServiceRequestFields bind:form {tab} {accounts} {contacts} {suppliers} />
<div class="flex justify-end gap-2 border-t pt-4">
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/solicitudes">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>