8 Commits

Author SHA1 Message Date
Ernesto Herrera
afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:58:51 -06:00
Ernesto Herrera
b47dc542f2 feat(crm): Cotizaciones/Cotizador vinculados (Fase B)
- Lista de cotizaciones muestra la Solicitud referenciada (QuoteResponse enriquecido
  con service_request_reference; columna con enlace a la solicitud).
- Cotizador vinculable a una solicitud (?service_request_id=) → prellena modo
  (mapper transport+load→RateMode), ruta, peso/dimensiones, etc.
- Cotizador vinculable a una cotización (?quote_id=) → botón "Agregar" por opción
  que crea el concepto de flete + cargos en la cotización y regresa a ella.
- Botón "Cotizador" en el detalle de la cotización (entrada vinculada).

Suite de cotizaciones en verde. svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:43:53 -06:00
Ernesto Herrera
8431132b10 feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)
- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:37:30 -06:00
Ernesto Herrera
8c7aeef1a6 feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago)
- Fecha de solicitud automática (hoy) editable al crear.
- Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas,
  aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad.
- Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con
  respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos.
- Agente en destino filtrado a proveedores clasificados corresponsal/aduanal.
- Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida).
- P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida.
- Moneda visible junto al valor de la mercancía.
- Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre.
- Formulario de tarifa: campo "Válida hasta" (calendario).
- Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible).
- Nuevos load types FTL/LTL. Suite backend en verde (109).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:25:20 -06:00
Ernesto Herrera
9c46f5bf3c feat(crm): ajustes de la sesión doc 2 (catálogos, bugs, oportunidades, facturación, UI)
Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
  de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
  activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.

Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
  amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).

Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
  Ganada/Perdida (migración).

Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.

UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).

Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:07:58 -06:00
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
83 changed files with 3012 additions and 163 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

@@ -0,0 +1,56 @@
"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-08-04 01:00:00.000000
- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais).
- crm.accounts.industry_other (especificar cuando el giro es "otro").
- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en
los valores guardados en accounts/suppliers; y país 'MX''MEX'.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "c2d3e4f5a6b7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# País a ISO alfa-3 en accounts (addresses ya es String(3)).
# Primero se amplía la columna; luego se normaliza el dato (evita truncamiento).
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=2), type_=sa.String(length=3),
existing_nullable=True, server_default=sa.text("'MEX'"),
)
op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'")
op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'")
# Giro "otro" — campo para especificar
op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados)
op.execute(
"UPDATE crm.catalog_items SET code = lpad(code, 2, '0') "
"WHERE catalog = 'forma_pago' AND char_length(code) = 1"
)
op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
def downgrade() -> None:
op.drop_column("accounts", "industry_other", schema=SCHEMA)
# Regresar país a String(2) sin truncar filas existentes
op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'")
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=3), type_=sa.String(length=2),
existing_nullable=True, server_default=sa.text("'MX'"),
)
# La normalización de formas de pago no se revierte (evita romper códigos multi-dígito).

View File

@@ -0,0 +1,75 @@
"""Expediente (crm.cases) + case_id en el ciclo comercial
Revision ID: d4e5f6a7b8c9
Revises: f0a1b2c3d4e5
Create Date: 2026-08-07 02:00:00.000000
Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a
crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "f0a1b2c3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (schema, tabla) donde se agrega case_id
_CASE_FK_TABLES = [
("crm", "opportunities"),
("crm", "service_requests"),
("crm", "quotes"),
("ops", "shipments"),
("fin", "invoices"),
]
def upgrade() -> None:
op.create_table(
"cases",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm")
op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm")
op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm")
op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm")
op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm")
op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm")
for schema, table in _CASE_FK_TABLES:
op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema)
op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema)
op.create_foreign_key(
f"fk_{schema}_{table}_case_id", table, "cases",
["case_id"], ["id"], source_schema=schema, referent_schema="crm",
)
def downgrade() -> None:
for schema, table in _CASE_FK_TABLES:
op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey")
op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema)
op.drop_column(table, "case_id", schema=schema)
for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"):
op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm")
op.drop_table("cases", schema="crm")

View File

@@ -0,0 +1,30 @@
"""Fechas separadas de ganada/perdida en la oportunidad
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-08-04 02:00:00.000000
Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas,
editables) además de closed_at y lost_reason ya existentes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("opportunities", "lost_date", schema=SCHEMA)
op.drop_column("opportunities", "won_date", schema=SCHEMA)

View File

@@ -0,0 +1,25 @@
"""Medio de contacto preferido en el prospecto (lead)
Revision ID: f0a1b2c3d4e5
Revises: e4f5a6b7c8d9
Create Date: 2026-08-07 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)

View File

@@ -13,6 +13,7 @@ class AccountBase(BaseModel):
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
person_type: str | None = Field(None, max_length=10) # fisica | moral
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str = Field("active", max_length=20) # active | inactive
# Comercial
@@ -38,7 +39,7 @@ class AccountBase(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3)
# Observaciones
notes: str | None = None
internal_notes: str | None = None
@@ -57,6 +58,7 @@ class AccountUpdate(BaseModel):
record_type: str | None = Field(None, max_length=20)
person_type: str | None = Field(None, max_length=10)
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
@@ -79,7 +81,7 @@ class AccountUpdate(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)

View File

@@ -31,6 +31,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro"
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Estatus: active | inactive
@@ -65,7 +66,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales

View File

@@ -14,7 +14,7 @@ class AddressBase(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais)
reference_notes: str | None = None
is_primary: bool = False
@@ -32,7 +32,7 @@ class AddressUpdate(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
reference_notes: str | None = None
is_primary: bool | None = None

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reference: str | None
account_id: int | None
title: str | None
stage: str
status: str
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class CaseTimelineEvent(BaseModel):
kind: str # oportunidad | solicitud | cotizacion | operacion | factura
id: int
reference: str | None = None
status: str | None = None
created_at: datetime
url: str
class CaseWithTimeline(CaseResponse):
timeline: list[CaseTimelineEvent] = []

View File

@@ -0,0 +1,28 @@
from sqlalchemy import ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Case(Base, TenantScopedMixin, TimestampMixin):
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
"""
__tablename__ = "cases"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP...
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
# abierto | cerrado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import CaseResponse, CaseWithTimeline
router = APIRouter()
def _with_timeline(db, case) -> CaseWithTimeline:
data = CaseWithTimeline.model_validate(case)
data.timeline = service.build_timeline(db, case) # type: ignore[assignment]
return data
@router.get("/cases", response_model=list[CaseResponse])
def list_cases(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
account_id: int | None = Query(None),
stage: str | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage)
@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline)
def get_case_by_ref(
reference: str,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Expediente + historia completa por su referencia (para UI y otros sistemas)."""
case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id)
return _with_timeline(db, case)
@router.get("/cases/{case_id}", response_model=CaseWithTimeline)
def get_case(
case_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
case = service.get_case(db, case_id, current_user["tenant_id"], company_id)
return _with_timeline(db, case)

View File

@@ -0,0 +1,109 @@
"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..common.folios import next_folio
from .models import Case
# Orden de etapas (solo se avanza, nunca retrocede)
STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"]
def create_case(
db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None,
title: str | None = None, stage: str = "oportunidad", user_id: str | None = None,
) -> Case:
"""Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca)."""
case = Case(
reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False),
account_id=account_id, title=title, stage=stage, status="abierto",
tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id,
)
db.add(case)
db.flush()
return case
def advance_stage(db: Session, case_id: int | None, stage: str) -> None:
"""Avanza la etapa del expediente si la nueva es posterior a la actual."""
if not case_id or stage not in STAGE_ORDER:
return
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
return
current = case.stage if case.stage in STAGE_ORDER else "oportunidad"
if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current):
case.stage = stage
def get_cases(
db: Session, tenant_id: int, company_id: int, search: str | None = None,
account_id: int | None = None, stage: str | None = None,
) -> list[Case]:
q = db.query(Case).filter(
Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None),
)
if account_id is not None:
q = q.filter(Case.account_id == account_id)
if stage:
q = q.filter(Case.stage == stage)
if search:
q = q.filter(Case.reference.ilike(f"%{search}%"))
return q.order_by(Case.created_at.desc()).all()
def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id,
Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def build_timeline(db: Session, case: Case) -> list[dict]:
"""Devuelve la historia del expediente: todas las entidades ligadas por case_id,
en orden cronológico. Un único lookup para la UI y para otros sistemas."""
# Import local para evitar ciclos de importación entre módulos.
from ..opportunities.models import Opportunity
from ..quotes.models import Quote
from ..service_requests.models import ServiceRequest
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.ops.shipments.models import Shipment
events: list[dict] = []
specs = [
("oportunidad", Opportunity, "/dashboard/crm/oportunidades"),
("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"),
("cotizacion", Quote, "/dashboard/crm/cotizaciones"),
("operacion", Shipment, "/dashboard/ops/embarques"),
("factura", Invoice, "/dashboard/fin/facturas"),
]
for kind, model, base_url in specs:
rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all()
for r in rows:
events.append({
"kind": kind,
"id": r.id,
"reference": getattr(r, "reference", None),
"status": getattr(r, "status", None),
"created_at": r.created_at,
"url": f"{base_url}/{r.id}",
})
events.sort(key=lambda e: e["created_at"])
return events

View File

@@ -766,3 +766,109 @@ 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'}]},
'incoterm': {'label': 'Incoterm (2020)',
'is_system': True,
'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'},
{'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'},
{'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'},
{'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'},
{'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'},
{'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'},
{'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'},
{'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'},
{'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'},
{'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'},
{'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]},
})
# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08).
# El SAT exige dos posiciones; se corrige el catálogo base.
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2)
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
from .seed_locations import LOCATION_CATALOGS # noqa: E402
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)

View File

@@ -0,0 +1,79 @@
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
para que el Cotizador encuentre ruta.
"""
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
_LOC = [
("MEX",
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
("USA",
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
("CHN",
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
("DEU",
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
("ESP",
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
("NLD",
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
[("NLRTM", "Róterdam")],
[("AMS", "Ámsterdam Schiphol")]),
("BRA",
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
("CAN",
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
("JPN",
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
("KOR",
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
[("ICN", "Seúl Incheon")]),
]
def _build() -> dict:
ciudad, puerto, aeropuerto = [], [], []
for iso3, cities, ports, airports in _LOC:
for code, label in cities:
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
for code, label in ports:
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
for code, label in airports:
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
return {
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
}
LOCATION_CATALOGS = _build()

View File

@@ -0,0 +1,96 @@
"""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 (F = factura, EXP = expediente; sin dirección).
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
# 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,
with_direction: bool = True,
) -> 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. ``with_direction=False``
omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``).
"""
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}"
if not with_direction:
return f"{entity}{period}-{sequence}"
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

@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
phone: str | None
company_name: str | None
source: str | None
preferred_contact_method: str | None = None
status: str
estimated_value: Decimal | None
owner_user_id: str | None

View File

@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)

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):
@@ -30,10 +31,13 @@ class OpportunityUpdate(BaseModel):
probability: int | None = Field(None, ge=0, le=100)
status: str | None = Field(None, max_length=20)
expected_close_date: date | None = None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None = Field(None, max_length=255)
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):
@@ -57,10 +61,16 @@ class OpportunityResponse(BaseModel):
status: str
expected_close_date: date | None
closed_at: datetime | None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None
source: str | None
owner_user_id: str | None
notes: str | None
operation_type: str | None = None
reference: str | None = None
case_id: int | None = None
converted_service_request_id: int | None = None
tenant_id: int
company_id: int
created_at: datetime

View File

@@ -34,7 +34,18 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó
lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
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...
# Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True)
# 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

@@ -1,9 +1,11 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..pipelines.models import Pipeline, PipelineStage
from .dto import OpportunityCreate, OpportunityUpdate
@@ -46,14 +48,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None:
opportunity.status = "won"
opportunity.probability = 100
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.won_date = opportunity.won_date or date.today()
opportunity.lost_date = None
elif stage.is_lost:
opportunity.status = "lost"
opportunity.probability = 0
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.lost_date = opportunity.lost_date or date.today()
opportunity.won_date = None
else:
opportunity.status = "open"
opportunity.probability = stage.probability
opportunity.closed_at = None
opportunity.won_date = None
opportunity.lost_date = None
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
@@ -149,6 +157,15 @@ 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)
# Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura
if not opportunity.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad",
)
opportunity.case_id = case.id
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
@@ -83,6 +85,8 @@ class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
service_request_reference: str | None = None # folio de la solicitud referenciada
case_id: int | None = None
status: str
total_cost: Decimal
total_sale: Decimal

View File

@@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
@@ -22,6 +23,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,11 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..cases import service as cases_service
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
@@ -70,7 +74,18 @@ def get_quotes(
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
quotes = query.order_by(Quote.created_at.desc()).all()
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
if sr_ids:
refs = dict(
db.query(ServiceRequest.id, ServiceRequest.reference)
.filter(ServiceRequest.id.in_(sr_ids))
.all()
)
for q in quotes:
q.service_request_reference = refs.get(q.service_request_id)
return quotes
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
@@ -89,18 +104,142 @@ 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))
# Expediente heredado de la solicitud
if obj.service_request_id and not obj.case_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
if sr:
obj.case_id = sr.case_id
cases_service.advance_stage(db, obj.case_id, "cotizacion")
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),
case_id=sr.case_id,
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)
cases_service.advance_stage(db, sr.case_id, "cotizacion")
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

@@ -255,3 +255,15 @@ def rate_quote(
tenant_id, _ = _ctx(current_user)
options = service.quote_cost(db, tenant_id, company_id, req)
return CostResult(request=req, options=options)
@cost_router.get("/rate-locations")
def rate_locations(
mode: str = Query(...),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
tenant_id, _ = _ctx(current_user)
return service.lane_locations(db, tenant_id, company_id, mode)

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")
@@ -477,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
return lines
def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
de las lanes, por lo que el costeo siempre encontrará ruta).
"""
sheets = _sheet_query(db, tenant_id, company_id).filter(
RateSheet.mode == mode, RateSheet.status == "activo",
).all()
origins: set[str] = set()
destinations: set[str] = set()
for sheet in sheets:
lanes = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
).all()
for lane in lanes:
origin = lane.origin or sheet.default_origin
if origin:
origins.add(origin)
if lane.destination:
destinations.add(lane.destination)
return {"origins": sorted(origins), "destinations": sorted(destinations)}
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
on_date = req.on_date or date.today()
sheets = _sheet_query(db, tenant_id, company_id).filter(
@@ -518,11 +544,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

@@ -13,6 +13,7 @@ from . import permissions # noqa: F401 (side-effect: registra permisos del CRM
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .cases.routes import router as cases_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
@@ -43,6 +44,7 @@ router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(cases_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)

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)
@@ -76,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int

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
@@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
@@ -57,6 +58,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

@@ -4,7 +4,10 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
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 +40,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 +108,15 @@ 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)
# Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí
if not obj.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
)
obj.case_id = case.id
db.add(obj)
db.commit()
db.refresh(obj)
@@ -166,10 +180,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 +206,24 @@ 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),
case_id=opp.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(obj)
db.flush()
# Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente)
if not obj.case_id:
obj.case_id = cases_service.create_case(
db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
).id
opp.case_id = obj.case_id
cases_service.advance_stage(db, obj.case_id, "solicitud")
# 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

@@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
router = APIRouter()
@@ -61,3 +61,29 @@ def get_upload_url(
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
return {"url": presigned_get_url(key)}
@router.get("/uploads/download")
def download_file(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
"""
tenant_id = current_user["tenant_id"]
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
if not key.startswith(prefix):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
filename = key.rsplit("/", 1)[-1]
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -100,6 +100,7 @@ class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
status: str
subtotal: Decimal
tax_amount: Decimal

View File

@@ -15,6 +15,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)

View File

@@ -6,6 +6,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
@@ -95,6 +97,15 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio F... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
# Expediente heredado del embarque (si la factura se genera de uno)
if obj.shipment_id and not obj.case_id:
sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
if sh:
obj.case_id = sh.case_id
cases_service.advance_stage(db, obj.case_id, "facturacion")
db.add(obj)
db.flush()
_recompute(db, obj)
@@ -281,6 +292,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
invoice = Invoice(
reference=shipment.reference,
case_id=shipment.case_id,
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
@@ -294,6 +306,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
)
db.add(invoice)
db.flush()
cases_service.advance_stage(db, shipment.case_id, "facturacion")
if quote:
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()

View File

@@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
closed_at: datetime | None = None
closed_by: str | None = None
created_by: str | None = None

View File

@@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)

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,15 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
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 +178,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 +214,16 @@ 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),
case_id=quote.case_id,
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 +240,14 @@ def create_shipment_from_quote(
db.add(shipment)
if sr:
sr.status = "liberada"
cases_service.advance_stage(db, quote.case_id, "operacion")
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,9 @@ 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.cases.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

@@ -15,7 +15,7 @@ def test_create_and_get_account(db):
)
assert acc.id is not None
assert acc.status == "active"
assert acc.country == "MX"
assert acc.country == "MEX" # ISO 3166-1 alfa-3 (alineado al catálogo pais)
got = service.get_account(db, acc.id, T, C)
assert got.name == "Importadora Demo"
assert got.rfc == "XAXX010101000"

View File

@@ -0,0 +1,48 @@
"""Pruebas del Expediente (crm.cases): minteo, propagación y timeline."""
from api.v1.modules.crm.cases import service as cases_service
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.quotes import service as q_service
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate, ServiceRequestFromOpportunityInput
T, C = 1, 1
def test_opportunity_mints_expediente(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C)
assert opp.case_id is not None
case = cases_service.get_case(db, opp.case_id, T, C)
assert (case.reference or "").startswith("EXP")
assert case.stage == "oportunidad"
def test_case_propagates_and_advances(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="importacion"), T, C)
sr = sr_service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C)
assert sr.case_id == opp.case_id
assert cases_service.get_case(db, opp.case_id, T, C).stage == "solicitud"
quotes = q_service.create_quotes_from_service_request(db, sr.id, T, C)
assert quotes[0].case_id == opp.case_id
case = cases_service.get_case(db, opp.case_id, T, C)
assert case.stage == "cotizacion"
# El timeline reúne toda la historia ligada al expediente
kinds = {e["kind"] for e in cases_service.build_timeline(db, case)}
assert {"oportunidad", "solicitud", "cotizacion"} <= kinds
def test_direct_service_request_mints_expediente(db):
# Solicitud directa (sin oportunidad) también obtiene expediente (fallback)
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
assert sr.case_id is not None
assert cases_service.get_case(db, sr.case_id, T, C).stage == "solicitud"
def test_advance_stage_never_regresses(db):
opp = opp_service.create_opportunity(db, OpportunityCreate(name="N", operation_type="exportacion"), T, C)
cases_service.advance_stage(db, opp.case_id, "facturacion")
cases_service.advance_stage(db, opp.case_id, "solicitud") # no debe retroceder
assert cases_service.get_case(db, opp.case_id, T, C).stage == "facturacion"

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,49 @@
"""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_invoice_without_direction(db):
# Facturas: entidad F sin sufijo de dirección (F2025-08-001)
folio = next_folio(db, T, C, "F", None, on_date=date(2025, 8, 3), with_direction=False)
assert folio == "F2025-08-001"
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

@@ -41,6 +41,8 @@ def test_move_to_won_closes_and_sets_probability(db):
assert moved.status == "won"
assert moved.probability == 100
assert moved.closed_at is not None
assert moved.won_date is not None # fecha de ganada
assert moved.lost_date is None
assert moved.stage_id == s_won.id
@@ -51,6 +53,8 @@ def test_move_to_lost(db):
assert moved.status == "lost"
assert moved.probability == 0
assert moved.closed_at is not None
assert moved.lost_date is not None # fecha de perdida
assert moved.won_date is None
def test_move_back_to_open_reopens(db):

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

@@ -143,6 +143,14 @@
filter: invert(1) brightness(1.15);
opacity: 0.9;
}
/* Las opciones de los <select> (catálogos) deben ser legibles en modo oscuro:
el control es transparente y el popup nativo hereda colores del sistema. */
select option,
select optgroup {
background-color: var(--color-popover);
color: var(--color-popover-foreground);
}
}
@layer components {

View File

@@ -0,0 +1,50 @@
/**
* Cliente API — Expedientes (referencia única de trazabilidad del trámite).
*/
import { api } from '$lib/api';
export interface Case {
id: number;
reference: string | null;
account_id: number | null;
title: string | null;
stage: string;
status: string;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export interface CaseTimelineEvent {
kind: string; // oportunidad | solicitud | cotizacion | operacion | factura
id: number;
reference: string | null;
status: string | null;
created_at: string;
url: string;
}
export interface CaseWithTimeline extends Case {
timeline: CaseTimelineEvent[];
}
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const casesAPI = {
list: (companyId: number, params?: { search?: string; account_id?: number; stage?: string }) =>
unwrap<Case[]>(api.get(`/v1/crm/cases?${qp(companyId, params)}`)),
get: (id: number, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/${id}?${qp(companyId)}`)),
byRef: (reference: string, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/by-ref/${encodeURIComponent(reference)}?${qp(companyId)}`))
};

View File

@@ -10,7 +10,9 @@ export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
export interface ServiceRequest {
id: number;
reference: string | null;
case_id: number | null;
account_id: number | null;
contact_id: number | null;
opportunity_id: number | null;
operation_type: string;
transport_mode: string | null;
@@ -18,15 +20,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;
@@ -68,8 +107,11 @@ export interface Quote {
id: number;
reference: string | null;
service_request_id: number | null;
service_request_reference: string | null;
case_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 +175,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 +198,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

@@ -7,11 +7,12 @@ import type { Contact, ContactInput } from './types';
export const contactsAPI = {
async list(
companyId: number,
params?: { search?: string; account_id?: number }
params?: { search?: string; account_id?: number; supplier_id?: number }
): Promise<Contact[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.search) qs.set('search', params.search);
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Contact[]>(`/v1/crm/contacts?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;

View File

@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';
export * from './cases';

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 {
@@ -103,5 +104,9 @@ export const rateSheetsAPI = {
return res.data as RateSheet;
},
quote: (req: CostRequest, companyId: number) => unwrap<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req))
quote: (req: CostRequest, companyId: number) => unwrap<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req)),
/** Orígenes/destinos que existen en los tarifarios activos (para alinear el cotizador con las rutas cotizables). */
locations: (companyId: number, mode: RateMode) =>
unwrap<{ origins: string[]; destinations: string[] }>(api.get(`/v1/crm/rate-locations?${qp(companyId, { mode })}`))
};

View File

@@ -20,6 +20,7 @@ export interface Account {
record_type: RecordType;
person_type: string | null;
industry: string | null;
industry_other: string | null;
account_type: string | null;
status: AccountStatus;
commercial_classification: string | null;
@@ -191,6 +192,7 @@ export interface Lead {
phone: string | null;
company_name: string | null;
source: string | null;
preferred_contact_method: string | null;
status: LeadStatus;
estimated_value: number | null;
owner_user_id: string | null;
@@ -261,10 +263,15 @@ export interface Opportunity {
status: OpportunityStatus;
expected_close_date: string | null;
closed_at: string | null;
won_date: string | null;
lost_date: string | null;
lost_reason: string | null;
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

@@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise<str
if (res.error) throw new Error(res.error);
return res.data!.url;
}
/** Descarga el archivo por el backend (sin exponer MinIO) y devuelve un blob. */
export async function downloadBlob(fileKey: string, companyId: number): Promise<Blob> {
return (api as any).getBlob(
`/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
) as Promise<Blob>;
}

View File

@@ -29,6 +29,9 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de registro</span><select class={inputCls} bind:value={form.record_type}>{#each crmCatalogs.options('tipo_registro') as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_persona') 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">Giro / Industria</span><select class={inputCls} bind:value={form.industry}><option value={undefined}>—</option>{#each crmCatalogs.options('giro') as g (g.value)}<option value={g.value}>{g.label}</option>{/each}</select></label>
{#if form.industry === 'otro'}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Especifica el giro</span><input class={inputCls} bind:value={form.industry_other} placeholder="Indica cuál" /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo operativo</span><select class={inputCls} bind:value={form.account_type}><option value={undefined}>—</option>{#each ACCOUNT_TYPES 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">Estatus</span><select class={inputCls} bind:value={form.status}>{#each crmCatalogs.options('estatus') as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { MapPin, Users, FileText, Plus, Trash2 } from '@lucide/svelte';
import { MapPin, Users, FileText, Plus, Trash2, Pencil } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
@@ -11,7 +11,7 @@
import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { onMount } from 'svelte';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { uploadFile, downloadBlob } from '$lib/api/uploads';
import { toast } from 'svelte-sonner';
onMount(() => {
@@ -35,6 +35,7 @@
let contacts = $state<Contact[]>([]);
let documents = $state<Document[]>([]);
let activeModal = $state<'address' | 'contact' | 'document' | null>(null);
let editingId = $state<number | null>(null); // null = alta; con valor = edición
let saving = $state(false);
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MEX', is_primary: false });
@@ -61,9 +62,17 @@
async function openDoc(d: Document) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
if (d.file_key) {
// Descarga por el backend (evita exponer MinIO / host interno)
const blob = await downloadBlob(d.file_key, companyId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} else if (d.file_url) {
window.open(d.file_url, '_blank', 'noopener');
} else {
toast.error('El documento no tiene archivo');
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}
@@ -94,19 +103,37 @@
}
function openModal(kind: 'address' | 'contact' | 'document') {
editingId = null;
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MEX', is_primary: false };
if (kind === 'contact') contactForm = { first_name: '' };
if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' };
activeModal = kind;
}
function editAddress(a: Address) {
editingId = a.id;
addressForm = { ...a };
activeModal = 'address';
}
function editContact(c: Contact) {
editingId = c.id;
contactForm = { ...c };
activeModal = 'contact';
}
function editDocument(d: Document) {
editingId = d.id;
documentForm = { ...d };
activeModal = 'document';
}
async function saveAddress(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
saving = true;
try {
await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success('Dirección agregada');
if (editingId) await addressesAPI.update(editingId, addressForm, companyId);
else await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Dirección actualizada' : 'Dirección agregada');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -122,8 +149,9 @@
if (!contactForm.first_name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success('Contacto agregado');
if (editingId) await contactsAPI.update(editingId, contactForm, companyId);
else await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Contacto actualizado' : 'Contacto agregado');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -139,8 +167,9 @@
if (!documentForm.name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success('Documento agregado');
if (editingId) await documentsAPI.update(editingId, documentForm, companyId);
else await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success(editingId ? 'Documento actualizado' : 'Documento agregado');
activeModal = null;
await load(companyId);
} catch (err) {
@@ -189,7 +218,7 @@
<Table.Cell class="text-sm">{[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'}</Table.Cell>
<Table.Cell>{a.postal_code ?? '—'}</Table.Cell>
<Table.Cell>{[a.city, a.state].filter(Boolean).join(', ') || '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editAddress(a)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -218,7 +247,7 @@
<Table.Cell class="text-sm">{[c.job_title, c.area ? crmCatalogs.label('area', c.area) : null].filter(Boolean).join(' · ') || '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editContact(c)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -246,7 +275,7 @@
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => editDocument(d)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
@@ -261,7 +290,7 @@
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (activeModal = null)}>
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if activeModal === 'address'}
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar dirección' : 'Nueva dirección'}</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de domicilio</span><select class={inputCls} bind:value={addressForm.address_type}>{#each crmCatalogs.options('tipo_domicilio') 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">Código Postal</span><input class={inputCls} maxlength="10" bind:value={addressForm.postal_code} /></label>
@@ -270,14 +299,14 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. interior</span><input class={inputCls} bind:value={addressForm.int_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Colonia</span><input class={inputCls} bind:value={addressForm.neighborhood} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Municipio / Ciudad</span><input class={inputCls} bind:value={addressForm.city} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span>{#if crmCatalogs.options('estado', addressForm.country).length > 0}<select class={inputCls} bind:value={addressForm.state}><option value={undefined}>—</option>{#each crmCatalogs.options('estado', addressForm.country) as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select>{:else}<input class={inputCls} bind:value={addressForm.state} placeholder="Estado / provincia" />{/if}</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span>{#if crmCatalogs.options('estado', addressForm.country ?? undefined).length > 0}<select class={inputCls} bind:value={addressForm.state}><option value={undefined}>—</option>{#each crmCatalogs.options('estado', addressForm.country ?? undefined) as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select>{:else}<input class={inputCls} bind:value={addressForm.state} placeholder="Estado / provincia" />{/if}</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País</span><select class={inputCls} bind:value={addressForm.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 sm:col-span-2"><span class="font-medium">Referencias</span><textarea rows="2" class={inputCls} bind:value={addressForm.reference_notes}></textarea></label>
<label class="flex items-center gap-2 text-sm sm:col-span-2"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={addressForm.is_primary} /><span>Domicilio principal</span></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'contact'}
<h3 class="mb-4 text-base font-semibold">Nuevo contacto</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar contacto' : 'Nuevo contacto'}</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveContact}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={contactForm.first_name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Apellidos</span><input class={inputCls} bind:value={contactForm.last_name} /></label>
@@ -297,7 +326,7 @@
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'document'}
<h3 class="mb-4 text-base font-semibold">Nuevo documento</h3>
<h3 class="mb-4 text-base font-semibold">{editingId ? 'Editar documento' : 'Nuevo documento'}</h3>
<form class="grid gap-3" onsubmit={saveDocument}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES 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">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label>

View File

@@ -0,0 +1,275 @@
<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';
// Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde)
const MODALIDAD_BY_TRANSPORT: Record<string, string[]> = {
maritimo: ['FCL', 'LCL', 'AMBAS'],
aereo: ['AEREO'],
terrestre: ['FTL', 'LTL']
// ferroviario / multimodal: sin modalidad
};
const modalidadOptions = $derived(
LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value))
);
const showModalidad = $derived(modalidadOptions.length > 0);
// Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona
$effect(() => {
const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? [];
if (allowed.length === 0) {
if (form.load_type) form.load_type = undefined;
return;
}
if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined;
if (!form.load_type && allowed.length === 1) form.load_type = allowed[0];
});
// 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';
});
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');
// Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol)
const UNIT_TO_CM: Record<string, number> = { cm: 1, m: 100, in: 2.54, ft: 30.48 };
const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1);
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const dimL = $derived((Number(form.length_cm) || 0) * unitCm);
const dimW = $derived((Number(form.width_cm) || 0) * unitCm);
const dimH = $derived((Number(form.height_cm) || 0) * unitCm);
const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0);
// Volumen SIEMPRE en m³ (cm³ / 1,000,000)
const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0);
// P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// Autocompletar el volumen en m³ a partir de las dimensiones/unidad
$effect(() => {
if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000;
});
// Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país)
$effect(() => {
if (form.origin_country) {
void crmCatalogs.ensure('ciudad', form.origin_country);
void crmCatalogs.ensure('puerto', form.origin_country);
void crmCatalogs.ensure('aeropuerto', form.origin_country);
}
});
$effect(() => {
if (form.destination_country) {
void crmCatalogs.ensure('ciudad', form.destination_country);
void crmCatalogs.ensure('puerto', form.destination_country);
void crmCatalogs.ensure('aeropuerto', form.destination_country);
}
});
// Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país
function portOptions(country: string | null | undefined) {
return [
...crmCatalogs.options('puerto', country ?? undefined),
...crmCatalogs.options('aeropuerto', country ?? undefined)
];
}
// Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos)
const destinationAgents = $derived(
suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal'))
);
const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers);
// 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', 'incoterm', 'medio_transporte'
]);
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">Tipo de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each (crmCatalogs.options('medio_transporte').length ? crmCatalogs.options('medio_transporte') : 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><select class={inputCls} bind:value={form.incoterm}><option value={undefined}>—</option>{#each crmCatalogs.options('incoterm') as i (i.value)}<option value={i.value}>{i.label}</option>{/each}</select></label>
{#if showModalidad}
<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 modalidadOptions as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
{/if}
<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>
{#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><select class={inputCls} bind:value={form.origin_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.origin_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
{:else}
<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}
{#if portOptions(form.origin_country).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 portOptions(form.origin_country) 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>
{#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><select class={inputCls} bind:value={form.destination_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.destination_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
{:else}
<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}
{#if portOptions(form.destination_country).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 portOptions(form.destination_country) 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 agentList 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><div class="flex items-center gap-1"><input type="number" min="0" step="0.01" class="{inputCls} w-full" bind:value={form.cargo_value} /><span class="text-xs text-muted-foreground">{form.currency ?? 'moneda'}</span></div></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)} kg</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 (P/Vol)</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

@@ -25,14 +25,47 @@
const hasOtro = $derived((form.classifications ?? []).includes('otro'));
// Utilidades para editar listas separadas por coma desde un catálogo (select + chips)
function csvArr(s: string | undefined): string[] {
return (s || '').split(',').map((x) => x.trim()).filter(Boolean);
}
function csvToggle(s: string | undefined, code: string): string {
const arr = csvArr(s);
const i = arr.indexOf(code);
if (i >= 0) arr.splice(i, 1);
else arr.push(code);
return arr.join(', ');
}
onMount(() => {
void crmCatalogs.preload([
'tipo_persona', 'estatus', 'clasificacion_proveedor', 'cobertura', 'moneda',
'regimen_fiscal', 'metodo_pago', 'forma_pago'
'regimen_fiscal', 'metodo_pago', 'forma_pago', 'pais', 'puerto', 'aeropuerto', 'aduana'
]);
});
</script>
{#snippet catCsv(labelText: string, catalog: string, value: string, set: (v: string) => void, placeholder: string)}
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">{labelText}</span>
{#if crmCatalogs.options(catalog).length}
<select class={inputCls} onchange={(e) => { set(csvToggle(value, e.currentTarget.value)); e.currentTarget.value = ''; }}>
<option value="">+ Agregar…</option>
{#each crmCatalogs.options(catalog) as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
{#if csvArr(value).length}
<div class="mt-1 flex flex-wrap gap-1">
{#each csvArr(value) as code (code)}
<span class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">{crmCatalogs.label(catalog, code)}<button type="button" class="text-muted-foreground hover:text-destructive" onclick={() => set(csvToggle(value, code))} aria-label="Quitar">×</button></span>
{/each}
</div>
{/if}
{:else}
<input class={inputCls} value={value} oninput={(e) => set(e.currentTarget.value)} {placeholder} />
{/if}
</label>
{/snippet}
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
@@ -56,10 +89,10 @@
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each crmCatalogs.options('cobertura') as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda de cotización</span><select class={inputCls} bind:value={form.quote_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">Países donde opera (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="México, Estados Unidos" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos donde opera</span><input class={inputCls} bind:value={portsStr} placeholder="Veracruz, Manzanillo" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aeropuertos donde opera</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas donde opera</span><input class={inputCls} bind:value={customsStr} placeholder="Nuevo Laredo, Colombia" /></label>
{@render catCsv('Países donde opera', 'pais', countriesStr, (v) => (countriesStr = v), 'México, Estados Unidos')}
{@render catCsv('Puertos donde opera', 'puerto', portsStr, (v) => (portsStr = v), 'Veracruz, Manzanillo')}
{@render catCsv('Aeropuertos donde opera', 'aeropuerto', airportsStr, (v) => (airportsStr = v), 'MEX, GDL')}
{@render catCsv('Aduanas donde opera', 'aduana', customsStr, (v) => (customsStr = v), 'Nuevo Laredo, Colombia')}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Horario de atención</span><input class={inputCls} bind:value={form.business_hours} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tiempo prom. de respuesta</span><input class={inputCls} bind:value={form.avg_response_time} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>

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,19 @@ 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: 'Expedientes', url: '/dashboard/crm/expedientes' },
{ 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

@@ -4,11 +4,12 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { contactsAPI, accountsAPI, type Contact, type ContactInput, type Account } from '$lib/api/crm';
import { contactsAPI, accountsAPI, suppliersAPI, type Contact, type ContactInput, type Account, type Supplier } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let items = $state<Contact[]>([]);
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let loading = $state(false);
let search = $state('');
let modalOpen = $state(false);
@@ -18,8 +19,18 @@
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
// A quién pertenece el contacto: cliente/prospecto (cuenta) o proveedor
function ownerLabel(c: Contact): { kind: string; name: string } | null {
if (c.account_id) {
const a = accounts.find((x) => x.id === c.account_id);
const kind = a?.record_type === 'prospecto' ? 'Prospecto' : 'Cliente';
return { kind, name: a?.name ?? `#${c.account_id}` };
}
if (c.supplier_id) {
const s = suppliers.find((x) => x.id === c.supplier_id);
return { kind: 'Proveedor', name: s?.name ?? `#${c.supplier_id}` };
}
return null;
}
const filtered = $derived(
@@ -41,7 +52,7 @@
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid)]);
[items, accounts, suppliers] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid), suppliersAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los contactos');
} finally {
@@ -136,7 +147,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Nombre</Table.Head>
<Table.Head>Cuenta</Table.Head>
<Table.Head>Pertenece a</Table.Head>
<Table.Head>Puesto</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Teléfono</Table.Head>
@@ -150,7 +161,7 @@
{c.first_name} {c.last_name ?? ''}
{#if c.is_primary}<span class="ml-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">Principal</span>{/if}
</Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{#if ownerLabel(c)}{@const o = ownerLabel(c)}<span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{o?.kind}</span> <span>{o?.name}</span>{:else}{/if}</Table.Cell>
<Table.Cell>{c.job_title ?? '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>

View File

@@ -93,6 +93,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Solicitud</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total venta</Table.Head>
<Table.Head class="text-right">Margen</Table.Head>
@@ -103,6 +104,7 @@
{#each filtered as q (q.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/cotizaciones/${q.id}`}>{q.reference ?? `#${q.id}`}</a></Table.Cell>
<Table.Cell>{#if q.service_request_id}<a class="text-sm hover:underline" href={`/dashboard/crm/solicitudes/${q.service_request_id}`}>{q.service_request_reference ?? `#${q.service_request_id}`}</a>{:else}<span class="text-sm text-muted-foreground">—</span>{/if}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[q.status] ?? ''}">{labelOf(QUOTE_STATUS, q.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.total_sale, q.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.margin, q.currency)}</Table.Cell>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail, Calculator } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
@@ -12,6 +12,7 @@
} from '$lib/api/crm';
import { shipmentsAPI } from '$lib/api/ops';
import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
const quoteId = $derived(Number(page.params.id));
@@ -39,6 +40,7 @@
async function load(cid: number, id: number) {
loading = true;
void crmCatalogs.preload(['moneda']);
try {
[quote, items, accounts, requests, suppliers] = await Promise.all([
quotesAPI.get(id, cid),
@@ -113,13 +115,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');
@@ -207,11 +220,13 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
<p class="mt-1 text-sm">
<p class="mt-1 flex items-center gap-2 text-sm">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
{#if quote.case_id}<a class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${quote.case_id}`}>📁 Expediente</a>{/if}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" href={`/dashboard/crm/cotizador?quote_id=${quote.id}${quote.service_request_id ? `&service_request_id=${quote.service_request_id}` : ''}`}><Calculator class="mr-1 h-4 w-4" /> Cotizador</Button>
<Button size="sm" variant="outline" onclick={openPdf} disabled={busy}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>
<Button size="sm" variant="outline" onclick={openEmail} disabled={busy}><Mail class="mr-1 h-4 w-4" /> Enviar por correo</Button>
{#if quote.status === 'borrador'}
@@ -222,7 +237,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>
@@ -283,8 +298,10 @@
<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">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">Moneda</span><select class={inputCls} bind:value={form.currency}>{#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">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 +329,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

@@ -5,9 +5,10 @@
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
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);
@@ -17,6 +18,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['moneda']);
void (async () => {
[accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]);
})();
@@ -49,7 +51,8 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="COT-0001" /></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">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">Moneda</span><select class={inputCls} bind:value={form.currency}>{#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">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

@@ -1,31 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calculator } from '@lucide/svelte';
import { Calculator, Plus } 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 { rateSheetsAPI, type CostOption, type RateMode } from '$lib/api/crm/rates';
import { serviceRequestsAPI, quoteItemsAPI } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
// Vinculación: ?service_request_id= (prellenar) y ?quote_id= (enviar resultado a concepto)
const srId = $derived(Number(page.url.searchParams.get('service_request_id')) || null);
const quoteId = $derived(Number(page.url.searchParams.get('quote_id')) || null);
// transport_mode + load_type de la solicitud → modo del tarifario
function transportModeToRateMode(transport: string | null, load: string | null): RateMode {
if (transport === 'aereo') return 'aereo';
if (transport === 'terrestre') return 'terrestre';
if (transport === 'maritimo') return load === 'LCL' ? 'maritimo_lcl' : 'maritimo_fcl';
return 'aereo';
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
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[]>([]);
let calculated = $state(false);
let working = $state(false);
// Orígenes/destinos alineados a las rutas de los tarifarios activos del modo
let locs = $state<{ origins: string[]; destinations: string[] }>({ origins: [], destinations: [] });
$effect(() => {
const cid = companyId;
const mode = f.mode;
if (!cid) return;
void (async () => {
try { locs = await rateSheetsAPI.locations(cid, mode); }
catch { locs = { origins: [], destinations: [] }; }
})();
});
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']));
// Prellenar desde la solicitud vinculada (si viene ?service_request_id=)
$effect(() => {
const cid = companyId;
const id = srId;
if (!cid || !id) return;
void (async () => {
try {
const sr = await serviceRequestsAPI.get(id, cid);
f = {
...f,
mode: transportModeToRateMode(sr.transport_mode, sr.load_type),
origin: sr.origin_port || sr.origin || '',
destination: sr.destination_port || sr.destination || '',
on_date: sr.estimated_shipment_date || sr.required_date || '',
gross_weight_kg: sr.weight ?? null,
volume_m3: sr.volume ?? null,
length_cm: sr.length_cm ?? null,
width_cm: sr.width_cm ?? null,
height_cm: sr.height_cm ?? null,
equipment_type: sr.container_equipment || '',
quantity: sr.container_count || sr.pallets_count || sr.pieces_count || 1,
dangerous: !!sr.hazardous_imo
};
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
}
})();
});
// Enviar una opción del cotizador como concepto(s) de la cotización vinculada
async function addToQuote(o: CostOption) {
if (!companyId || !quoteId) return;
working = true;
try {
// Línea base (flete) + una línea por cada cargo adicional
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'flete_internacional',
description: `${o.rate_sheet_name}${o.detail ? ' — ' + o.detail : ''}`,
supplier_id: o.supplier_id ?? undefined, quantity: 1,
unit_cost: o.base_cost, unit_sale: o.base_cost, currency: o.currency ?? undefined
}, companyId);
for (const c of o.charges) {
await quoteItemsAPI.create({
quote_id: quoteId, concept: 'otros', description: c.concept,
quantity: 1, unit_cost: c.amount, unit_sale: c.amount, currency: o.currency ?? undefined
}, companyId);
}
toast.success('Concepto(s) agregado(s) a la cotización');
await goto(`/dashboard/crm/cotizaciones/${quoteId}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar a la cotización');
} finally {
working = false;
}
}
async function calc() {
if (!companyId) return;
if (!f.destination.trim()) { toast.error('Indica el destino'); return; }
@@ -34,6 +126,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;
@@ -59,8 +152,20 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={f.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Origen</span><input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span>
{#if locs.origins.length}
<select class={inputCls} bind:value={f.origin}><option value=""></option>{#each locs.origins as o (o)}<option value={o}>{o}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" />
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span>
{#if locs.destinations.length}
<select class={inputCls} bind:value={f.destination}><option value=""></option>{#each locs.destinations as d (d)}<option value={d}>{d}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" />
{/if}
</label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
@@ -70,11 +175,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>
@@ -90,7 +209,7 @@
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head></Table.Row></Table.Header>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head>{#if quoteId}<Table.Head></Table.Head>{/if}</Table.Row></Table.Header>
<Table.Body>
{#each options as o, i (o.rate_sheet_id + '-' + i)}
<Table.Row class={i === 0 ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : ''}>
@@ -100,6 +219,7 @@
<Table.Cell class="font-semibold">{money(o.total_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{o.detail ?? '—'}</Table.Cell>
<Table.Cell>{o.transit_days ?? '—'}</Table.Cell>
{#if quoteId}<Table.Cell class="text-right"><Button size="sm" variant="outline" onclick={() => addToQuote(o)} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button></Table.Cell>{/if}
</Table.Row>
{/each}
</Table.Body>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { FolderKanban, Search, ChevronRight } from '@lucide/svelte';
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 { casesAPI, accountsAPI, type Case, type Account } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
let items = $state<Case[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
search.trim()
? items.filter((c) => `${c.reference ?? ''} ${c.title ?? ''} ${accountName(c.account_id)}`.toLowerCase().includes(search.trim().toLowerCase()))
: items
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([casesAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los expedientes');
} finally {
loading = 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">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> Expedientes</h1>
<p class="mt-1 text-sm text-muted-foreground">Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).</p>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o cliente…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin expedientes. Se crean automáticamente al generar una oportunidad.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Expediente</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Etapa</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head>Creado</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as c (c.id)}
<Table.Row>
<Table.Cell class="font-mono font-medium"><a class="hover:underline" href={`/dashboard/crm/expedientes/${c.id}`}>{c.reference ?? `#${c.id}`}</a></Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{STAGE_LABEL[c.stage] ?? c.stage}</Table.Cell>
<Table.Cell>{c.status}</Table.Cell>
<Table.Cell>{formatDate(c.created_at)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" href={`/dashboard/crm/expedientes/${c.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { ArrowLeft, FolderKanban, Target, FileText, Receipt, Ship, DollarSign } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, type CaseWithTimeline } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const caseId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let data = $state<CaseWithTimeline | null>(null);
let loading = $state(false);
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
const KIND: Record<string, { label: string; icon: any }> = {
oportunidad: { label: 'Oportunidad', icon: Target },
solicitud: { label: 'Solicitud', icon: FileText },
cotizacion: { label: 'Cotización', icon: Receipt },
operacion: { label: 'Operación / Embarque', icon: Ship },
factura: { label: 'Factura', icon: DollarSign }
};
$effect(() => {
const cid = companyId;
const id = caseId;
if (!cid || !id) return;
void (async () => {
loading = true;
try { data = await casesAPI.get(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el expediente'); }
finally { loading = false; }
})();
});
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/expedientes"><ArrowLeft class="mr-1 h-4 w-4" /> Expedientes</Button>
{#if loading && !data}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if data}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> <span class="font-mono">{data.reference ?? `Expediente #${data.id}`}</span></h1>
<p class="mt-1 text-sm text-muted-foreground">Etapa: <b>{STAGE_LABEL[data.stage] ?? data.stage}</b> · {data.status}{#if data.title} · {data.title}{/if}</p>
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Historia del trámite</Card.Title>
<Card.Description>Todos los documentos ligados a este expediente, en orden cronológico.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.timeline.length === 0}
<p class="text-sm text-muted-foreground">Sin movimientos aún.</p>
{:else}
<ol class="relative ml-3 border-l pl-6">
{#each data.timeline as ev (ev.kind + '-' + ev.id)}
{@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }}
<li class="mb-5">
<span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background">
<K.icon class="h-3.5 w-3.5" />
</span>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs uppercase text-muted-foreground">{K.label}</span>
<a class="font-mono text-sm font-medium hover:underline" href={ev.url}>{ev.reference ?? `#${ev.id}`}</a>
{#if ev.status}<span class="rounded-full bg-muted px-2 py-0.5 text-[10px]">{ev.status}</span>{/if}
<span class="text-xs text-muted-foreground">{formatDate(ev.created_at)}</span>
</div>
</li>
{/each}
</ol>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -16,7 +16,8 @@
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 { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
let pipelines = $state<Pipeline[]>([]);
@@ -32,6 +33,18 @@
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' });
// Cierre de oportunidad (ganada/perdida) con fecha y, si se pierde, motivo
let closeOpen = $state(false);
let closing = $state(false);
let closeCtx = $state<{ opp: Opportunity; stageId: number; isWon: boolean } | null>(null);
let closeForm = $state<{ date: string; reason: string }>({ date: '', reason: '' });
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const currentStages = $derived(
@@ -56,6 +69,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['incoterm']);
void load(cid);
});
@@ -96,8 +110,6 @@
companyId
);
const defs: [string, number, boolean, boolean][] = [
['Prospecto', 10, false, false],
['Contactado', 25, false, false],
['Propuesta', 50, false, false],
['Negociación', 75, false, false],
['Ganada', 100, true, false],
@@ -124,7 +136,8 @@
form = {
name: '',
pipeline_id: selectedPipelineId ?? undefined,
stage_id: currentStages[0]?.id
stage_id: currentStages[0]?.id,
operation_type: 'exportacion'
};
modalOpen = true;
}
@@ -152,17 +165,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;
}
}
@@ -179,6 +210,14 @@
if (!id || !companyId) return;
const opp = opps.find((o) => o.id === id);
if (!opp || opp.stage_id === stageId) return;
// Al mover a Ganada/Perdida, pedir fecha (y motivo si se pierde) antes de cerrar
const stage = currentStages.find((s) => s.id === stageId);
if (stage && (stage.is_won || stage.is_lost)) {
closeCtx = { opp, stageId, isWon: !!stage.is_won };
closeForm = { date: new Date().toISOString().slice(0, 10), reason: '' };
closeOpen = true;
return;
}
try {
const updated = await opportunitiesAPI.move(id, stageId, companyId);
opps = opps.map((o) => (o.id === id ? updated : o));
@@ -186,6 +225,24 @@
toast.error(e instanceof Error ? e.message : 'No se pudo mover la oportunidad');
}
}
async function confirmClose() {
if (!companyId || !closeCtx) return;
closing = true;
try {
await opportunitiesAPI.move(closeCtx.opp.id, closeCtx.stageId, companyId);
const patch = closeCtx.isWon
? { won_date: closeForm.date || undefined }
: { lost_date: closeForm.date || undefined, lost_reason: closeForm.reason || undefined };
const updated = await opportunitiesAPI.update(closeCtx.opp.id, patch, companyId);
opps = opps.map((o) => (o.id === closeCtx!.opp.id ? updated : o));
closeOpen = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cerrar la oportunidad');
} finally {
closing = false;
}
}
</script>
<div class="space-y-6">
@@ -251,6 +308,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 +324,12 @@
<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)}>
{#if opp.won_date}
<p class="mt-1 text-[10px] text-emerald-600 dark:text-emerald-400">Ganada: {opp.won_date}</p>
{:else if opp.lost_date}
<p class="mt-1 text-[10px] text-red-600 dark:text-red-400">Perdida: {opp.lost_date}{#if opp.lost_reason}{opp.lost_reason}{/if}</p>
{/if}
<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 +357,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 +388,61 @@
</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><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.incoterm}><option value={undefined}>—</option>{#each crmCatalogs.options('incoterm') as i (i.value)}<option value={i.value}>{i.label}</option>{/each}</select></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}
{#if closeOpen && closeCtx}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (closeOpen = 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()}>
<h2 class="mb-1 text-lg font-semibold">{closeCtx.isWon ? 'Marcar como ganada' : 'Marcar como perdida'}</h2>
<p class="mb-4 text-sm text-muted-foreground">{closeCtx.opp.name}</p>
<div class="grid gap-4">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">{closeCtx.isWon ? 'Fecha de ganada' : 'Fecha de perdida'}</span>
<input type="date" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={closeForm.date} />
</label>
{#if !closeCtx.isWon}
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Motivo de la pérdida</span>
<textarea rows="3" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={closeForm.reason} placeholder="¿Por qué se perdió esta oportunidad?"></textarea>
</label>
{/if}
</div>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (closeOpen = false)}>Cancelar</Button>
<Button onclick={confirmClose} disabled={closing}>{closing ? 'Guardando…' : (closeCtx.isWon ? 'Marcar ganada' : 'Marcar perdida')}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -4,10 +4,14 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm';
import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
onMount(() => void crmCatalogs.ensure('medio_contacto'));
let items = $state<Lead[]>([]);
let loading = $state(false);
let search = $state('');
@@ -233,6 +237,13 @@
{#each LEAD_SOURCES 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">Medio de contacto preferido</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.preferred_contact_method}>
<option value={undefined}>—</option>
{#each crmCatalogs.options('medio_contacto') 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">Estado</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.status}>

View File

@@ -4,22 +4,28 @@
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm';
import { serviceRequestsAPI, accountsAPI, type ServiceRequest, type Account } from '$lib/api/crm';
import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<ServiceRequest[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
let clientFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
items.filter((r) => {
if (statusFilter && r.status !== statusFilter) return false;
if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q);
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''} ${accountName(r.account_id)}`.toLowerCase().includes(q);
}
return true;
})
@@ -34,7 +40,7 @@
async function load(cid: number) {
loading = true;
try {
items = await serviceRequestsAPI.list(cid);
[items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
} finally {
@@ -76,6 +82,10 @@
<option value="">Todos los estatus</option>
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
<select class={inputCls} bind:value={clientFilter}>
<option value="">Todos los clientes</option>
{#each accounts as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
@@ -89,6 +99,7 @@
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Operación</Table.Head>
<Table.Head>Medio</Table.Head>
<Table.Head>Ruta</Table.Head>
@@ -100,6 +111,7 @@
{#each filtered as r (r.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell>
<Table.Cell>{accountName(r.account_id)}</Table.Cell>
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell>
<Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell>
<Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>

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;
@@ -138,10 +160,12 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
{#if sr.case_id}<a class="mt-1 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${sr.case_id}`}>📁 Expediente</a>{/if}
</div>
<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 +173,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">
@@ -184,12 +187,13 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS 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">Válida hasta</span><input type="date" class={inputCls} bind:value={newRate.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div>
</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 +210,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: {}, request_date: new Date().toISOString().slice(0, 10) });
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>

View File

@@ -6,6 +6,7 @@
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type InvoiceInput } from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
@@ -16,6 +17,7 @@
$effect(() => {
const cid = companyId;
if (!cid) return;
void crmCatalogs.preload(['moneda']);
void (async () => { accounts = await accountsAPI.list(cid); })();
});
@@ -44,9 +46,9 @@
<Card.Root>
<Card.Content class="pt-6">
<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} placeholder="F-0001" /></label>
<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 genera automáticamente (F2026-08-001)" /></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">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">Moneda</span><select class={inputCls} bind:value={form.currency}>{#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">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>