diff --git a/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py b/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py new file mode 100644 index 0000000..3f91f0d --- /dev/null +++ b/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py @@ -0,0 +1,60 @@ +"""crm quote_settings (marca por tenant) + quotes.pdf_file_key + +Revision ID: a0b1c2d3e4f5 +Revises: f8a9b0c1d2e3 +Create Date: 2026-07-29 00:00:00.000000 + +PDF de cotización con formato maestro + branding por tenant + envío por correo. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "a0b1c2d3e4f5" +down_revision: Union[str, None] = "f8a9b0c1d2e3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA) + + op.create_table( + "quote_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("emitter_name", sa.String(length=255), nullable=True), + sa.Column("emitter_rfc", sa.String(length=13), nullable=True), + sa.Column("emitter_address", sa.Text(), nullable=True), + sa.Column("emitter_phone", sa.String(length=60), nullable=True), + sa.Column("emitter_email", sa.String(length=255), nullable=True), + sa.Column("emitter_website", sa.String(length=255), nullable=True), + sa.Column("logo_file_key", sa.String(length=512), nullable=True), + sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")), + sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")), + sa.Column("default_terms", sa.Text(), nullable=True), + sa.Column("footer_note", sa.Text(), 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"]), + schema=SCHEMA, + ) + op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA) + op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA) + op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA) + # Una configuración por compañía + op.create_index( + "uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"], + unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_table("quote_settings", schema=SCHEMA) + op.drop_column("quotes", "pdf_file_key", schema=SCHEMA) diff --git a/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py new file mode 100644 index 0000000..02a030a --- /dev/null +++ b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py @@ -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) diff --git a/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py new file mode 100644 index 0000000..724438c --- /dev/null +++ b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py @@ -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) diff --git a/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py new file mode 100644 index 0000000..2b9d241 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py @@ -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). diff --git a/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py b/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py new file mode 100644 index 0000000..33673c9 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py @@ -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") diff --git a/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py new file mode 100644 index 0000000..d261148 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py @@ -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) diff --git a/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py b/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py new file mode 100644 index 0000000..ac46b23 --- /dev/null +++ b/backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py @@ -0,0 +1,90 @@ +"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers + +Revision ID: e6f7a8b9c0d1 +Revises: d5e6f7a8b9c0 +Create Date: 2026-07-22 00:00:00.000000 + +Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores): +catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes +(observaciones comerciales, "otro" de medio de contacto y de clasificación). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e6f7a8b9c0d1" +down_revision: Union[str, None] = "d5e6f7a8b9c0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + # ----- crm.catalog_items ----- + op.create_table( + "catalog_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("catalog", sa.String(length=60), nullable=False), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("parent_catalog", sa.String(length=60), nullable=True), + sa.Column("parent_code", sa.String(length=64), nullable=True), + # NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant. + sa.Column("tenant_id", sa.Integer(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("extra", sa.JSON(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + 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"), + schema=SCHEMA, + ) + op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA) + op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA) + op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA) + op.create_index( + "ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA + ) + # Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas. + op.create_index( + "uq_crm_catalog_items_global", + "catalog_items", + ["catalog", "code"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("tenant_id IS NULL"), + ) + op.create_index( + "uq_crm_catalog_items_tenant", + "catalog_items", + ["catalog", "code", "tenant_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("tenant_id IS NOT NULL"), + ) + + # ----- columnas nuevas ----- + # Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto. + op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA) + op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA) + # Proveedores: "otro" de la clasificación múltiple. + op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("suppliers", "classification_other", schema=SCHEMA) + op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA) + op.drop_column("accounts", "commercial_observations", schema=SCHEMA) + + op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA) + op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA) + op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA) + op.drop_table("catalog_items", schema=SCHEMA) diff --git a/backend/alembic/versions/e7f8a9b0c1d2_widen_address_country.py b/backend/alembic/versions/e7f8a9b0c1d2_widen_address_country.py new file mode 100644 index 0000000..ed3744f --- /dev/null +++ b/backend/alembic/versions/e7f8a9b0c1d2_widen_address_country.py @@ -0,0 +1,44 @@ +"""ampliar crm.addresses.country a 3 (país ISO alfa-3 del catálogo) + +Revision ID: e7f8a9b0c1d2 +Revises: e6f7a8b9c0d1 +Create Date: 2026-07-22 00:30:00.000000 + +El catálogo de País usa códigos ISO 3166 alfa-3 (MEX, USA, …). La columna +addresses.country era String(2); se amplía a String(3) para almacenarlos. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e7f8a9b0c1d2" +down_revision: Union[str, None] = "e6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.alter_column( + "addresses", "country", + type_=sa.String(length=3), + existing_type=sa.String(length=2), + existing_nullable=True, + server_default=sa.text("'MEX'"), + schema=SCHEMA, + ) + + +def downgrade() -> None: + # Trunca a 2 chars por si hay códigos alfa-3 guardados (rollback de dev). + op.execute("UPDATE crm.addresses SET country = left(country, 2) WHERE length(country) > 2") + op.alter_column( + "addresses", "country", + type_=sa.String(length=2), + existing_type=sa.String(length=3), + existing_nullable=True, + server_default=sa.text("'MX'"), + schema=SCHEMA, + ) diff --git a/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py new file mode 100644 index 0000000..6504d2a --- /dev/null +++ b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py @@ -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) diff --git a/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py index 14a832f..8ca10a2 100644 --- a/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py +++ b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py @@ -4,8 +4,12 @@ Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega ``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los catálogos, conservando las columnas de texto libre que ya existían. +Re-encadenada sobre d4e5f6a7b8c9 al integrar main: esta rama y la de CRM habían salido las +dos de e6f7a8b9c0d1, y con dos cabezas ``alembic upgrade head`` falla. La historia queda +lineal, con las migraciones del timbrado detrás de las del CRM. + Revision ID: f7a8b9c0d1e2 -Revises: e6f7a8b9c0d1 +Revises: d4e5f6a7b8c9 Create Date: 2026-08-07 00:00:00.000000 """ @@ -17,7 +21,7 @@ from alembic import op from api.v1.modules.fin.catalogs.seed_data import sync_catalogs revision: str = "f7a8b9c0d1e2" -down_revision: Union[str, None] = "e6f7a8b9c0d1" +down_revision: Union[str, None] = "d4e5f6a7b8c9" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py b/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py new file mode 100644 index 0000000..0f53d88 --- /dev/null +++ b/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py @@ -0,0 +1,131 @@ +"""crm rates: tarifarios (rate_sheets/lanes/breaks/charges) + +Revision ID: f8a9b0c1d2e3 +Revises: e7f8a9b0c1d2 +Create Date: 2026-07-27 00:00:00.000000 + +Módulo Tarifario: base de costos para Cotizaciones (import por Excel + motor de costeo). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "f8a9b0c1d2e3" +down_revision: Union[str, None] = "e7f8a9b0c1d2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def _scoped() -> list[sa.Column]: + return [ + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + ] + + +def _idx(table: str) -> None: + op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA) + op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA) + op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA) + + +def upgrade() -> None: + # ----- rate_sheets ----- + op.create_table( + "rate_sheets", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("supplier_id", sa.Integer(), nullable=True), + sa.Column("mode", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("currency", sa.String(length=3), nullable=True, server_default=sa.text("'USD'")), + sa.Column("valid_from", sa.Date(), nullable=True), + sa.Column("valid_to", sa.Date(), nullable=True), + sa.Column("default_origin", sa.String(length=20), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")), + sa.Column("source_file", sa.String(length=512), nullable=True), + sa.Column("source_url", sa.String(length=1024), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + *_scoped(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]), + schema=SCHEMA, + ) + _idx("rate_sheets") + op.create_index("ix_crm_rate_sheets_mode", "rate_sheets", ["mode"], schema=SCHEMA) + op.create_index("ix_crm_rate_sheets_supplier_id", "rate_sheets", ["supplier_id"], schema=SCHEMA) + + # ----- rate_lanes ----- + op.create_table( + "rate_lanes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("rate_sheet_id", sa.Integer(), nullable=False), + sa.Column("origin", sa.String(length=20), nullable=True), + sa.Column("destination", sa.String(length=20), nullable=True), + sa.Column("region", sa.String(length=60), nullable=True), + sa.Column("equipment_type", sa.String(length=20), nullable=True), + sa.Column("rate_unit", sa.String(length=20), nullable=True), + sa.Column("min_charge", sa.Numeric(precision=14, scale=4), nullable=True), + sa.Column("flat_rate", sa.Numeric(precision=14, scale=4), nullable=True), + sa.Column("transit_days", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]), + schema=SCHEMA, + ) + _idx("rate_lanes") + op.create_index("ix_crm_rate_lanes_rate_sheet_id", "rate_lanes", ["rate_sheet_id"], schema=SCHEMA) + op.create_index("ix_crm_rate_lanes_origin", "rate_lanes", ["origin"], schema=SCHEMA) + op.create_index("ix_crm_rate_lanes_destination", "rate_lanes", ["destination"], schema=SCHEMA) + + # ----- rate_breaks ----- + op.create_table( + "rate_breaks", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("rate_lane_id", sa.Integer(), nullable=False), + sa.Column("from_qty", sa.Numeric(precision=12, scale=3), nullable=False, server_default=sa.text("0")), + sa.Column("rate", sa.Numeric(precision=14, scale=4), nullable=False, server_default=sa.text("0")), + *_scoped(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]), + schema=SCHEMA, + ) + _idx("rate_breaks") + op.create_index("ix_crm_rate_breaks_rate_lane_id", "rate_breaks", ["rate_lane_id"], schema=SCHEMA) + + # ----- rate_charges ----- + op.create_table( + "rate_charges", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("rate_sheet_id", sa.Integer(), nullable=True), + sa.Column("rate_lane_id", sa.Integer(), nullable=True), + sa.Column("concept", sa.String(length=60), nullable=False), + sa.Column("charge_type", sa.String(length=20), nullable=False, server_default=sa.text("'fijo'")), + sa.Column("value", sa.Numeric(precision=14, scale=4), nullable=True), + sa.Column("condition", sa.Text(), nullable=True), + *_scoped(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]), + sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]), + schema=SCHEMA, + ) + _idx("rate_charges") + + +def downgrade() -> None: + op.drop_table("rate_charges", schema=SCHEMA) + op.drop_table("rate_breaks", schema=SCHEMA) + op.drop_table("rate_lanes", schema=SCHEMA) + op.drop_table("rate_sheets", schema=SCHEMA) diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index a9bad09..287d255 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel): tenant: Optional["TenantInfoDTO"] = None tenant_id: Optional[int] = None tenant_slug: Optional[str] = None + # Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED. + # Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y + # que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token + # de arriba sigue siendo el de Keycloak (para llamadas al Hub). + session_token: Optional[str] = None + session_id: Optional[str] = None class Config: json_schema_extra = { @@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel): """DTO para solicitud de refresh token""" refresh_token: str = Field(..., description="Refresh token") + # Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el + # inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el + # refresh del token KC contra el Hub falla ("Token is not active" del relay). + session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)") + # session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser). + session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)") class UserInfoResponseDTO(BaseModel): diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index f566340..50d6869 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -24,6 +24,12 @@ from .dto import ( ) from .service import AuthService +import logging +from typing import Optional +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["Authentication"]) security = HTTPBearer() @@ -402,10 +408,16 @@ async def dev_login(): @router.get("/my-companies") async def get_my_companies( current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), ): """ Retorna las compañías accesibles para el usuario actual. - STUB: implementa con tu modelo de compañías. + + Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con + el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se + garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se + resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario). + En dev-local retorna una compañía ficticia para que el dashboard funcione. """ from core.config import settings @@ -415,8 +427,239 @@ async def get_my_companies( "id": settings.DEV_LOCAL_AUTH_COMPANY_ID, "name": "Empresa Dev Local", "tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID, + "rfc": None, + "logo": None, "is_active": True, }] - # Implementa aquí la consulta real a tu tabla de compañías. + from core.security import ( + resolve_effective_tenant_id_from_user, + _ensure_user_tenant_for_company, + ) + from api.v1.modules.core.tenants.models import Tenant + from sqlalchemy import text + + user_id = current_user.get("sub") or current_user.get("id") + tenant_id = resolve_effective_tenant_id_from_user(current_user) + + # 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por + # defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de + # su tenant. Así cualquier usuario del mismo tenant (misma organización del + # Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se + # asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin + # asigna el rol; el primer usuario recibe super_admin vía /permissions/me). + if tenant_id: + tenant_id = int(tenant_id) + company_ids = [ + int(r[0]) + for r in db.execute( + text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"), + {"tid": tenant_id}, + ).fetchall() + ] + if not company_ids: + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + default_name = ( + (tenant.name if tenant else None) + or current_user.get("tenant_slug") + or "Mi empresa" + ) + created = db.execute( + text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"), + {"tid": tenant_id, "name": default_name}, + ).fetchone() + db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + company_ids = [int(created[0])] + logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0]) + + # Auto-ligado por tenant (solo membresía, sin rol). + if user_id: + for cid in company_ids: + try: + _ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) + except Exception as exc: + logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc) + + # 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona + # también para hub_admin sin tenant en el token: verá las compañías que creó + # o a las que fue asignado. La membresía la determina el CRM, no el Hub. + if not user_id: + return [] + rows = db.execute( + text( + """ + SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug + FROM a76.company c + LEFT JOIN core.tenants t ON t.id = c.tenant_id + WHERE c.id IN ( + SELECT company_id FROM core.user_tenants + WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL + UNION + SELECT company_id FROM core.user_company_roles + WHERE user_id = :uid AND is_active + ) + ORDER BY c.id + """ + ), + {"uid": str(user_id)}, + ).fetchall() + + return [ + { + "id": int(r[0]), + "name": r[1] or "Empresa", + "tenant_id": int(r[4]), + "tenant_name": r[5], + "tenant_slug": r[6], + "rfc": r[2], + "logo": r[3], + "is_active": True, + } + for r in rows + ] + + +class _CreateCompanyDTO(BaseModel): + name: str + tenant_id: int + rfc: Optional[str] = None + + +async def _sync_tenants_from_hub(request: Request, db: Session) -> None: + """ + Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y + los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los + tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías. + Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no + responde, no bloquea (se devuelven los tenants ya sincronizados). + """ + import httpx + from sqlalchemy import text as _text + from core.config import settings + from core import session_store + from api.v1.modules.core.tenants.models import Tenant, TenantType + + sid = request.cookies.get("crm_sid") if request else None + kc_token = None + if sid: + sess = session_store.get_session(sid) + kc_token = (sess or {}).get("access_token") + if not kc_token: + return + + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get( + f"{settings.HUB_URL}api/v1/hub/tenants", + headers={"Authorization": f"Bearer {kc_token}"}, + ) + if r.status_code != 200: + logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code) + return + payload = r.json() + items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or []) + for t in items: + tid = t.get("id") + if tid is None: + continue + name = t.get("name") or t.get("display_name") or t.get("slug") + slug = t.get("slug") or f"tenant-{tid}" + existing = db.query(Tenant).filter(Tenant.id == int(tid)).first() + if existing: + if name and existing.name != name: + existing.name = name + else: + db.add(Tenant( + id=int(tid), name=name or slug, slug=slug, + keycloak_realm=slug, type=TenantType.SHARED, is_active=True, + )) + db.commit() + db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))")) + db.commit() + except Exception as exc: + logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc) + try: + db.rollback() + except Exception: + pass + + +@router.get("/assignable-tenants") +async def assignable_tenants( + request: Request, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace; + aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario + con tenant ve el suyo. + """ + from api.v1.modules.core.tenants.models import Tenant + from core.security import resolve_effective_tenant_id_from_user, is_hub_admin + + if is_hub_admin(current_user): + # Sincroniza automáticamente los tenants del Workspace antes de listar. + await _sync_tenants_from_hub(request, db) + rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712 + return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows] + + tid = resolve_effective_tenant_id_from_user(current_user) + if tid: + t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712 + return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else [] return [] + + +@router.post("/companies", status_code=201) +async def create_company( + data: _CreateCompanyDTO, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al + usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con + tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me). + """ + from sqlalchemy import text as _text + from api.v1.modules.core.tenants.models import Tenant + from core.security import ( + resolve_effective_tenant_id_from_user, + is_hub_admin, + _ensure_user_tenant_for_company, + ) + + name = (data.name or "").strip() + if len(name) < 2: + raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.") + + tid = int(data.tenant_id) + tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712 + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado.") + + # Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant; + # un usuario ligado a un tenant, solo en el suyo. + if not is_hub_admin(current_user): + own = resolve_effective_tenant_id_from_user(current_user) + if own is None or int(own) != tid: + raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.") + + created = db.execute( + _text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"), + {"t": tid, "n": name, "r": (data.rfc or None)}, + ).fetchone() + db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + cid = int(created[0]) + + user_id = current_user.get("sub") or current_user.get("id") + if user_id: + try: + _ensure_user_tenant_for_company(db, str(user_id), tid, cid) + except Exception as exc: + logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc) + + return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True} diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 524db10..8c707be 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -213,55 +213,160 @@ class AuthService: logger.error(f"Unexpected login error: {str(e)}") raise HTTPException(status_code=500, detail="Authentication error") + def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]: + """ + Decodifica una sesión local del CRM (HS256) verificando la firma pero + SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims + o None si la firma no valida o no es una sesión local del CRM. + """ + if not session_token: + return None + try: + claims = jwt.decode( + session_token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + except JWTError: + return None + if not claims.get("crm_session") or claims.get("source") != "local": + return None + return claims + + def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Construye los claims de la sesión local a partir del token KC (decode).""" + kc_claims = self._decode_kc_user_from_token(data.get("access_token", "")) + claims: Dict[str, Any] = dict(kc_claims) + # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + + async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae + is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no + responde, se cae al decode del token KC. Así la sesión local sabe si el + usuario es hub_admin sin volver a consultar al Hub en cada request. + """ + from core.security import verify_token + + claims: Dict[str, Any] = {} + try: + info = await verify_token(data.get("access_token", "")) + if isinstance(info, dict): + claims = dict(info) + except Exception as exc: + logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc) + + if not claims: + return self._session_claims_from_kc(data) + + # tenant_id/tenant_slug explícitos del Hub tienen precedencia. + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ - Refresca el access token usando el Hub + Refresca la sesión. + + - Intenta el refresh del token KC contra el Hub (comportamiento histórico). + - Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM + (patrón SIWEB) que la app usa como bearer y que dura por inactividad, de + modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s). + - Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se + RESPETA la revocación central de Keycloak (sin re-emisión de fallback). """ + from datetime import datetime, timezone + + session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False)) + prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None + prev_sst = prev_claims.get("sst") if prev_claims else None + prev_session_id = refresh_data.session_id if session_enabled else None + + # Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que + # mande el cliente (puede estar desactualizado). Fail-silent. + kc_refresh = refresh_data.refresh_token + if session_enabled and prev_session_id: + from core import session_store + + sess = session_store.get_session(prev_session_id) + if sess and sess.get("refresh_token"): + kc_refresh = sess["refresh_token"] + + # ── Intento de refresh del token KC contra el Hub ──────────────────────── + kc_ok = False + data: Optional[Dict[str, Any]] = None try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{settings.HUB_URL}api/v1/auth/refresh", - json=refresh_data.model_dump() + json={"refresh_token": kc_refresh}, ) - - if response.status_code == 200: + kc_ok = response.status_code == 200 + if kc_ok: data = response.json() - from core.workspace_profile_sync import sync_workspace_profile_for_user - from core.workspace_profile_client import WorkspaceProfileClient + else: + logger.warning("Hub rechazó el refresh (status %s)", response.status_code) + except Exception as exc: + logger.warning("Hub inalcanzable en refresh: %s", exc) + kc_ok = False - workspace_profile = None - try: - workspace_profile = await WorkspaceProfileClient().get_me( - data.get("access_token", "") - ) - except Exception as exc: - logger.warning( - "workspace_profile_sync_failed", - extra={ - "event": "workspace_profile_sync_failed", - "phase": "refresh", - "error": str(exc), - }, - ) - workspace_profile = None + # ── Camino feliz: el Hub renovó el token KC ────────────────────────────── + if kc_ok and data is not None: + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient - await sync_workspace_profile_for_user( - self.db, - access_token=data.get("access_token"), - keycloak_user_id=(workspace_profile or {}).get("sub") - or data.get("sub") - or data.get("user_id"), - tenant_id=data.get("tenant_id"), - workspace_profile=workspace_profile, - force=True, + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", "")) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)}, ) - return TokenResponseDTO(**data) - - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + workspace_profile = None - except Exception as e: - logger.error(f"Token refresh error: {str(e)}") - raise HTTPException(status_code=500, detail="Token refresh error") + await sync_workspace_profile_for_user( + self.db, + access_token=data.get("access_token"), + keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + + resp = TokenResponseDTO(**data) + + if session_enabled: + from core import local_session, session_store + + start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp()) + claims = await self._session_claims(data) + new_access = data.get("access_token", "") + new_refresh = data.get("refresh_token", "") + # Reutiliza la sesión de valkey si ya existía; si no, la crea. + if prev_session_id and session_store.get_session(prev_session_id): + session_store.update_session_tokens(prev_session_id, new_access, new_refresh) + resp.session_id = prev_session_id + else: + resp.session_id = session_store.create_session(new_access, new_refresh, start) + resp.session_token = local_session.mint_session_token(claims, session_start=start) + + return resp + + # El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación + # central de Keycloak (no hay re-emisión local de fallback). El usuario + # re-entra por el App Launcher. La sesión local de larga duración evita el + # bucle: el refresh solo se intenta al expirar la sesión local por + # inactividad (idle), no cada ~60s como con el token KC crudo. + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") async def get_user_info(self, access_token: str) -> UserInfoResponseDTO: """ diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py index a134440..3ed0883 100644 --- a/backend/api/v1/modules/core/invites/routes.py +++ b/backend/api/v1/modules/core/invites/routes.py @@ -37,13 +37,33 @@ async def create_invite( required_permissions=["user.create"], ) + # tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el + # token), se resuelve desde la compañía destino (a76.company → core.tenants). tenant_slug: str = current_user.get("tenant_slug") or "" + if not tenant_slug: + from sqlalchemy import text as _text + row = db.execute( + _text( + "SELECT t.slug FROM a76.company c " + "JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c" + ), + {"c": data.company_id}, + ).first() + if row and row[0]: + tenant_slug = row[0] + created_by: str = current_user.get("sub") or "" + # El invite se crea en el Hub: se necesita el token KC (la sesión local no la + # acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta. + from core.hub_token import get_hub_access_token + + kc_token = await get_hub_access_token(request) + service = InviteService(db) return await service.create_invite( data=data, created_by=created_by, tenant_slug=tenant_slug, - user_access_token=credentials.credentials, + user_access_token=kc_token or credentials.credentials, ) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4dd1a10..44a9d2b 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -53,12 +53,17 @@ async def get_user_statistics( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) @@ -84,12 +89,19 @@ async def list_users( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + # El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta. + # Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado. + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index 95f6708..55a4bf6 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -13,15 +13,18 @@ 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 commercial_classification: str | None = Field(None, max_length=20) preferred_contact_method: str | None = Field(None, max_length=20) + preferred_contact_other: str | None = Field(None, max_length=120) language: str | None = Field(None, max_length=40) email: EmailStr | None = None phone: str | None = Field(None, max_length=40) website: str | None = Field(None, max_length=255) + commercial_observations: str | None = None # observaciones generales # Fiscal tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) @@ -39,7 +42,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 @@ -58,14 +61,17 @@ 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) preferred_contact_method: str | None = Field(None, max_length=20) + preferred_contact_other: str | None = Field(None, max_length=120) language: str | None = Field(None, max_length=40) email: EmailStr | None = None phone: str | None = Field(None, max_length=40) website: str | None = Field(None, max_length=255) + commercial_observations: str | None = None tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) tax_regime_id: int | None = None @@ -80,7 +86,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) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index d91fdda..e498c62 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -32,6 +32,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 @@ -40,12 +41,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin): # ----- Información comercial ----- # Clasificación: importador | exportador | ambos commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True) - # Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro + # Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Texto libre cuando el medio de contacto es "otro" + preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True) language: Mapped[str | None] = mapped_column(String(40), nullable=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True) phone: Mapped[str | None] = mapped_column(String(40), nullable=True) website: Mapped[str | None] = mapped_column(String(255), nullable=True) + commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales # ----- Información fiscal ----- # Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario @@ -72,7 +76,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 diff --git a/backend/api/v1/modules/crm/addresses/dto.py b/backend/api/v1/modules/crm/addresses/dto.py index e04ec29..1a8cf73 100644 --- a/backend/api/v1/modules/crm/addresses/dto.py +++ b/backend/api/v1/modules/crm/addresses/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/addresses/models.py b/backend/api/v1/modules/crm/addresses/models.py index 9cf7537..ab41fc1 100644 --- a/backend/api/v1/modules/crm/addresses/models.py +++ b/backend/api/v1/modules/crm/addresses/models.py @@ -29,7 +29,8 @@ class Address(Base, TenantScopedMixin, TimestampMixin): neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) # colonia postal_code: Mapped[str | None] = mapped_column(String(10), nullable=True) # código postal city: Mapped[str | None] = mapped_column(String(120), nullable=True) # municipio - state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado - country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'")) + state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado (código catálogo) + # País como código ISO 3166 alfa-3 del catálogo (p. ej. MEX). Ampliado de 2→3. + country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'")) reference_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # referencias is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) diff --git a/backend/api/v1/modules/crm/cases/__init__.py b/backend/api/v1/modules/crm/cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/cases/dto.py b/backend/api/v1/modules/crm/cases/dto.py new file mode 100644 index 0000000..35fdaa1 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/dto.py @@ -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] = [] diff --git a/backend/api/v1/modules/crm/cases/models.py b/backend/api/v1/modules/crm/cases/models.py new file mode 100644 index 0000000..772c430 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/cases/routes.py b/backend/api/v1/modules/crm/cases/routes.py new file mode 100644 index 0000000..13cad12 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/cases/service.py b/backend/api/v1/modules/crm/cases/service.py new file mode 100644 index 0000000..733c629 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/service.py @@ -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 diff --git a/backend/api/v1/modules/crm/catalogs/dto.py b/backend/api/v1/modules/crm/catalogs/dto.py new file mode 100644 index 0000000..c4def27 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/dto.py @@ -0,0 +1,46 @@ +"""Schemas (DTOs) de los catálogos de referencia del CRM.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class CatalogItemBase(BaseModel): + code: str = Field(..., max_length=64) + label: str = Field(..., max_length=255) + parent_catalog: str | None = Field(None, max_length=60) + parent_code: str | None = Field(None, max_length=64) + sort_order: int = 0 + is_active: bool = True + + +class CatalogItemCreate(CatalogItemBase): + pass + + +class CatalogItemUpdate(BaseModel): + """PATCH: todos los campos opcionales.""" + + code: str | None = Field(None, max_length=64) + label: str | None = Field(None, max_length=255) + parent_code: str | None = Field(None, max_length=64) + sort_order: int | None = None + is_active: bool | None = None + + +class CatalogItemResponse(CatalogItemBase): + model_config = ConfigDict(from_attributes=True) + + id: int + catalog: str + tenant_id: int | None + is_system: bool + extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo) + + +class CatalogMeta(BaseModel): + """Metadata de un catálogo para la pantalla de administración.""" + + catalog: str + label: str + scope: str # 'global' | 'tenant' + is_system: bool + count: int diff --git a/backend/api/v1/modules/crm/catalogs/models.py b/backend/api/v1/modules/crm/catalogs/models.py new file mode 100644 index 0000000..f20acdd --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/models.py @@ -0,0 +1,54 @@ +"""Modelo de catálogos de referencia del CRM (T2026-07-081/082). + +Un único modelo genérico ``CatalogItem`` respalda todos los catálogos +(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo +(``catalog``) e identifica una opción por ``code`` (clave) + ``label`` +(descripción que se visualiza). + +Alcance: +- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos. +- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra). + +Los catálogos dependientes (p. ej. Estado depende de País) usan +``parent_catalog`` + ``parent_code`` para filtrarse. +""" + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from core.database import Base + + +class CatalogItem(Base): + __tablename__ = "catalog_items" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(64), nullable=False) + label: Mapped[str] = mapped_column(String(255), nullable=False) + + # Dependencia (Estado→País, Municipio→Estado, …) + parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True) + parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente). + tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + # Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar). + is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + extra: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/api/v1/modules/crm/catalogs/routes.py b/backend/api/v1/modules/crm/catalogs/routes.py index 8c75dfe..e389ee7 100644 --- a/backend/api/v1/modules/crm/catalogs/routes.py +++ b/backend/api/v1/modules/crm/catalogs/routes.py @@ -1,6 +1,10 @@ -"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).""" +"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10). -from fastapi import APIRouter, Depends, Query +Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales +(Aduanasoft) y catálogos propios de cada cliente (tenant). +""" + +from fastapi import APIRouter, Depends, Query, status from sqlalchemy.orm import Session from core.database import get_core_db @@ -8,7 +12,9 @@ from core.security import get_current_user from ..accounts.models import Account from ..suppliers.models import Supplier +from . import service as catalog_service from .data import INCOTERMS, PARTICIPANT_ROLES +from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta router = APIRouter() @@ -31,6 +37,76 @@ def list_participant_roles( return PARTICIPANT_ROLES +# ---------------------------------------------------------------------------- +# Catálogos de referencia (CRUD) — T2026-07-081/082 +# ---------------------------------------------------------------------------- + + +@router.get("/catalogs", response_model=list[CatalogMeta]) +def list_catalog_meta( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Lista los catálogos disponibles (global + del tenant) con su conteo.""" + return catalog_service.list_meta(db, current_user["tenant_id"]) + + +@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse]) +def list_catalog_items( + catalog: str, + company_id: int = Query(..., description="Company ID"), + parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"), + include_inactive: bool = Query(False), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Opciones de un catálogo (global + del tenant), activas y ordenadas.""" + return catalog_service.list_items( + db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive + ) + + +@router.post( + "/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED +) +def create_catalog_item( + catalog: str, + data: CatalogItemCreate, + company_id: int = Query(..., description="Company ID"), + scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Inserta una opción en un catálogo.""" + return catalog_service.create_item(db, catalog, data, current_user, scope=scope) + + +@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse) +def update_catalog_item( + catalog: str, + item_id: int, + data: CatalogItemUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Edita una opción de catálogo.""" + return catalog_service.update_item(db, catalog, item_id, data, current_user) + + +@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_catalog_item( + catalog: str, + item_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Borra una opción de catálogo (los catálogos base del sistema no se borran).""" + catalog_service.delete_item(db, catalog, item_id, current_user) + + @router.get("/participants") def list_participants( company_id: int = Query(..., description="Company ID"), diff --git a/backend/api/v1/modules/crm/catalogs/seed.py b/backend/api/v1/modules/crm/catalogs/seed.py new file mode 100644 index 0000000..a44adb9 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed.py @@ -0,0 +1,69 @@ +"""Siembra de catálogos globales (Aduanasoft) del CRM. + +Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se +puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno: + + docker compose exec backend python -m api.v1.modules.crm.catalogs.seed +""" + +import logging + +from sqlalchemy.orm import Session + +from .models import CatalogItem +from .seed_data import GLOBAL_CATALOGS + +logger = logging.getLogger(__name__) + + +def seed_global_catalogs(db: Session) -> dict: + """Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}.""" + summary: dict[str, int] = {} + for catalog, meta in GLOBAL_CATALOGS.items(): + is_system = bool(meta.get("is_system", False)) + existing = { + row.code + for row in db.query(CatalogItem.code).filter( + CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None) + ) + } + added = 0 + for order, item in enumerate(meta["items"]): + if item["code"] in existing: + continue + db.add( + CatalogItem( + catalog=catalog, + code=item["code"], + label=item["label"], + parent_catalog=item.get("parent_catalog"), + parent_code=item.get("parent_code"), + extra=item.get("extra"), + tenant_id=None, + sort_order=order, + is_active=True, + is_system=is_system, + ) + ) + added += 1 + if added: + summary[catalog] = added + db.commit() + total = sum(summary.values()) + logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary)) + return summary + + +def _run() -> None: + from core.database import CoreSessionLocal + + db = CoreSessionLocal() + try: + result = seed_global_catalogs(db) + print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)") + finally: + db.close() + + +if __name__ == "__main__": + _run() diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py new file mode 100644 index 0000000..c857f03 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -0,0 +1,874 @@ +"""Datos semilla de los catálogos de referencia del CRM. + +SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra) ++ catálogos del módulo Tarifario. Globales con tenant_id NULL. +""" + +GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro', + 'is_system': True, + 'items': [{'code': 'cliente', 'label': 'Cliente'}, {'code': 'prospecto', 'label': 'Prospecto'}]}, + 'tipo_persona': {'label': 'Tipo de persona', + 'is_system': True, + 'items': [{'code': 'fisica', 'label': 'Persona física'}, + {'code': 'moral', 'label': 'Persona moral'}]}, + 'estatus': {'label': 'Estatus', + 'is_system': True, + 'items': [{'code': 'active', 'label': 'Activo'}, {'code': 'inactive', 'label': 'Inactivo'}]}, + 'giro': {'label': 'Giro o industria', + 'is_system': False, + 'items': [{'code': 'importadora', 'label': 'Importadora'}, + {'code': 'exportadora', 'label': 'Exportadora'}, + {'code': 'manufactura', 'label': 'Manufactura'}, + {'code': 'comercializadora', 'label': 'Comercializadora'}, + {'code': 'logistica', 'label': 'Logística y transporte'}, + {'code': 'agencia_aduanal', 'label': 'Agencia aduanal'}, + {'code': 'maquiladora', 'label': 'Maquiladora / IMMEX'}, + {'code': 'servicios', 'label': 'Servicios'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'clasificacion_cliente': {'label': 'Clasificación del cliente', + 'is_system': False, + 'items': [{'code': 'importador', 'label': 'Importador'}, + {'code': 'exportador', 'label': 'Exportador'}, + {'code': 'importador_exportador', 'label': 'Importador/Exportador'}]}, + 'medio_contacto': {'label': 'Medio de contacto preferido', + 'is_system': False, + 'items': [{'code': 'llamada', 'label': 'Llamada telefónica'}, + {'code': 'correo', 'label': 'Correo electrónico'}, + {'code': 'videoconferencia', 'label': 'Videoconferencia'}, + {'code': 'whatsapp', 'label': 'WhatsApp'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'idioma': {'label': 'Idioma', + 'is_system': False, + 'items': [{'code': 'es', 'label': 'Español'}, + {'code': 'en', 'label': 'Inglés'}, + {'code': 'zh', 'label': 'Chino (mandarín)'}, + {'code': 'pt', 'label': 'Portugués'}, + {'code': 'fr', 'label': 'Francés'}, + {'code': 'de', 'label': 'Alemán'}, + {'code': 'ja', 'label': 'Japonés'}, + {'code': 'ko', 'label': 'Coreano'}, + {'code': 'it', 'label': 'Italiano'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'regimen_fiscal': {'label': 'Régimen fiscal', + 'is_system': False, + 'items': [{'code': 'fisica', 'label': 'Persona física'}, + {'code': 'moral', 'label': 'Persona moral'}]}, + 'uso_cfdi': {'label': 'Uso de CFDI (SAT)', + 'is_system': True, + 'items': [{'code': 'G01', 'label': 'Adquisición de mercancías'}, + {'code': 'G02', 'label': 'Devoluciones, descuentos o bonificaciones'}, + {'code': 'G03', 'label': 'Gastos en general'}, + {'code': 'I01', 'label': 'Construcciones'}, + {'code': 'I02', 'label': 'Mobiliario y equipo de oficina por inversiones'}, + {'code': 'I03', 'label': 'Equipo de transporte'}, + {'code': 'I04', 'label': 'Equipo de cómputo y accesorios'}, + {'code': 'I05', 'label': 'Dados, troqueles, moldes, matrices y herramental'}, + {'code': 'I06', 'label': 'Comunicaciones telefónicas'}, + {'code': 'I07', 'label': 'Comunicaciones satelitales'}, + {'code': 'I08', 'label': 'Otra maquinaria y equipo'}, + {'code': 'D01', 'label': 'Honorarios médicos, dentales y gastos hospitalarios'}, + {'code': 'D02', 'label': 'Gastos médicos por incapacidad o discapacidad'}, + {'code': 'D03', 'label': 'Gastos funerales'}, + {'code': 'D04', 'label': 'Donativos'}, + {'code': 'D05', 'label': 'Intereses por créditos hipotecarios'}, + {'code': 'D06', 'label': 'Aportaciones voluntarias al SAR'}, + {'code': 'D07', 'label': 'Primas por seguros de gastos médicos'}, + {'code': 'D08', 'label': 'Gastos de transportación escolar obligatoria'}, + {'code': 'D09', 'label': 'Depósitos en cuentas para el ahorro'}, + {'code': 'D10', 'label': 'Pagos por servicios educativos (colegiaturas)'}, + {'code': 'S01', 'label': 'Sin efectos fiscales'}, + {'code': 'CP01', 'label': 'Pagos'}, + {'code': 'CN01', 'label': 'Nómina'}, + {'code': 'P01', 'label': 'Por definir'}]}, + 'forma_pago': {'label': 'Forma de pago (SAT)', + 'is_system': True, + 'items': [{'code': '1', 'label': 'Efectivo'}, + {'code': '2', 'label': 'Cheque nominativo'}, + {'code': '3', 'label': 'Transferencia electrónica de fondos'}, + {'code': '4', 'label': 'Tarjeta de crédito'}, + {'code': '5', 'label': 'Monedero electrónico'}, + {'code': '6', 'label': 'Dinero electrónico'}, + {'code': '8', 'label': 'Vales de despensa'}, + {'code': '12', 'label': 'Dación en pago'}, + {'code': '13', 'label': 'Pago por subrogación'}, + {'code': '14', 'label': 'Pago por consignación'}, + {'code': '15', 'label': 'Condonación'}, + {'code': '17', 'label': 'Compensación'}, + {'code': '23', 'label': 'Novación'}, + {'code': '24', 'label': 'Confusión'}, + {'code': '25', 'label': 'Remisión de deuda'}, + {'code': '26', 'label': 'Prescripción o caducidad'}, + {'code': '27', 'label': 'A satisfacción del acreedor'}, + {'code': '28', 'label': 'Tarjeta de débito'}, + {'code': '29', 'label': 'Tarjeta de servicios'}, + {'code': '30', 'label': 'Aplicación de anticipos'}, + {'code': '31', 'label': 'Intermediario pagos'}, + {'code': '99', 'label': 'Por definir'}]}, + 'metodo_pago': {'label': 'Método de pago (SAT)', + 'is_system': True, + 'items': [{'code': 'PPD', 'label': 'Pago en parcialidades o diferido'}, + {'code': 'PUE', 'label': 'Pago en una sola exhibición'}]}, + 'moneda': {'label': 'Moneda (ISO 4217)', + 'is_system': True, + 'items': [{'code': 'CRC', 'label': 'Colón costarricense'}, + {'code': 'CUC', 'label': 'Peso Convertible'}, + {'code': 'CUP', 'label': 'Peso Cubano'}, + {'code': 'CVE', 'label': 'Cabo Verde Escudo'}, + {'code': 'CZK', 'label': 'Corona checa'}, + {'code': 'DJF', 'label': 'Franco de Djibouti'}, + {'code': 'DKK', 'label': 'Corona danesa'}, + {'code': 'DOP', 'label': 'Peso Dominicano'}, + {'code': 'DZD', 'label': 'Dinar argelino'}, + {'code': 'EGP', 'label': 'Libra egipcia'}, + {'code': 'ERN', 'label': 'Nakfa'}, + {'code': 'ETB', 'label': 'Birr etíope'}, + {'code': 'EUR', 'label': 'Euro'}, + {'code': 'FJD', 'label': 'Dólar de Fiji'}, + {'code': 'FKP', 'label': 'Libra malvinense'}, + {'code': 'GBP', 'label': 'Libra Esterlina'}, + {'code': 'GEL', 'label': 'Lari'}, + {'code': 'GHS', 'label': 'Cedi de Ghana'}, + {'code': 'GIP', 'label': 'Libra de Gibraltar'}, + {'code': 'GMD', 'label': 'Dalasi'}, + {'code': 'GNF', 'label': 'Franco guineano'}, + {'code': 'GTQ', 'label': 'Quetzal'}, + {'code': 'GYD', 'label': 'Dólar guyanés'}, + {'code': 'HKD', 'label': 'Dolar De Hong Kong'}, + {'code': 'HNL', 'label': 'Lempira'}, + {'code': 'HRK', 'label': 'Kuna'}, + {'code': 'HTG', 'label': 'Gourde'}, + {'code': 'HUF', 'label': 'Florín'}, + {'code': 'IDR', 'label': 'Rupia'}, + {'code': 'ILS', 'label': 'Nuevo Shekel Israelí'}, + {'code': 'INR', 'label': 'Rupia india'}, + {'code': 'IQD', 'label': 'Dinar iraquí'}, + {'code': 'IRR', 'label': 'Rial iraní'}, + {'code': 'ISK', 'label': 'Corona islandesa'}, + {'code': 'JMD', 'label': 'Dólar Jamaiquino'}, + {'code': 'JOD', 'label': 'Dinar jordano'}, + {'code': 'JPY', 'label': 'Yen'}, + {'code': 'KES', 'label': 'Chelín keniano'}, + {'code': 'KGS', 'label': 'Som'}, + {'code': 'KHR', 'label': 'Riel'}, + {'code': 'KMF', 'label': 'Franco Comoro'}, + {'code': 'KPW', 'label': 'Corea del Norte ganó'}, + {'code': 'KRW', 'label': 'Won'}, + {'code': 'KWD', 'label': 'Dinar kuwaití'}, + {'code': 'KYD', 'label': 'Dólar de las Islas Caimán'}, + {'code': 'KZT', 'label': 'Tenge'}, + {'code': 'LAK', 'label': 'Kip'}, + {'code': 'LBP', 'label': 'Libra libanesa'}, + {'code': 'LKR', 'label': 'Rupia de Sri Lanka'}, + {'code': 'LRD', 'label': 'Dólar liberiano'}, + {'code': 'LSL', 'label': 'Loti'}, + {'code': 'LYD', 'label': 'Dinar libio'}, + {'code': 'MAD', 'label': 'Dirham marroquí'}, + {'code': 'MDL', 'label': 'Leu moldavo'}, + {'code': 'MGA', 'label': 'Ariary malgache'}, + {'code': 'MKD', 'label': 'Denar'}, + {'code': 'MMK', 'label': 'Kyat'}, + {'code': 'MNT', 'label': 'Tugrik'}, + {'code': 'MOP', 'label': 'Pataca'}, + {'code': 'MRO', 'label': 'Ouguiya'}, + {'code': 'MUR', 'label': 'Rupia de Mauricio'}, + {'code': 'MVR', 'label': 'Rupia'}, + {'code': 'MWK', 'label': 'Kwacha'}, + {'code': 'MXN', 'label': 'Peso Mexicano'}, + {'code': 'MXV', 'label': 'México Unidad de Inversión (UDI)'}, + {'code': 'MYR', 'label': 'Ringgit malayo'}, + {'code': 'MZN', 'label': 'Mozambique Metical'}, + {'code': 'NAD', 'label': 'Dólar de Namibia'}, + {'code': 'NGN', 'label': 'Naira'}, + {'code': 'NIO', 'label': 'Córdoba Oro'}, + {'code': 'NOK', 'label': 'Corona noruega'}, + {'code': 'NPR', 'label': 'Rupia nepalí'}, + {'code': 'NZD', 'label': 'Dólar de Nueva Zelanda'}, + {'code': 'OMR', 'label': 'Rial omaní'}, + {'code': 'PAB', 'label': 'Balboa'}, + {'code': 'PEN', 'label': 'Nuevo Sol'}, + {'code': 'PGK', 'label': 'Kina'}, + {'code': 'PHP', 'label': 'Peso filipino'}, + {'code': 'PKR', 'label': 'Rupia de Pakistán'}, + {'code': 'PLN', 'label': 'Zloty'}, + {'code': 'PYG', 'label': 'Guaraní'}, + {'code': 'QAR', 'label': 'Qatar Rial'}, + {'code': 'RON', 'label': 'Leu rumano'}, + {'code': 'RSD', 'label': 'Dinar serbio'}, + {'code': 'RUB', 'label': 'Rublo ruso'}, + {'code': 'RWF', 'label': 'Franco ruandés'}, + {'code': 'SAR', 'label': 'Riyal saudí'}, + {'code': 'SBD', 'label': 'Dólar de las Islas Salomón'}, + {'code': 'SCR', 'label': 'Rupia de Seychelles'}, + {'code': 'SDG', 'label': 'Libra sudanesa'}, + {'code': 'SEK', 'label': 'Corona sueca'}, + {'code': 'SGD', 'label': 'Dolar De Singapur'}, + {'code': 'SHP', 'label': 'Libra de Santa Helena'}, + {'code': 'SLL', 'label': 'Leona'}, + {'code': 'SOS', 'label': 'Chelín somalí'}, + {'code': 'SRD', 'label': 'Dólar de Suriname'}, + {'code': 'SSP', 'label': 'Libra sudanesa Sur'}, + {'code': 'STD', 'label': 'Dobra'}, + {'code': 'SVC', 'label': 'Colon El Salvador'}, + {'code': 'SYP', 'label': 'Libra Siria'}, + {'code': 'SZL', 'label': 'Lilangeni'}, + {'code': 'THB', 'label': 'Baht'}, + {'code': 'TJS', 'label': 'Somoni'}, + {'code': 'TMT', 'label': 'Turkmenistán nuevo manat'}, + {'code': 'TND', 'label': 'Dinar tunecino'}, + {'code': 'TOP', 'label': "Pa'anga"}, + {'code': 'TRY', 'label': 'Lira turca'}, + {'code': 'TTD', 'label': 'Dólar de Trinidad y Tobago'}, + {'code': 'TWD', 'label': 'Nuevo dólar de Taiwán'}, + {'code': 'TZS', 'label': 'Shilling tanzano'}, + {'code': 'UAH', 'label': 'Hryvnia'}, + {'code': 'UGX', 'label': 'Shilling de Uganda'}, + {'code': 'USD', 'label': 'Dolar americano'}, + {'code': 'USN', 'label': 'Dólar estadounidense (día siguiente)'}, + {'code': 'UYI', 'label': 'Peso Uruguay en Unidades Indexadas (URUIURUI)'}, + {'code': 'UYU', 'label': 'Peso Uruguayo'}, + {'code': 'UZS', 'label': 'Uzbekistán Sum'}, + {'code': 'VEF', 'label': 'Bolívar'}, + {'code': 'VND', 'label': 'Dong'}, + {'code': 'VUV', 'label': 'Vatu'}, + {'code': 'WST', 'label': 'Tala'}, + {'code': 'XAF', 'label': 'Franco CFA BEAC'}, + {'code': 'XAG', 'label': 'Plata'}, + {'code': 'XAU', 'label': 'Oro'}, + {'code': 'XBA', 'label': 'Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)'}, + {'code': 'XBB', 'label': 'Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)'}, + {'code': 'XBC', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)'}, + {'code': 'XBD', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)'}, + {'code': 'XCD', 'label': 'Dólar del Caribe Oriental'}, + {'code': 'XDR', 'label': 'DEG (Derechos Especiales de Giro)'}, + {'code': 'XOF', 'label': 'Franco CFA BCEAO'}, + {'code': 'XPD', 'label': 'Paladio'}, + {'code': 'XPF', 'label': 'Franco CFP'}, + {'code': 'XPT', 'label': 'Platino'}, + {'code': 'XSU', 'label': 'Sucre'}, + {'code': 'XTS', 'label': 'Códigos reservados específicamente para propósitos de prueba'}, + {'code': 'XUA', 'label': 'Unidad ADB de Cuenta'}, + {'code': 'XXX', + 'label': 'Los códigos asignados para las transacciones en que intervenga ninguna moneda'}, + {'code': 'YER', 'label': 'Rial yemení'}, + {'code': 'ZAR', 'label': 'Rand'}, + {'code': 'ZMW', 'label': 'Kwacha zambiano'}, + {'code': 'ZWL', 'label': 'Zimbabwe Dólar'}, + {'code': 'NULL', 'label': 'NULL'}]}, + 'pais': {'label': 'País (ISO 3166)', + 'is_system': True, + 'items': [{'code': 'ABW', 'label': 'Aruba'}, + {'code': 'AFG', 'label': 'Afganistán'}, + {'code': 'AGO', 'label': 'Angola'}, + {'code': 'AIA', 'label': 'Anguila'}, + {'code': 'ALA', 'label': 'Islas Åland'}, + {'code': 'ALB', 'label': 'Albania'}, + {'code': 'AND', 'label': 'Andorra'}, + {'code': 'ARE', 'label': 'Emiratos Árabes Unidos (Los)'}, + {'code': 'ARG', 'label': 'Argentina'}, + {'code': 'ARM', 'label': 'Armenia'}, + {'code': 'ASM', 'label': 'Samoa Americana'}, + {'code': 'ATA', 'label': 'Antártida'}, + {'code': 'ATF', 'label': 'Territorios Australes Franceses (los)'}, + {'code': 'ATG', 'label': 'Antigua y Barbuda'}, + {'code': 'AUS', 'label': 'Australia'}, + {'code': 'AUT', 'label': 'Austria'}, + {'code': 'AZE', 'label': 'Azerbaiyán'}, + {'code': 'BDI', 'label': 'Burundi'}, + {'code': 'BEL', 'label': 'Bélgica'}, + {'code': 'BEN', 'label': 'Benín'}, + {'code': 'BES', 'label': 'Bonaire, San Eustaquio y Saba'}, + {'code': 'BFA', 'label': 'Burkina Faso'}, + {'code': 'BGD', 'label': 'Bangladés'}, + {'code': 'BGR', 'label': 'Bulgaria'}, + {'code': 'BHR', 'label': 'Baréin'}, + {'code': 'BHS', 'label': 'Bahamas (las)'}, + {'code': 'BIH', 'label': 'Bosnia y Herzegovina'}, + {'code': 'BLM', 'label': 'San Bartolomé'}, + {'code': 'BLR', 'label': 'Bielorrusia'}, + {'code': 'BLZ', 'label': 'Belice'}, + {'code': 'BMU', 'label': 'Bermudas'}, + {'code': 'BOL', 'label': 'Bolivia, Estado Plurinacional de'}, + {'code': 'BRA', 'label': 'Brasil'}, + {'code': 'BRB', 'label': 'Barbados'}, + {'code': 'BRN', 'label': 'Brunéi Darussalam'}, + {'code': 'BTN', 'label': 'Bután'}, + {'code': 'BVT', 'label': 'Isla Bouvet'}, + {'code': 'BWA', 'label': 'Botsuana'}, + {'code': 'CAF', 'label': 'República Centroafricana (la)'}, + {'code': 'CAN', 'label': 'Canadá'}, + {'code': 'CCK', 'label': 'Islas Cocos (Keeling)'}, + {'code': 'CHE', 'label': 'Suiza'}, + {'code': 'CHL', 'label': 'Chile'}, + {'code': 'CHN', 'label': 'China'}, + {'code': 'CIV', 'label': "Côte d'Ivoire"}, + {'code': 'CMR', 'label': 'Camerún'}, + {'code': 'COD', 'label': 'Congo (la República Democrática del)'}, + {'code': 'COG', 'label': 'Congo'}, + {'code': 'COK', 'label': 'Islas Cook (las)'}, + {'code': 'COL', 'label': 'Colombia'}, + {'code': 'COM', 'label': 'Comoras'}, + {'code': 'CPV', 'label': 'Cabo Verde'}, + {'code': 'CRI', 'label': 'Costa Rica'}, + {'code': 'CUB', 'label': 'Cuba'}, + {'code': 'CUW', 'label': 'Curaçao'}, + {'code': 'CXR', 'label': 'Isla de Navidad'}, + {'code': 'CYM', 'label': 'Islas Caimán (las)'}, + {'code': 'CYP', 'label': 'Chipre'}, + {'code': 'CZE', 'label': 'República Checa (la)'}, + {'code': 'DEU', 'label': 'Alemania'}, + {'code': 'DJI', 'label': 'Yibuti'}, + {'code': 'DMA', 'label': 'Dominica'}, + {'code': 'DNK', 'label': 'Dinamarca'}, + {'code': 'DOM', 'label': 'República Dominicana (la)'}, + {'code': 'DZA', 'label': 'Argelia'}, + {'code': 'ECU', 'label': 'Ecuador'}, + {'code': 'EGY', 'label': 'Egipto'}, + {'code': 'ERI', 'label': 'Eritrea'}, + {'code': 'ESH', 'label': 'Sahara Occidental'}, + {'code': 'ESP', 'label': 'España'}, + {'code': 'EST', 'label': 'Estonia'}, + {'code': 'ETH', 'label': 'Etiopía'}, + {'code': 'FIN', 'label': 'Finlandia'}, + {'code': 'FJI', 'label': 'Fiyi'}, + {'code': 'FLK', 'label': 'Islas Malvinas [Falkland] (las)'}, + {'code': 'FRA', 'label': 'Francia'}, + {'code': 'FRO', 'label': 'Islas Feroe (las)'}, + {'code': 'FSM', 'label': 'Micronesia (los Estados Federados de)'}, + {'code': 'GAB', 'label': 'Gabón'}, + {'code': 'GBR', 'label': 'Reino Unido (el)'}, + {'code': 'GEO', 'label': 'Georgia'}, + {'code': 'GGY', 'label': 'Guernsey'}, + {'code': 'GHA', 'label': 'Ghana'}, + {'code': 'GIB', 'label': 'Gibraltar'}, + {'code': 'GIN', 'label': 'Guinea'}, + {'code': 'GLP', 'label': 'Guadalupe'}, + {'code': 'GMB', 'label': 'Gambia (La)'}, + {'code': 'GNB', 'label': 'Guinea-Bisáu'}, + {'code': 'GNQ', 'label': 'Guinea Ecuatorial'}, + {'code': 'GRC', 'label': 'Grecia'}, + {'code': 'GRD', 'label': 'Granada'}, + {'code': 'GRL', 'label': 'Groenlandia'}, + {'code': 'GTM', 'label': 'Guatemala'}, + {'code': 'GUF', 'label': 'Guayana Francesa'}, + {'code': 'GUM', 'label': 'Guam'}, + {'code': 'GUY', 'label': 'Guyana'}, + {'code': 'HKG', 'label': 'Hong Kong'}, + {'code': 'HMD', 'label': 'Isla Heard e Islas McDonald'}, + {'code': 'HND', 'label': 'Honduras'}, + {'code': 'HRV', 'label': 'Croacia'}, + {'code': 'HTI', 'label': 'Haití'}, + {'code': 'HUN', 'label': 'Hungría'}, + {'code': 'IDN', 'label': 'Indonesia'}, + {'code': 'IMN', 'label': 'Isla de Man'}, + {'code': 'IND', 'label': 'India'}, + {'code': 'IOT', 'label': 'Territorio Británico del Océano Índico (el)'}, + {'code': 'IRL', 'label': 'Irlanda'}, + {'code': 'IRN', 'label': 'Irán (la República Islámica de)'}, + {'code': 'IRQ', 'label': 'Irak'}, + {'code': 'ISL', 'label': 'Islandia'}, + {'code': 'ISR', 'label': 'Israel'}, + {'code': 'ITA', 'label': 'Italia'}, + {'code': 'JAM', 'label': 'Jamaica'}, + {'code': 'JEY', 'label': 'Jersey'}, + {'code': 'JOR', 'label': 'Jordania'}, + {'code': 'JPN', 'label': 'Japón'}, + {'code': 'KAZ', 'label': 'Kazajistán'}, + {'code': 'KEN', 'label': 'Kenia'}, + {'code': 'KGZ', 'label': 'Kirguistán'}, + {'code': 'KHM', 'label': 'Camboya'}, + {'code': 'KIR', 'label': 'Kiribati'}, + {'code': 'KNA', 'label': 'San Cristóbal y Nieves'}, + {'code': 'KOR', 'label': 'Corea (la República de)'}, + {'code': 'KWT', 'label': 'Kuwait'}, + {'code': 'LAO', 'label': 'Lao, (la) República Democrática Popular'}, + {'code': 'LBN', 'label': 'Líbano'}, + {'code': 'LBR', 'label': 'Liberia'}, + {'code': 'LBY', 'label': 'Libia'}, + {'code': 'LCA', 'label': 'Santa Lucía'}, + {'code': 'LIE', 'label': 'Liechtenstein'}, + {'code': 'LKA', 'label': 'Sri Lanka'}, + {'code': 'LSO', 'label': 'Lesoto'}, + {'code': 'LTU', 'label': 'Lituania'}, + {'code': 'LUX', 'label': 'Luxemburgo'}, + {'code': 'LVA', 'label': 'Letonia'}, + {'code': 'MAC', 'label': 'Macao'}, + {'code': 'MAF', 'label': 'San Martín (parte francesa)'}, + {'code': 'MAR', 'label': 'Marruecos'}, + {'code': 'MCO', 'label': 'Mónaco'}, + {'code': 'MDA', 'label': 'Moldavia (la República de)'}, + {'code': 'MDG', 'label': 'Madagascar'}, + {'code': 'MDV', 'label': 'Maldivas'}, + {'code': 'MEX', 'label': 'México'}, + {'code': 'MHL', 'label': 'Islas Marshall (las)'}, + {'code': 'MKD', 'label': 'Macedonia (la antigua República Yugoslava de)'}, + {'code': 'MLI', 'label': 'Malí'}, + {'code': 'MLT', 'label': 'Malta'}, + {'code': 'MMR', 'label': 'Myanmar'}, + {'code': 'MNE', 'label': 'Montenegro'}, + {'code': 'MNG', 'label': 'Mongolia'}, + {'code': 'MNP', 'label': 'Islas Marianas del Norte (las)'}, + {'code': 'MOZ', 'label': 'Mozambique'}, + {'code': 'MRT', 'label': 'Mauritania'}, + {'code': 'MSR', 'label': 'Montserrat'}, + {'code': 'MTQ', 'label': 'Martinica'}, + {'code': 'MUS', 'label': 'Mauricio'}, + {'code': 'MWI', 'label': 'Malaui'}, + {'code': 'MYS', 'label': 'Malasia'}, + {'code': 'MYT', 'label': 'Mayotte'}, + {'code': 'NAM', 'label': 'Namibia'}, + {'code': 'NCL', 'label': 'Nueva Caledonia'}, + {'code': 'NER', 'label': 'Níger (el)'}, + {'code': 'NFK', 'label': 'Isla Norfolk'}, + {'code': 'NGA', 'label': 'Nigeria'}, + {'code': 'NIC', 'label': 'Nicaragua'}, + {'code': 'NIU', 'label': 'Niue'}, + {'code': 'NLD', 'label': 'Países Bajos (los)'}, + {'code': 'NOR', 'label': 'Noruega'}, + {'code': 'NPL', 'label': 'Nepal'}, + {'code': 'NRU', 'label': 'Nauru'}, + {'code': 'NZL', 'label': 'Nueva Zelanda'}, + {'code': 'OMN', 'label': 'Omán'}, + {'code': 'PAK', 'label': 'Pakistán'}, + {'code': 'PAN', 'label': 'Panamá'}, + {'code': 'PCN', 'label': 'Pitcairn'}, + {'code': 'PER', 'label': 'Perú'}, + {'code': 'PHL', 'label': 'Filipinas (las)'}, + {'code': 'PLW', 'label': 'Palaos'}, + {'code': 'PNG', 'label': 'Papúa Nueva Guinea'}, + {'code': 'POL', 'label': 'Polonia'}, + {'code': 'PRI', 'label': 'Puerto Rico'}, + {'code': 'PRK', 'label': 'Corea (la República Democrática Popular de)'}, + {'code': 'PRT', 'label': 'Portugal'}, + {'code': 'PRY', 'label': 'Paraguay'}, + {'code': 'PSE', 'label': 'Palestina, Estado de'}, + {'code': 'PYF', 'label': 'Polinesia Francesa'}, + {'code': 'QAT', 'label': 'Catar'}, + {'code': 'REU', 'label': 'Reunión'}, + {'code': 'ROU', 'label': 'Rumania'}, + {'code': 'RUS', 'label': 'Rusia, (la) Federación de'}, + {'code': 'RWA', 'label': 'Ruanda'}, + {'code': 'SAU', 'label': 'Arabia Saudita'}, + {'code': 'SDN', 'label': 'Sudán (el)'}, + {'code': 'SEN', 'label': 'Senegal'}, + {'code': 'SGP', 'label': 'Singapur'}, + {'code': 'SGS', 'label': 'Georgia del sur y las islas sandwich del sur'}, + {'code': 'SHN', 'label': 'Santa Helena, Ascensión y Tristán de Acuña'}, + {'code': 'SJM', 'label': 'Svalbard y Jan Mayen'}, + {'code': 'SLB', 'label': 'Islas Salomón (las)'}, + {'code': 'SLE', 'label': 'Sierra leona'}, + {'code': 'NULL', 'label': 'NULL'}]}, + 'estado': {'label': 'Estado / Provincia', + 'is_system': True, + 'items': [{'code': 'AGU', 'label': 'Aguascalientes', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'BCN', 'label': 'Baja California', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'BCS', 'label': 'Baja California Sur', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'CAM', 'label': 'Campeche', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'CHP', 'label': 'Chiapas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'CHH', 'label': 'Chihuahua', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'CMX', 'label': 'Ciudad de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'COA', 'label': 'Coahuila', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'COL', 'label': 'Colima', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'DUR', 'label': 'Durango', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'GUA', 'label': 'Guanajuato', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'GRO', 'label': 'Guerrero', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'HID', 'label': 'Hidalgo', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'JAL', 'label': 'Jalisco', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'MEX', 'label': 'Estado de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'MIC', 'label': 'Michoacán', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'MOR', 'label': 'Morelos', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'NAY', 'label': 'Nayarit', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'NLE', 'label': 'Nuevo León', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'OAX', 'label': 'Oaxaca', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'PUE', 'label': 'Puebla', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'QUE', 'label': 'Querétaro', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'ROO', 'label': 'Quintana Roo', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'SLP', 'label': 'San Luis Potosí', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'SIN', 'label': 'Sinaloa', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'SON', 'label': 'Sonora', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'TAB', 'label': 'Tabasco', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'TAM', 'label': 'Tamaulipas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'TLA', 'label': 'Tlaxcala', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'VER', 'label': 'Veracruz', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'YUC', 'label': 'Yucatán', 'parent_catalog': 'pais', 'parent_code': 'MEX'}, + {'code': 'ZAC', 'label': 'Zacatecas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}]}, + 'tipo_domicilio': {'label': 'Tipo de domicilio', + 'is_system': False, + 'items': [{'code': 'fiscal', 'label': 'Fiscal'}, + {'code': 'oficina', 'label': 'Oficina'}, + {'code': 'sucursal', 'label': 'Sucursal'}, + {'code': 'bodega', 'label': 'Bodega'}, + {'code': 'patio', 'label': 'Patio'}, + {'code': 'terminal', 'label': 'Terminal'}, + {'code': 'almacen', 'label': 'Almacén'}]}, + 'area': {'label': 'Área / Departamento', + 'is_system': False, + 'items': [{'code': 'ventas', 'label': 'Ventas'}, + {'code': 'operaciones', 'label': 'Operaciones'}, + {'code': 'facturacion', 'label': 'Facturación'}, + {'code': 'cobranza', 'label': 'Cobranza'}, + {'code': 'servicio_cliente', 'label': 'Servicio al cliente'}]}, + 'cobertura': {'label': 'Cobertura', + 'is_system': True, + 'items': [{'code': 'nacional', 'label': 'Nacional'}, + {'code': 'internacional', 'label': 'Internacional'}]}, + 'clasificacion_proveedor': {'label': 'Clasificación del proveedor', + 'is_system': False, + 'items': [{'code': 'naviera', 'label': 'Naviera'}, + {'code': 'aerolinea', 'label': 'Aerolínea'}, + {'code': 'transportista_terrestre', 'label': 'Transportista Terrestre'}, + {'code': 'ferrocarril', 'label': 'Ferrocarril'}, + {'code': 'agente_aduanal', 'label': 'Agente Aduanal'}, + {'code': 'agente_carga', 'label': 'Agente de Carga'}, + {'code': 'agente_corresponsal', 'label': 'Agente Corresponsal'}, + {'code': 'almacen', 'label': 'Almacén'}, + {'code': 'aseguradora', 'label': 'Aseguradora'}, + {'code': 'paqueteria', 'label': 'Paquetería'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'tipo_equipo': {'label': 'Tipo de equipo / contenedor', + 'is_system': True, + 'items': [{'code': '40DC', + 'label': "40' Standard", + 'extra': {'modo': 'maritimo', + 'largo_m': 12.035, + 'ancho_m': 2.35, + 'alto_m': 2.392, + 'capacidad_m3': 67.7, + 'tara_kg': 3700, + 'carga_max_kg': 26790}}, + {'code': '20DC', + 'label': "20' Standard", + 'extra': {'modo': 'maritimo', + 'largo_m': 5.9, + 'ancho_m': 2.35, + 'alto_m': 2.392, + 'capacidad_m3': 33.2, + 'tara_kg': 2230, + 'carga_max_kg': 21770}}, + {'code': '20OT', + 'label': "20' Open Top", + 'extra': {'modo': 'maritimo', + 'largo_m': 5.894, + 'ancho_m': 2.311, + 'alto_m': 2.354, + 'capacidad_m3': 32.23, + 'tara_kg': 2400, + 'carga_max_kg': 30490}}, + {'code': '20FR', + 'label': "20' Flat Rack", + 'extra': {'modo': 'maritimo', + 'largo_m': 5.62, + 'ancho_m': 2.23, + 'alto_m': 2.233, + 'tara_kg': 2530, + 'carga_max_kg': 21470}}, + {'code': '40HC', + 'label': "40' High Cube", + 'extra': {'modo': 'maritimo', + 'largo_m': 12.036, + 'ancho_m': 2.35, + 'alto_m': 2.697, + 'capacidad_m3': 76.3, + 'tara_kg': 3970, + 'carga_max_kg': 26510}}, + {'code': '20PL', + 'label': "20' Platform", + 'extra': {'modo': 'maritimo', + 'largo_m': 6.058, + 'ancho_m': 2.438, + 'alto_m': 0.37, + 'tara_kg': 2520, + 'carga_max_kg': 27960}}, + {'code': '20FRC', + 'label': "20' Flat Rack Collapsible", + 'extra': {'modo': 'maritimo', + 'largo_m': 5.618, + 'ancho_m': 2.206, + 'alto_m': 2.233, + 'tara_kg': 2750, + 'carga_max_kg': 27730}}, + {'code': '20BK', + 'label': "20' Bulk", + 'extra': {'modo': 'maritimo', + 'largo_m': 5.93, + 'ancho_m': 2.35, + 'alto_m': 2.34, + 'capacidad_m3': 32.0, + 'tara_kg': 2450, + 'carga_max_kg': 21350}}, + {'code': '20TK', + 'label': "20' Tank", + 'extra': {'modo': 'maritimo', + 'largo_m': 6.058, + 'ancho_m': 2.438, + 'alto_m': 2.438, + 'tara_kg': 4100, + 'carga_max_kg': 26200}}, + {'code': 'LD2', + 'label': 'LD2', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 3.5, + 'tara_kg': 30, + 'carga_max_kg': 1225, + 'nota': 'Aviones 767'}}, + {'code': 'LD3', + 'label': 'LD3', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 4.2, + 'tara_kg': 80, + 'carga_max_kg': 1587, + 'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}}, + {'code': 'LBD', + 'label': 'LBD (Flex Door)', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 7.0, + 'tara_kg': 123, + 'carga_max_kg': 2449, + 'nota': 'Aviones 767'}}, + {'code': 'LD6', + 'label': 'LD6', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 8.9, + 'tara_kg': 175, + 'carga_max_kg': 3175, + 'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}}, + {'code': 'PAG', + 'label': 'PAP / PIP / PAG', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 10.0, + 'tara_kg': 120, + 'carga_max_kg': 6033, + 'nota': 'Boeing 747/767/777/DC10'}}, + {'code': 'LD9', + 'label': 'LD9 AAP', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 10.0, + 'tara_kg': 85, + 'carga_max_kg': 1588, + 'nota': 'Boeing 747/777/DC10'}}, + {'code': 'XAW', + 'label': 'XAW', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 14.0, + 'tara_kg': 170, + 'carga_max_kg': 5000, + 'nota': 'Boeing 747/777/DC10'}}, + {'code': 'PMC', + 'label': 'PMC', + 'extra': {'modo': 'aereo', + 'capacidad_m3': 12.7, + 'tara_kg': 130, + 'carga_max_kg': 6804, + 'nota': 'Boeing 747/767/777'}}, + {'code': 'LD8', + 'label': 'LD8', + 'extra': {'modo': 'aereo', 'capacidad_m3': 7.2, 'tara_kg': 120, 'carga_max_kg': 2450}}, + {'code': 'DV48', + 'label': "Dry Van 48'", + 'extra': {'modo': 'terrestre', + 'largo_m': 14.63, + 'ancho_m': 2.59, + 'alto_m': 2.3, + 'capacidad_m3': 98.0, + 'carga_max_kg': 20412, + 'pallets': 22}}, + {'code': 'SD', + 'label': 'Legal Step Deck (Single Drop)', + 'extra': {'modo': 'terrestre', + 'largo_m': 11.58, + 'ancho_m': 2.59, + 'alto_m': 3.05, + 'carga_max_kg': 20865}}, + {'code': 'TANK', + 'label': 'Tanker', + 'extra': {'modo': 'terrestre', + 'largo_m': 16.15, + 'ancho_m': 2.59, + 'alto_m': 2.3, + 'capacidad_l': 22712}}, + {'code': 'DV53', + 'label': "Dry Van 53'", + 'extra': {'modo': 'terrestre', + 'largo_m': 16.15, + 'ancho_m': 2.59, + 'alto_m': 2.3, + 'capacidad_m3': 99.11, + 'carga_max_kg': 20412, + 'pallets': 26}}, + {'code': 'DD', + 'label': 'Double Drop (Low Boy)', + 'extra': {'modo': 'terrestre', + 'largo_m': 8.53, + 'ancho_m': 2.59, + 'alto_m': 3.51, + 'carga_max_kg': 18144}}, + {'code': 'RF48', + 'label': "48' Reefer Trailer", + 'extra': {'modo': 'terrestre', + 'largo_m': 14.63, + 'ancho_m': 2.4, + 'alto_m': 2.3, + 'capacidad_m3': 90.0, + 'carga_max_kg': 19958, + 'pallets': 20}}, + {'code': 'FB48', + 'label': "48' Legal Flatbed", + 'extra': {'modo': 'terrestre', + 'largo_m': 14.63, + 'ancho_m': 2.59, + 'alto_m': 2.59, + 'carga_max_kg': 21772}}, + {'code': 'PUP28', + 'label': "Pup Trailer 28'", + 'extra': {'modo': 'terrestre', + 'largo_m': 8.53, + 'ancho_m': 2.59, + 'alto_m': 2.3, + 'capacidad_m3': 57.45, + 'carga_max_kg': 9979, + 'pallets': 14}}, + {'code': 'IM53', + 'label': "Intermodal 53' Container", + 'extra': {'modo': 'terrestre', + 'largo_m': 16.15, + 'ancho_m': 2.59, + 'alto_m': 2.3, + 'capacidad_m3': 99.11, + 'carga_max_kg': 19958, + 'pallets': 24}}]}, + 'modo_tarifario': {'label': 'Modo de tarifario', + 'is_system': True, + 'items': [{'code': 'aereo', 'label': 'Aéreo'}, + {'code': 'maritimo_fcl', 'label': 'Marítimo FCL'}, + {'code': 'maritimo_lcl', 'label': 'Marítimo LCL'}, + {'code': 'terrestre', 'label': 'Terrestre'}]}, + 'unidad_tarifa': {'label': 'Unidad de tarifa', + 'is_system': True, + 'items': [{'code': 'per_kg', 'label': 'Por kg'}, + {'code': 'per_wm', 'label': 'Por peso/medida (W/M)'}, + {'code': 'per_container', 'label': 'Por contenedor'}, + {'code': 'flat', 'label': 'Tarifa plana'}]}, + 'concepto_cargo': {'label': 'Concepto de cargo', + 'is_system': False, + 'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'}, + {'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'}, + {'code': 'moc', 'label': 'MOC (mínimo origen)'}, + {'code': 'afs', 'label': 'AFS'}, + {'code': 'thc', 'label': 'THC (manejo en terminal)'}, + {'code': 'maniobras', 'label': 'Maniobras'}, + {'code': 'almacenaje', 'label': 'Almacenaje'}, + {'code': 'seguro', 'label': 'Seguro'}, + {'code': 'despacho', 'label': 'Despacho aduanal'}, + {'code': 'documentacion', 'label': 'Documentación'}, + {'code': 'custodia', 'label': 'Custodia'}, + {'code': 'otro', 'label': 'Otro'}]}} + +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) diff --git a/backend/api/v1/modules/crm/catalogs/seed_locations.py b/backend/api/v1/modules/crm/catalogs/seed_locations.py new file mode 100644 index 0000000..759f4a7 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_locations.py @@ -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=`). 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() diff --git a/backend/api/v1/modules/crm/catalogs/service.py b/backend/api/v1/modules/crm/catalogs/service.py new file mode 100644 index 0000000..8f62977 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/service.py @@ -0,0 +1,168 @@ +"""Lógica de negocio de los catálogos de referencia del CRM.""" + +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from core.security import is_hub_admin + +from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta +from .models import CatalogItem +from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS + +# Metadata de catálogos (labels y si el cliente puede llenarlos). +CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()} +CATALOG_LABELS.update(TENANT_CATALOG_LABELS) +# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft). +TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys()) +KNOWN_CATALOGS = set(CATALOG_LABELS.keys()) + + +def _require_known(catalog: str) -> None: + if catalog not in KNOWN_CATALOGS: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe") + + +def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]: + """Lista todos los catálogos disponibles con su conteo (global + del tenant).""" + out: list[CatalogMeta] = [] + for key, label in CATALOG_LABELS.items(): + is_tenant = key in TENANT_CATALOG_KEYS + count = ( + db.query(CatalogItem) + .filter( + CatalogItem.catalog == key, + or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id), + ) + .count() + ) + out.append( + CatalogMeta( + catalog=key, + label=label, + scope="tenant" if is_tenant else "global", + is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)), + count=count, + ) + ) + return out + + +def list_items( + db: Session, + catalog: str, + tenant_id: int, + parent_code: str | None = None, + include_inactive: bool = False, +) -> list[CatalogItem]: + _require_known(catalog) + q = db.query(CatalogItem).filter( + CatalogItem.catalog == catalog, + or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id), + ) + if not include_inactive: + q = q.filter(CatalogItem.is_active.is_(True)) + if parent_code: + q = q.filter(CatalogItem.parent_code == parent_code) + return q.order_by(CatalogItem.sort_order, CatalogItem.label).all() + + +def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None: + """Devuelve el tenant_id a usar al escribir (None = global) y valida permisos. + + - scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft). + - scope 'tenant' (default) → se guarda en el tenant del usuario. + """ + wants_global = scope == "global" + if wants_global: + if not is_hub_admin(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo un administrador de Aduanasoft puede editar catálogos globales.", + ) + return None + return int(current_user["tenant_id"]) + + +def create_item( + db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None +) -> CatalogItem: + _require_known(catalog) + target_tenant = _resolve_write_scope(catalog, scope, current_user) + + # No duplicar por (catalog, code, tenant_id) + exists = ( + db.query(CatalogItem) + .filter( + CatalogItem.catalog == catalog, + CatalogItem.code == data.code, + CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant, + ) + .first() + ) + if exists: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.", + ) + + item = CatalogItem( + catalog=catalog, + code=data.code, + label=data.label, + parent_catalog=data.parent_catalog, + parent_code=data.parent_code, + tenant_id=target_tenant, + sort_order=data.sort_order, + is_active=data.is_active, + is_system=False, + created_by=current_user.get("sub"), + updated_by=current_user.get("sub"), + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem: + _require_known(catalog) + item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado") + if item.tenant_id is None: + # Global (Aduanasoft): solo hub_admin. + if not is_hub_admin(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Solo un administrador de Aduanasoft puede editar este catálogo global.", + ) + elif item.tenant_id != int(current_user["tenant_id"]): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado") + return item + + +def update_item( + db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict +) -> CatalogItem: + item = _get_writable(db, catalog, item_id, current_user) + payload: dict[str, Any] = data.model_dump(exclude_unset=True) + for field, value in payload.items(): + setattr(item, field, value) + item.updated_by = current_user.get("sub") + db.commit() + db.refresh(item) + return item + + +def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None: + item = _get_writable(db, catalog, item_id, current_user) + if item.is_system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.", + ) + db.delete(item) + db.commit() diff --git a/backend/api/v1/modules/crm/common/__init__.py b/backend/api/v1/modules/crm/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py new file mode 100644 index 0000000..19eaeb2 --- /dev/null +++ b/backend/api/v1/modules/crm/common/folios.py @@ -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)}" diff --git a/backend/api/v1/modules/crm/common/pricing.py b/backend/api/v1/modules/crm/common/pricing.py new file mode 100644 index 0000000..98d152c --- /dev/null +++ b/backend/api/v1/modules/crm/common/pricing.py @@ -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)) diff --git a/backend/api/v1/modules/crm/documents/models.py b/backend/api/v1/modules/crm/documents/models.py index b07b20d..4cb4dfd 100644 --- a/backend/api/v1/modules/crm/documents/models.py +++ b/backend/api/v1/modules/crm/documents/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/leads/dto.py b/backend/api/v1/modules/crm/leads/dto.py index 904dd47..6173b46 100644 --- a/backend/api/v1/modules/crm/leads/dto.py +++ b/backend/api/v1/modules/crm/leads/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/leads/models.py b/backend/api/v1/modules/crm/leads/models.py index a1e4140..74f20a4 100644 --- a/backend/api/v1/modules/crm/leads/models.py +++ b/backend/api/v1/modules/crm/leads/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index 24e0620..0b341a4 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index d9939b1..427c127 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -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 + ) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index f82d8e5..a75ff18 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -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) diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py index 3ff1387..a55e280 100644 --- a/backend/api/v1/modules/crm/quotes/dto.py +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -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,9 +85,12 @@ 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 + pdf_file_key: str | None = None sent_at: datetime | None = None accepted_at: datetime | None = None rejected_at: datetime | None = None @@ -100,3 +105,30 @@ class QuoteResponse(QuoteBase): @property def margin(self) -> Decimal: return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0)) + + +# ----- Configuración de marca del formato de cotización ----- + +class QuoteSettingsInput(BaseModel): + emitter_name: str | None = Field(None, max_length=255) + emitter_rfc: str | None = Field(None, max_length=13) + emitter_address: str | None = None + emitter_phone: str | None = Field(None, max_length=60) + emitter_email: str | None = Field(None, max_length=255) + emitter_website: str | None = Field(None, max_length=255) + accent_color: str | None = Field(None, max_length=9) + quote_prefix: str | None = Field(None, max_length=12) + default_terms: str | None = None + footer_note: str | None = None + + +class QuoteSettingsResponse(QuoteSettingsInput): + model_config = ConfigDict(from_attributes=True) + id: int | None = None + logo_file_key: str | None = None + + +class SendQuoteEmailRequest(BaseModel): + to: str | None = None + subject: str | None = None + message: str | None = None diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py index ee3ff16..317e471 100644 --- a/backend/api/v1/modules/crm/quotes/models.py +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -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) @@ -34,10 +37,35 @@ class Quote(Base, TenantScopedMixin, TimestampMixin): notes: Mapped[str | None] = mapped_column(Text, nullable=True) terms: Mapped[str | None] = mapped_column(Text, nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + # Clave del PDF generado en MinIO (para regenerar/enviar) + pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) +class QuoteSettings(Base, TenantScopedMixin, TimestampMixin): + """Configuración de marca del formato de cotización, por compañía (tenant). + + Encabezado del emisor, logo y textos por defecto que se imprimen en el PDF. + """ + + __tablename__ = "quote_settings" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + emitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + emitter_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) + emitter_address: Mapped[str | None] = mapped_column(Text, nullable=True) + emitter_phone: Mapped[str | None] = mapped_column(String(60), nullable=True) + emitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + emitter_website: Mapped[str | None] = mapped_column(String(255), nullable=True) + logo_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) + accent_color: Mapped[str | None] = mapped_column(String(9), nullable=True, server_default=text("'#2f6bf0'")) + quote_prefix: Mapped[str | None] = mapped_column(String(12), nullable=True, server_default=text("'COT'")) + default_terms: Mapped[str | None] = mapped_column(Text, nullable=True) + footer_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + class QuoteItem(Base, TenantScopedMixin, TimestampMixin): """Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros).""" diff --git a/backend/api/v1/modules/crm/quotes/pdf.py b/backend/api/v1/modules/crm/quotes/pdf.py new file mode 100644 index 0000000..9887673 --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/pdf.py @@ -0,0 +1,376 @@ +"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema. + +Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección, +tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado +(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant. +""" + +from __future__ import annotations + +import io +from decimal import Decimal + +_W = 612 +_H = 792 +_ML = 50 # margen izquierdo +_MR = 562 # margen derecho (x) + +CONCEPT_LABELS = { + "flete_internacional": "Flete internacional", + "transporte_terrestre": "Transporte terrestre", + "despacho_aduanal": "Despacho aduanal", + "gastos_destino": "Gastos en destino", + "otros": "Otros cargos", +} + +_TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "}) + + +def _esc(text) -> str: + s = ("" if text is None else str(text)).translate(_TRANSLATE) + s = s.encode("latin-1", "replace").decode("latin-1") + return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") + + +def _money(value) -> str: + return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}" + + +def _num(value) -> str: + return f"{Decimal(str(value or 0)):,.2f}" + + +# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar) +def _text_w(s: str, size: float, bold: bool = False) -> float: + return len(s) * size * (0.56 if bold else 0.52) + + +def _fit(s: str, size: float, max_w: float) -> str: + s = s or "" + if _text_w(s, size) <= max_w: + return s + while s and _text_w(s + "…", size) > max_w: + s = s[:-1] + return s + "…" + + +def _wrap(text: str, width_chars: int) -> list[str]: + words = (text or "").split() + if not words: + return [] + out, cur = [], "" + for w in words: + cand = f"{cur} {w}".strip() + if len(cand) > width_chars and cur: + out.append(cur) + cur = w + else: + cur = cand + if cur: + out.append(cur) + return out + + +def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: + try: + h = (hexs or "#12294c").lstrip("#") + return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] + except Exception: + return (0.07, 0.16, 0.30) + + +def _prep_logo(logo_bytes: bytes | None): + if not logo_bytes: + return None + try: + from PIL import Image + + im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") + im.thumbnail((600, 300)) + buf = io.BytesIO() + im.save(buf, format="JPEG", quality=88) + return buf.getvalue(), im.width, im.height + except Exception: + return None + + +class _Canvas: + """Acumula operadores de contenido con paginación simple.""" + + def __init__(self): + self.pages: list[list[str]] = [[]] + self.y = _H + + @property + def ops(self) -> list[str]: + return self.pages[-1] + + def new_page(self): + self.pages.append([]) + self.y = _H - 50 + + def ensure(self, needed: float): + if self.y - needed < 50: + self.new_page() + + def rect(self, x, y, w, h, rgb): + r, g, b = rgb + self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f") + + def line(self, x1, y1, x2, y2, rgb, width=0.6): + r, g, b = rgb + self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S") + + def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False): + font = "F2" if bold else "F1" + r, g, b = rgb + tx = x - _text_w(str(s), size, bold) if right else x + self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET") + + +def build_quote_pdf( + *, + emitter: dict, + head: dict, + client: dict, + cargo: list[tuple[str, str]], + route: list[tuple[str, str]], + items: list[dict], + currency: str, + subtotal, + terms: str | None, + footer: str | None, + logo_bytes: bytes | None = None, + accent: str | None = "#12294c", +) -> bytes: + ACC = _hex_rgb(accent) + INK = (0.10, 0.15, 0.24) + GRAY = (0.42, 0.47, 0.55) + LINE = (0.80, 0.84, 0.90) + ZEBRA = (0.955, 0.965, 0.980) + logo = _prep_logo(logo_bytes) + + c = _Canvas() + + # ---------------- Encabezado ---------------- + c.rect(0, _H - 12, _W, 12, ACC) # banda superior + logo_bottom = _H - 95 + if logo: + _, lw, lh = logo + dw, dh = 150.0, 150.0 * lh / lw + if dh > 55: + dh, dw = 55.0, 55.0 * lw / lh + c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q") + else: + c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True) + + # Emisor (derecha) + ex, ey = 320, _H - 42 + c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True) + ey -= 14 + em_lines = [] + if emitter.get("rfc"): + em_lines.append(f"RFC: {emitter['rfc']}") + for a in (emitter.get("address") or "").splitlines(): + if a.strip(): + em_lines.append(a.strip()) + contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x]) + if contact: + em_lines.append(contact) + for ln in em_lines[:5]: + c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY) + ey -= 11 + + # Título + regla + c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True) + c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2) + + # Panel de datos (derecha) + px, pw = 320, _MR - 320 + py_top = _H - 128 + ph = 74 + c.rect(px, py_top - ph, pw, ph, ZEBRA) + c.line(px, py_top, px, py_top - ph, LINE) + hy = py_top - 15 + info = [ + ("No.", head.get("reference") or "-"), + ("Fecha", head.get("issue_date") or "-"), + ("Vigencia", head.get("valid_until") or "-"), + ("Ejecutivo", head.get("owner") or "-"), + ("Estatus", str(head.get("status") or "-").capitalize()), + ] + for k, v in info: + c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True) + c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK) + hy -= 12.5 + + c.y = _H - 215 + + # ---------------- Helpers de sección ---------------- + def section(title: str): + c.ensure(30) + c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC) + c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True) + c.y -= 26 + + def kv_block(pairs: list[tuple[str, str]]): + rows = [(k, v) for k, v in pairs if v not in (None, "", "None")] + if not rows: + return False + col_w = (_MR - _ML) / 2 + i = 0 + while i < len(rows): + c.ensure(16) + for col in range(2): + if i + col < len(rows): + k, v = rows[i + col] + x = _ML + 6 + col * col_w + c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True) + c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK) + c.y -= 16 + i += 2 + c.y -= 4 + return True + + # ---------------- Cliente ---------------- + section("Cliente") + if not kv_block([ + ("Cliente", client.get("name")), ("RFC", client.get("rfc")), + ("Correo", client.get("email")), ("Teléfono", client.get("phone")), + ]): + c.text(_ML + 6, c.y - 11, "—", 9, GRAY) + c.y -= 16 + + # ---------------- Carga / Ruta (solo si hay datos) ---------------- + if [v for _, v in cargo if v not in (None, "", "None")]: + section("Información de la carga") + kv_block(cargo) + if [v for _, v in route if v not in (None, "", "None")]: + section("Ruta logística") + kv_block(route) + + # ---------------- Costos ---------------- + section("Costos cotizados") + x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6 + row_h = 18 + # encabezado de tabla + c.ensure(row_h) + c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC) + c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True) + c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True) + c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True) + c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True) + c.y -= row_h + z = False + for it in items: + code = str(it.get("concept") or "") + label = CONCEPT_LABELS.get(code, code) + desc = str(it.get("description") or "") + if desc: + label = f"{label} - {desc}" + qty = Decimal(str(it.get("quantity") or 0)) + unit = Decimal(str(it.get("unit_sale") or 0)) + amount = (qty * unit).quantize(Decimal("0.01")) + c.ensure(row_h) + if z: + c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA) + c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK) + c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True) + c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True) + c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True) + c.y -= row_h + z = not z + if not items: + c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY) + c.y -= row_h + # borde de la tabla + c.line(_ML, c.y, _MR, c.y, LINE) + c.y -= 12 + + # ---------------- Totales (caja derecha) ---------------- + tb_x, tb_w = 360, _MR - 360 + c.ensure(58) + c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA) + c.line(tb_x, c.y, tb_x, c.y - 58, LINE) + ty = c.y - 16 + c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True) + c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True) + ty -= 15 + c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True) + c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True) + ty -= 6 + c.rect(tb_x, ty - 20, tb_w, 20, ACC) + c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True) + c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True) + c.y -= 70 + + # ---------------- Condiciones ---------------- + if terms: + section("Condiciones comerciales") + for para in terms.splitlines(): + for ln in (_wrap(para, 108) or [""]): + c.ensure(13) + c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY) + c.y -= 12 + c.y -= 4 + + # pie en todas las páginas + for ops in c.pages: + if footer: + r, g, b = GRAY + ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET") + ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f") + + # ---------------- Ensamblado ---------------- + streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages] + objects: list[bytes] = [] + + def add(obj: bytes): + objects.append(obj) + + n_pages = len(c.pages) + has_img = 1 if logo else 0 + # numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams + img_num = 5 if has_img else None + base = 6 if has_img else 5 + page_nums = list(range(base, base + n_pages)) + content_nums = list(range(base + n_pages, base + 2 * n_pages)) + + kids = " ".join(f"{n} 0 R" for n in page_nums) + add(b"<< /Type /Catalog /Pages 2 0 R >>") + add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1")) + add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + if logo: + jpeg, lw, lh = logo + add( + ( + f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " + f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" + ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" + ) + for i in range(n_pages): + res = "/Font << /F1 3 0 R /F2 4 0 R >>" + if has_img and i == 0: + res += f" /XObject << /Im0 {img_num} 0 R >>" + add( + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] " + f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" + ).encode("latin-1") + ) + for stream in streams: + add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") + + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n" + xref_pos = len(out) + total = len(objects) + 1 + out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("latin-1") + out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1") + return bytes(out) diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py new file mode 100644 index 0000000..01cde30 --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -0,0 +1,250 @@ +"""PDF de cotización, configuración de marca por tenant y envío por correo.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +from ..accounts.models import Account +from ..service_requests.models import ServiceRequest +from .models import Quote, QuoteItem, QuoteSettings +from .pdf import build_quote_pdf +from .service import get_quote + +logger = logging.getLogger(__name__) + +DEFAULT_TERMS = ( + "Tarifas sujetas a disponibilidad de espacio.\n" + "Cualquier variación en peso o volumen generará ajuste tarifario.\n" + "No incluye cargos extraordinarios, maniobras especiales o servicios no especificados.\n" + "Tarifas sujetas a revisión por parte de la línea transportista y autoridades correspondientes." +) + + +# ---------------- Configuración de marca ---------------- +def get_settings(db: Session, tenant_id: int, company_id: int) -> QuoteSettings | None: + return ( + db.query(QuoteSettings) + .filter(QuoteSettings.tenant_id == tenant_id, QuoteSettings.company_id == company_id, + QuoteSettings.deleted_at.is_(None)) + .first() + ) + + +def upsert_settings(db: Session, tenant_id: int, company_id: int, data: dict) -> QuoteSettings: + obj = get_settings(db, tenant_id, company_id) + if obj is None: + obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id) + db.add(obj) + for field, value in data.items(): + if value is not None: + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> QuoteSettings: + obj = get_settings(db, tenant_id, company_id) + if obj is None: + obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id) + db.add(obj) + obj.logo_file_key = file_key + db.commit() + db.refresh(obj) + 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( + text("SELECT name, rfc, logo FROM a76.company WHERE id = :c"), {"c": company_id} + ).first() + if row: + return {"name": row[0], "rfc": row[1], "logo": row[2]} + except Exception: + pass + return {} + + +# ---------------- Construcción del PDF ---------------- +def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) -> bytes: + items = ( + db.query(QuoteItem) + .filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)) + .order_by(QuoteItem.id.asc()) + .all() + ) + account = ( + db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None + ) + sr = ( + db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + if quote.service_request_id else None + ) + settings = get_settings(db, tenant_id, company_id) + company = _company_row(db, company_id) + + # Emisor: config del tenant con respaldo en a76.company + emitter = { + "name": (settings.emitter_name if settings else None) or company.get("name") or "Emisor", + "rfc": (settings.emitter_rfc if settings else None) or company.get("rfc"), + "address": settings.emitter_address if settings else None, + "phone": settings.emitter_phone if settings else None, + "email": settings.emitter_email if settings else None, + "website": settings.emitter_website if settings else None, + } + accent = (settings.accent_color if settings else None) or "#12294c" + prefix = (settings.quote_prefix if settings else None) or "COT" + terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS + footer = settings.footer_note if settings else None + + # Logo (MinIO) + logo_bytes = None + logo_key = settings.logo_file_key if settings else None + if logo_key: + try: + from core.storage_s3 import get_object_bytes + logo_bytes = get_object_bytes(logo_key) + except Exception as exc: + logger.warning("No se pudo leer el logo del tarifario: %s", exc) + + reference = quote.reference or f"{prefix}-{datetime.now().strftime('%Y%m%d')}-{quote.id:03d}" + head = { + "reference": reference, + "issue_date": quote.issue_date.isoformat() if quote.issue_date else None, + "valid_until": quote.valid_until.isoformat() if quote.valid_until else None, + "owner": quote.owner_user_id or "-", + "status": quote.status, + } + client = { + "name": account.name if account else None, + "rfc": account.rfc if account else None, + "email": account.email if account else None, + "phone": account.phone if account else None, + } + cargo = [] + route = [] + if sr: + cargo = [ + ("Tipo de mercancía", sr.cargo_type), ("Descripción", sr.commodity), + ("Peso", str(sr.weight) if sr.weight is not None else None), + ("Volumen", str(sr.volume) if sr.volume is not None else None), + ("Tipo de carga", sr.load_type), ("Equipo", sr.container_equipment), + ] + route = [ + ("Operación", sr.operation_type), ("Modo", sr.transport_mode), + ("Servicio", sr.service_type), ("Incoterm", sr.incoterm), + ("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), + ] + + return build_quote_pdf( + emitter=emitter, head=head, client=client, cargo=cargo, route=route, + items=[{"concept": i.concept, "description": i.description, "quantity": i.quantity, "unit_sale": i.unit_sale} for i in items], + currency=quote.currency, subtotal=quote.total_sale, terms=terms, footer=footer, + logo_bytes=logo_bytes, accent=accent, + ) + + +def _store_pdf(db: Session, quote: Quote, tenant_id: int, company_id: int, pdf_bytes: bytes) -> str: + from core.storage_s3 import put_object_bytes + ref = (quote.reference or f"cot-{quote.id}").replace("/", "-") + key = f"tenants/{tenant_id}/companies/{company_id}/crm-quotes/{quote.id}/cotizacion-{ref}.pdf" + put_object_bytes(key, pdf_bytes, content_type="application/pdf") + quote.pdf_file_key = key + db.commit() + return key + + +def get_pdf_url(db: Session, quote_id: int, tenant_id: int, company_id: int) -> str: + from core.storage_s3 import presigned_get_url + quote = get_quote(db, quote_id, tenant_id, company_id) + pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id) + key = _store_pdf(db, quote, tenant_id, company_id, pdf_bytes) + return presigned_get_url(key) + + +# ---------------- Envío por correo ---------------- +async def send_quote_email( + db: Session, quote_id: int, tenant_id: int, company_id: int, + to: str | None, subject: str | None, message: str | None, +) -> dict: + import ssl + from email import encoders + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + import aiosmtplib + + from core.config import settings as cfg + + quote = get_quote(db, quote_id, tenant_id, company_id) + account = db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None + recipient = to or (account.email if account else None) + if not recipient: + raise HTTPException(status_code=400, detail="No hay correo destino (captura uno o pon el correo del cliente).") + + pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id) + _store_pdf(db, quote, tenant_id, company_id, pdf_bytes) + ref = quote.reference or f"COT-{quote.id}" + + msg = MIMEMultipart() + msg["From"] = f"{cfg.SMTP_FROM_NAME} <{cfg.SMTP_USER}>" + msg["To"] = recipient + msg["Subject"] = subject or f"Cotización {ref}" + html = ( + "
" + f"

{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '
')}

" + f"

Cotización {ref}

" + ) + msg.attach(MIMEText(html, "html")) + part = MIMEBase("application", "pdf") + part.set_payload(pdf_bytes) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"') + msg.attach(part) + + if not (cfg.SMTP_USER and cfg.SMTP_PASSWORD): + raise HTTPException(status_code=503, detail="El correo saliente (SMTP) no está configurado en el servidor.") + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + # Puerto 465 = SSL implícito; los demás (587/2525/…) = STARTTLS. + await aiosmtplib.send( + msg, + hostname=cfg.SMTP_HOST, + port=cfg.SMTP_PORT, + username=cfg.SMTP_USER, + password=cfg.SMTP_PASSWORD, + use_tls=(cfg.SMTP_PORT == 465), + start_tls=(cfg.SMTP_PORT != 465), + tls_context=ctx, + validate_certs=False, + timeout=30, + ) + except Exception as exc: + logger.error("Error enviando cotización %s: %s", quote_id, exc) + raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}") + + # Marca como enviada + if quote.status == "borrador": + quote.status = "enviada" + quote.sent_at = datetime.now(timezone.utc) + db.commit() + return {"sent_to": recipient, "reference": ref} diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py index ccc3138..ed335fa 100644 --- a/backend/api/v1/modules/crm/quotes/routes.py +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -1,22 +1,76 @@ -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from . import service +from . import pdf_service, service from .dto import ( QuoteCreate, QuoteItemCreate, QuoteItemResponse, QuoteItemUpdate, QuoteResponse, + QuoteSettingsInput, + QuoteSettingsResponse, QuoteUpdate, + SendQuoteEmailRequest, ) router = APIRouter() +# ----- Configuración de marca del formato de cotización ----- + +@router.get("/quote-settings", response_model=QuoteSettingsResponse) +def get_quote_settings( + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id) + return obj or QuoteSettingsResponse() + + +@router.put("/quote-settings", response_model=QuoteSettingsResponse) +def save_quote_settings( + payload: QuoteSettingsInput, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return pdf_service.upsert_settings(db, current_user["tenant_id"], company_id, payload.model_dump(exclude_unset=True)) + + +@router.post("/quote-settings/logo", response_model=QuoteSettingsResponse) +async def upload_quote_logo( + company_id: int = Query(...), + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + from core.storage_s3 import put_object_bytes + tenant_id = current_user["tenant_id"] + content = await file.read() + safe = (file.filename or "logo").replace("/", "-") + key = f"tenants/{tenant_id}/companies/{company_id}/crm-quote-logo/{safe}" + put_object_bytes(key, content, content_type=file.content_type or "image/png") + return pdf_service.set_logo_key(db, tenant_id, company_id, key) + + +@router.get("/quote-settings/logo-url") +def get_logo_url( + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + from core.storage_s3 import presigned_get_url + obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id) + if not obj or not obj.logo_file_key: + return {"url": None} + return {"url": presigned_get_url(obj.logo_file_key)} + + def _user_id(current_user: dict) -> str | None: return current_user.get("sub") or current_user.get("id") @@ -56,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, @@ -98,6 +170,39 @@ def reject_quote( return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id) +@router.get("/quotes/{quote_id}/pdf") +def quote_pdf( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO).""" + tenant_id = current_user["tenant_id"] + quote = service.get_quote(db, quote_id, tenant_id, company_id) + pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id) + ref = (quote.reference or f"cot-{quote.id}").replace("/", "-") + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'}, + ) + + +@router.post("/quotes/{quote_id}/send-email") +async def quote_send_email( + quote_id: int, + payload: SendQuoteEmailRequest, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Genera el PDF y lo envía por correo (al cliente o al destinatario indicado).""" + return await pdf_service.send_quote_email( + db, quote_id, current_user["tenant_id"], company_id, payload.to, payload.subject, payload.message + ) + + @router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED) def clone_quote( quote_id: int, diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index f4be755..7a0b71d 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -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: diff --git a/backend/api/v1/modules/crm/rates/__init__.py b/backend/api/v1/modules/crm/rates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py new file mode 100644 index 0000000..da4a025 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -0,0 +1,178 @@ +"""Schemas del módulo Tarifario.""" + +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + + +# ---------- Quiebres y cargos ---------- +class RateBreakDTO(BaseModel): + model_config = ConfigDict(from_attributes=True) + from_qty: Decimal = Field(0) + rate: Decimal = Field(0) + + +class RateChargeDTO(BaseModel): + model_config = ConfigDict(from_attributes=True) + concept: str = Field(..., max_length=60) + charge_type: str = Field("fijo", max_length=20) + value: Decimal | None = None + condition: str | None = None + + +class RateChargeCreate(BaseModel): + concept: str = Field(..., max_length=60) + charge_type: str = Field("fijo", max_length=20) + value: Decimal | None = None + condition: str | None = None + rate_lane_id: int | None = None + + +class RateChargeUpdate(BaseModel): + concept: str | None = Field(None, max_length=60) + charge_type: str | None = Field(None, max_length=20) + value: Decimal | None = None + condition: str | None = None + + +class RateChargeResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + rate_sheet_id: int | None + rate_lane_id: int | None + concept: str + charge_type: str + value: Decimal | None + condition: str | None + + +# ---------- Rutas ---------- +class RateLaneBase(BaseModel): + origin: str | None = Field(None, max_length=20) + destination: str | None = Field(None, max_length=20) + region: str | None = Field(None, max_length=60) + equipment_type: str | None = Field(None, max_length=20) + rate_unit: str | None = Field(None, max_length=20) + min_charge: Decimal | None = None + flat_rate: Decimal | None = None + transit_days: int | None = None + notes: str | None = None + + +class RateLaneCreate(RateLaneBase): + breaks: list[RateBreakDTO] = Field(default_factory=list) + + +class RateLaneUpdate(RateLaneBase): + breaks: list[RateBreakDTO] | None = None + + +class RateLaneResponse(RateLaneBase): + model_config = ConfigDict(from_attributes=True) + id: int + rate_sheet_id: int + breaks: list[RateBreakDTO] = Field(default_factory=list) + + +# ---------- Tarifario (cabecera) ---------- +class RateSheetBase(BaseModel): + supplier_id: int | None = None + mode: str = Field(..., max_length=20) + name: str = Field(..., min_length=1, max_length=255) + currency: str | None = Field("USD", max_length=3) + valid_from: date | None = None + valid_to: date | None = None + default_origin: str | None = Field(None, max_length=20) + status: str = Field("borrador", max_length=20) + notes: str | None = None + + +class RateSheetCreate(RateSheetBase): + pass + + +class RateSheetUpdate(BaseModel): + supplier_id: int | None = None + mode: str | None = Field(None, max_length=20) + name: str | None = Field(None, max_length=255) + currency: str | None = Field(None, max_length=3) + valid_from: date | None = None + valid_to: date | None = None + default_origin: str | None = Field(None, max_length=20) + status: str | None = Field(None, max_length=20) + notes: str | None = None + + +class RateSheetResponse(RateSheetBase): + model_config = ConfigDict(from_attributes=True) + id: int + tenant_id: int + company_id: int + source_file: str | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime + lane_count: int | None = None + + +# ---------- Importación ---------- +class ImportPreviewRow(BaseModel): + row: int + data: dict + ok: bool + warnings: list[str] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + + +class ImportPreview(BaseModel): + mode: str + total: int + valid: int + rows: list[ImportPreviewRow] + columns: list[str] + + +class ImportConfirm(RateSheetCreate): + lanes: list[RateLaneCreate] + + +# ---------- Costeo ---------- +class CostRequest(BaseModel): + mode: str + origin: str | None = None + destination: str | None = None + 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 + + +class CostChargeLine(BaseModel): + concept: str + amount: Decimal + + +class CostOption(BaseModel): + rate_sheet_id: int + rate_sheet_name: str + supplier_id: int | None + currency: str | None + chargeable: Decimal | None = None # peso/wm facturable usado + base_cost: Decimal + charges: list[CostChargeLine] = Field(default_factory=list) + total_cost: Decimal + transit_days: int | None = None + detail: str | None = None + + +class CostResult(BaseModel): + request: CostRequest + options: list[CostOption] diff --git a/backend/api/v1/modules/crm/rates/models.py b/backend/api/v1/modules/crm/rates/models.py new file mode 100644 index 0000000..b06c936 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/models.py @@ -0,0 +1,93 @@ +"""Modelos del módulo Tarifario (base de costos para Cotizaciones). + +Un ``RateSheet`` (tarifario) pertenece a un proveedor y agrupa muchas +``RateLane`` (rutas origen→destino). Cada ruta tiene, según el modo: +- Aéreo / LCL: varios ``RateBreak`` (quiebres de peso/volumen con su tarifa). +- FCL / terrestre: una tarifa plana por contenedor/unidad (``flat_rate``). +Los ``RateCharge`` son cargos adicionales a nivel tarifario o ruta. +""" + +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +class RateSheet(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "rate_sheets" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + supplier_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True + ) + # aereo | maritimo_fcl | maritimo_lcl | terrestre + mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + currency: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'USD'")) + valid_from: Mapped[date | None] = mapped_column(Date, nullable=True) + valid_to: Mapped[date | None] = mapped_column(Date, nullable=True) + default_origin: Mapped[str | None] = mapped_column(String(20), nullable=True) + # borrador | activo | vencido | reemplazado + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'")) + source_file: Mapped[str | None] = mapped_column(String(512), nullable=True) + source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + + +class RateLane(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "rate_lanes" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + rate_sheet_id: Mapped[int] = mapped_column( + Integer, ForeignKey("crm.rate_sheets.id"), nullable=False, index=True + ) + origin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + destination: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + region: Mapped[str | None] = mapped_column(String(60), nullable=True) + # Solo FCL/terrestre (código del catálogo tipo_equipo). Nulo en aéreo/LCL. + equipment_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + # per_kg | per_wm | per_container | flat + rate_unit: Mapped[str | None] = mapped_column(String(20), nullable=True) + min_charge: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True) + flat_rate: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True) + transit_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class RateBreak(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "rate_breaks" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + rate_lane_id: Mapped[int] = mapped_column( + Integer, ForeignKey("crm.rate_lanes.id"), nullable=False, index=True + ) + # Umbral del quiebre (kg en aéreo; W/M en LCL) + from_qty: Mapped[Decimal] = mapped_column(Numeric(12, 3), nullable=False, server_default=text("0")) + rate: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False, server_default=text("0")) + + +class RateCharge(Base, TenantScopedMixin, TimestampMixin): + __tablename__ = "rate_charges" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + rate_sheet_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.rate_sheets.id"), nullable=True, index=True + ) + rate_lane_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.rate_lanes.id"), nullable=True, index=True + ) + concept: Mapped[str] = mapped_column(String(60), nullable=False) + # fijo | por_kg | por_guia | por_contenedor | porcentaje + charge_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fijo'")) + value: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True) + condition: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py new file mode 100644 index 0000000..38f2803 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -0,0 +1,269 @@ +"""Endpoints del módulo Tarifario.""" + +from datetime import date + +from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import ( + CostRequest, + CostResult, + ImportPreview, + RateBreakDTO, + RateChargeCreate, + RateChargeResponse, + RateChargeUpdate, + RateLaneCreate, + RateLaneResponse, + RateSheetCreate, + RateSheetResponse, + RateSheetUpdate, +) + +router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"]) + + +def _ctx(current_user: dict): + return current_user["tenant_id"], current_user.get("sub") or current_user.get("id") + + +def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse: + out = RateSheetResponse.model_validate(sheet) + out.lane_count = service.lane_count(db, tenant_id, sheet.id) + return out + + +def _lane_out(db: Session, lane) -> RateLaneResponse: + out = RateLaneResponse.model_validate(lane) + out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)] + return out + + +# ---------------- Tarifarios ---------------- +@router.get("", response_model=list[RateSheetResponse]) +def list_sheets( + company_id: int = Query(...), + mode: str | None = Query(None), + supplier_id: int | None = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id) + return [_sheet_out(db, tenant_id, s) for s in sheets] + + +@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED) +def create_sheet( + data: RateSheetCreate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, user_id = _ctx(current_user) + sheet = service.create_sheet(db, tenant_id, company_id, data, user_id) + return _sheet_out(db, tenant_id, sheet) + + +@router.get("/template") +def download_template( + mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"), + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), +): + content = service.build_template(mode) + return Response( + content=content, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'}, + ) + + +@router.post("/import/preview", response_model=ImportPreview) +async def import_preview( + company_id: int = Query(...), + mode: str = Form(...), + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), +): + content = await file.read() + return service.parse_excel(mode, content) + + +@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED) +async def import_sheet( + company_id: int = Query(...), + mode: str = Form(...), + name: str = Form(...), + supplier_id: int | None = Form(None), + currency: str = Form("USD"), + valid_from: date | None = Form(None), + valid_to: date | None = Form(None), + default_origin: str | None = Form(None), + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, user_id = _ctx(current_user) + content = await file.read() + header = RateSheetCreate( + mode=mode, name=name, supplier_id=supplier_id, currency=currency, + valid_from=valid_from, valid_to=valid_to, default_origin=default_origin, + ) + sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id) + return _sheet_out(db, tenant_id, sheet) + + +@router.get("/{sheet_id}", response_model=RateSheetResponse) +def get_sheet( + sheet_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id)) + + +@router.patch("/{sheet_id}", response_model=RateSheetResponse) +def update_sheet( + sheet_id: int, + data: RateSheetUpdate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, user_id = _ctx(current_user) + return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id)) + + +@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_sheet( + sheet_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.delete_sheet(db, tenant_id, company_id, sheet_id) + + +# ---------------- Rutas (lanes) ---------------- +@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse]) +def list_lanes( + sheet_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.get_sheet(db, tenant_id, company_id, sheet_id) + return [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)] + + +@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED) +def create_lane( + sheet_id: int, + data: RateLaneCreate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + lane = service.create_lane(db, tenant_id, company_id, sheet_id, data) + return _lane_out(db, lane) + + +@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_lane( + sheet_id: int, + lane_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.delete_lane(db, tenant_id, sheet_id, lane_id) + + +# ---------------- Cargos adicionales ---------------- +@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse]) +def list_charges( + sheet_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.get_sheet(db, tenant_id, company_id, sheet_id) + return service.list_charges(db, tenant_id, sheet_id) + + +@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED) +def create_charge( + sheet_id: int, + data: RateChargeCreate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + return service.create_charge(db, tenant_id, company_id, sheet_id, data) + + +@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse) +def update_charge( + sheet_id: int, + charge_id: int, + data: RateChargeUpdate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + return service.update_charge(db, tenant_id, sheet_id, charge_id, data) + + +@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_charge( + sheet_id: int, + charge_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.delete_charge(db, tenant_id, sheet_id, charge_id) + + +# ---------------- Motor de costeo ---------------- +cost_router = APIRouter(tags=["Tarifario"]) + + +@cost_router.post("/rate-quote", response_model=CostResult) +def rate_quote( + req: CostRequest, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Calcula opciones de costo (por proveedor) para una ruta/carga.""" + 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) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py new file mode 100644 index 0000000..c6d103f --- /dev/null +++ b/backend/api/v1/modules/crm/rates/service.py @@ -0,0 +1,565 @@ +"""Lógica del módulo Tarifario: CRUD, importación por Excel y motor de costeo.""" + +import io +from datetime import date +from decimal import Decimal +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from .dto import ( + CostChargeLine, + CostOption, + CostRequest, + ImportConfirm, + ImportPreview, + ImportPreviewRow, + RateLaneCreate, + 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") + + +# ============================================================ CRUD tarifarios +def _sheet_query(db: Session, tenant_id: int, company_id: int): + return db.query(RateSheet).filter( + RateSheet.tenant_id == tenant_id, + RateSheet.company_id == company_id, + RateSheet.deleted_at.is_(None), + ) + + +def list_sheets(db: Session, tenant_id: int, company_id: int, mode: str | None = None, + supplier_id: int | None = None) -> list[RateSheet]: + q = _sheet_query(db, tenant_id, company_id) + if mode: + q = q.filter(RateSheet.mode == mode) + if supplier_id: + q = q.filter(RateSheet.supplier_id == supplier_id) + return q.order_by(RateSheet.created_at.desc()).all() + + +def lane_count(db: Session, tenant_id: int, sheet_id: int) -> int: + return ( + db.query(RateLane) + .filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id, + RateLane.deleted_at.is_(None)) + .count() + ) + + +def get_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> RateSheet: + sheet = _sheet_query(db, tenant_id, company_id).filter(RateSheet.id == sheet_id).first() + if not sheet: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tarifario no encontrado") + return sheet + + +def create_sheet(db: Session, tenant_id: int, company_id: int, data: RateSheetCreate, + user_id: str | None) -> RateSheet: + sheet = RateSheet( + tenant_id=tenant_id, company_id=company_id, + **data.model_dump(), + created_by=user_id, updated_by=user_id, + ) + db.add(sheet) + db.commit() + db.refresh(sheet) + return sheet + + +def update_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int, + data: RateSheetUpdate, user_id: str | None) -> RateSheet: + sheet = get_sheet(db, tenant_id, company_id, sheet_id) + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(sheet, field, value) + sheet.updated_by = user_id + db.commit() + db.refresh(sheet) + return sheet + + +def delete_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> None: + from sqlalchemy import func + sheet = get_sheet(db, tenant_id, company_id, sheet_id) + sheet.deleted_at = func.now() + db.commit() + + +# ============================================================ Rutas (lanes) +def list_lanes(db: Session, tenant_id: int, sheet_id: int) -> list[RateLane]: + return ( + db.query(RateLane) + .filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id, + RateLane.deleted_at.is_(None)) + .order_by(RateLane.region, RateLane.destination) + .all() + ) + + +def breaks_of(db: Session, lane_id: int) -> list[RateBreak]: + return ( + db.query(RateBreak) + .filter(RateBreak.rate_lane_id == lane_id, RateBreak.deleted_at.is_(None)) + .order_by(RateBreak.from_qty) + .all() + ) + + +def _add_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int, + lane_data: RateLaneCreate) -> RateLane: + payload = lane_data.model_dump(exclude={"breaks"}) + lane = RateLane(tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, **payload) + db.add(lane) + db.flush() # id + for br in lane_data.breaks: + db.add(RateBreak( + tenant_id=tenant_id, company_id=company_id, rate_lane_id=lane.id, + from_qty=br.from_qty, rate=br.rate, + )) + return lane + + +def create_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int, + lane_data: RateLaneCreate) -> RateLane: + get_sheet(db, tenant_id, company_id, sheet_id) # valida pertenencia + lane = _add_lane(db, tenant_id, company_id, sheet_id, lane_data) + db.commit() + db.refresh(lane) + return lane + + +def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> None: + from sqlalchemy import func + lane = ( + db.query(RateLane) + .filter(RateLane.id == lane_id, RateLane.rate_sheet_id == sheet_id, + RateLane.tenant_id == tenant_id) + .first() + ) + if not lane: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ruta no encontrada") + lane.deleted_at = func.now() + db.commit() + + +# ============================================================ Cargos adicionales +def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]: + return ( + db.query(RateCharge) + .filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id, + RateCharge.deleted_at.is_(None)) + .order_by(RateCharge.concept) + .all() + ) + + +def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge: + get_sheet(db, tenant_id, company_id, sheet_id) + ch = RateCharge( + tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, + rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type, + value=data.value, condition=data.condition, + ) + db.add(ch) + db.commit() + db.refresh(ch) + return ch + + +def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge: + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(ch, field, value) + db.commit() + db.refresh(ch) + return ch + + +def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None: + from sqlalchemy import func + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + ch.deleted_at = func.now() + db.commit() + + +# ============================================================ Importación Excel +# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre). +TEMPLATES: dict[str, list[str]] = { + "aereo": ["Region", "Origen", "Destino", "IATA", "Min", "100", "300", "500", "1000"], + "maritimo_fcl": ["Origen", "Destino", "Tipo contenedor", "Tarifa", "Transito", "Notas"], + "maritimo_lcl": ["Origen", "Destino", "Tarifa W/M", "Minimo", "Notas"], + "terrestre": ["Origen", "Destino", "Tarifa", "Transito", "Notas"], +} + + +def build_template(mode: str) -> bytes: + """Genera un .xlsx con los encabezados del modo + una fila de ejemplo.""" + import openpyxl + + if mode not in TEMPLATES: + raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido") + wb = openpyxl.Workbook() + ws = wb.active + ws.title = mode + headers = TEMPLATES[mode] + ws.append(headers) + examples = { + "aereo": ["EUROPA", "NLU", "Frankfurt", "FRA", 190, 1.00, 1.00, 0.95, 0.90], + "maritimo_fcl": ["MXZLO", "CNSHA", "40HC", 2500, 28, "THC no incluido"], + "maritimo_lcl": ["MXZLO", "USLAX", 45, 80, "1 W/M = 1 ton o 1 m3"], + "terrestre": ["Monterrey", "Laredo", 850, 1, ""], + } + ws.append(examples[mode]) + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _num(v: Any) -> Decimal | None: + if v is None or v == "": + return None + try: + return Decimal(str(v).replace("$", "").replace(",", "").strip()) + except Exception: + return None + + +def parse_excel(mode: str, content: bytes) -> ImportPreview: + """Lee el Excel y devuelve una vista previa con validaciones (no persiste).""" + import openpyxl + + if mode not in TEMPLATES: + raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido") + try: + wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True, read_only=True) + except Exception: + raise HTTPException(status_code=400, detail="No se pudo leer el archivo Excel") + ws = wb.active + rows_iter = ws.iter_rows(values_only=True) + header = next(rows_iter, None) + if not header: + raise HTTPException(status_code=400, detail="El archivo está vacío") + cols = [str(c).strip() if c is not None else "" for c in header] + idx = {name.lower(): i for i, name in enumerate(cols)} + + def cell(row, name): + i = idx.get(name.lower()) + return row[i] if i is not None and i < len(row) else None + + preview_rows: list[ImportPreviewRow] = [] + valid = 0 + for n, row in enumerate(rows_iter, start=2): + if row is None or all(c is None or str(c).strip() == "" for c in row): + continue + errors: list[str] = [] + warnings: list[str] = [] + data: dict = {} + if mode == "aereo": + data = { + "region": cell(row, "Region"), + "origin": cell(row, "Origen"), + "destination": cell(row, "Destino") or cell(row, "IATA"), + "iata": cell(row, "IATA"), + "min_charge": _num(cell(row, "Min")), + "breaks": {b: _num(cell(row, b)) for b in ("100", "300", "500", "1000")}, + } + if not data["destination"]: + errors.append("Falta destino/IATA") + if not any(v is not None for v in data["breaks"].values()): + errors.append("Sin tarifas por quiebre") + elif mode == "maritimo_fcl": + data = { + "origin": cell(row, "Origen"), + "destination": cell(row, "Destino"), + "equipment_type": cell(row, "Tipo contenedor"), + "flat_rate": _num(cell(row, "Tarifa")), + "transit_days": _num(cell(row, "Transito")), + "notes": cell(row, "Notas"), + } + if data["flat_rate"] is None: + errors.append("Falta la tarifa") + if not data["equipment_type"]: + warnings.append("Sin tipo de contenedor") + elif mode == "maritimo_lcl": + data = { + "origin": cell(row, "Origen"), + "destination": cell(row, "Destino"), + "wm_rate": _num(cell(row, "Tarifa W/M")), + "min_charge": _num(cell(row, "Minimo")), + "notes": cell(row, "Notas"), + } + if data["wm_rate"] is None: + errors.append("Falta la tarifa W/M") + else: # terrestre + data = { + "origin": cell(row, "Origen"), + "destination": cell(row, "Destino"), + "flat_rate": _num(cell(row, "Tarifa")), + "transit_days": _num(cell(row, "Transito")), + "notes": cell(row, "Notas"), + } + if data["flat_rate"] is None: + errors.append("Falta la tarifa") + if not data.get("destination"): + errors.append("Falta destino") + ok = not errors + if ok: + valid += 1 + preview_rows.append(ImportPreviewRow(row=n, data=_jsonable(data), ok=ok, + warnings=warnings, errors=errors)) + return ImportPreview(mode=mode, total=len(preview_rows), valid=valid, + rows=preview_rows, columns=cols) + + +def _jsonable(d: dict) -> dict: + out = {} + for k, v in d.items(): + if isinstance(v, Decimal): + out[k] = float(v) + elif isinstance(v, dict): + out[k] = {kk: (float(vv) if isinstance(vv, Decimal) else vv) for kk, vv in v.items()} + else: + out[k] = v + return out + + +def _rows_to_lanes(mode: str, rows: list[ImportPreviewRow], default_origin: str | None) -> list[RateLaneCreate]: + lanes: list[RateLaneCreate] = [] + for r in rows: + if not r.ok: + continue + d = r.data + origin = d.get("origin") or default_origin + if mode == "aereo": + breaks = [ + {"from_qty": Decimal(b), "rate": Decimal(str(v))} + for b, v in (d.get("breaks") or {}).items() if v is not None + ] + lanes.append(RateLaneCreate( + origin=str(origin) if origin else None, + destination=str(d.get("destination")), + region=d.get("region"), rate_unit="per_kg", + min_charge=_num(d.get("min_charge")), + breaks=breaks, # type: ignore[arg-type] + )) + elif mode == "maritimo_fcl": + lanes.append(RateLaneCreate( + origin=str(origin) if origin else None, destination=str(d.get("destination")), + equipment_type=d.get("equipment_type"), rate_unit="per_container", + flat_rate=_num(d.get("flat_rate")), + transit_days=int(d["transit_days"]) if d.get("transit_days") else None, + notes=d.get("notes"), + )) + elif mode == "maritimo_lcl": + lanes.append(RateLaneCreate( + origin=str(origin) if origin else None, destination=str(d.get("destination")), + rate_unit="per_wm", min_charge=_num(d.get("min_charge")), + breaks=[{"from_qty": Decimal(0), "rate": Decimal(str(d["wm_rate"]))}], # type: ignore[arg-type] + notes=d.get("notes"), + )) + else: + lanes.append(RateLaneCreate( + origin=str(origin) if origin else None, destination=str(d.get("destination")), + rate_unit="flat", flat_rate=_num(d.get("flat_rate")), + transit_days=int(d["transit_days"]) if d.get("transit_days") else None, + notes=d.get("notes"), + )) + return lanes + + +def confirm_import(db: Session, tenant_id: int, company_id: int, data: ImportConfirm, + user_id: str | None) -> RateSheet: + """Crea el tarifario + rutas a partir de la vista previa confirmada.""" + sheet = RateSheet( + tenant_id=tenant_id, company_id=company_id, + supplier_id=data.supplier_id, mode=data.mode, name=data.name, + currency=data.currency, valid_from=data.valid_from, valid_to=data.valid_to, + default_origin=data.default_origin, status=data.status or "borrador", + notes=data.notes, created_by=user_id, updated_by=user_id, + ) + db.add(sheet) + db.flush() + for lane in data.lanes: + _add_lane(db, tenant_id, company_id, sheet.id, lane) + db.commit() + db.refresh(sheet) + return sheet + + +def import_from_excel(db: Session, tenant_id: int, company_id: int, mode: str, + content: bytes, header: RateSheetCreate, user_id: str | None) -> RateSheet: + """Atajo: parsea el Excel y crea el tarifario en un solo paso.""" + preview = parse_excel(mode, content) + lanes = _rows_to_lanes(mode, preview.rows, header.default_origin) + return confirm_import( + db, tenant_id, company_id, + ImportConfirm(**header.model_dump(), lanes=lanes), user_id, + ) + + +# ============================================================ Motor de costeo +def _volumetric_kg(volume_m3: Decimal | None) -> Decimal: + return (volume_m3 or Decimal(0)) * AIR_VOLUMETRIC_FACTOR + + +def _rate_for(breaks: list[RateBreak], qty: Decimal) -> Decimal | None: + """Tarifa aplicable al peso/wm 'qty' (mayor quiebre cuyo umbral <= qty).""" + if not breaks: + return None + applicable = None + for b in breaks: + if b.from_qty <= qty: + applicable = b.rate + if applicable is None: + applicable = breaks[0].rate # por debajo del primer quiebre → tarifa base (gobierna el mínimo) + return applicable + + +def _best_break_cost(breaks: list[RateBreak], qty: Decimal) -> Decimal: + """Costo base con optimización de quiebre (declarar peso mayor si conviene).""" + base_rate = _rate_for(breaks, qty) + base = (qty * base_rate) if base_rate is not None else Decimal(0) + for b in breaks: + if b.from_qty > qty: + candidate = b.from_qty * b.rate + if candidate < base: + base = candidate + return base + + +def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal, + chargeable: Decimal, quantity: int, dangerous: bool) -> list[CostChargeLine]: + charges = ( + db.query(RateCharge) + .filter( + RateCharge.deleted_at.is_(None), + or_(RateCharge.rate_sheet_id == sheet.id, RateCharge.rate_lane_id == lane.id), + ) + .all() + ) + lines: list[CostChargeLine] = [] + for c in charges: + if c.concept == "dgr" and not dangerous: + continue + v = c.value or Decimal(0) + if c.charge_type == "fijo" or c.charge_type == "por_guia": + amt = v + elif c.charge_type == "por_kg": + amt = v * chargeable + elif c.charge_type == "por_contenedor": + amt = v * quantity + elif c.charge_type == "porcentaje": + amt = base * v / Decimal(100) + else: + amt = v + lines.append(CostChargeLine(concept=c.concept, amount=amt)) + 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( + RateSheet.mode == req.mode, + RateSheet.status == "activo", + or_(RateSheet.valid_from.is_(None), RateSheet.valid_from <= on_date), + or_(RateSheet.valid_to.is_(None), RateSheet.valid_to >= on_date), + ).all() + + gross = req.gross_weight_kg or Decimal(0) + options: list[CostOption] = [] + for sheet in sheets: + lanes_q = db.query(RateLane).filter( + RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None), + ) + if req.destination: + lanes_q = lanes_q.filter(RateLane.destination == req.destination) + for lane in lanes_q.all(): + # Origen: match exacto o el default del tarifario. + lane_origin = lane.origin or sheet.default_origin + if req.origin and lane_origin and lane_origin != req.origin: + continue + if req.mode == "maritimo_fcl": + if req.equipment_type and lane.equipment_type and lane.equipment_type != req.equipment_type: + continue + chargeable = Decimal(req.quantity) + base = (lane.flat_rate or Decimal(0)) * req.quantity + detail = f"{req.quantity} x {lane.equipment_type or 'contenedor'}" + elif req.mode == "terrestre": + chargeable = Decimal(req.quantity) + base = (lane.flat_rate or Decimal(0)) * req.quantity + detail = "tarifa por ruta" + elif req.mode == "maritimo_lcl": + tons = gross / Decimal(1000) + wm = max(tons, req.volume_m3 or Decimal(0)) + brks = breaks_of(db, lane.id) + base = _best_break_cost(brks, wm) if brks else Decimal(0) + chargeable = wm + base = max(base, lane.min_charge or Decimal(0)) + detail = f"W/M {wm.quantize(Decimal('0.01'))}" + else: # aereo + # 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 (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)) + options.append(CostOption( + rate_sheet_id=sheet.id, rate_sheet_name=sheet.name, supplier_id=sheet.supplier_id, + currency=sheet.currency, chargeable=chargeable, base_cost=base, + charges=charge_lines, total_cost=total, transit_days=lane.transit_days, detail=detail, + )) + options.sort(key=lambda o: o.total_cost) + return options diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index 2a47678..09353dc 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -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 @@ -21,6 +22,8 @@ from .metrics.routes import router as metrics_router from .opportunities.routes import router as opportunities_router from .pipelines.routes import router as pipelines_router from .quotes.routes import router as quotes_router +from .rates.routes import cost_router as rates_cost_router +from .rates.routes import router as rates_router from .service_requests.routes import router as service_requests_router from .suppliers.routes import router as suppliers_router from .uploads.routes import router as uploads_router @@ -41,6 +44,9 @@ 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) +router.include_router(rates_router) +router.include_router(rates_cost_router) diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index 6eb44b4..9e51a9b 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index 3182e76..36d0bf7 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -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).""" diff --git a/backend/api/v1/modules/crm/service_requests/service.py b/backend/api/v1/modules/crm/service_requests/service.py index 016dbed..ca7bfa2 100644 --- a/backend/api/v1/modules/crm/service_requests/service.py +++ b/backend/api/v1/modules/crm/service_requests/service.py @@ -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 diff --git a/backend/api/v1/modules/crm/suppliers/dto.py b/backend/api/v1/modules/crm/suppliers/dto.py index 8a0d9b6..70c49ff 100644 --- a/backend/api/v1/modules/crm/suppliers/dto.py +++ b/backend/api/v1/modules/crm/suppliers/dto.py @@ -13,6 +13,7 @@ class SupplierBase(BaseModel): person_type: str | None = Field(None, max_length=10) status: str = Field("active", max_length=20) classifications: list[str] = Field(default_factory=list) + classification_other: str | None = Field(None, max_length=120) # Comercial services_offered: str | None = None coverage: str | None = Field(None, max_length=20) @@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel): person_type: str | None = Field(None, max_length=10) status: str | None = Field(None, max_length=20) classifications: list[str] | None = None + classification_other: str | None = Field(None, max_length=120) services_offered: str | None = None coverage: str | None = Field(None, max_length=20) countries: list[str] | None = None diff --git a/backend/api/v1/modules/crm/suppliers/models.py b/backend/api/v1/modules/crm/suppliers/models.py index 3108be4..65cb9d6 100644 --- a/backend/api/v1/modules/crm/suppliers/models.py +++ b/backend/api/v1/modules/crm/suppliers/models.py @@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin): # Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril, # agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list) + # Texto libre cuando la clasificación incluye "otro" + classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # ----- Información comercial ----- services_offered: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 63757dd..7968a27 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -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}"'}, + ) diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 0d6ce1f..9318b33 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -125,6 +125,7 @@ class InvoiceResponse(InvoiceBase): model_config = ConfigDict(from_attributes=True) id: int + case_id: int | None = None status: str subtotal: Decimal tax_amount: Decimal diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index 1daec38..98efd2f 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -26,6 +26,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 ) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 21e5387..c897b07 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -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 @@ -97,6 +99,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) @@ -310,6 +321,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, @@ -323,6 +335,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() diff --git a/backend/api/v1/modules/ops/shipments/dto.py b/backend/api/v1/modules/ops/shipments/dto.py index e9b15d9..af889a0 100644 --- a/backend/api/v1/modules/ops/shipments/dto.py +++ b/backend/api/v1/modules/ops/shipments/dto.py @@ -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 diff --git a/backend/api/v1/modules/ops/shipments/models.py b/backend/api/v1/modules/ops/shipments/models.py index d65c542..6d04170 100644 --- a/backend/api/v1/modules/ops/shipments/models.py +++ b/backend/api/v1/modules/ops/shipments/models.py @@ -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 ) diff --git a/backend/api/v1/modules/ops/shipments/routes.py b/backend/api/v1/modules/ops/shipments/routes.py index 66985fa..265bd01 100644 --- a/backend/api/v1/modules/ops/shipments/routes.py +++ b/backend/api/v1/modules/ops/shipments/routes.py @@ -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) diff --git a/backend/api/v1/modules/ops/shipments/service.py b/backend/api/v1/modules/ops/shipments/service.py index 75f7943..adf57fb 100644 --- a/backend/api/v1/modules/ops/shipments/service.py +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -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 diff --git a/backend/core/config.py b/backend/core/config.py index a36a8ad..b45a1e2 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -42,6 +42,19 @@ class Settings(BaseSettings): PERMISSION_CACHE_ENABLED: bool = True PERMISSION_CACHE_TTL_SECONDS: int = 300 + # Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del + # token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite + # una sesión local firmada (HS256) con vida por inactividad (idle) y cap + # absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar + # la sesión local (no cada ~60s), lo que elimina el bucle de login. + # + # SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la + # sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback: + # con SESSION_STORE_ENABLED=False el comportamiento no cambia. + SESSION_STORE_ENABLED: bool = False + SESSION_IDLE_MINUTES: int = 30 + SESSION_MAX_HOURS: int = 10 + # Synchronization SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" diff --git a/backend/core/hub_token.py b/backend/core/hub_token.py new file mode 100644 index 0000000..2c2c6a3 --- /dev/null +++ b/backend/core/hub_token.py @@ -0,0 +1,67 @@ +""" +Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub. + +Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256) +que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en +la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar. +""" + +import logging +import time +from typing import Optional + +import httpx +from jose import jwt + +from core.config import settings +from core import session_store + +logger = logging.getLogger(__name__) + + +def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool: + """True si el token KC no está expirado (con margen).""" + try: + claims = jwt.get_unverified_claims(token) + exp = claims.get("exp") + return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds + except Exception: + return False + + +async def get_hub_access_token(request) -> Optional[str]: + """ + Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid), + refrescándolo contra el Hub si está por expirar. None si no hay sesión. + Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido). + """ + sid = request.cookies.get("crm_sid") if request is not None else None + if not sid: + return None + sess = session_store.get_session(sid) + if not sess: + return None + + access = sess.get("access_token") + refresh = sess.get("refresh_token") + + if access and _kc_exp_ok(access): + return access + + if refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": refresh}, + ) + if r.status_code == 200: + data = r.json() + new_access = data.get("access_token") or access + session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh) + return new_access + logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code) + except Exception as exc: + logger.warning("get_hub_access_token: refresh falló: %s", exc) + + return access diff --git a/backend/core/local_session.py b/backend/core/local_session.py new file mode 100644 index 0000000..48e8061 --- /dev/null +++ b/backend/core/local_session.py @@ -0,0 +1,94 @@ +""" +Sesión local del CRM (patrón SIWEB). + +Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que +transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la +app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante +su ventana de inactividad, de modo que el refresh del token KC solo se intenta al +expirar la sesión local — no cada ~60s. Esto elimina el bucle de login. + +Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina +(no hay re-emisión de fallback). + +Marcadores del token: + - source: "local" + crm_session: True → distingue de tokens KC (RS256) y del + token dev-local (dev_local: True). + - sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap). + - exp → sliding por inactividad (idle); se + re-emite en cada refresh mientras no se supere el cap. + +Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07). +Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto +(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from jose import JWTError, jwt + +from core.config import settings + +# Claims de identidad que se propagan del token KC a la sesión local. +_IDENTITY_CLAIMS = ( + "sub", "email", "preferred_username", "username", "name", + "given_name", "family_name", "first_name", "last_name", + "tenant_id", "tenant_slug", "roles", "permissions", + "is_hub_admin", "avatar_url", +) + + +def _now_epoch() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str: + """ + Emite un JWT de sesión local a partir de los claims (verificados) del usuario. + `session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si + no se provee, se usa el momento actual (sesión nueva). + """ + now = _now_epoch() + sst = int(session_start) if session_start else now + + payload: Dict[str, Any] = { + k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None + } + payload.update({ + "source": "local", + "crm_session": True, + "sst": sst, + "iat": now, + "exp": now + settings.SESSION_IDLE_MINUTES * 60, + }) + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def verify_session_token(token: str) -> Optional[Dict[str, Any]]: + """ + Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por + inactividad y no superó el cap absoluto de vida; None en cualquier otro caso. + Nunca lanza (para poder encadenar con la validación contra el Hub). + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + return None + + # Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local). + if not payload.get("crm_session") or payload.get("source") != "local": + return None + + # Cap absoluto de vida de sesión (independiente del sliding por idle). + sst = payload.get("sst") + if isinstance(sst, (int, float)): + if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600: + return None + + return payload + + +def session_start_of(payload: Dict[str, Any]) -> Optional[int]: + """Extrae el epoch de inicio de sesión (sst) de un payload de sesión local.""" + sst = payload.get("sst") + return int(sst) if isinstance(sst, (int, float)) else None diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 89d7001..64fc8e1 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -3,6 +3,7 @@ import time import httpx from datetime import datetime, timezone from typing import Callable, Optional +from cachetools import TTLCache from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system logger = logging.getLogger(__name__) +# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al +# Hub en cada request. Valor: "valid" o "invalid:". TTL corto para que +# los cambios de licencia se propaguen en minutos. +_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600) + def _normalize_text(value: str | None) -> str: if not value: @@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] + # Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que + # el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC + # guardado en valkey y se cachea por tenant. + if getattr(settings, "SESSION_STORE_ENABLED", False): + try: + from core.local_session import verify_session_token + local_claims = verify_session_token(token) + except Exception: + local_claims = None + if local_claims is not None: + return await self._handle_local_session_license(request, call_next, local_claims) + tenant_override = request.headers.get("X-Tenant-Override") if not tenant_override: # Fallback para flujos SSO cuando el override no viaja en header. @@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): } ) + async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict): + """ + Valida licencia para una sesión local del CRM (patrón SIWEB). + + El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en + valkey (refrescándolo si está vencido) para consultar verify-license, con + caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se + permite el paso: la sesión local se emitió tras un login válido (el App + Launcher solo ofrece apps licenciadas), evitando bloquear por un problema + transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean. + """ + from core import session_store + + tenant_key = str(local_claims.get("tenant_id") or "") + + cached = _license_cache.get(tenant_key) if tenant_key else None + if cached == "valid": + return await call_next(request) + if isinstance(cached, str) and cached.startswith("invalid:"): + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402}, + ) + + tenant_override = ( + tenant_key + or request.cookies.get("sso_tenant_id") + or request.cookies.get("sso_tenant_pub") + or "" + ) + + sid = request.cookies.get("crm_sid") + sess = session_store.get_session(sid) if sid else None + kc_token = (sess or {}).get("access_token") or "" + kc_refresh = (sess or {}).get("refresh_token") or "" + + async def _verify(tok: str): + if not tok: + return None + headers = {"Authorization": f"Bearer {tok}"} + if tenant_override: + headers["X-Tenant-Override"] = str(tenant_override) + try: + async with httpx.AsyncClient(timeout=5.0) as client: + return await client.get( + f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers + ) + except Exception as exc: + logger.warning("[license] verify-license (sesión local) error de red: %s", exc) + return None + + resp = await _verify(kc_token) + + # ¿El KC token guardado está vencido? Refrescar una vez y reintentar. + needs_refresh = resp is None or resp.status_code == 401 + if not needs_refresh and resp.status_code == 200: + try: + _d = resp.json() + except Exception: + _d = {} + if not _d.get("valid", False) and _is_token_issue_message( + _d.get("message"), _d.get("detail"), _d.get("reason") + ): + needs_refresh = True + + if needs_refresh and kc_refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + rr = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": kc_refresh}, + ) + if rr.status_code == 200: + nt = rr.json() + kc_token = nt.get("access_token") or kc_token + if sid: + session_store.update_session_tokens( + sid, kc_token, nt.get("refresh_token") or kc_refresh + ) + resp = await _verify(kc_token) + else: + logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code) + except Exception as exc: + logger.warning("[license] refresh KC para verify-license falló: %s", exc) + + if resp is not None and resp.status_code == 200: + try: + data = resp.json() + except Exception: + data = {} + if data.get("valid", False): + expires_at_str = data.get("expires_at") + if expires_at_str: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < datetime.now(timezone.utc): + msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción." + if tenant_key: + _license_cache[tenant_key] = f"invalid:{msg}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402}, + ) + except (ValueError, TypeError): + pass + if tenant_key: + _license_cache[tenant_key] = "valid" + request.state.license_info = data + return await call_next(request) + + message = data.get("message", "Sin licencia asignada para este tenant") + if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")): + if tenant_key: + _license_cache[tenant_key] = f"invalid:{message}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": message, "status_code": 402}, + ) + + # No concluyente (Hub no dio 200, o el problema de token persiste porque su + # refresh falla): la sesión local es válida → permitir sin cachear. Evita el + # bucle de 401 por el bug de refresh del Hub. + logger.warning( + "[license] verify-license no concluyente para sesión local (tenant=%s) — se permite", + tenant_key, + ) + return await call_next(request) + class RequestLoggingMiddleware(BaseHTTPMiddleware): """ diff --git a/backend/core/security.py b/backend/core/security.py index cb188c9..ced790d 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, if cache_key in token_cache: return token_cache[cache_key] + # Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada + # (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh + # del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que + # elimina el bucle de login. verify_session_token retorna None para tokens KC + # (RS256), así que no interfiere con el flujo normal. + if settings.SESSION_STORE_ENABLED: + from core.local_session import verify_session_token + + local_claims = verify_session_token(token) + if local_claims is not None: + token_cache[cache_key] = local_claims + return local_claims + # Shortcut para tokens de desarrollo local if settings.DEV_LOCAL_AUTH: try: @@ -653,6 +666,20 @@ def validate_access_to_resource( tenant_id = resolve_effective_tenant_id_from_user(current_user) + # Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace), + # resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar + # por compañía seleccionada cuando el token no está ligado a un tenant. + if tenant_id is None and company_id: + try: + from sqlalchemy import text as _text + row = db.execute( + _text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id} + ).first() + if row and row[0] is not None: + tenant_id = int(row[0]) + except Exception as exc: + logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc) + # Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me) # o rol local "super_admin" en la compañía (fuente de verdad: BD de a76). # Se reemplazó el antiguo "admin" in realm_access.roles para que la diff --git a/backend/core/session_store.py b/backend/core/session_store.py new file mode 100644 index 0000000..e54e4fd --- /dev/null +++ b/backend/core/session_store.py @@ -0,0 +1,111 @@ +""" +Store de sesión en Valkey/Redis (patrón SIWEB). + +Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por +un session_id opaco. La app usa la sesión local firmada (ver core.local_session) +para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub +(provisioning, my-apps, my-tenants), refrescándolos best-effort. + +Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op +y la sesión local firmada sigue sosteniendo la app. +""" + +import json +import logging +import uuid +from typing import Optional + +from core.config import settings + +try: + import redis # type: ignore +except Exception: # pragma: no cover - redis es opcional en algunos entornos + redis = None # type: ignore + +logger = logging.getLogger(__name__) + +_KEY_PREFIX = "crm:session:" +_client = None + + +def _get_client(): + """Cliente Redis/Valkey compartido (perezoso). None si no está disponible.""" + global _client + if redis is None: + return None + if _client is None: + try: + _client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True) + except Exception as exc: + logger.warning("session_store_init_failed: %s", exc) + return None + return _client + + +def _ttl_seconds() -> int: + # La sesión en valkey vive como máximo lo que la vida absoluta de la sesión. + return settings.SESSION_MAX_HOURS * 3600 + + +def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]: + """Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está).""" + client = _get_client() + if client is None: + return None + session_id = str(uuid.uuid4()) + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": int(session_start), + }) + try: + client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data) + return session_id + except Exception as exc: + logger.warning("session_store_create_failed: %s", exc) + return None + + +def get_session(session_id: str) -> Optional[dict]: + """Devuelve {access_token, refresh_token, sst} de la sesión, o None.""" + client = _get_client() + if client is None or not session_id: + return None + try: + raw = client.get(f"{_KEY_PREFIX}{session_id}") + return json.loads(raw) if raw else None + except Exception as exc: + logger.warning("session_store_get_failed: %s", exc) + return None + + +def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None: + """Actualiza los tokens KC de una sesión existente conservando su TTL y su sst.""" + client = _get_client() + if client is None or not session_id: + return + try: + key = f"{_KEY_PREFIX}{session_id}" + ttl = client.ttl(key) + if ttl and ttl > 0: + existing = client.get(key) + sst = json.loads(existing).get("sst") if existing else None + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": sst, + }) + client.setex(key, ttl, data) + except Exception as exc: + logger.warning("session_store_update_failed: %s", exc) + + +def delete_session(session_id: str) -> None: + """Elimina la sesión (logout). Fail-silent.""" + client = _get_client() + if client is None or not session_id: + return + try: + client.delete(f"{_KEY_PREFIX}{session_id}") + except Exception as exc: + logger.warning("session_store_delete_failed: %s", exc) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3419051..7d2a646 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index d91fc91..7de05b3 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -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" diff --git a/backend/tests/test_cases.py b/backend/tests/test_cases.py new file mode 100644 index 0000000..9d6e522 --- /dev/null +++ b/backend/tests/test_cases.py @@ -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" diff --git a/backend/tests/test_catalogs_seed.py b/backend/tests/test_catalogs_seed.py new file mode 100644 index 0000000..a0f55c1 --- /dev/null +++ b/backend/tests/test_catalogs_seed.py @@ -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 diff --git a/backend/tests/test_folios.py b/backend/tests/test_folios.py new file mode 100644 index 0000000..406f954 --- /dev/null +++ b/backend/tests/test_folios.py @@ -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 diff --git a/backend/tests/test_local_session.py b/backend/tests/test_local_session.py new file mode 100644 index 0000000..2781bc7 --- /dev/null +++ b/backend/tests/test_local_session.py @@ -0,0 +1,74 @@ +""" +Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session. + +Lógica pura (firma HS256 + claims); no requiere BD ni valkey. +""" + +from datetime import datetime, timezone + +from jose import jwt + +from core.config import settings +from core.local_session import mint_session_token, verify_session_token, session_start_of + + +def _now() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def test_round_trip_conserva_identidad(): + claims = { + "sub": "kc-user-123", + "email": "user@example.com", + "tenant_id": 11, + "tenant_slug": "aduanasoft", + "is_hub_admin": True, + } + token = mint_session_token(claims) + out = verify_session_token(token) + + assert out is not None + assert out["sub"] == "kc-user-123" + assert out["tenant_id"] == 11 + assert out["tenant_slug"] == "aduanasoft" + assert out["is_hub_admin"] is True + assert out["source"] == "local" + assert out["crm_session"] is True + assert isinstance(out["sst"], int) + + +def test_cap_absoluto_rechaza_sesion_vieja(): + # session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle. + old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120) + token = mint_session_token({"sub": "x"}, session_start=old_start) + assert verify_session_token(token) is None + + +def test_preserva_session_start(): + start = _now() - 60 + token = mint_session_token({"sub": "x"}, session_start=start) + out = verify_session_token(token) + assert out is not None + assert session_start_of(out) == start + + +def test_firma_alterada_se_rechaza(): + token = mint_session_token({"sub": "x"}) + # Alterar el último carácter de la firma invalida el token. + tampered = token[:-1] + ("A" if token[-1] != "A" else "B") + assert verify_session_token(tampered) is None + + +def test_token_no_crm_se_rechaza(): + # Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse. + other = jwt.encode( + {"sub": "x", "exp": _now() + 600}, + settings.SECRET_KEY, + algorithm="HS256", + ) + assert verify_session_token(other) is None + + +def test_token_basura_se_rechaza(): + assert verify_session_token("no-es-un-jwt") is None + assert verify_session_token("") is None diff --git a/backend/tests/test_opportunities.py b/backend/tests/test_opportunities.py index 4dfa519..de8f320 100644 --- a/backend/tests/test_opportunities.py +++ b/backend/tests/test_opportunities.py @@ -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): diff --git a/backend/tests/test_pricing.py b/backend/tests/test_pricing.py new file mode 100644 index 0000000..b352715 --- /dev/null +++ b/backend/tests/test_pricing.py @@ -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) diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index 0d10639..4a08191 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -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() diff --git a/backend/tests/test_service_requests.py b/backend/tests/test_service_requests.py index 2e798d8..1ea641f 100644 --- a/backend/tests/test_service_requests.py +++ b/backend/tests/test_service_requests.py @@ -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 diff --git a/backend/tests/test_shipments.py b/backend/tests/test_shipments.py index b987b38..4364a53 100644 --- a/backend/tests/test_shipments.py +++ b/backend/tests/test_shipments.py @@ -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 diff --git a/deploy/RUNBOOK-produccion.md b/deploy/RUNBOOK-produccion.md new file mode 100644 index 0000000..301ee89 --- /dev/null +++ b/deploy/RUNBOOK-produccion.md @@ -0,0 +1,162 @@ +# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga + +> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de +> datos de prod. **Este runbook no lo ejecuta ningún agente automático.** +> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario` +> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar. +> +> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod +> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes. + +Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local +(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo +completo antes de tocar prod. + +--- + +## 0. Alcance del cambio + +- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub + (App Launcher → `/auth/sso?relay=` → `POST {HUB_URL}/api/v1/auth/sso-exchange`). +- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey** + por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`. +- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril + (Ventas, Operaciones, Facturación, Consulta); permisos por módulo. + +**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`, +`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes +de desplegar (paso 5). No corras migraciones a ciegas. + +--- + +## 1. Prerrequisitos de infraestructura + +- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod. +- [ ] Docker + Docker Compose en el server. +- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`, + `celery_worker`, `celery_beat`. +- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`). +- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**. +- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos. + +--- + +## 2. Variables de entorno (`.env` en el server de prod) + +Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de +producción**. Nunca subas el `.env` con secretos al repo. + +```env +# --- Seguridad --- +ENVIRONMENT=production +DEV_LOCAL_AUTH=false +SECRET_KEY= + +# --- Sesión local (patrón SIWEB) --- +SESSION_STORE_ENABLED=true +SESSION_IDLE_MINUTES=30 +SESSION_MAX_HOURS=10 +VALKEY_URL=redis://valkey:6379/0 + +# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) --- +WORKSPACE_URL=https:// +HUB_URL=https:// +INTERNAL_HUB_URL=https:// +VITE_HUB_URL=https:// + +# --- Keycloak (single-realm / single-client) --- +KEYCLOAK_URL=https:///kcauth +VITE_KEYCLOAK_URL=https:///kcauth +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=aduanasoft +KEYCLOAK_CLIENT_SECRET= + +# --- Dominio del CRM (mismo origen app + API vía nginx) --- +ORIGIN=https:// +APP_PUBLIC_URL=https:// +VITE_API_URL=https:///api/ +INTERNAL_API_URL=http://backend:8000/api/ +CORS_ORIGINS=https:// + +# --- PostgreSQL --- +CORE_DB_HOST=postgres +CORE_DB_PORT=5432 +CORE_DB_NAME=crm_core +CORE_DB_USER= +POSTGRES_APP_PASSWORD= + +# --- MinIO / S3 --- +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_BUCKET=crm +S3_REGION=us-east-1 +S3_USE_SSL=false +``` + +> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé +> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod. + +--- + +## 3. Build del frontend — **en CI, no en el server** + +La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen +en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build: + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend +# push al registry interno si aplica +``` + +El backend usa la imagen/código directamente (uvicorn sin --reload). + +--- + +## 4. Despliegue + +```bash +# En el server de prod, en el directorio del proyecto: +docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +docker compose ps # verifica que todos queden healthy +``` + +--- + +## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho** + +1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro): + revisar y aplicar **solo** las que correspondan, con backup previo. +2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py` + (`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta). +3. **Sync de permisos** (registra el catálogo por módulo): + +```bash +docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()" +``` + +--- + +## 6. Verificación / smoke test + +```bash +curl -fsS https:///api/health && echo OK +``` + +- [ ] Login vía **App Launcher del Workspace** entra sin bucle. +- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto). +- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace. +- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda + (toast) y persiste al refrescar. +- [ ] Licencia válida (sin bucle 401 / "sesión expirada"). + +--- + +## 7. Rollback + +1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend. +2. Restaurar `.env` anterior si se cambió. +3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles. +4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin + redeploy de código (feature-flag), como mitigación rápida. diff --git a/deploy/docker-compose.testing.yml b/deploy/docker-compose.testing.yml index ee5adfb..838cdd5 100644 --- a/deploy/docker-compose.testing.yml +++ b/deploy/docker-compose.testing.yml @@ -5,11 +5,26 @@ # docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build services: backend: + # DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores; + # el backend debe resolver workspace.aduanasoft.com (Hub) en runtime. + dns: + - "8.8.8.8" + - "1.1.1.1" ports: !override - "127.0.0.1:8000:8000" + # Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista + # (no lee el .env dentro del contenedor), así que los flags van aquí. Valores + # desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo. + environment: + - SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true} + - SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30} + - SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10} command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] frontend: + dns: + - "8.8.8.8" + - "1.1.1.1" build: context: ./frontend dockerfile: Dockerfile.prod @@ -21,8 +36,11 @@ services: VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft} environment: - NODE_ENV=production + # El callback OIDC (/auth/callback) intercambia el código por tokens con el + # secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend. + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} volumes: !override [] - command: !override ["pnpm", "start"] + command: !override ["node", "build/index.js"] ports: !override - "127.0.0.1:5173:5173" @@ -31,3 +49,10 @@ services: minio: ports: !override [] + +# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local. +# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red. +networks: + app-hub: + external: false + driver: bridge diff --git a/deploy/nginx/testing.crm.aduanasoft.com.conf b/deploy/nginx/testing.crm.aduanasoft.com.conf index af625f2..5e15101 100644 --- a/deploy/nginx/testing.crm.aduanasoft.com.conf +++ b/deploy/nginx/testing.crm.aduanasoft.com.conf @@ -29,9 +29,8 @@ server { # ---- HTTPS ---- server { - listen 443 ssl; - listen [::]:443 ssl; - http2 on; + listen 443 ssl http2; + listen [::]:443 ssl http2; server_name testing.crm.aduanasoft.com; ssl_certificate /etc/letsencrypt/live/testing.crm.aduanasoft.com/fullchain.pem; @@ -50,6 +49,13 @@ server { # Subida de documentos (máx. 25 MB en la app) + margen client_max_body_size 30m; + # Buffers grandes para headers de respuesta: /auth/sso setea el JWT (fragmentado + # si supera ~4KB) + refresh/id_token/tenant como cookies → el header excede el + # buffer default de nginx (evita "upstream sent too big header" → 502). + proxy_buffer_size 32k; + proxy_buffers 16 32k; + proxy_busy_buffers_size 64k; + gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 1024; diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 336991d..5ebeead 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -38,6 +38,9 @@ ENV INTERNAL_API_URL=${INTERNAL_API_URL} # Copiar el resto del código COPY . . +# Subir el límite de heap de Node para el build (VM chico → evita OOM en vite build) +ENV NODE_OPTIONS="--max-old-space-size=3072" + # Construir el proyecto RUN pnpm run build @@ -49,10 +52,8 @@ FROM node:22-alpine AS runtime WORKDIR /app -RUN apk add --no-cache wget - -RUN npm config set strict-ssl false && \ - npm install -g pnpm +# Runtime SIN dependencias de red (redes restringidas): busybox ya trae wget y +# se arranca con node directo (sin pnpm), así el runtime no toca apk/npm. # Crear usuario no-root para seguridad antes de copiar con --chown RUN addgroup -g 1001 -S nodejs @@ -85,5 +86,5 @@ ENV INTERNAL_API_URL=http://backend:8000/api/ ENTRYPOINT ["/entrypoint.sh"] -# Ejecutar aplicación con Node.js -CMD ["pnpm", "start"] +# Ejecutar aplicación con Node.js (adapter-node, sin pnpm) +CMD ["node", "build/index.js"] diff --git a/frontend/src/app.css b/frontend/src/app.css index 990d5f9..5b58f9d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -143,6 +143,14 @@ filter: invert(1) brightness(1.15); opacity: 0.9; } + + /* Las opciones de los - - - + + + + {#if form.industry === 'otro'} + + {/if} - + {:else if tab === 'comercial'}
- - - + + + {#if form.preferred_contact_method === 'otro'} + + {/if} + +
{:else if tab === 'fiscal'}
+ - - + + + @@ -98,7 +116,7 @@
{:else if tab === 'observaciones'}
- +
{/if} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index ec3f880..3900dd2 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -1,5 +1,5 @@ + +{#if tab === 'datos'} +
+ + + + + + + + + {#if form.status} + + {/if} +
+{:else if tab === 'ruta'} +
+ + + + {#if showModalidad} + + {/if} + +

Origen

+ + {#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.origin_country).length} + + {:else} + + {/if} + + +

Destino

+ + {#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.destination_country).length} + + {:else} + + {/if} + + + + + +
+{:else if tab === 'mercancia'} +
+ + + + + +
+ + + + +
+
+{:else if tab === 'dimensiones'} +
+ + + + + + + + + + +
+ {#if isFcl} +
+

FCL — Contenedor completo

+ + +
+ {/if} + {#if isLcl} +
+

LCL — Carga consolidada

+ + + + +
+ {/if} + {#if isAir} +
+

Aéreo — Peso / Volumen (P/Vol)

+

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.

+
+
Cantidad de bultos{airQty}
+
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)} kg
+
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
+
Peso a cobrar (P/Vol){airChargeable.toFixed(2)} kg
+
+
+ {/if} +{:else if tab === 'servicios'} +

Servicios adicionales

+

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.

+
+ {#each crmCatalogs.options('servicio_adicional') as s (s.value)} + {@const checked = (form.additional_services ?? []).includes(s.value)} +
+ + {#if checked} +
+ setServiceCost(s.value, e.currentTarget.value)} /> + {form.currency ?? ''} +
+ {/if} +
+ {/each} +
+ +{:else if tab === 'notas'} +
+ + + +
+{/if} diff --git a/frontend/src/lib/components/crm/SupplierFields.svelte b/frontend/src/lib/components/crm/SupplierFields.svelte index 9a84ccf..c98f6c5 100644 --- a/frontend/src/lib/components/crm/SupplierFields.svelte +++ b/frontend/src/lib/components/crm/SupplierFields.svelte @@ -1,6 +1,7 @@ +{#snippet catCsv(labelText: string, catalog: string, value: string, set: (v: string) => void, placeholder: string)} + +{/snippet} + {#if tab === 'generales'}
- - + +
-

Clasificación (una o varias)

+

Clasificación del proveedor (una o varias)

- {#each SUPPLIER_CLASSIFICATIONS as c (c.value)} + {#each crmCatalogs.options('clasificacion_proveedor') as c (c.value)} {/each}
+ {#if hasOtro} + + {/if}
{:else if tab === 'comercial'}
- - - - - - + + + {@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')} @@ -56,16 +102,16 @@
{:else if tab === 'fiscal'}
- - - + + +
{:else if tab === 'observaciones'}
- +
{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 2206adc..147e66b 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -43,7 +43,7 @@ export const ACCOUNT_STATUS: Option[] = [ export const COMMERCIAL_CLASSIFICATION: Option[] = [ { value: 'importador', label: 'Importador' }, { value: 'exportador', label: 'Exportador' }, - { value: 'ambos', label: 'Importador / Exportador' } + { value: 'importador_exportador', label: 'Importador / Exportador' } ]; export const ACCOUNT_TYPES: Option[] = [ @@ -58,7 +58,7 @@ export const ACCOUNT_TYPES: Option[] = [ export const CONTACT_METHODS: Option[] = [ { value: 'llamada', label: 'Llamada telefónica' }, { value: 'correo', label: 'Correo electrónico' }, - { value: 'videollamada', label: 'Videoconferencia' }, + { value: 'videoconferencia', label: 'Videoconferencia' }, { value: 'whatsapp', label: 'WhatsApp' }, { value: 'otro', label: 'Otro' } ]; @@ -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[] = [ diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index bd901a3..e4068ea 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -6,6 +6,7 @@ import { Briefcase, Ship, Receipt, + Building, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -41,16 +42,21 @@ 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: 'Solicitudes', url: '/dashboard/crm/solicitudes' }, - { title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' }, { 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: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Actividades', url: '/dashboard/crm/actividades' }, + { title: 'Catálogos', url: '/dashboard/crm/catalogos' }, ], }, { @@ -70,6 +76,11 @@ export function getNavMain(): NavMainItem[] { { title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' }, ], }, + { + title: 'Compañías', + url: '/dashboard/companias', + icon: Building, + }, { title: 'Usuarios', url: '/dashboard/users', @@ -86,6 +97,7 @@ export function getNavMain(): NavMainItem[] { icon: Settings2, items: [ { title: 'General', url: '/dashboard/settings/general' }, + { title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' }, { title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' }, ], }, diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index 547c9c1..f4cd37f 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -145,9 +145,18 @@ > Tenant {#if userTenants.length === 0} - - Sin tenant asignado - + {#if companyStore.activeCompany?.tenant_name} + +
+ +
+ {companyStore.activeCompany.tenant_name} +
+ {:else} + + Sin tenant asignado + + {/if} {:else} {#each userTenants as tenant (tenant.id)} { + it('convierte nombre con acentos y espacios a slug válido', () => { + const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.'); + expect(slug).toBe('logistica-pena-cia-s-a-de-c-v'); + expect(TENANT_SLUG_RE.test(slug)).toBe(true); + }); + + it('quita guiones al inicio y al final', () => { + expect(slugifyTenantName(' --Hola-- ')).toBe('hola'); + }); + + it('cadena sin caracteres válidos da string vacío', () => { + expect(slugifyTenantName('!!!')).toBe(''); + }); +}); + +describe('validateTenantForm', () => { + it('acepta datos válidos', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' }) + ).toBeNull(); + }); + + it('rechaza nombre demasiado corto', () => { + expect( + validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' }) + ).toMatch(/al menos 2/); + }); + + it('rechaza slug con mayúsculas o espacios', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' }) + ).toMatch(/slug/i); + }); + + it('rechaza email inválido', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' }) + ).toMatch(/email/i); + }); +}); + +describe('hubErrorMessage', () => { + it('devuelve el detail string tal cual', () => { + expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe'); + }); + + it('formatea el primer error de validación de Pydantic', () => { + const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] }; + expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email'); + }); + + it('mensaje de permisos ante 403 sin detail', () => { + expect(hubErrorMessage(null, 403)).toMatch(/permisos/i); + }); + + it('mensaje genérico con status ante cuerpo desconocido', () => { + expect(hubErrorMessage(null, 500)).toMatch(/500/); + }); +}); + +describe('isForbiddenStatus', () => { + it('true para 401 y 403', () => { + expect(isForbiddenStatus(401)).toBe(true); + expect(isForbiddenStatus(403)).toBe(true); + }); + it('false para otros', () => { + expect(isForbiddenStatus(422)).toBe(false); + expect(isForbiddenStatus(200)).toBe(false); + }); +}); diff --git a/frontend/src/lib/server/workspace-provision.shared.ts b/frontend/src/lib/server/workspace-provision.shared.ts new file mode 100644 index 0000000..e71b283 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.shared.ts @@ -0,0 +1,85 @@ +/** + * Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub). + * Sin dependencias de entorno para poder testearse en aislamiento (Vitest). + */ + +// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$). +export const TENANT_SLUG_RE = /^[a-z0-9-]+$/; + +// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su +// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros. +export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const; +export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number]; + +/** + * Deriva un slug candidato a partir del nombre de la organización: + * minúsculas, sin acentos, espacios y símbolos → guiones. + */ +export function slugifyTenantName(name: string): string { + return name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100); +} + +export type TenantFormValues = { + name: string; + slug: string; + contact_email: string; +}; + +/** + * Valida los campos obligatorios del alta de organización antes de llamar al Hub, + * para dar feedback inmediato sin gastar un round-trip. + * Devuelve un mensaje de error o null si es válido. + */ +export function validateTenantForm(values: TenantFormValues): string | null { + if (!values.name || values.name.trim().length < 2) { + return 'El nombre de la organización debe tener al menos 2 caracteres.'; + } + if (!TENANT_SLUG_RE.test(values.slug)) { + return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).'; + } + if (!values.contact_email || !values.contact_email.includes('@')) { + return 'El email de contacto es obligatorio y debe ser válido.'; + } + return null; +} + +/** + * Extrae un mensaje legible del cuerpo de error de FastAPI (Hub). + * - 401/403 → mensaje de permisos. + * - detail string → tal cual. + * - detail array (422 Pydantic) → "campo: msg" del primer error. + * - cualquier otro → mensaje genérico con el status. + */ +export function hubErrorMessage(body: unknown, status: number): string { + const detail = + body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null; + + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as { loc?: unknown[]; msg?: string }; + const loc = Array.isArray(first.loc) ? first.loc : []; + const field = loc.length ? String(loc[loc.length - 1]) : ''; + const msg = first.msg ?? 'dato inválido'; + return field ? `${field}: ${msg}` : msg; + } + + if (status === 401 || status === 403) { + return 'No tienes permisos de administrador en el workspace para esta acción.'; + } + + return `El workspace respondió con un error (${status}).`; +} + +/** True si el status del Hub indica falta de permisos/sesión. */ +export function isForbiddenStatus(status: number): boolean { + return status === 401 || status === 403; +} diff --git a/frontend/src/lib/server/workspace-provision.ts b/frontend/src/lib/server/workspace-provision.ts new file mode 100644 index 0000000..4bc5af1 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.ts @@ -0,0 +1,126 @@ +/** + * Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y + * USUARIOS (invitaciones) desde el CRM. + * + * Principio de seguridad: SIEMPRE se llama server-side reenviando el token del + * usuario autenticado (Bearer). Es el Hub quien valida el permiso — + * `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para + * invitaciones. El CRM NO guarda secretos ni hace bypass de autorización. + * + * Endpoints del Hub (base = getHubBackendUrl()): + * GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin) + * POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin) + * POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant) + */ + +import { getHubBackendUrl } from '$lib/server/workspace-auth'; +import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared'; + +const HUB_API_PREFIX = '/api/v1/hub'; + +function hubUrl(path: string): string { + return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`; +} + +export type WorkspaceTenant = { + id: number; + name: string; + slug: string; + display_name?: string | null; + contact_name?: string | null; + contact_email: string; + status: string; + is_self_hosted: boolean; + has_license: boolean; + created_at: string; +}; + +export type WorkspaceInvite = { + id: number; + email: string; + tenant_slug: string; + role: string; + invite_url: string; + expires_at: string; + created_at: string; +}; + +export type HubResult = + | { ok: true; data: T } + | { ok: false; status: number; forbidden: boolean; error: string }; + +/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */ +async function toErrorResult(res: Response): Promise> { + const body = await res.json().catch(() => null); + return { + ok: false, + status: res.status, + forbidden: isForbiddenStatus(res.status), + error: hubErrorMessage(body, res.status) + }; +} + +const jsonHeaders = (accessToken: string) => ({ + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' +}); + +/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */ +export async function listWorkspaceTenants( + accessToken: string, + fetch: typeof globalThis.fetch +): Promise> { + const res = await fetch(hubUrl('/tenants'), { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) return toErrorResult(res); + const data = (await res.json()) as { tenants?: WorkspaceTenant[] }; + return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] }; +} + +export type CreateTenantInput = { + name: string; + slug: string; + contact_email: string; + contact_name?: string; + contact_phone?: string; + display_name?: string; + is_self_hosted: boolean; + app_url?: string; +}; + +/** Crea una organización (tenant) y provisiona su realm en Keycloak. */ +export async function createWorkspaceTenant( + accessToken: string, + fetch: typeof globalThis.fetch, + input: CreateTenantInput +): Promise> { + const res = await fetch(hubUrl('/tenants'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(res); + return { ok: true, data: (await res.json()) as WorkspaceTenant }; +} + +export type CreateInviteInput = { + email: string; + tenant_slug: string; + role: string; +}; + +/** Genera una invitación de un solo uso para un usuario en un tenant. */ +export async function createWorkspaceInvite( + accessToken: string, + fetch: typeof globalThis.fetch, + input: CreateInviteInput +): Promise> { + const res = await fetch(hubUrl('/invites'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(res); + return { ok: true, data: (await res.json()) as WorkspaceInvite }; +} diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 4b8794a..aacecac 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -11,6 +11,8 @@ interface Company { rfc?: string; logo?: string; tenant_id: number; + tenant_name?: string; + tenant_slug?: string; } class CompanyStore { @@ -158,7 +160,9 @@ class CompanyStore { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ companyId: company.id }), + // tenant_id de la compañía → override para que el backend escale por + // ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token). + body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }), credentials: 'include' }); } catch (error) { diff --git a/frontend/src/lib/stores/crm-catalogs.svelte.ts b/frontend/src/lib/stores/crm-catalogs.svelte.ts new file mode 100644 index 0000000..8d4dcc7 --- /dev/null +++ b/frontend/src/lib/stores/crm-catalogs.svelte.ts @@ -0,0 +1,77 @@ +/** + * Store reactivo de catálogos de referencia del CRM. + * + * Carga desde el backend (/v1/crm/catalogs) y cachea por catálogo. Los + * formularios leen `options(catalog)` (reactivo) sin refetch. Los catálogos + * dependientes (Estado por País) se piden con `ensure(catalog, parentCode)`. + */ +import { referenceCatalogsAPI } from '$lib/api/crm/catalogs'; +import { companyStore } from '$lib/stores/company.svelte'; +import type { Option } from '$lib/components/crm/format'; + +class CrmCatalogStore { + private _cache = $state>({}); + private _pending = new Set(); + private _companyId: number | null = null; + + /** Toma la compañía activa; si cambió, limpia el caché. Devuelve su id (o null). */ + private syncCompany(): number | null { + const cid = companyStore.activeCompany?.id ?? null; + if (cid !== this._companyId) { + this._companyId = cid; + this._cache = {}; + this._pending.clear(); + } + return cid; + } + + private keyOf(catalog: string, parentCode?: string): string { + return parentCode ? `${catalog}:${parentCode}` : catalog; + } + + /** Opciones de un catálogo ya cargado (vacío si aún no se ha cargado). */ + options(catalog: string, parentCode?: string): Option[] { + return this._cache[this.keyOf(catalog, parentCode)] ?? []; + } + + /** Descripción de una clave (para listados/detalle). */ + label(catalog: string, code: string | null | undefined): string { + if (!code) return '—'; + return this.options(catalog).find((o) => o.value === code)?.label ?? code; + } + + /** Carga un catálogo (con dependiente opcional) si aún no está en caché. */ + async ensure(catalog: string, parentCode?: string): Promise { + const cid = this.syncCompany(); + const key = this.keyOf(catalog, parentCode); + if (cid == null || key in this._cache || this._pending.has(key)) return; + this._pending.add(key); + try { + const items = await referenceCatalogsAPI.list(catalog, cid, parentCode); + this._cache[key] = items.map((i) => ({ value: i.code, label: i.label })); + } catch { + this._cache[key] = []; + } finally { + this._pending.delete(key); + } + } + + /** Precarga varios catálogos globales en paralelo. */ + async preload(catalogs: string[]): Promise { + await Promise.all(catalogs.map((c) => this.ensure(c))); + } + + /** Invalida el caché de un catálogo (tras editar en administración). */ + invalidate(catalog?: string): void { + if (!catalog) { + this._cache = {}; + return; + } + for (const k of Object.keys(this._cache)) { + if (k === catalog || k.startsWith(`${catalog}:`)) delete this._cache[k]; + } + this._cache = { ...this._cache }; + } +} + +export const crmCatalogs = new CrmCatalogStore(); diff --git a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts index 3d9736d..bc7dad7 100644 --- a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts @@ -14,8 +14,8 @@ import { json } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, setAuthTokens } from '$lib/server/api'; -import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; export const POST = async ({ cookies, fetch }: RequestEvent) => { const refreshToken = cookies.get('refresh_token'); @@ -27,34 +27,43 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => { try { const baseUrl = getServerApiUrl(); + // Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el + // inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla. + const currentSession = getAccessTokenFromCookies(cookies); + const sessionId = cookies.get('crm_sid'); + const response = await fetch(`${baseUrl}v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ + refresh_token: refreshToken, + ...(currentSession ? { session_token: currentSession } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) }); if (!response.ok) { - // El refresh token expiró o fue invalidado por Keycloak (sesión terminada). - // Limpiar las cookies para que el servidor redirigir al login en la siguiente carga. - cookies.delete('refresh_token', { path: '/' }); - clearAccessTokenCookies(cookies); - cookies.delete('active_company_id', { path: '/' }); + // El refresh falló y no hubo sesión local que re-emitir (cap superado o + // patrón apagado). Limpiar cookies para que el server redirija al login. + clearAuthTokens(cookies); const status = response.status === 401 ? 401 : 400; return json({ error: 'Refresh token expired or invalid' }, { status }); } const data = (await response.json()) as { - access_token: string; + access_token?: string; refresh_token?: string; + session_token?: string; + session_id?: string; expires_in?: number; }; - // Actualizar las cookies HttpOnly con los nuevos tokens - setAuthTokens(cookies, data.access_token, data.refresh_token); + // Actualiza cookies considerando la sesión local; devuelve el bearer de la app. + const appToken = applyRefreshedTokens(cookies, data); - // Devolver solo el access_token al cliente - return json({ access_token: data.access_token }); + // Devolver solo el token de app al cliente (sesión local si aplica, o KC). + return json({ access_token: appToken ?? data.access_token ?? '' }); } catch (error) { console.error('[silent-refresh] Error inesperado:', error); return json({ error: 'Internal server error' }, { status: 500 }); diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 143fe04..b899aa9 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -9,7 +9,7 @@ import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api'; +import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api'; export const POST = async ({ request, cookies, fetch }: RequestEvent) => { const body = await request.json(); @@ -28,9 +28,11 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => { // Modo SSO relay: validar acceso vía Hub y actualizar cookie de override if (tenant_id) { try { + // Validación contra el Hub → token KC (el access_token puede ser la sesión local). + const kcToken = getKcAccessToken(cookies) ?? accessToken; const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, + headers: { 'Authorization': `Bearer ${kcToken}` }, }); if (!tenantsRes.ok) { return json({ error: 'Could not validate tenant access' }, { status: 403 }); diff --git a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts index 3820adf..18326e1 100644 --- a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts +++ b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts @@ -6,22 +6,28 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ cookies, request }) => { try { - const { companyId } = await request.json(); + const body = await request.json(); + const companyId = body?.companyId; + const tenantId = body?.tenantId; if (!companyId || typeof companyId !== 'number') { return json({ error: 'Invalid company ID' }, { status: 400 }); } - // Establecer la cookie desde el servidor - cookies.set('active_company_id', companyId.toString(), { - path: '/', - maxAge: 60 * 60 * 24 * 30, // 30 días - sameSite: 'lax', - httpOnly: false, // Permitir acceso desde JavaScript - secure: process.env.NODE_ENV === 'production' - }); + const isProd = process.env.NODE_ENV === 'production'; + const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd }; - return json({ success: true, companyId }); + // Compañía activa (legible desde JS) + cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false }); + + // Fijar el tenant de la compañía como override → el backend escala por ese + // tenant aunque el token no lo traiga (caso hub_admin operando por compañía). + if (typeof tenantId === 'number' && Number.isFinite(tenantId)) { + cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true }); + cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false }); + } + + return json({ success: true, companyId, tenantId: tenantId ?? null }); } catch (error) { console.error('Error setting active company:', error); return json({ error: 'Internal server error' }, { status: 500 }); diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 42cade0..da1b217 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -1,132 +1,15 @@ -import { redirect, isRedirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { - clearWorkspaceReturnPath, - getWorkspaceLoginUrl, - readWorkspaceReturnPath, - storeReturnPath, -} from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ url, cookies, fetch }) => { - // Obtener el código y state de los query params - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const errorParam = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - - if (errorParam) { - console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription); - // login_required means no KC session exists yet → send to Workspace login. - // Preserve the intended destination through the detour so /login can pick it up. - if (state) { - try { - const stateObj = JSON.parse(state); - const returnPath = stateObj.redirect_url; - if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') { - storeReturnPath(cookies, returnPath); - } - } catch { /* ignore malformed state */ } - } - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } - - if (!code) { - console.error('❌ [Callback Server] No se recibió código de autorización'); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } - - try { - // Intercambiar código por tokens usando el backend de Keycloak - // En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080 - // En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback - const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080'; - const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master'; - const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend'; - const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; - - // La redirect_uri debe coincidir exactamente con la registrada en Keycloak. - // resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque - // ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback). - const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth'); - const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`; - - const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; - - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: redirectUri, - client_id: KEYCLOAK_CLIENT_ID, - ...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET }) - }); - - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: body.toString() - }); - - if (!tokenResponse.ok) { - const errorData = await tokenResponse.text(); - console.error('❌ [Callback Server] Error al intercambiar código:', errorData); - throw new Error('Error al obtener tokens'); - } - - const tokens = await tokenResponse.json(); - - // Establecer las cookies en el servidor - // access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization) - // refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona) - const { isSecureContext } = await import('$lib/server/workspace-auth'); - const isProduction = isSecureContext(); - - setAccessTokenCookies(cookies, tokens.access_token, { - secure: isProduction, - maxAge: 60 * 60 * 24 * 7 // 7 días - }); - - if (tokens.refresh_token) { - cookies.set('refresh_token', tokens.refresh_token, { - path: '/', - httpOnly: true, // *** HttpOnly: nunca expuesto a JS *** - secure: isProduction, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 // 30 días - }); - } - - if (tokens.id_token) { - cookies.set('id_token', tokens.id_token, { - path: '/', - httpOnly: true, - secure: isProduction, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 7 - }); - } - - // Obtener la URL de redirección del state o ir al dashboard - let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard'); - if (state) { - try { - const stateObj = JSON.parse(state); - redirectTo = stateObj.redirect_url || '/dashboard'; - } catch (e) { - console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state'); - } - } - - clearWorkspaceReturnPath(cookies); - - // Redirigir a la página de destino - throw redirect(303, redirectTo); - - } catch (err: any) { - if (isRedirect(err)) throw err; - console.error('❌ [Callback Server] Error procesando autenticación:', err); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } +/** + * Callback OIDC — OBSOLETO. + * + * El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por + * el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta + * ruta se conserva solo para no romper enlaces viejos; cualquier acceso se + * redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al + * Workspace). No se intercambia ningún `code` con Keycloak. + */ +export const load: PageServerLoad = async () => { + throw redirect(303, '/dashboard'); }; diff --git a/frontend/src/routes/auth/post-logout/+server.ts b/frontend/src/routes/auth/post-logout/+server.ts index 5c3ba8d..1dc6ec6 100644 --- a/frontend/src/routes/auth/post-logout/+server.ts +++ b/frontend/src/routes/auth/post-logout/+server.ts @@ -3,11 +3,10 @@ import type { RequestHandler } from './$types'; import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth'; /** - * KC redirects here after completing the logout flow. - * This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*). - * We then send the user to workspace login so it can apply myApps() launcher logic. + * Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak + * (el cierre completo se hace desde el Workspace); se conserva por compatibilidad + * y redirige al App Launcher del Workspace. */ -export const GET: RequestHandler = async ({ request, url }) => { - const systemBaseUrl = url.origin; - throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); +export const GET: RequestHandler = async () => { + throw redirect(303, getWorkspaceLoginUrl()); }; diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index e56cdaf..9b7b62c 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -134,12 +134,32 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const isProduction = isSecureContext(); console.log('[SSO] ORIGIN-based secure context:', isProduction); + // Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una + // sesión local firmada, el access_token guarda ESA sesión (bearer de la app, + // sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia + // cookie kc_access_token (solo para llamadas directas al Hub). Si no vino + // (flag apagado), comportamiento histórico: access_token = token KC. + const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null; + const kcAccessToken = tokens.access_token as string; + // access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB - setAccessTokenCookies(cookies, tokens.access_token as string, { + setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, { secure: isProduction, maxAge: 60 * 60 * 24 * 7, }); + if (sessionToken) { + // kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub. + cookies.set('kc_access_token', kcAccessToken, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, + }); + if (typeof tokens.session_id === 'string') { + cookies.set('crm_sid', tokens.session_id, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, + }); + } + } + // refresh_token — HttpOnly (never exposed to JS) if (typeof tokens.refresh_token === 'string') { cookies.set('refresh_token', tokens.refresh_token, { diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index a3f98a9..9a38390 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types'; import { validateAuth, getAuthTokens, + getKcAccessToken, getUserCompanies, clearAuthTokens } from '$lib/server/api'; @@ -30,12 +31,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; if (!DEV_LOCAL_AUTH) { + // Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el + // access_token guarda la sesión local del CRM, no el token de Keycloak). + const kcToken = getKcAccessToken(cookies) ?? accessToken; try { const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantOverride = cookies.get('sso_tenant_id'); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { headers: { - 'Authorization': `Bearer ${accessToken}`, + 'Authorization': `Bearer ${kcToken}`, ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) } }); @@ -46,8 +50,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // No bloquear el dashboard si falla la carga de tenants } - const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; - myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id')); } return { diff --git a/frontend/src/routes/dashboard/companias/+page.svelte b/frontend/src/routes/dashboard/companias/+page.svelte new file mode 100644 index 0000000..8ea54f7 --- /dev/null +++ b/frontend/src/routes/dashboard/companias/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+

+ Compañías +

+

+ Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del + Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa. +

+
+ + + + Nueva compañía + El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa. + + +
+ + + +
+
+ +
+
+
+ + + + Compañías ({companies.length}) + Empresas a las que tienes acceso. + + + {#if loading} +

Cargando…

+ {:else if companies.length === 0} +

Aún no tienes compañías. Crea una arriba.

+ {:else} +
+ + + + + + + + + + + {#each companies as c (c.id)} + + + + + + + {/each} + +
NombreRFCTenantActiva
{c.name}{c.rfc ?? '—'}{c.tenant_id} + {#if companyStore.activeCompany?.id === c.id} + activa + {:else} + + {/if} +
+
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/crm/catalogos/+page.svelte b/frontend/src/routes/dashboard/crm/catalogos/+page.svelte new file mode 100644 index 0000000..bb33ce5 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/catalogos/+page.svelte @@ -0,0 +1,217 @@ + + +
+
+

Catálogos

+

+ Administra las opciones de los catálogos del CRM. Los catálogos base del sistema (SAT/ISO) solo se pueden activar/desactivar; los de tu empresa se pueden insertar, editar y borrar. +

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} +
+ + Catálogos + + {#each metas as m (m.catalog)} + + {/each} + + + + + + {selected?.label ?? 'Opciones'} + + {#if selected} + {selected.scope === 'global' ? 'Catálogo global (Aduanasoft)' : 'Catálogo de tu empresa'} · {selected.count} opciones + {#if selected.is_system} · base del sistema (no se borra){/if} + {/if} + + + +
+ + + +
+ + {#if loading} +

Cargando…

+ {:else if items.length === 0} +

Sin opciones. Agrega la primera arriba.

+ {:else} +
+ + ClaveDescripciónOrigenActivoAcciones + + {#each items as it (it.id)} + + {it.code} + + {#if editId === it.id} + + {:else} + {it.label} + {/if} + + {it.tenant_id === null ? 'Global' : 'Empresa'}{#if it.is_system} · base{/if} + + {#if editId === it.id} + + {:else} + {it.is_active ? 'Sí' : 'No'} + {/if} + + + {#if editId === it.id} + + + {:else} + + {#if !it.is_system} + + {/if} + {/if} + + + {/each} + + +
+ {/if} +
+
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/contactos/+page.svelte b/frontend/src/routes/dashboard/crm/contactos/+page.svelte index 41aff07..5c0f37c 100644 --- a/frontend/src/routes/dashboard/crm/contactos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/contactos/+page.svelte @@ -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([]); let accounts = $state([]); + let suppliers = $state([]); 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 @@ Nombre - Cuenta + Pertenece a Puesto Email Teléfono @@ -150,7 +161,7 @@ {c.first_name} {c.last_name ?? ''} {#if c.is_primary}Principal{/if} - {accountName(c.account_id)} + {#if ownerLabel(c)}{@const o = ownerLabel(c)}{o?.kind} {o?.name}{:else}—{/if} {c.job_title ?? '—'} {c.email ?? '—'} {c.phone ?? c.mobile ?? '—'} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte index cc5317a..09be57f 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte @@ -93,6 +93,7 @@ Folio + Solicitud Estatus Total venta Margen @@ -103,6 +104,7 @@ {#each filtered as q (q.id)} {q.reference ?? `#${q.id}`} + {#if q.service_request_id}{q.service_request_reference ?? `#${q.service_request_id}`}{:else}{/if} {labelOf(QUOTE_STATUS, q.status)} {formatMoney(q.total_sale, q.currency)} {formatMoney(q.margin, q.currency)} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 0f39db5..d252b41 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -1,5 +1,5 @@ + +
+
+

Cotizador

+

Calcula el costo por proveedor a partir de los tarifarios vigentes.

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + Datos de la carga + +
+ + + + + {#if isFcl} + + + {:else} + + + {#if isAir} + + + + + {/if} + {/if} + + + +
+ {#if isAir && airVolumetric > 0} +
+ P/Vol (volumétrico): {airVolumetric.toFixed(2)} + Peso bruto: {(Number(f.gross_weight_kg) || 0).toFixed(2)} kg + Peso a cobrar: {airChargeable.toFixed(2)} kg + P/Vol = (L×A×H) × bultos ÷ 6000 +
+ {/if} +
+
+
+ + {#if calculated} + + Opciones ({options.length}) + Ordenadas por costo total. El precio de venta se define en la cotización (costo + margen). + + + {#if options.length === 0} +

No hay tarifas vigentes para esa ruta/modo. Verifica que exista un tarifario activo con esa ruta.

+ {:else} +
+ + TarifarioBaseCargosTotalDetalleTránsito{#if quoteId}{/if} + + {#each options as o, i (o.rate_sheet_id + '-' + i)} + + {o.rate_sheet_name} + {money(o.base_cost, o.currency)} + {o.charges.length ? o.charges.map((c) => `${c.concept}: ${money(c.amount, o.currency)}`).join(', ') : '—'} + {money(o.total_cost, o.currency)} + {o.detail ?? '—'} + {o.transit_days ?? '—'} + {#if quoteId}{/if} + + {/each} + + +
+ {/if} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte index d9a1b5f..fcd59b1 100644 --- a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte @@ -1,5 +1,5 @@ + +
+
+

Expedientes

+

Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).

+
+ + + +
+ + +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin expedientes. Se crean automáticamente al generar una oportunidad.

+ {:else} +
+ + + + Expediente + Cliente + Etapa + Estatus + Creado + + + + + {#each filtered as c (c.id)} + + {c.reference ?? `#${c.id}`} + {accountName(c.account_id)} + {STAGE_LABEL[c.stage] ?? c.stage} + {c.status} + {formatDate(c.created_at)} + + + {/each} + + +
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte new file mode 100644 index 0000000..f520370 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte @@ -0,0 +1,81 @@ + + +
+ + + {#if loading && !data} +

Cargando…

+ {:else if data} +
+

{data.reference ?? `Expediente #${data.id}`}

+

Etapa: {STAGE_LABEL[data.stage] ?? data.stage} · {data.status}{#if data.title} · {data.title}{/if}

+
+ + + Historia del trámite + Todos los documentos ligados a este expediente, en orden cronológico. + + + {#if data.timeline.length === 0} +

Sin movimientos aún.

+ {:else} +
    + {#each data.timeline as ev (ev.kind + '-' + ev.id)} + {@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }} +
  1. + + + +
    + {K.label} + {ev.reference ?? `#${ev.id}`} + {#if ev.status}{ev.status}{/if} + {formatDate(ev.created_at)} +
    +
  2. + {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 698378d..04b9ed8 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -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([]); @@ -32,6 +33,18 @@ let saving = $state(false); let form = $state({ name: '' }); + // Convertir oportunidad → solicitud (la dirección se hereda de la oportunidad) + let convertOpen = $state(false); + let converting = $state(false); + let convertOpp = $state(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; + } + }
@@ -251,6 +308,9 @@ ondragstart={(e) => onDragStart(e, opp.id)} >

{opp.name}

+ {#if opp.reference} +

{opp.reference}

+ {/if} {#if accountName(opp.account_id)}

{accountName(opp.account_id)}

{/if} @@ -264,7 +324,12 @@ {opp.probability}% {/if}
- @@ -292,6 +357,13 @@ {#each accounts as a (a.id)}{/each} +
{/if} + +{#if convertOpen && convertOpp} + +{/if} + +{#if closeOpen && closeCtx} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte index 99de7a1..6c282fe 100644 --- a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte @@ -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([]); let loading = $state(false); let search = $state(''); @@ -233,6 +237,13 @@ {#each LEAD_SOURCES as s (s.value)}{/each} + - - - - - - + +
+ {#each SR_FORM_TABS as t (t.value)} + + {/each} +
-
- Logística y carga - - - - - - - - - - - -
+ -
+
diff --git a/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte new file mode 100644 index 0000000..4bb981a --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte @@ -0,0 +1,179 @@ + + +
+
+
+

Tarifarios

+

Costos de proveedores por ruta, base de las cotizaciones.

+
+ +
+ + + +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if sheets.length === 0} +

Sin tarifarios. Importa uno con el botón de arriba.

+ {:else} +
+ + NombreModoMonedaRutasVigenciaEstatusAcciones + + {#each sheets as s (s.id)} + + {s.name} + {modeLabel(s.mode)} + {s.currency ?? '—'} + {s.lane_count ?? 0} + {s.valid_from ? formatDate(s.valid_from) : '—'} → {s.valid_to ? formatDate(s.valid_to) : '—'} + {s.status} + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if showImport} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte new file mode 100644 index 0000000..0b123fa --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte @@ -0,0 +1,253 @@ + + +
+ + {#if loading && !sheet} +

Cargando…

+ {:else if sheet} +
+
+

{sheet.name}

+

+ {crmCatalogs.label('modo_tarifario', sheet.mode)} · {sheet.currency ?? '—'} · {lanes.length} rutas + · vigencia {sheet.valid_from ? formatDate(sheet.valid_from) : '—'} → {sheet.valid_to ? formatDate(sheet.valid_to) : '—'} + · estatus {sheet.status} +

+
+
+ {#if sheet.status !== 'activo'} + + {:else} + + {/if} +
+
+ + + +
Rutas ({lanes.length}) + El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. +
+ +
+ + {#if lanes.length === 0} +

Sin rutas. Agrega una o importa por Excel.

+ {:else} +
+ + RegiónOrigenDestinoEquipoMínimoTarifa / QuiebresTránsito + + {#each lanes as l (l.id)} + + {l.region ?? '—'} + {l.origin ?? sheet.default_origin ?? '—'} + {l.destination ?? '—'} + {eq(l.equipment_type)} + {l.min_charge ?? '—'} + {l.flat_rate != null ? l.flat_rate : breaksTxt(l)} + {l.transit_days ?? '—'} + + + {/each} + + +
+ {/if} +
+
+ + + Cargos adicionales ({charges.length}) + Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…). + + +
+ + + + + +
+ {#if charges.length === 0} +

Sin cargos adicionales.

+ {:else} + + ConceptoTipoValorCondición + + {#each charges as c (c.id)} + + {conceptLabel(c.concept)} + {chargeTypeLabel(c.charge_type)} + {c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''} + {c.condition ?? '—'} + + + {/each} + + + {/if} +
+
+ {/if} +
+ +{#if showLane && sheet} + +{/if} diff --git a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte index b4c74ae..ce567a7 100644 --- a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte @@ -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({ 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 @@
- + - + diff --git a/frontend/src/routes/dashboard/roles/+page.svelte b/frontend/src/routes/dashboard/roles/+page.svelte index 938eb89..729ebd8 100644 --- a/frontend/src/routes/dashboard/roles/+page.svelte +++ b/frontend/src/routes/dashboard/roles/+page.svelte @@ -1,26 +1,231 @@
-

- - Roles y permisos +

+ Roles y permisos

-

- Gestión de roles y control de acceso. +

+ Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.

- - - Roles del sistema - Implementa aquí la gestión de roles y permisos de tu proyecto. - - -

Sección en construcción.

-
-
+ {#if !companyId} + Selecciona una compañía activa para gestionar sus roles. + {:else} + + + Nuevo rol + + +
+ + + +
+
+
+
+ + + + Roles ({roles.length}) + Haz clic en un rol para ver y editar sus permisos. + + + {#if loading} +

Cargando…

+ {:else if roles.length === 0} +

No hay roles. Crea uno arriba.

+ {:else} + {#each roles as r (r.id)} +
+
+ + +
+ {#if expandedRoleId === r.id} +
+ {#if !rolePerms[r.id]} +

Cargando permisos…

+ {:else} +
+ {#each Object.entries(byModule) as [mod, perms] (mod)} +
+

{mod}

+
+ {#each perms as p (p.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} + {/if} +
+
+ {/if}
diff --git a/frontend/src/routes/dashboard/settings/cotizacion/+page.svelte b/frontend/src/routes/dashboard/settings/cotizacion/+page.svelte new file mode 100644 index 0000000..be1788b --- /dev/null +++ b/frontend/src/routes/dashboard/settings/cotizacion/+page.svelte @@ -0,0 +1,104 @@ + + +
+
+

Formato de cotización

+

Marca y encabezados que se imprimen en el PDF de las cotizaciones (por compañía).

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + Logo + +
+ {#if logoUrl} + Logo + {:else} +
Sin logo
+ {/if} + +
+
+
+ + + Datos del emisor + Aparecen en el encabezado del PDF. + + +
+ + + + + + + + +
+
+
+ + + Textos por defecto + +
+ + +
+
+
+ +
+ {/if} +
diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 4ecfca5..4aec654 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1,23 +1,253 @@
-

- - Usuarios +

+ Usuarios

-

Gestión de usuarios y accesos.

+

+ Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones). +

- - - Usuarios del sistema - Implementa aquí la gestión de usuarios de tu proyecto. - - -

Sección en construcción.

-
-
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + + Dar de alta usuario + Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido. + + +
+ + +
+
+ {#if roles.length === 0} + Primero crea roles en "Roles y permisos". + {:else}{/if} + +
+ {#if inviteUrl} +
+ Si el correo no llega, comparte: + + +
+ {/if} +
+
+ + + + Usuarios ({users.length}) + Asigna o quita roles (los roles se definen en Roles y permisos). + + + {#if loading} +

Cargando…

+ {:else if users.length === 0} +

No hay usuarios en esta compañía todavía.

+ {:else} +
+ + + + + + + + + + + {#each users as u (u.id)} + + + + + + + {/each} + +
UsuarioEmailRolesAsignar rol
{fullName(u)}{u.email ?? '—'} +
+ {#each rolesByUser[String(u.id)] ?? [] as a (a.id)} + + {a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`} + + + {:else} + sin rol + {/each} +
+
+ +
+
+ {/if} +
+
+ {/if}
diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts new file mode 100644 index 0000000..e0533c1 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts @@ -0,0 +1,78 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getKcAccessToken } from '$lib/server/api'; +import { + listWorkspaceTenants, + createWorkspaceTenant, + type CreateTenantInput +} from '$lib/server/workspace-provision'; +import { validateTenantForm } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const accessToken = getKcAccessToken(cookies); + if (!accessToken) { + return { tenants: [], forbidden: true, loadError: null }; + } + + const res = await listWorkspaceTenants(accessToken, fetch); + if (!res.ok) { + // 401/403 → el usuario no es admin del workspace: se muestra estado informativo, + // no un error. Otros status sí se reportan como error de carga. + return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error }; + } + + return { tenants: res.data, forbidden: false, loadError: null }; +}; + +export const actions: Actions = { + create: async ({ request, cookies, fetch }) => { + const accessToken = getKcAccessToken(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const name = String(data.get('name') ?? '').trim(); + const slug = String(data.get('slug') ?? '') + .trim() + .toLowerCase(); + const contact_email = String(data.get('contact_email') ?? '').trim(); + const contact_name = String(data.get('contact_name') ?? '').trim(); + const contact_phone = String(data.get('contact_phone') ?? '').trim(); + const display_name = String(data.get('display_name') ?? '').trim(); + const app_url = String(data.get('app_url') ?? '').trim(); + const is_self_hosted = data.get('is_self_hosted') === 'on'; + + // Valores para repoblar el formulario si algo falla. + const values = { + name, + slug, + contact_email, + contact_name, + contact_phone, + display_name, + app_url, + is_self_hosted + }; + + const validationError = validateTenantForm({ name, slug, contact_email }); + if (validationError) return fail(422, { error: validationError, values }); + + // Solo se envían los campos opcionales con valor, para no mandar strings vacíos. + const payload: CreateTenantInput = { + name, + slug, + contact_email, + is_self_hosted, + ...(contact_name ? { contact_name } : {}), + ...(contact_phone ? { contact_phone } : {}), + ...(display_name ? { display_name } : {}), + ...(app_url ? { app_url } : {}) + }; + + const res = await createWorkspaceTenant(accessToken, fetch, payload); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte new file mode 100644 index 0000000..99c3399 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte @@ -0,0 +1,180 @@ + + +
+
+

+ Organizaciones del workspace +

+

+ Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base. +

+
+ + {#if data.forbidden} + + + +
+

Requiere permisos de administrador del workspace

+

+ El alta de organizaciones solo está disponible para administradores del Hub + (hub_admin). Tu usuario actual no tiene ese rol. +

+
+
+
+ {:else} + {#if data.loadError} +
+ No se pudo consultar el workspace: {data.loadError} +
+ {/if} + + + + + Nueva organización + El slug identifica al tenant; no se puede cambiar después. + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la organización')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + + + + +
+ +
+ +
+
+
+
+ + + + + Organizaciones ({data.tenants.length}) + Tenants activos en el workspace. + + + {#if data.tenants.length === 0} +

+ Aún no hay organizaciones registradas. +

+ {:else} +
+ + + + + + + + + + + + {#each data.tenants as t (t.id)} + + + + + + + + {/each} + +
NombreSlugContactoEstatusLicencia
{t.display_name || t.name}{t.slug}{t.contact_email} + + {t.status} + + {t.has_license ? 'Sí' : '—'}
+
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts new file mode 100644 index 0000000..4488b97 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -0,0 +1,60 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getUserCompanies } from '$lib/server/api'; +import { createWorkspaceInvite } from '$lib/server/workspace-provision'; +import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + // La organización destino sale de las compañías del usuario (mismo origen que + // el switcher, vía /v1/auth/my-companies). Así no se escribe el slug a mano ni + // se depende de listar todos los tenants del Hub con un token KC que caduca. + const companiesRaw = await getUserCompanies(cookies, fetch); + const companies = (companiesRaw ?? []) + .filter((c) => c && c.tenant_slug) + .map((c) => ({ + id: c.id as number, + name: c.name as string, + tenant_slug: c.tenant_slug as string, + tenant_name: (c.tenant_name as string) || (c.tenant_slug as string) + })); + + return { companies, roles: WORKSPACE_INVITE_ROLES }; +}; + +export const actions: Actions = { + invite: async ({ request, cookies, fetch }) => { + const accessToken = getKcAccessToken(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const email = String(data.get('email') ?? '').trim(); + const tenant_slug = String(data.get('tenant_slug') ?? '') + .trim() + .toLowerCase(); + const role = String(data.get('role') ?? 'user').trim(); + + const values = { email, tenant_slug, role }; + + if (!email || !email.includes('@')) { + return fail(422, { error: 'Ingresa un email válido para invitar.', values }); + } + if (!tenant_slug) { + return fail(422, { error: 'Selecciona la compañía destino.', values }); + } + if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) { + return fail(422, { error: 'Rol inválido.', values }); + } + + const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role }); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { + success: true, + email: res.data.email, + inviteUrl: res.data.invite_url, + expiresAt: res.data.expires_at + }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte new file mode 100644 index 0000000..0401ff3 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte @@ -0,0 +1,141 @@ + + +
+
+

+ Alta de usuarios (invitaciones) +

+

+ Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para + fijar su contraseña y activarse. +

+
+ + {#if created} + + +
+ +
+

Invitación enviada a {created.email}

+

+ Si el correo no llega, comparte este enlace directamente: +

+
+ + +
+
+
+
+
+ {/if} + + + + Nueva invitación + Elige la compañía destino y el rol del usuario. + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success('Invitación creada'); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la invitación')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + +
+ +
+ +
+
+
+
+
diff --git a/frontend/src/routes/join/+page.server.ts b/frontend/src/routes/join/+page.server.ts index bd58d8b..db0690f 100644 --- a/frontend/src/routes/join/+page.server.ts +++ b/frontend/src/routes/join/+page.server.ts @@ -1,7 +1,7 @@ import { redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; -import { redirectToKeycloakLogin } from '$lib/server/workspace-auth'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ url, cookies, fetch }) => { const code = url.searchParams.get('code')?.toUpperCase().trim() ?? ''; @@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { if (!accessToken) { // Sesión KC expiró entre redirecciones — volver a auth - redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + redirectToWorkspaceLogin(cookies, url); } const apiUrl = getServerApiUrl(); @@ -76,7 +76,7 @@ export const actions: Actions = { if (!accessToken) { // Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume - redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + redirectToWorkspaceLogin(cookies, url); } // Si ya hay sesión, consumir directamente vía redirect a step=consume diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 66d5d5f..ff64071 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -4,12 +4,9 @@ import type { Actions, PageServerLoad } from './$types'; import { clearAuthTokens, getAuthTokens } from '$lib/server/api'; import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; import { - getWorkspaceLoginUrl, readWorkspaceReturnPath, clearWorkspaceReturnPath, - storeReturnPath, - redirectToKeycloakAuthorization, - redirectToKeycloakLogin, + redirectToWorkspaceLogin, getHubBackendUrl, isSecureContext, } from '$lib/server/workspace-auth'; @@ -65,29 +62,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => { clearAuthTokens(cookies); + // Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo + // OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher + // (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le + // manda al dashboard; si no hay sesión local, el layout lo reenvía al + // Workspace (App Launcher) para re-entrar por relay. if (url.searchParams.get('sso_verified') === '1') { - const existingReturnPath = readWorkspaceReturnPath(cookies, ''); - const intendedPath = - existingReturnPath && existingReturnPath !== '/login' - ? existingReturnPath - : (url.searchParams.get('redirect') || '/dashboard'); - storeReturnPath(cookies, intendedPath); - redirectToKeycloakAuthorization(url.origin, intendedPath); + throw redirect(303, '/dashboard'); } - const redirectParam = url.searchParams.get('redirect'); - if (redirectParam) { - const existingReturnPath = readWorkspaceReturnPath(cookies, ''); - const intendedPath = - existingReturnPath && existingReturnPath !== '/login' - ? existingReturnPath - : redirectParam; - storeReturnPath(cookies, intendedPath); - redirectToKeycloakLogin(url.origin, intendedPath); - } - - storeReturnPath(cookies, '/dashboard'); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); + // Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo. + redirectToWorkspaceLogin(cookies, url); }; export const actions: Actions = { diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 8e692e7..4ccba83 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,37 +1,28 @@ import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import type { RequestHandler } from './$types'; -import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { - buildKeycloakLogoutUrl, - clearWorkspaceReturnPath, - getWorkspaceLoginUrl -} from '$lib/server/workspace-auth'; +import { clearAuthTokens } from '$lib/server/api'; +import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth'; -export const POST: RequestHandler = async ({ cookies, url }) => { - const systemBaseUrl = url.origin; - - const idToken = cookies.get('id_token'); - - // Eliminar todas las cookies de autenticación - clearAccessTokenCookies(cookies); - cookies.delete('refresh_token', { path: '/' }); +/** + * Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace. + * NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace + * desde el Workspace. Patrón SIWEB. + */ +export const POST: RequestHandler = async ({ cookies }) => { + // Eliminar todas las cookies de autenticación (incluye sesión local + token KC) + clearAuthTokens(cookies); cookies.delete('id_token', { path: '/' }); - cookies.delete('active_company_id', { path: '/' }); - cookies.delete('active_system', { path: '/' }); cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); clearWorkspaceReturnPath(cookies); - // En modo local no hay Keycloak ni Hub — ir directo al login local. + // Modo local: no hay Workspace — ir al login local. if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { throw redirect(303, '/login'); } - // Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado. - if (!idToken) { - throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); - } - - throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken)); + // Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por + // completo, el usuario cierra sesión desde el Workspace. + throw redirect(303, getWorkspaceLoginUrl()); };