Compare commits
8 Commits
feature/cr
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe659e56a | ||
|
|
b47dc542f2 | ||
|
|
8431132b10 | ||
|
|
8c7aeef1a6 | ||
|
|
9c46f5bf3c | ||
|
|
f1e6fba75d | ||
|
|
36e98ee976 | ||
|
|
915bdd19fe |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal file
75
backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py
Normal 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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
0
backend/api/v1/modules/crm/cases/__init__.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal file
31
backend/api/v1/modules/crm/cases/dto.py
Normal 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] = []
|
||||
28
backend/api/v1/modules/crm/cases/models.py
Normal file
28
backend/api/v1/modules/crm/cases/models.py
Normal 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)
|
||||
51
backend/api/v1/modules/crm/cases/routes.py
Normal file
51
backend/api/v1/modules/crm/cases/routes.py
Normal 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)
|
||||
109
backend/api/v1/modules/crm/cases/service.py
Normal file
109
backend/api/v1/modules/crm/cases/service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal file
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal 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()
|
||||
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
0
backend/api/v1/modules/crm/common/__init__.py
Normal file
96
backend/api/v1/modules/crm/common/folios.py
Normal file
96
backend/api/v1/modules/crm/common/folios.py
Normal 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)}"
|
||||
37
backend/api/v1/modules/crm/common/pricing.py
Normal file
37
backend/api/v1/modules/crm/common/pricing.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"'},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
48
backend/tests/test_cases.py
Normal file
48
backend/tests/test_cases.py
Normal 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"
|
||||
49
backend/tests/test_catalogs_seed.py
Normal file
49
backend/tests/test_catalogs_seed.py
Normal 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
|
||||
49
backend/tests/test_folios.py
Normal file
49
backend/tests/test_folios.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
29
backend/tests/test_pricing.py
Normal file
29
backend/tests/test_pricing.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
50
frontend/src/lib/api/crm/cases.ts
Normal file
50
frontend/src/lib/api/crm/cases.ts
Normal 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)}`))
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
|
||||
export { activitiesAPI } from './activities';
|
||||
export { metricsAPI } from './metrics';
|
||||
export * from './commercial';
|
||||
export * from './cases';
|
||||
|
||||
@@ -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 })}`))
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
275
frontend/src/lib/components/crm/ServiceRequestFields.svelte
Normal file
275
frontend/src/lib/components/crm/ServiceRequestFields.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal file
98
frontend/src/routes/dashboard/crm/expedientes/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user