diff --git a/.gitignore b/.gitignore index 20b031b..9864097 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,8 @@ celerybeat-schedule.* celerybeat.pid backend/api/v1/modules/reports/generated/ docker-compose.override.yml + +SUNRISE/ +# Corredor de la corrida autonoma continua: vive local, no se versiona. +automatizacion/ +docker-compose.dev.yml 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/c5d6e7f8a9b0_crm_carril_efc.py b/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py new file mode 100644 index 0000000..868b9b5 --- /dev/null +++ b/backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py @@ -0,0 +1,180 @@ +"""Carril CRM -> EFC: columnas efc_* en crm.cases + outbox transaccional (dos tablas). + +El expediente NO se crea aquí: ya existe como ``crm.cases`` (ver d4e5f6a7b8c9), con su folio +en ``reference`` y su consecutivo en ``crm.folio_counters``. Esta migración solo le cuelga lo +que el carril hacia EFC necesita y crea el outbox. Es aditiva a propósito: la estructura del +expediente es de quien la definió, nosotros aportamos la conexión. + +Las seis columnas ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle con +el que el CRM habla de un expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es +un caché de la resolución y el ``pedimento_app`` del lado de EFC es mutable —se reescribe al +completar el provisional—, así que apoyarse en él rompería en cuanto llegue la data real. + +``efc_storage_token`` es INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda +los objetos de este expediente. Que no cambie nunca es lo que permite completar el pedimento +sin mover un solo archivo. + +``efc_link_state`` nace en 'PENDING' para las filas que ya existen. Eso es deliberado: al +encender el carril, el barrido de respaldo replica el histórico. Mientras ``EFC_API_URL`` esté +vacía el carril está apagado y no se encola nada. + +Revision ID: c5d6e7f8a9b0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-08-10 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c5d6e7f8a9b0" +down_revision: Union[str, None] = "d4e5f6a7b8c9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ---------- crm.cases: el espejo de EFC ---------- + op.add_column("cases", sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True), schema="crm") + op.add_column("cases", sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True), schema="crm") + op.add_column("cases", sa.Column("efc_storage_token", sa.String(length=25), nullable=True), schema="crm") + op.add_column( + "cases", + # PENDING | LINKED | FAILED + sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")), + schema="crm", + ) + # El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin + # obligar a nadie a ir a los logs del worker. + op.add_column("cases", sa.Column("efc_error_code", sa.String(length=60), nullable=True), schema="crm") + op.add_column("cases", sa.Column("efc_error_detail", sa.Text(), nullable=True), schema="crm") + op.create_index("ix_crm_cases_efc_link_state", "cases", ["efc_link_state"], schema="crm") + + # Relleno del token para los expedientes que ya existían. Sin esto, el barrido de + # reconciliación los encola, EFC los rechaza con + # {'storage_token': ['This field may not be null.']} y agotan sus 8 intentos hasta + # quedar en `failed`: ruido permanente por un dato que se podía derivar. + # + # La condición de longitud replica la guarda de storage_token(): en los 25 caracteres de + # `pedimento_app` caben hasta 6 dígitos de company. Lo que no cabe se queda NULL a + # propósito y el encolado lo salta avisando, porque un token recortado apuntaría a la + # carpeta de otro expediente y mezclaría documentos en silencio. + # + # `reference IS NOT NULL` porque el folio es nullable en crm.cases: un expediente sin + # folio no tiene con qué identificarse ante EFC. + op.execute( + """ + UPDATE crm.cases + SET efc_storage_token = 'CRM-' || company_id::text || '-' || reference + WHERE efc_storage_token IS NULL + AND reference IS NOT NULL + AND length('CRM-' || company_id::text || '-' || reference) <= 25 + """ + ) + + # ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ---------- + op.create_table( + "efc_sync_outbox", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("kind", sa.String(length=20), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("expediente_ref", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("efc_pedimento_id", sa.String(length=36), 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"), + schema="crm", + ) + op.create_index("ix_crm_efc_sync_outbox_status", "efc_sync_outbox", ["status"], schema="crm") + op.create_index("ix_crm_efc_sync_outbox_kind_status", "efc_sync_outbox", ["kind", "status"], schema="crm") + op.create_index("ix_crm_efc_sync_outbox_expediente_ref", "efc_sync_outbox", ["expediente_ref"], schema="crm") + op.create_index("ix_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", ["tenant_id"], schema="crm") + op.create_index("ix_crm_efc_sync_outbox_company_id", "efc_sync_outbox", ["company_id"], schema="crm") + op.create_foreign_key( + "fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", "tenants", + ["tenant_id"], ["id"], source_schema="crm", referent_schema="core", + ) + + # ---------- crm.efc_file_outbox: archivos ---------- + op.create_table( + "efc_file_outbox", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("kind", sa.String(length=30), nullable=False), + sa.Column("s3_key", sa.String(length=1024), nullable=False), + sa.Column("file_name", sa.String(length=255), nullable=False), + sa.Column("content_type", sa.String(length=100), nullable=True), + sa.Column("efc_tipo", sa.String(length=40), nullable=False), + # La pareja (tabla, id) desambigua entre las DOS secuencias de documentos del CRM: + # crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten. + sa.Column("source_table", sa.String(length=30), nullable=False), + sa.Column("source_id", sa.Integer(), nullable=True), + sa.Column("crm_document_ref", sa.String(length=64), nullable=True), + sa.Column("expediente_ref", sa.Integer(), nullable=False), + sa.Column("delete_local", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")), + sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("efc_document_id", sa.String(length=36), 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"), + schema="crm", + ) + op.create_index("ix_crm_efc_file_outbox_status", "efc_file_outbox", ["status"], schema="crm") + op.create_index("ix_crm_efc_file_outbox_kind_status", "efc_file_outbox", ["kind", "status"], schema="crm") + # Índice de la guarda _ya_entregado, que es lo que se consulta en cada encolado. + op.create_index("ix_crm_efc_file_outbox_source", "efc_file_outbox", ["source_table", "source_id"], schema="crm") + op.create_index("ix_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", ["expediente_ref"], schema="crm") + op.create_index("ix_crm_efc_file_outbox_tenant_id", "efc_file_outbox", ["tenant_id"], schema="crm") + op.create_index("ix_crm_efc_file_outbox_company_id", "efc_file_outbox", ["company_id"], schema="crm") + op.create_foreign_key( + "fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", "tenants", + ["tenant_id"], ["id"], source_schema="crm", referent_schema="core", + ) + # expediente_ref -> crm.cases.id. El nombre de la columna conserva el término del dominio: + # crm.cases ES el expediente (así lo nombra su propio docstring), y el carril, EFC y el + # ticket hablan de expedientes. Renombrarlo a case_ref solo movería la ambigüedad de sitio. + op.create_foreign_key( + "fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "cases", + ["expediente_ref"], ["id"], source_schema="crm", referent_schema="crm", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", schema="crm", type_="foreignkey") + op.drop_constraint("fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", schema="crm", type_="foreignkey") + op.drop_index("ix_crm_efc_file_outbox_company_id", table_name="efc_file_outbox", schema="crm") + op.drop_index("ix_crm_efc_file_outbox_tenant_id", table_name="efc_file_outbox", schema="crm") + op.drop_index("ix_crm_efc_file_outbox_expediente_ref", table_name="efc_file_outbox", schema="crm") + op.drop_index("ix_crm_efc_file_outbox_source", table_name="efc_file_outbox", schema="crm") + op.drop_index("ix_crm_efc_file_outbox_kind_status", table_name="efc_file_outbox", schema="crm") + op.drop_index("ix_crm_efc_file_outbox_status", table_name="efc_file_outbox", schema="crm") + op.drop_table("efc_file_outbox", schema="crm") + + op.drop_constraint("fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", schema="crm", type_="foreignkey") + op.drop_index("ix_crm_efc_sync_outbox_company_id", table_name="efc_sync_outbox", schema="crm") + op.drop_index("ix_crm_efc_sync_outbox_tenant_id", table_name="efc_sync_outbox", schema="crm") + op.drop_index("ix_crm_efc_sync_outbox_expediente_ref", table_name="efc_sync_outbox", schema="crm") + op.drop_index("ix_crm_efc_sync_outbox_kind_status", table_name="efc_sync_outbox", schema="crm") + op.drop_index("ix_crm_efc_sync_outbox_status", table_name="efc_sync_outbox", schema="crm") + op.drop_table("efc_sync_outbox", schema="crm") + + op.drop_index("ix_crm_cases_efc_link_state", table_name="cases", schema="crm") + op.drop_column("cases", "efc_error_detail", schema="crm") + op.drop_column("cases", "efc_error_code", schema="crm") + op.drop_column("cases", "efc_link_state", schema="crm") + op.drop_column("cases", "efc_storage_token", schema="crm") + op.drop_column("cases", "efc_pedimento_id", schema="crm") + op.drop_column("cases", "efc_organizacion_id", schema="crm") 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..b46d319 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 al integrar main: esta rama y la del CRM habían salido las dos de +d5e6f7a8b9c0, y con dos cabezas ``alembic upgrade head`` falla. La historia queda lineal, +con las migraciones de facturación detrás de las del CRM. + Revision ID: f7a8b9c0d1e2 -Revises: e6f7a8b9c0d1 +Revises: g1h2i3j4k5l6 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] = "g1h2i3j4k5l6" 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/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py b/backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py similarity index 97% rename from backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py rename to backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py index edeac6f..29fdcce 100644 --- a/backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py +++ b/backend/alembic/versions/g1h2i3j4k5l6_fin_sat_catalogs_concepts_issuer.py @@ -1,8 +1,13 @@ """Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor y amarre de facturas y partidas a los catálogos. -Revision ID: e6f7a8b9c0d1 -Revises: d5e6f7a8b9c0 +Renumerada al integrar main: nació como e6f7a8b9c0d1, el mismo identificador que la +migración de catálogos del CRM, porque ambas ramas salieron de d5e6f7a8b9c0 sin verse. Se +renumera ésta y no la del CRM porque aquélla ya está en main y hay otra migración que la +referencia por id. + +Revision ID: g1h2i3j4k5l6 +Revises: c5d6e7f8a9b0 Create Date: 2026-08-07 00:00:00.000000 """ @@ -13,8 +18,8 @@ from alembic import op from api.v1.modules.fin.catalogs.seed_data import sync_catalogs -revision: str = "e6f7a8b9c0d1" -down_revision: Union[str, None] = "d5e6f7a8b9c0" +revision: str = "g1h2i3j4k5l6" +down_revision: Union[str, None] = "c5d6e7f8a9b0" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None 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/permissions/service.py b/backend/api/v1/modules/core/permissions/service.py index b31c8d0..256aeb1 100644 --- a/backend/api/v1/modules/core/permissions/service.py +++ b/backend/api/v1/modules/core/permissions/service.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Set, Optional, List from sqlalchemy.orm import Session -from sqlalchemy import and_, or_ +from sqlalchemy import and_, or_, text from core.database import RLS_TENANT_KEY from .cache import PermissionCache @@ -51,8 +51,18 @@ class PermissionService: return None try: - # Sin modelo de compañía en la plantilla — implementa la consulta aquí. - pass + # La tabla de compañías del CRM es ``a76.company`` (esquema legado). No tiene modelo + # ORM en este proyecto, así que se consulta con SQL crudo, igual que ``seed_crm.py``. + # + # Sin este respaldo la función devolvía None siempre que la sesión no traía contexto + # RLS, y con ella se scopean las consultas de permisos de los cinco llamadores de + # abajo: el efecto neto era que nadie resolvía permisos. + row = self.db.execute( + text("SELECT tenant_id FROM a76.company WHERE id = :company_id"), + {"company_id": company_id}, + ).first() + if row is not None and row[0] is not None: + return int(row[0]) except Exception as exc: logger.warning( "resolve_tenant_id_for_company_failed", @@ -470,8 +480,35 @@ class PermissionService: sync_res = self.sync_permissions() logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.") - # 1. Obtener el tenant_id (implementa con tu modelo de compañía) - tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1 + # 1. tenant_id del rol: se toma de la COMPAÑÍA, que es su fuente autoritativa. + # ``company_roles`` referencia a la vez a ``a76.company`` y a ``core.tenants``, así + # que el tenant del rol tiene que ser el de su compañía o la fila queda cruzada + # entre dos tenants. + # + # Antes esto era ``self.db.info.get(RLS_TENANT_KEY) or 1``. Cuando la sesión no + # traía contexto RLS —justo el caso de ``/permissions/me`` en el primer acceso— el + # rol se creaba con ``tenant_id=1``; en una instalación real ese tenant no existe y + # el INSERT moría con ForeignKeyViolation. El bootstrap quedaba a medias, sin rol + # ni permisos, toda la API respondía 403 y ``/permissions/me`` seguía devolviendo + # 200 con la lista vacía: el fallo se leía en pantalla como "no tienes permisos" + # en lugar de como el error de configuración que era. + # Se consulta la compañía DIRECTAMENTE y no vía ``_resolve_tenant_id_for_company``: + # ese helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser + # el de la compañía. Para leer permisos esa preferencia está bien y ahorra una + # consulta en el camino caliente; para escribir una fila atada por FK a las dos + # tablas, no: si difirieran, el rol nacería cruzado. + _row = self.db.execute( + text("SELECT tenant_id FROM a76.company WHERE id = :company_id"), + {"company_id": company_id}, + ).first() + if _row is None or _row[0] is None: + logger.error( + "Bootstrap: la compañía %s no existe en a76.company, no hay tenant al que " + "colgar el rol. Se aborta sin crear nada.", + company_id, + ) + return False + tenant_id = int(_row[0]) # 2. Buscar si ya existe el rol "super_admin" admin_role = self.db.query(CompanyRole).filter( 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..f5b4981 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/models.py @@ -0,0 +1,52 @@ +from sqlalchemy import ForeignKey, Integer, 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 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) + + # ── Espejo del carril hacia EFC (T2026-08-046) ────────────────────────────────────────── + # EFC es la fuente única de los documentos del expediente: cada expediente se refleja allá + # como un *pedimento provisional* y los archivos viven en su MinIO, no en el del CRM. + # + # Estas columnas son un ESPEJO, nunca el handle. El handle con el que el CRM habla de este + # expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es un caché de la + # resolución, y del lado de EFC el ``pedimento_app`` es mutable —se reescribe al completar + # el provisional con la data aduanera real—, así que apoyarse en él rompería justo cuando + # llegue esa data. La liga vive en EFC, en la tabla desechable ``pedimento_expediente``. + efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + # INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los objetos de este + # expediente. Que no cambie nunca es lo que permite completar el pedimento sin mover ni un + # archivo. + efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True) + # PENDING | LINKED | FAILED + efc_link_state: Mapped[str] = mapped_column( + String(20), nullable=False, server_default=text("'PENDING'"), index=True + ) + # El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin + # obligar a nadie a ir a los logs del worker. + efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True) + efc_error_detail: Mapped[str | None] = mapped_column(Text, 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..db76cc3 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/service.py @@ -0,0 +1,130 @@ +"""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() + + # ── Carril hacia EFC (T2026-08-046) ────────────────────────────────────────────────── + # EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento + # provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es + # precisamente la llave con la que las dos mitades se reconocen. + # + # El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO + # del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la + # data aduanera real sin mover un solo archivo. + # + # Todo es best-effort y va en la MISMA transacción que el expediente: + # - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM; + # - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat + # recoge lo pendiente. Un sistema de terceros caído no puede romper un alta. + # El import es diferido para no acoplar el arranque del módulo del expediente al carril. + from ..expediente_gateway import service as gateway + from ..expediente_gateway.storage import storage_token + + case.efc_storage_token = storage_token(company_id, case.reference) + db.flush() + gateway.replicate_expediente_best_effort(db, case) + 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/expediente_gateway/__init__.py b/backend/api/v1/modules/crm/expediente_gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/expediente_gateway/doc_types.py b/backend/api/v1/modules/crm/expediente_gateway/doc_types.py new file mode 100644 index 0000000..5c1e2cd --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/doc_types.py @@ -0,0 +1,64 @@ +"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM. + +Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en +``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con +despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un +tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción. + +Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el +tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` / +``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript +(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo +**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo. + +Las tres fuentes del CRM y su origen: + +- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts`` +- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo +- ``fin.invoices`` → el PDF de factura (``factura_venta``) + +``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada. +""" + +# --- crm.documents --------------------------------------------------------------------------- +_TIPOS_DOCUMENTOS_CLIENTE = ( + "constancia_fiscal", + "acta_constitutiva", + "identificacion", + "comprobante_domicilio", + "contrato", + "presentacion", + "certificacion", + "licencia", + "convenio", + "tarifario", +) + +# --- ops.shipment_documents ------------------------------------------------------------------ +_TIPOS_DOCUMENTOS_EMBARQUE = ( + "MBL", + "HBL", + "MAWB", + "HAWB", + "CMR", + "factura_comercial", + "packing_list", + "carta_encomienda", + "carta_garantia", + "certificado_permiso", +) + +# --- fin.invoices ---------------------------------------------------------------------------- +_TIPOS_FACTURACION = ("factura_venta",) + +# --- común a varias fuentes ------------------------------------------------------------------- +_TIPOS_COMUNES = ("otro",) + +EFC_DOC_TYPES: frozenset[str] = frozenset( + _TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES +) + + +def is_valid_doc_type(doc_type: str | None) -> bool: + """``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red.""" + return bool(doc_type) and doc_type in EFC_DOC_TYPES diff --git a/backend/api/v1/modules/crm/expediente_gateway/models.py b/backend/api/v1/modules/crm/expediente_gateway/models.py new file mode 100644 index 0000000..bf6fa15 --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/models.py @@ -0,0 +1,131 @@ +"""Outbox transaccional del carril CRM Agentes de Carga -> EFC. + +DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los +expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian +por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos. + +**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción +que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra +base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de +huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier +crash— pero deja de ser el parche de una ventana estructural. +""" +from datetime import datetime +from typing import Optional + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES. +KIND_EXPEDIENTE = "expediente" +KIND_COMPLETAR = "completar" + +# Tipos del outbox de ARCHIVOS (efc_file_outbox). +FILE_KIND_DOCUMENTO = "documento" + +# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias +# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y +# ops.shipment_documents.id = 5 coexisten. +SOURCE_CRM_DOCUMENTS = "crm.documents" +SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents" +SOURCE_FIN_INVOICES = "fin.invoices" + +# Estados (columna status). +STATUS_PENDING = "pending" +STATUS_SENT = "sent" +STATUS_FAILED = "failed" + +# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual). +# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de +# rendirse y dejar la fila visible para que una persona la reintente a mano. +MAX_ATTEMPTS = 8 + + +class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin): + """Cola de metadatos hacia EFC: crear el expediente provisional y completarlo.""" + + __tablename__ = "efc_sync_outbox" + __table_args__ = ( + Index("ix_crm_efc_sync_outbox_status", "status"), + Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"), + {"schema": "crm"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + kind: Mapped[str] = mapped_column(String(20), nullable=False) + + # Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data + # aduanera si el kind es 'completar'). + payload: Mapped[dict] = mapped_column(JSON, nullable=False) + + # id local del expediente (crm.cases.id) que originó la fila. + expediente_ref: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + + # Ciclo de vida. + status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'")) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Acuse de EFC al confirmar (trazabilidad). + efc_pedimento_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True) + + +class EfcFileOutbox(Base, TenantScopedMixin, TimestampMixin): + """Cola de ARCHIVOS hacia EFC. + + El binario vive en el MinIO del CRM (durable); esta fila referencia su ``s3_key`` y el expediente + destino. El worker lo sube a EFC y, con ``delete_local`` (corte directo), BORRA la copia local al + confirmar la entrega. + + ``delete_local`` **es el mecanismo de «EFC es la fuente única»**: "solo EFC" es el estado FINAL + (eventual), no el inmediato. Entre que el usuario sube el archivo y que EFC lo confirma, la copia + local es lo único que hay, y borrarla antes perdería el archivo si la entrega fallara. + + ``source_table`` es un añadido necesario sobre el original de Anexo22, que solo llevaba + ``source_id``. El CRM tiene dos tablas de documentos con secuencias independientes, así que un + entero solo es ambiguo entre ellas. Es el mismo problema que Anexo22 resolvió con su mapa por + ``kind``, y su comentario dice qué pasa si se ignora: un UPDATE con el id de otra tabla **vacía la + columna de un documento ajeno** que tuviera ese mismo entero — daño en el dato de otro, sin un + solo error visible. Un ``(kind, source_table)`` que no esté en el mapa **no toca nada**, en vez + de caer por omisión. + """ + + __tablename__ = "efc_file_outbox" + __table_args__ = ( + Index("ix_crm_efc_file_outbox_status", "status"), + Index("ix_crm_efc_file_outbox_kind_status", "kind", "status"), + Index("ix_crm_efc_file_outbox_source", "source_table", "source_id"), + {"schema": "crm"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + kind: Mapped[str] = mapped_column(String(30), nullable=False) + + # Objeto en MinIO a subir + metadata para el upload a EFC. + s3_key: Mapped[str] = mapped_column(String(1024), nullable=False) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + efc_tipo: Mapped[str] = mapped_column(String(40), nullable=False) # tipo de documento en EFC + + # Origen: la pareja (tabla, id) desambigua entre las dos secuencias de documentos del CRM. + source_table: Mapped[str] = mapped_column(String(30), nullable=False) + source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + # El handle autoritativo que viaja a EFC y garantiza la idempotencia del lado de allá. + crm_document_ref: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + + expediente_ref: Mapped[int] = mapped_column( + Integer, ForeignKey("crm.cases.id"), nullable=False, index=True + ) + delete_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + # Ciclo de vida. + status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'")) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + efc_document_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True) diff --git a/backend/api/v1/modules/crm/expediente_gateway/routes.py b/backend/api/v1/modules/crm/expediente_gateway/routes.py new file mode 100644 index 0000000..7ac4bd5 --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/routes.py @@ -0,0 +1,58 @@ +"""Endpoints de operación y observabilidad del carril CRM -> EFC. + +Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con +el auth normal del CRM y acotado por tenant/company, como el resto del módulo. +Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...`` +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from . import service + +router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"]) + + +@router.get("/outbox") +def list_outbox( + company_id: int = Query(..., description="Company ID"), + tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"), + status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"), + limit: int = Query(100, ge=1, le=500), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Filas de los dos outbox, para ver los fallos y su ``last_error``.""" + return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit) + + +@router.post("/outbox/{outbox_id}/retry") +def retry_outbox( + outbox_id: int, + company_id: int = Query(..., description="Company ID"), + tipo: str = Query("file", description="Tabla de la fila: sync|file"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Reintento manual de una fila: la resetea a ``pending`` y la re-despacha. + + Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend + pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a + la cola cuando no hay nada que entregar. + """ + ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo) + if not ok: + raise HTTPException(status_code=404, detail="Fila de outbox no encontrada") + return {"status": "requeued", "id": outbox_id} + + +@router.get("/metrics") +def metrics( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Conteo de los dos outbox por status (pending/sent/failed) para monitoreo.""" + return service.outbox_metrics(db, current_user["tenant_id"], company_id) diff --git a/backend/api/v1/modules/crm/expediente_gateway/service.py b/backend/api/v1/modules/crm/expediente_gateway/service.py new file mode 100644 index 0000000..e3b8b93 --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/service.py @@ -0,0 +1,755 @@ +"""Carril CRM Agentes de Carga -> EFC: encolado, entrega y reconciliación. + +Clon del gateway de Anexo22 (``anexo22/.../pedimentos/pedimento_gateway/service.py``), que es el +carril de referencia ya en producción. Quien conozca uno debe poder leer el otro, así que la tabla +de equivalencias va aquí: + +====================================== ====================================== +Anexo22 CRM +====================================== ====================================== +``replicate_pedimento_best_effort`` ``replicate_expediente_best_effort`` +``_enqueue_pedimento_outbox`` ``_enqueue_expediente_outbox`` +``_dispatch_delivery`` igual +``deliver_row`` / ``_deliver_pedimento`` ``deliver_row`` / ``_deliver_expediente`` +``_register_failure`` **idéntico** +``_ya_entregado(source_id, kind)`` ``_ya_entregado(source_table, source_id, kind)`` +``deliver_file_row`` **idéntico**, con ensure-then-upload y ``delete_local`` +``_register_file_failure`` **idéntico** +``_resolve_org_id`` + ``_org_id_cache`` igual — dict módulo-global, por worker, sin invalidación +``list_outbox`` / ``retry_outbox_row`` / ``outbox_metrics`` igual, para las dos tablas +``find_pedimento_gaps`` ``find_expediente_gaps`` +====================================== ====================================== + +**La máquina de reintentos tiene tres capas y las tres se conservan:** + +1. En el cliente HTTP: 3 intentos, backoff lineal ``0.15 * (attempt + 1)``, corte seco en 4xx. +2. En el worker: ``deliver_row`` **nunca lanza**; registra el fallo en la propia fila. +3. En el beat: barridos cada 120 s que re-despachan lo ``pending``. + +No hay ``autoretry_for``, ``retry_backoff`` ni ``max_retries`` en las tareas: duplicarían el +mecanismo que ya está en el cliente y en el barrido. + +**Cuatro guardas de idempotencia**, en este orden: +1. ``_ya_entregado(source_table, source_id, kind)`` antes de encolar. +2. ``if row.status == STATUS_SENT: return`` al entrar a entregar. +3. El ``crm_document_ref`` que viaja con la subida: EFC devuelve 200 con el que ya existía. +4. El UNIQUE parcial del lado de EFC — la única que garantiza la base. + +**Por qué ``find_expediente_gaps`` sigue aquí aunque el CRM sea mono-base.** En Anexo22 el outbox se +commitea aparte del pedimento (dos bases distintas) y ese doble-commit es lo que obligó a inventar el +barrido de huecos. Aquí la fila del outbox va en la MISMA transacción que el expediente, así que esa +ventana no existe. El barrido se conserva porque cubre otras dos cosas: los expedientes creados +**antes** de activar la integración, y cualquier crash. Queda escrito para que el siguiente que lo +lea no lo borre creyendo que es redundante. +""" +import logging +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.orm import Session + +from core.config import settings +from core.database import scoped_core_db +from core.efc_client import EfcClient, EfcClientError, efc_client + +from ..cases.models import Case +from .models import ( + KIND_COMPLETAR, + KIND_EXPEDIENTE, + MAX_ATTEMPTS, + STATUS_FAILED, + STATUS_PENDING, + STATUS_SENT, + EfcFileOutbox, + EfcSyncOutbox, +) + +logger = logging.getLogger(__name__) + +# Cache de organización EFC por slug de tenant. Dict módulo-global: vive por worker y NO se +# invalida, igual que el del carril de Anexo22. Es correcto porque la organización de un tenant no +# cambia de id: el resolver de EFC es idempotente y devuelve siempre la misma. Si algún día pudiera +# cambiar, reiniciar el worker la vuelve a resolver. +_org_id_cache: dict[str, str] = {} + + +@contextmanager +def _savepoint(db: Session): + """Aísla un encolado dentro de la transacción del usuario con un SAVEPOINT. + + **Esto es lo único del encolado que NO se clona del carril de Anexo22, y la razón es de fondo.** + Allá el outbox vive en otra base que el pedimento, así que su ``except`` podía hacer + ``db.rollback()`` sin consecuencias: revertía la sesión del outbox y la del pedimento ni se + enteraba. + + Aquí el CRM es mono-base y el encolado corre DENTRO de la transacción del usuario. Un + ``db.rollback()`` en el ``except`` se llevaría por delante la solicitud y el expediente que el + usuario acaba de crear — exactamente lo contrario de best-effort, y sin un solo error visible + para él. Con el SAVEPOINT, un fallo del encolado deshace **solo** la fila del outbox y la + operación local sigue en pie para que el llamador la commitee. + """ + nested = db.begin_nested() + try: + yield nested + except Exception: + nested.rollback() + raise + + +# ══ Expediente: encolado y entrega ══════════════════════════════════════════ + +def replicate_expediente_best_effort(db: Session, expediente: Case) -> None: + """Encola la réplica del expediente a EFC y dispara la entrega inmediata. + + Best-effort en todo: si EFC no está configurado, o si el encolado o el despacho fallan, **no se + propaga el error**. El expediente local ya existe y la operación del usuario no se puede romper + porque un sistema de terceros no conteste. El barrido periódico recoge lo que quede pendiente. + """ + if not settings.EFC_API_URL: + return + row = _enqueue_expediente_outbox(db, expediente) + if row is None: + return + _dispatch_delivery(row.id, row.tenant_id, row.company_id) + + +def _enqueue_expediente_outbox(db: Session, expediente: Case) -> Optional[EfcSyncOutbox]: + """Inserta la fila de outbox del expediente. Devuelve ``None`` si falla, sin romper nada. + + A diferencia del original, **no commitea**: el CRM es mono-base, así que la fila viaja en la + misma transacción que el expediente. Eso cierra de raíz la ventana del doble-commit que en + Anexo22 obligó a inventar el barrido de huecos. + """ + try: + if _expediente_ya_encolado(db, expediente.id): + return None + # Sin folio o sin token no hay nada que replicar: EFC exige los dos y responde + # {'storage_token': ['This field may not be null.']}, que NO es un fallo transitorio. + # Encolarlo de todos modos quemaría los 8 intentos para acabar en `failed`, ensuciando + # el tablero de ops con algo que ningún reintento puede arreglar. + # + # Pasa de verdad en dos casos: expedientes nacidos antes de que existiera el carril + # (los rellena la migración c5d6e7f8a9b0) y aquellos cuyo token no cabe en los 25 + # caracteres de `pedimento_app`. Se avisa en WARNING porque es una omisión silenciosa: + # el expediente vive en el CRM y sus documentos nunca llegarán a EFC. + if not expediente.reference or not expediente.efc_storage_token: + logger.warning( + "expediente_gateway: expediente id=%s SIN replicar — folio=%r token=%r. " + "No se encola: EFC rechaza ambos nulos y el reintento no lo arregla.", + expediente.id, expediente.reference, expediente.efc_storage_token, + ) + return None + # El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría + # una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que + # es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia. + payload = { + "source": "crm", + "crm_company_id": expediente.company_id, + "crm_expediente_id": expediente.id, + "folio": expediente.reference, + "storage_token": expediente.efc_storage_token, + } + row = EfcSyncOutbox( + kind=KIND_EXPEDIENTE, + payload=payload, + expediente_ref=expediente.id, + status=STATUS_PENDING, + tenant_id=expediente.tenant_id, + company_id=expediente.company_id, + ) + with _savepoint(db): + db.add(row) + db.flush() + return row + except Exception: + logger.warning( + "expediente_gateway: no se pudo encolar el expediente id=%s en el outbox", + getattr(expediente, "id", None), exc_info=True, + ) + return None + + +def _expediente_ya_encolado(db: Session, expediente_id: int) -> bool: + """¿Ya hay una fila viva de alta para este expediente? Evita encolar la misma réplica dos veces.""" + return ( + db.query(EfcSyncOutbox.id) + .filter( + EfcSyncOutbox.expediente_ref == expediente_id, + EfcSyncOutbox.kind == KIND_EXPEDIENTE, + EfcSyncOutbox.status.in_((STATUS_PENDING, STATUS_SENT)), + ) + .first() + is not None + ) + + +def enqueue_completar_best_effort(db: Session, expediente: Case, campos: dict) -> None: + """Encola el completado del provisional en EFC con la data aduanera real.""" + if not settings.EFC_API_URL: + return + try: + row = EfcSyncOutbox( + kind=KIND_COMPLETAR, + payload={ + "source": "crm", + "crm_company_id": expediente.company_id, + "crm_expediente_id": expediente.id, + "folio": expediente.reference, + "pedimento": campos, + }, + expediente_ref=expediente.id, + status=STATUS_PENDING, + tenant_id=expediente.tenant_id, + company_id=expediente.company_id, + ) + with _savepoint(db): + db.add(row) + db.flush() + except Exception: + logger.warning( + "expediente_gateway: no se pudo encolar el completado del expediente id=%s", + getattr(expediente, "id", None), exc_info=True, + ) + return + _dispatch_delivery(row.id, row.tenant_id, row.company_id) + + +def _dispatch_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None: + """Dispara la tarea de entrega propagando el contexto RLS por headers de Celery. + + Best-effort: si el broker no responde, el barrido la recoge. Los headers son obligatorios — + ``core/celery_app.py`` materializa el contexto de RLS a partir de ellos, y sin ellos la tarea + corre sin tenant y no ve nada. + """ + try: + from .tasks import deliver_outbox_row # import diferido: evita ciclo con celery_app + deliver_outbox_row.apply_async( + args=[outbox_id, tenant_id, company_id], + headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)}, + ) + except Exception: + logger.warning( + "expediente_gateway: no se pudo despachar la entrega outbox_id=%s (lo tomará el sweep)", + outbox_id, exc_info=True, + ) + + +def deliver_row(db: Session, row: EfcSyncOutbox, client: Optional[EfcClient] = None) -> None: + """Entrega una fila del outbox de expedientes a EFC. Actualiza estado y ``attempts``. + + **No lanza nunca**: los fallos se registran en la propia fila para reconciliación. Un fallo no + puede matar al worker ni perder la intención de entregar. + """ + client = client or efc_client + if not client.is_configured: + logger.info("expediente_gateway: EFC no configurado; se deja pendiente row=%s", row.id) + return + if row.status == STATUS_SENT: + return + try: + if row.kind == KIND_EXPEDIENTE: + _deliver_expediente(db, row, client) + elif row.kind == KIND_COMPLETAR: + _deliver_completar(db, row, client) + else: + row.status = STATUS_FAILED + row.last_error = f"kind desconocido: {row.kind}" + db.commit() + except EfcClientError as exc: + _register_failure(db, row, exc, retryable=exc.retryable) + except Exception as exc: # noqa: BLE001 — cualquier fallo se registra, no rompe el worker + _register_failure(db, row, exc, retryable=True) + + +def _register_failure(db: Session, row: EfcSyncOutbox, exc: Exception, retryable: bool) -> None: + row.attempts = (row.attempts or 0) + 1 + row.last_error = str(exc)[:2000] + if (not retryable) or row.attempts >= MAX_ATTEMPTS: + row.status = STATUS_FAILED + db.commit() + logger.warning( + "expediente_gateway: entrega falló row=%s attempts=%s retryable=%s status=%s: %s", + row.id, row.attempts, retryable, row.status, exc, + ) + + +def _deliver_expediente(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None: + payload = dict(row.payload or {}) + org_id = _resolve_org_id(client, row.tenant_id) + payload["organizacion"] = {"efc_organizacion_id": org_id} + payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or "" + resp = client.ingest_expediente(payload) + efc = (resp or {}).get("efc") or {} + + row.status = STATUS_SENT + row.sent_at = datetime.now(timezone.utc) + row.efc_pedimento_id = efc.get("pedimento_id") + _stamp_expediente_link(db, row.expediente_ref, org_id, efc.get("pedimento_id")) + db.commit() + logger.info( + "expediente_gateway: expediente replicado row=%s efc_pedimento_id=%s", + row.id, row.efc_pedimento_id, + ) + + +def _deliver_completar(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None: + payload = dict(row.payload or {}) + org_id = _resolve_org_id(client, row.tenant_id) + payload["organizacion"] = {"efc_organizacion_id": org_id} + payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or "" + folio = payload.get("folio") + client.completar_expediente(folio, payload) + row.status = STATUS_SENT + row.sent_at = datetime.now(timezone.utc) + db.commit() + logger.info("expediente_gateway: expediente completado en EFC row=%s folio=%s", row.id, folio) + + +def _stamp_expediente_link(db: Session, expediente_id: Optional[int], org_id: str, + pedimento_id: Optional[str]) -> None: + """Refleja en la fila del expediente que EFC ya lo tiene, para que la UI lo pinte. + + Es un espejo, no un handle: el CRM sigue hablando de este expediente por su ``folio``. Se guarda + porque el proxy de descarga necesita el ``organizacion_id`` para preguntarle a EFC. + """ + if expediente_id is None: + return + expediente = db.query(Case).filter(Case.id == expediente_id).first() + if expediente is None: + return + expediente.efc_organizacion_id = org_id + if pedimento_id: + expediente.efc_pedimento_id = pedimento_id + expediente.efc_link_state = "LINKED" + expediente.efc_error_code = None + expediente.efc_error_detail = None + + +# ══ Archivos: encolado y entrega ════════════════════════════════════════════ + +def _ya_entregado(db: Session, source_table: str, source_id: int, kind: str) -> bool: + """¿Este archivo ya se entregó al expediente? Evita re-encolar lo que ya está allá. + + Sin esta guarda, un reintento encolaba otra entrega del mismo archivo — que además **falla al + leer el objeto local, porque la primera entrega ya lo borró** con ``delete_local``. Ruido en el + log y una fila del outbox condenada a ``failed``. + + Lleva ``source_table`` además de ``source_id``, a diferencia del original: el CRM tiene dos + tablas de documentos con secuencias independientes, así que el id solo es ambiguo y esta guarda + se dispararía de más, saltándose la entrega de un documento distinto que casualmente comparte + entero. + """ + return ( + db.query(EfcFileOutbox.id) + .filter( + EfcFileOutbox.source_table == source_table, + EfcFileOutbox.source_id == source_id, + EfcFileOutbox.kind == kind, + EfcFileOutbox.status == STATUS_SENT, + ) + .first() + is not None + ) + + +def enqueue_file_best_effort( + db: Session, + *, + kind: str, + s3_key: str, + file_name: str, + content_type: Optional[str], + efc_tipo: str, + source_table: str, + source_id: int, + crm_document_ref: str, + expediente_ref: int, + tenant_id: int, + company_id: int, + delete_local: bool = True, +) -> Optional[EfcFileOutbox]: + """Encola un archivo hacia el expediente de EFC. Devuelve la fila, o ``None`` si no se encoló. + + **No commitea**: la fila va en la misma transacción que el documento que la origina, de modo que + no puede existir un documento sin su intención de entrega ni al revés. + """ + if not settings.EFC_API_URL: + return None + if _ya_entregado(db, source_table, source_id, kind): + return None + try: + row = EfcFileOutbox( + kind=kind, + s3_key=s3_key, + file_name=file_name, + content_type=content_type, + efc_tipo=efc_tipo, + source_table=source_table, + source_id=source_id, + crm_document_ref=crm_document_ref, + expediente_ref=expediente_ref, + delete_local=delete_local, + status=STATUS_PENDING, + tenant_id=tenant_id, + company_id=company_id, + ) + with _savepoint(db): + db.add(row) + db.flush() + return row + except Exception: + logger.warning( + "expediente_gateway: no se pudo encolar el archivo %s (%s:%s)", + s3_key, source_table, source_id, exc_info=True, + ) + return None + + +def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None: + try: + from .tasks import deliver_file_outbox_row # import diferido + deliver_file_outbox_row.apply_async( + args=[outbox_id, tenant_id, company_id], + headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)}, + ) + except Exception: + logger.warning( + "expediente_gateway: no se pudo despachar entrega de archivo outbox_id=%s (lo tomará el sweep)", + outbox_id, exc_info=True, + ) + + +def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None: + """Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia. + + **Ensure-then-upload**: si EFC contesta 404 ``expediente_no_encontrado``, la creación del + provisional puede venir en camino (el outbox de expedientes y el de archivos son colas + distintas), así que se asegura el expediente y se reintenta el upload **una** vez. + + **No lanza nunca**: como ``deliver_row``, registra el fallo en la propia fila. + """ + client = client or efc_client + if not client.is_configured or row.status == STATUS_SENT: + return + try: + org_id = _resolve_org_id(client, row.tenant_id) + expediente = db.query(Case).filter(Case.id == row.expediente_ref).first() + if expediente is None: + raise EfcClientError( + f"expediente {row.expediente_ref} no encontrado para el archivo '{row.kind}'", + retryable=True, + ) + + from core.storage_s3 import get_object_bytes + content = get_object_bytes(row.s3_key) + ct = row.content_type or "application/octet-stream" + + try: + resp = client.upload_documento( + org_id, row.company_id, expediente.id, row.efc_tipo, + row.file_name, content, ct, crm_document_ref=row.crm_document_ref, + ) + except EfcClientError as exc: + if exc.status_code == 404 and exc.code == "expediente_no_encontrado": + # La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez. + client.ingest_expediente({ + "source": "crm", + "crm_tenant_slug": (_tenant_slug(row.tenant_id)[0] or ""), + "crm_company_id": row.company_id, + "crm_expediente_id": expediente.id, + "folio": expediente.reference, + "storage_token": expediente.efc_storage_token, + "organizacion": {"efc_organizacion_id": org_id}, + }) + resp = client.upload_documento( + org_id, row.company_id, expediente.id, row.efc_tipo, + row.file_name, content, ct, crm_document_ref=row.crm_document_ref, + ) + else: + raise + + doc_id = resp.get("id") if isinstance(resp, dict) else None + + if row.delete_local: + try: + from core.storage_s3 import delete_object_if_exists + delete_object_if_exists(row.s3_key) + except Exception: + # Ya está en EFC: no poder borrar la copia local no invalida la entrega. + logger.warning( + "expediente_gateway: no se pudo borrar el archivo local %s (ya en EFC)", + row.s3_key, exc_info=True, + ) + + row.status = STATUS_SENT + row.sent_at = datetime.now(timezone.utc) + row.efc_document_id = doc_id + db.commit() + _marcar_documento_entregado(db, row, doc_id) + logger.info( + "expediente_gateway: archivo entregado row=%s kind=%s efc_document_id=%s", + row.id, row.kind, doc_id, + ) + except EfcClientError as exc: + _register_file_failure(db, row, exc, exc.retryable) + except Exception as exc: # noqa: BLE001 + _register_file_failure(db, row, exc, True) + + +def _register_file_failure(db: Session, row: EfcFileOutbox, exc: Exception, retryable: bool) -> None: + row.attempts = (row.attempts or 0) + 1 + row.last_error = str(exc)[:2000] + if (not retryable) or row.attempts >= MAX_ATTEMPTS: + row.status = STATUS_FAILED + db.commit() + _marcar_documento_fallido(db, row, exc) + logger.warning( + "expediente_gateway: entrega de archivo falló row=%s attempts=%s status=%s: %s", + row.id, row.attempts, row.status, exc, + ) + + +# El mapa (kind, source_table) -> modelo del documento de origen. Un par que NO esté aquí **no toca +# nada**, en vez de caer por omisión sobre una tabla cualquiera: escribir con el id de otra tabla +# vaciaría las columnas de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro, +# sin un solo error visible. +def _modelo_de_origen(source_table: str): + if source_table == "crm.documents": + from ..documents.models import Document + return Document + if source_table == "ops.shipment_documents": + from api.v1.modules.ops.shipments.models import ShipmentDocument + return ShipmentDocument + return None + + +def _fila_de_origen(db: Session, row: EfcFileOutbox): + modelo = _modelo_de_origen(row.source_table) + if modelo is None or row.source_id is None: + return None + return ( + db.query(modelo) + .filter( + modelo.id == row.source_id, + modelo.tenant_id == row.tenant_id, + modelo.company_id == row.company_id, + ) + .first() + ) + + +def _marcar_documento_entregado(db: Session, row: EfcFileOutbox, doc_id) -> None: + """Cierra la entrega en la fila del documento: el badge de la UI pasa a «En expediente».""" + documento = _fila_de_origen(db, row) + if documento is None: + return + documento.efc_document_id = str(doc_id) if doc_id else None + documento.efc_sync_state = "SYNCED" + documento.efc_synced_at = datetime.now(timezone.utc) + documento.efc_error_code = None + documento.efc_error_detail = None + if row.delete_local: + # El objeto local ya no está: dejar la key apuntaría a algo inexistente y la descarga se + # ramificaría por el camino equivocado. + documento.file_key = None + db.commit() + + +def _marcar_documento_fallido(db: Session, row: EfcFileOutbox, exc: Exception) -> None: + """Refleja el fallo en la fila del documento para que la ficha lo muestre sin ir a los logs.""" + documento = _fila_de_origen(db, row) + if documento is None: + return + documento.efc_attempts = row.attempts + documento.efc_error_detail = str(exc)[:2000] + documento.efc_error_code = getattr(exc, "code", None) + if row.status == STATUS_FAILED: + documento.efc_sync_state = "FAILED" + db.commit() + + +# ══ Organización ════════════════════════════════════════════════════════════ + +def _resolve_org_id(client: EfcClient, tenant_id: int) -> str: + slug, name = _tenant_slug(tenant_id) + if not slug: + raise EfcClientError( + f"tenant {tenant_id} sin slug; no se puede resolver la organización EFC.", + retryable=False, + ) + if slug in _org_id_cache: + return _org_id_cache[slug] + resp = client.resolve_organizacion(slug, name) + org_id = resp.get("id") if isinstance(resp, dict) else None + if not org_id: + raise EfcClientError("El resolver de organización de EFC no devolvió id.", retryable=True) + _org_id_cache[slug] = org_id + return org_id + + +def _tenant_slug(tenant_id: int) -> tuple[Optional[str], Optional[str]]: + from api.v1.modules.core.tenants.models import Tenant + + with scoped_core_db(tenant_id=tenant_id) as db: + t = db.query(Tenant).filter(Tenant.id == tenant_id).first() + if t is None: + return None, None + return t.slug, t.name + + +# ══ Tablero de ops ══════════════════════════════════════════════════════════ + +def _outbox_to_dict(r: EfcSyncOutbox) -> dict: + return { + "id": r.id, + "tabla": "sync", + "kind": r.kind, + "status": r.status, + "attempts": r.attempts, + "last_error": r.last_error, + "expediente_ref": r.expediente_ref, + "efc_pedimento_id": r.efc_pedimento_id, + "created_at": r.created_at.isoformat() if r.created_at else None, + "sent_at": r.sent_at.isoformat() if r.sent_at else None, + } + + +def _file_outbox_to_dict(r: EfcFileOutbox) -> dict: + return { + "id": r.id, + "tabla": "file", + "kind": r.kind, + "status": r.status, + "attempts": r.attempts, + "last_error": r.last_error, + "expediente_ref": r.expediente_ref, + "file_name": r.file_name, + "efc_tipo": r.efc_tipo, + "source_table": r.source_table, + "source_id": r.source_id, + "crm_document_ref": r.crm_document_ref, + "efc_document_id": r.efc_document_id, + "created_at": r.created_at.isoformat() if r.created_at else None, + "sent_at": r.sent_at.isoformat() if r.sent_at else None, + } + + +def list_outbox(db: Session, tenant_id: int, company_id: int, tipo: Optional[str] = None, + status: Optional[str] = None, limit: int = 100) -> list[dict]: + """Lista filas de los DOS outbox para el tablero de ops. ``tipo`` ∈ ``sync`` | ``file``.""" + salida: list[dict] = [] + + if tipo in (None, "", "sync"): + q = db.query(EfcSyncOutbox).filter( + EfcSyncOutbox.tenant_id == tenant_id, EfcSyncOutbox.company_id == company_id + ) + if status: + q = q.filter(EfcSyncOutbox.status == status) + salida += [ + _outbox_to_dict(r) + for r in q.order_by(EfcSyncOutbox.created_at.desc()).limit(limit).all() + ] + + if tipo in (None, "", "file"): + q = db.query(EfcFileOutbox).filter( + EfcFileOutbox.tenant_id == tenant_id, EfcFileOutbox.company_id == company_id + ) + if status: + q = q.filter(EfcFileOutbox.status == status) + salida += [ + _file_outbox_to_dict(r) + for r in q.order_by(EfcFileOutbox.created_at.desc()).limit(limit).all() + ] + + salida.sort(key=lambda d: (d.get("created_at") or ""), reverse=True) + return salida[:limit] + + +def retry_outbox_row(db: Session, outbox_id: int, tenant_id: int, company_id: int, + tipo: str = "file") -> bool: + """Reintento manual: resetea la fila a ``pending`` (``attempts=0``) y la re-despacha. + + Devuelve ``False`` si no existe para ese tenant/company — el llamador lo traduce a **404 con + mensaje específico**, no a un 200 silencioso: es contrato con el frontend, que pinta el botón + según lo que reciba. + """ + modelo = EfcSyncOutbox if tipo == "sync" else EfcFileOutbox + r = ( + db.query(modelo) + .filter(modelo.id == outbox_id, modelo.tenant_id == tenant_id, modelo.company_id == company_id) + .first() + ) + if r is None: + return False + r.status = STATUS_PENDING + r.attempts = 0 + r.last_error = None + db.commit() + if tipo == "sync": + _dispatch_delivery(r.id, r.tenant_id, r.company_id) + else: + _reset_documento_pendiente(db, r) + _dispatch_file_delivery(r.id, r.tenant_id, r.company_id) + return True + + +def _reset_documento_pendiente(db: Session, row: EfcFileOutbox) -> None: + documento = _fila_de_origen(db, row) + if documento is None: + return + documento.efc_sync_state = "PENDING" + documento.efc_error_code = None + documento.efc_error_detail = None + db.commit() + + +def outbox_metrics(db: Session, tenant_id: int, company_id: int) -> dict: + """Conteo de los dos outbox por status (monitoreo). Los conteos suman las dos tablas.""" + from sqlalchemy import func + + counts = {STATUS_PENDING: 0, STATUS_SENT: 0, STATUS_FAILED: 0} + for modelo in (EfcSyncOutbox, EfcFileOutbox): + rows = ( + db.query(modelo.status, func.count()) + .filter(modelo.tenant_id == tenant_id, modelo.company_id == company_id) + .group_by(modelo.status) + .all() + ) + for estado, n in rows: + counts[estado] = counts.get(estado, 0) + n + return { + "pending": counts.get(STATUS_PENDING, 0), + "sent": counts.get(STATUS_SENT, 0), + "failed": counts.get(STATUS_FAILED, 0), + } + + +def find_expediente_gaps(db: Session, limit: int = 200) -> list: + """Expedientes (no borrados) SIN ninguna fila de outbox que los referencie. + + Nunca se encolaron: expedientes creados **antes** de activar la integración, o un crash. Se + re-encolan para no perder la réplica. + + Los ``failed`` **no son huecos** —existen como fila, son visibles y reintentables desde el + tablero—, así que la fila los excluye por estar presente, no por su estado. Corre sin contexto + de tenant (beat); cada expediente lleva el suyo. + """ + from sqlalchemy import exists + + ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Case.id) + return ( + db.query(Case) + .filter( + Case.deleted_at.is_(None), + ~ya_encolado, + # Mismo criterio que el encolado: lo que le falta folio o token no es un hueco + # recuperable, es algo que EFC rechazaría siempre. Sin este filtro la + # reconciliación los reencola cada 5 minutos para verlos fallar de nuevo. + Case.reference.isnot(None), + Case.efc_storage_token.isnot(None), + ) + .order_by(Case.id.desc()) + .limit(limit) + .all() + ) diff --git a/backend/api/v1/modules/crm/expediente_gateway/storage.py b/backend/api/v1/modules/crm/expediente_gateway/storage.py new file mode 100644 index 0000000..fd10d7e --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/storage.py @@ -0,0 +1,39 @@ +"""La llave de almacenamiento del expediente en EFC. + +Vive en el carril y no en el módulo del expediente a propósito: el expediente (``crm.cases``) es +del CRM y no sabe nada de EFC; esto es exclusivamente cómo EFC nombra su carpeta. + +El generador de folios NO está aquí. Es ``crm/common/folios.py::next_folio``, que ya reserva el +consecutivo mensual por ``(tenant, company, entidad, periodo)`` con bloqueo de fila. El carril lo +consume, no lo reimplementa. +""" + +from __future__ import annotations + +# Longitud de ``Pedimento.pedimento_app`` en EFC (api/customs/models.py). El token se guarda ahí. +PEDIMENTO_APP_MAX = 25 + + +def storage_token(company_id: int, folio: str) -> str: + """``CRM-{company_id}-{folio}`` — la llave del pedimento provisional en EFC. + + Empieza con letras, así que es imposible que colisione con la llave de un pedimento real, que + es ``^\\d{2}-\\d{2}-\\d{4}-\\d{7}$``. El ``company_id`` va dentro porque el puente con EFC es + tenant → organización 1:1 pero un tenant tiene N companies: sin él, dos companies del mismo + tenant generarían el mismo ``EXP2026-08-001`` y chocarían en el ``unique_together`` de EFC. + + PRESUPUESTO DE CARACTERES: ``CRM-`` (4) + company + ``-`` (1) + ``EXP2026-08-001`` (14) = 19 + + los dígitos del company. En los 25 de ``pedimento_app`` caben hasta **6 dígitos** de company, + no 7 como decía la primera versión de este docstring: con 7 salen 26 y el insert del lado de + EFC reventaría. Un consecutivo de 4 dígitos (mes con más de 999 expedientes) gasta uno más. + + Se valida en vez de truncar: un token recortado apuntaría a la carpeta de OTRO expediente y + los documentos se mezclarían en silencio, que es peor que fallar aquí. + """ + token = f"CRM-{company_id}-{folio}" + if len(token) > PEDIMENTO_APP_MAX: + raise ValueError( + f"storage_token de {len(token)} caracteres excede los {PEDIMENTO_APP_MAX} de " + f"pedimento_app en EFC: {token!r}. Revisa el largo del company_id o del consecutivo." + ) + return token diff --git a/backend/api/v1/modules/crm/expediente_gateway/tasks.py b/backend/api/v1/modules/crm/expediente_gateway/tasks.py new file mode 100644 index 0000000..9573b25 --- /dev/null +++ b/backend/api/v1/modules/crm/expediente_gateway/tasks.py @@ -0,0 +1,143 @@ +"""Tareas Celery del carril CRM Agentes de Carga -> EFC. + +- ``deliver_outbox_row`` / ``sweep_outbox``: expedientes (alta del provisional y completado). +- ``deliver_file_outbox_row`` / ``sweep_file_outbox``: archivos. +- ``sweep_expediente_gaps``: reconciliación de expedientes que nunca se encolaron. + +**La trampa de RLS, que es lo que más fácil se pasa por alto.** ``core/celery_app.py`` materializa el +contexto desde los headers ``rls_tenant_id`` / ``rls_company_id``. Por tanto: + +- Las tareas **por fila** se despachan siempre con esos headers. +- Los **barridos corren sin contexto de tenant**: leen los ids pendientes con una sesión sin scope y + despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con + scope e iterara, o no vería nada o se saltaría el aislamiento. + +**Sin ``autoretry_for``, ``retry_backoff`` ni ``max_retries``**: duplicarían el mecanismo de +reintento que ya está en el cliente (3 intentos con backoff lineal) y en el barrido (cada 120 s +hasta ``MAX_ATTEMPTS``). +""" +import logging + +from core.celery_app import celery_app +from core.config import settings +from core.database import scoped_core_db + +from . import service +from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox + +# ── Registro de modelos: NO son imports decorativos, no los quites ────────────────────── +# El worker de Celery NO carga la app: importa este módulo y sus dependencias, y nada más. +# SQLAlchemy resuelve las ForeignKey por NOMBRE de tabla contra su registro global, así que +# si la clase del otro extremo nunca se importó, la configuración de mappers falla con +# +# Foreign key associated with column 'cases.account_id' could not find table 'crm.accounts' +# +# y la tarea muere con PendingRollbackError. El síntoma es cruel: la fila del outbox se +# queda en `pending` con attempts=0 y SIN last_error —porque el fallo ocurre antes de poder +# registrarlo—, así que el carril se ve encolando bien y no entrega nunca. En la app web no +# pasa: `main.py` monta todos los routers y con ellos se importan todos los modelos. +# +# El juego es el mínimo verificado con `configure_mappers()` en un proceso limpio: +# - accounts : cierra la FK cases.account_id, que es la que rompía; +# - documents y ops.shipments : las dos fuentes del outbox de archivos; +# - tenants : lo consulta el resolver de organización al entregar. +from api.v1.modules.core.tenants import models as _m_tenants # noqa: F401 +from api.v1.modules.crm.accounts import models as _m_accounts # noqa: F401 +from api.v1.modules.crm.documents import models as _m_documents # noqa: F401 +from api.v1.modules.ops.shipments import models as _m_shipments # noqa: F401 + +logger = logging.getLogger(__name__) + + +# ── Expedientes ───────────────────────────────────────────────────────────── + +@celery_app.task(name="expediente_gateway.deliver_outbox_row") +def deliver_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None: + with scoped_core_db(tenant_id, company_id) as db: + row = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.id == outbox_id).first() + if row is None: + logger.warning( + "expediente_gateway: outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id + ) + return + service.deliver_row(db, row) + + +@celery_app.task(name="expediente_gateway.sweep_outbox") +def sweep_outbox(limit: int = 100) -> int: + """Re-despacha filas pendientes de expediente. Sin contexto de tenant: cada fila lleva el suyo.""" + with scoped_core_db() as db: + rows = ( + db.query(EfcSyncOutbox.id, EfcSyncOutbox.tenant_id, EfcSyncOutbox.company_id) + .filter(EfcSyncOutbox.status == STATUS_PENDING) + .order_by(EfcSyncOutbox.created_at.asc()) + .limit(limit) + .all() + ) + + for rid, tid, cid in rows: + deliver_outbox_row.apply_async( + args=[rid, tid, cid], + headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""}, + ) + if rows: + logger.info("expediente_gateway: sweep (expedientes) re-despachó %s filas pendientes", len(rows)) + return len(rows) + + +# ── Archivos ──────────────────────────────────────────────────────────────── + +@celery_app.task(name="expediente_gateway.deliver_file_outbox_row") +def deliver_file_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None: + with scoped_core_db(tenant_id, company_id) as db: + row = db.query(EfcFileOutbox).filter(EfcFileOutbox.id == outbox_id).first() + if row is None: + logger.warning( + "expediente_gateway: file outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id + ) + return + service.deliver_file_row(db, row) + + +@celery_app.task(name="expediente_gateway.sweep_file_outbox") +def sweep_file_outbox(limit: int = 100) -> int: + """Re-despacha archivos pendientes (EFC o el broker caídos cuando el usuario subió el archivo).""" + with scoped_core_db() as db: + rows = ( + db.query(EfcFileOutbox.id, EfcFileOutbox.tenant_id, EfcFileOutbox.company_id) + .filter(EfcFileOutbox.status == STATUS_PENDING) + .order_by(EfcFileOutbox.created_at.asc()) + .limit(limit) + .all() + ) + for rid, tid, cid in rows: + deliver_file_outbox_row.apply_async( + args=[rid, tid, cid], + headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""}, + ) + if rows: + logger.info("expediente_gateway: sweep (archivos) re-despachó %s archivos pendientes", len(rows)) + return len(rows) + + +# ── Reconciliación de huecos ──────────────────────────────────────────────── + +@celery_app.task(name="expediente_gateway.sweep_expediente_gaps") +def sweep_expediente_gaps(limit: int = 200) -> int: + """Detecta expedientes que nunca se encolaron a EFC y los re-encola. + + No-op si la integración está apagada. + """ + if not settings.EFC_API_URL: + return 0 + n = 0 + with scoped_core_db() as db: + gaps = service.find_expediente_gaps(db, limit=limit) + for expediente in gaps: + service.replicate_expediente_best_effort(db, expediente) + n += 1 + if n: + db.commit() + if n: + logger.info("expediente_gateway: sweep de huecos re-encoló %s expedientes", n) + return n 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..c3c9823 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -13,14 +13,18 @@ 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 +from .expediente_gateway.routes import router as expediente_gateway_router from .leads.routes import router as leads_router 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 +45,13 @@ 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) +# Tablero de operación del carril hacia EFC: cola pendiente, métricas y reintento manual. +# No es una ruta de negocio; existe para que una persona vea y desatore la entrega sin +# entrar a la base. Hereda el enforcement de crm.access del router agregador. +router.include_router(expediente_gateway_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 1d20c52..121613f 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -120,6 +120,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 cf6fc41..147aa6b 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 3d10574..912f7f0 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 @@ -96,6 +98,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) @@ -282,6 +293,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, @@ -295,6 +307,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/celery_app.py b/backend/core/celery_app.py index 08b1c58..40b25c3 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -96,6 +96,10 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_): celery_app.conf.update( include=[ "api.v1.modules.core.help_center.tasks", + # Sin esta línea el worker rechaza las tareas del carril con "Received unregistered + # task of type 'expediente_gateway.deliver_outbox_row'": la fila queda en `pending` + # con 0 intentos y NUNCA se drena, aunque el encolado se vea perfecto. + "api.v1.modules.crm.expediente_gateway.tasks", # Agrega aquí las tareas de tu proyecto: # "api.v1.modules.example.tasks", ] @@ -120,6 +124,25 @@ celery_app.conf.beat_schedule = { "task": "cleanup_orphan_layout_imports", "schedule": 3600.0, }, + # Carril CRM -> EFC. Los tres intervalos vienen del carril de referencia de Anexo22: 120 s para + # las dos colas y 300 s para la reconciliación. El reintento NO es exponencial a propósito —el + # backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo. + # + # Estos barridos son la red que atrapa la ventana entre el encolado y el commit: el despacho + # inmediato puede llegar al worker antes de que la transacción confirme, no encontrar la fila + # y darse por vencido. Aquí se recoge. + "efc-sweep-outbox-every-2-min": { + "task": "expediente_gateway.sweep_outbox", + "schedule": 120.0, + }, + "efc-sweep-file-outbox-every-2-min": { + "task": "expediente_gateway.sweep_file_outbox", + "schedule": 120.0, + }, + "efc-sweep-expediente-gaps-every-5-min": { + "task": "expediente_gateway.sweep_expediente_gaps", + "schedule": 300.0, + }, } if __name__ == "__main__": diff --git a/backend/core/config.py b/backend/core/config.py index ff4be27..222e5f3 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/" @@ -62,7 +75,7 @@ class Settings(BaseSettings): # URL pública del frontend — usada en links de email (invitaciones, etc.) APP_PUBLIC_URL: str = "http://localhost:3000" - @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before") + @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", "EFC_API_URL", mode="before") @classmethod def strip_quotes(cls, v: str) -> str: if v and isinstance(v, str): @@ -103,6 +116,25 @@ class Settings(BaseSettings): S3_FILE_STORAGE: bool = True S3_PRESIGNED_EXPIRES_SECONDS: int = 3600 + # ── EFC (expediente electrónico) ──────────────────────────────────────────────────────── + # Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC. + # Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar + # otros obligaría a quien opera los dos sistemas a recordar dos juegos de variables para + # exactamente lo mismo. + # EFC_API_URL vacía = integración APAGADA. Todo el enganche es best-effort y hace no-op: + # el CRM sigue funcionando igual, guardando los archivos solo en su MinIO. + EFC_API_URL: str = "" + EFC_API_KEY: str = "" # == CRM_INTEGRATION_API_KEY del lado de EFC + EFC_API_VERIFY_SSL: bool = True + EFC_API_TIMEOUT_MS: int = 8000 # metadatos: resolver, ingest, completar + # Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO + # del proxy_read_timeout del nginx de EFC (ver core/efc_client.py). + EFC_UPLOAD_TIMEOUT_MS: int = 55000 + # Scaffolding de mTLS: activarlo es configuración, no código. + EFC_MTLS_CA_PATH: str = "" + EFC_MTLS_CERT_PATH: str = "" + EFC_MTLS_KEY_PATH: str = "" + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, diff --git a/backend/core/efc_client.py b/backend/core/efc_client.py new file mode 100644 index 0000000..4350360 --- /dev/null +++ b/backend/core/efc_client.py @@ -0,0 +1,301 @@ +"""Cliente HTTP hacia EFC (carril de integración CRM Agentes de Carga -> EFC). + +Clon del cliente del gateway de Anexo22, que es el carril de referencia ya en producción +(``anexo22/backend/api/v1/modules/pedimentos/pedimento_gateway/client.py``), con las rutas +cambiadas a ``.../integrations/crm/...``. No es una reinterpretación: el molde de reintentos, el +corte en 4xx y la forma del error se conservan tal cual. + +**Síncrono a propósito** (``httpx.Client``): el consumidor es el worker de Celery que drena el +outbox, que corre en contexto sync. El único punto async del carril es el proxy de descarga de cara +al usuario, y ése no usa este cliente. + +Todos los endpoints se autentican con el header ``X-Api-Key`` +(``settings.EFC_API_KEY`` == ``CRM_INTEGRATION_API_KEY`` del lado de EFC). +""" +import logging +import time +from typing import Any, Optional + +import httpx + +from core.config import settings + +logger = logging.getLogger(__name__) + +# Rutas de EFC (prefijo /api/v1/, ver config/urls.py del backend de EFC). +_PATH_ORG_BUSCAR = "/api/v1/organization/integrations/crm/organizaciones/" +_PATH_ORG_RESOLVER = "/api/v1/organization/integrations/crm/organizaciones/resolver/" +_PATH_EXPEDIENTE = "/api/v1/customs/integrations/crm/expedientes/" +_PATH_EXPEDIENTE_COMPLETAR = "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/" +_PATH_EXPEDIENTE_DETALLE = "/api/v1/customs/integrations/crm/expedientes/{folio}/" +_PATH_DOCS = "/api/v1/record/integrations/crm/documentos/" +_PATH_DOCS_LIST = "/api/v1/record/integrations/crm/documentos/list/" +_PATH_DOC_DESCARGAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/" +_PATH_DOC_ELIMINAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/" +_PATH_DOC_REEMPLAZAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/" + + +class EfcClientError(Exception): + """Error de comunicación con EFC. + + ``retryable=True`` marca fallos transitorios (5xx/timeout/red) que el worker debe reintentar. + ``status_code``/``code`` exponen la respuesta de EFC para que el worker pueda ramificar + (p. ej. 404 ``expediente_no_encontrado`` → ensure-then-upload). + + El worker decide **por el campo ``retryable``**, nunca parseando el mensaje: un texto de error + cambia con cualquier refactor del otro repo y con él se caería la política de reintentos sin que + nada se vea roto. + """ + + def __init__(self, message: str, status_code: Optional[int] = None, + code: Optional[str] = None, retryable: bool = False): + super().__init__(message) + self.status_code = status_code + self.code = code + self.retryable = retryable + + +class EfcClient: + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + timeout_ms: Optional[int] = None, + upload_timeout_ms: Optional[int] = None, + verify_ssl: Optional[bool] = None, + retries: int = 2, + transport: Optional[httpx.BaseTransport] = None, + ): + self.base_url = (base_url if base_url is not None else settings.EFC_API_URL).rstrip("/") + self.api_key = api_key if api_key is not None else settings.EFC_API_KEY + self.timeout_s = max(0.1, float(timeout_ms or settings.EFC_API_TIMEOUT_MS) / 1000.0) + # Timeout aparte para las subidas: los 8 s de los metadatos no alcanzan para un archivo de + # 25 MB. Tiene que quedar POR DEBAJO del proxy_read_timeout del nginx de EFC — si el CRM + # esperara más, vería un 504 opaco sin saber si el documento entró. Fallando primero de este + # lado, el reintento con el mismo efc_document_ref es limpio. + self.upload_timeout_s = max( + 0.1, float(upload_timeout_ms or settings.EFC_UPLOAD_TIMEOUT_MS) / 1000.0 + ) + self.verify_ssl = settings.EFC_API_VERIFY_SSL if verify_ssl is None else verify_ssl + self.retries = max(0, int(retries)) + self.transport = transport + # mTLS (scaffolding pre-prod): si hay CA se usa para verificar; si hay par cert/key se + # presenta como certificado de cliente. Vacío = TLS normal. + self._ca_path = settings.EFC_MTLS_CA_PATH or "" + self._cert_path = settings.EFC_MTLS_CERT_PATH or "" + self._key_path = settings.EFC_MTLS_KEY_PATH or "" + + def _client_kwargs(self, timeout_s: Optional[float] = None) -> dict: + """``verify``/``cert`` para httpx según config mTLS (o TLS normal si no hay mTLS).""" + verify = self._ca_path if self._ca_path else self.verify_ssl + kwargs = { + "timeout": timeout_s or self.timeout_s, + "verify": verify, + "transport": self.transport, + } + if self._cert_path and self._key_path: + kwargs["cert"] = (self._cert_path, self._key_path) + return kwargs + + @property + def is_configured(self) -> bool: + """``False`` = integración deshabilitada (best-effort): sin URL o sin key.""" + return bool(self.base_url and self.api_key) + + # ── HTTP interno ────────────────────────────────────────────────────────── + + def _request(self, method: str, path: str, *, json: Any = None, + params: dict = None, files: dict = None, data: dict = None, + stream: bool = False, timeout_s: Optional[float] = None): + if not self.is_configured: + raise EfcClientError("EFC no configurado (EFC_API_URL/EFC_API_KEY vacíos).", retryable=False) + + url = f"{self.base_url}{path}" + headers = {"X-Api-Key": self.api_key} + last_error: Optional[Exception] = None + + for attempt in range(self.retries + 1): + try: + client = httpx.Client(**self._client_kwargs(timeout_s)) + try: + response = client.request(method, url, headers=headers, json=json, + params=params, files=files, data=data) + except Exception: + client.close() + raise + + if 200 <= response.status_code < 300: + if stream: + # El caller lee response.content y cierra el cliente. + return response, client + client.close() + return response + + # 5xx: transitorio, reintentar. + if response.status_code >= 500 and attempt < self.retries: + client.close() + time.sleep(0.15 * (attempt + 1)) + continue + + # 4xx u otro: no reintentar. Extraer code/mensaje de EFC. + code, message = _parse_error_body(response) + client.close() + raise EfcClientError( + message or f"EFC respondió {response.status_code}", + status_code=response.status_code, + code=code, + retryable=response.status_code >= 500, + ) + + except (httpx.TimeoutException, httpx.NetworkError) as exc: + last_error = exc + if attempt >= self.retries: + break + time.sleep(0.15 * (attempt + 1)) + except EfcClientError: + raise + except Exception as exc: # noqa: BLE001 — cualquier fallo inesperado es no-retryable + raise EfcClientError(str(exc), retryable=False) from exc + + raise EfcClientError( + f"EFC inaccesible tras {self.retries + 1} intentos: {last_error}", + retryable=True, + ) + + # ── Organización ────────────────────────────────────────────────────────── + + def buscar_organizaciones(self, q: str) -> list: + """Búsqueda por texto, para el alta manual desde una pantalla de administración.""" + return self._request("GET", _PATH_ORG_BUSCAR, params={"q": q}).json() + + def resolve_organizacion(self, tenant_slug: str, tenant_name: Optional[str] = None) -> dict: + payload = {"tenant_slug": tenant_slug} + if tenant_name: + payload["tenant_name"] = tenant_name + return self._request("POST", _PATH_ORG_RESOLVER, json=payload).json() + + # ── Expediente (pedimento provisional en EFC) ────────────────────────────── + + def ingest_expediente(self, payload: dict) -> dict: + """Crea el pedimento provisional del expediente. 201 si es nuevo, 200 si ya existía.""" + return self._request("POST", _PATH_EXPEDIENTE, json=payload).json() + + def completar_expediente(self, folio: str, payload: dict) -> dict: + """Completa un provisional con la data aduanera real. Ningún archivo se mueve.""" + path = _PATH_EXPEDIENTE_COMPLETAR.format(folio=folio) + return self._request("POST", path, json=payload).json() + + def get_expediente(self, folio: str, organizacion_id: str) -> dict: + path = _PATH_EXPEDIENTE_DETALLE.format(folio=folio) + return self._request("GET", path, params={"organizacion_id": str(organizacion_id)}).json() + + # ── Documentos ──────────────────────────────────────────────────────────── + + def upload_documento(self, organizacion_id: str, crm_company_id: int, crm_expediente_id: int, + tipo: str, filename: str, content: bytes, + content_type: str = "application/octet-stream", + crm_document_ref: Optional[str] = None) -> dict: + """Sube un documento al expediente. Multipart, nunca base64. + + ``crm_document_ref`` es **el handle de NUESTRO registro de origen**, y es lo que después + permite recuperar el archivo sin guardar de este lado ningún identificador de EFC. EFC lo + guarda junto al documento con un UNIQUE parcial por ``(organizacion, ref)``, así que una + entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201). + + Es la cuarta capa de idempotencia del carril y la única que garantiza la base: la entrega la + hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde— + duplicaría el documento sin esto. + """ + files = {"file": (filename, content, content_type)} + data = { + "organizacion_id": str(organizacion_id), + "crm_company_id": str(int(crm_company_id)), + "crm_expediente_id": str(int(crm_expediente_id)), + "tipo": tipo, + } + if crm_document_ref: + data["crm_document_ref"] = crm_document_ref + return self._request( + "POST", _PATH_DOCS, files=files, data=data, timeout_s=self.upload_timeout_s + ).json() + + def list_documentos(self, organizacion_id: str, crm_expediente_id: int, *, + tipo: Optional[str] = None, + crm_document_ref: Optional[str] = None) -> list: + """Documentos del expediente. ``tipo`` y ``crm_document_ref`` son filtros OPCIONALES. + + Preguntar por ``crm_document_ref`` es lo que permite recuperar ``efc_document_id`` si el + cache local se perdió, sin guardar identificadores ajenos como handle. + """ + params = { + "organizacion_id": str(organizacion_id), + "crm_expediente_id": str(int(crm_expediente_id)), + } + if tipo: + params["tipo"] = tipo + if crm_document_ref: + params["crm_document_ref"] = crm_document_ref + return self._request("GET", _PATH_DOCS_LIST, params=params).json() + + def replace_documento(self, organizacion_id: str, doc_id: str, filename: str, content: bytes, + content_type: str = "application/octet-stream") -> dict: + """Sustituye el CONTENIDO de un documento conservando su fila (mismo id, mismo tipo). + + EFC sube primero y borra el viejo al final, así que una subida fallida deja el anterior + intacto y descargable. + """ + files = {"file": (filename, content, content_type)} + data = {"organizacion_id": str(organizacion_id)} + path = _PATH_DOC_REEMPLAZAR.format(doc_id=doc_id) + return self._request( + "PUT", path, files=files, data=data, timeout_s=self.upload_timeout_s + ).json() + + def download_documento(self, organizacion_id: str, doc_id: str) -> tuple[bytes, str]: + path = _PATH_DOC_DESCARGAR.format(doc_id=doc_id) + params = {"organizacion_id": str(organizacion_id)} + response, client = self._request("GET", path, params=params, stream=True) + try: + content = response.content + filename = _filename_from_response(response, default=str(doc_id)) + finally: + client.close() + return content, filename + + def download_url(self, doc_id: str) -> str: + """URL absoluta del endpoint de descarga de EFC, para el proxy async de la fase 7. + + El proxy no puede usar ``download_documento``: éste es síncrono y bufferiza el archivo + entero. Lo que necesita es la URL y el header, y hace su propio streaming. + """ + return f"{self.base_url}{_PATH_DOC_DESCARGAR.format(doc_id=doc_id)}" + + @property + def auth_headers(self) -> dict: + """El header de autenticación, para el proxy async que no pasa por ``_request``.""" + return {"X-Api-Key": self.api_key} + + +def _parse_error_body(response) -> tuple[Optional[str], Optional[str]]: + """Extrae ``(code, message)`` del cuerpo de error estructurado de EFC + (``{"error": {"code", "message"}}``) sin reventar si no es JSON.""" + try: + body = response.json() + except Exception: + return None, None + err = body.get("error") if isinstance(body, dict) else None + if isinstance(err, dict): + return err.get("code"), err.get("message") + return None, None + + +def _filename_from_response(response, default: str) -> str: + disp = response.headers.get("Content-Disposition", "") + if "filename=" in disp: + return disp.split("filename=")[-1].strip().strip('"') or default + return default + + +# Instancia por defecto (lee settings). Los tests inyectan su propio transport construyendo +# EfcClient(transport=httpx.MockTransport(...)). +efc_client = EfcClient() 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 7881476..b428706 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/contracts/efc_crm_contract.json b/backend/tests/contracts/efc_crm_contract.json new file mode 100644 index 0000000..2eb7768 --- /dev/null +++ b/backend/tests/contracts/efc_crm_contract.json @@ -0,0 +1,182 @@ +{ + "_meta": { + "nombre": "Contrato del carril CRM Agentes de Carga <-> EFC", + "ticket": "T2026-08-046", + "version": 1, + "por_que_existe": "CRM y EFC son dos repos con despliegue independiente. Nada obliga a que sus dos mitades del carril evolucionen juntas, y una ruta renombrada o una clave de payload que cambia solo se descubre en produccion, cuando un documento deja de llegar al expediente. Este archivo es la unica forma de que un cambio unilateral salga rojo en CI.", + "como_se_usa": "Cada repo afirma su lado contra ESTE archivo. El CRM en backend/tests/test_contrato_efc.py. EFC debe afirmar el suyo cuando aterricen sus fases 1-4, contra una copia identica byte a byte de este JSON; si las dos copias divergen, el contrato deja de servir para lo unico que sirve.", + "regla": "Cambiar algo aqui es cambiar el contrato: obliga a un PR en los DOS repos." + }, + + "carril_efc": { + "_nota": "Endpoints maquina-a-maquina que EXPONE EFC y CONSUME el CRM. Header obligatorio en todas: X-Api-Key.", + "header_autenticacion": "X-Api-Key", + "endpoints": { + "organizaciones_buscar": { + "metodo": "GET", + "path": "/api/v1/organization/integrations/crm/organizaciones/" + }, + "organizaciones_resolver": { + "metodo": "POST", + "path": "/api/v1/organization/integrations/crm/organizaciones/resolver/", + "request_claves": ["tenant_slug", "tenant_name"], + "response_claves": ["id", "nombre", "rfc", "hub_tenant_slug", "is_active", "created"] + }, + "expediente_crear": { + "metodo": "POST", + "path": "/api/v1/customs/integrations/crm/expedientes/", + "request_claves": [ + "crm_tenant_slug", + "crm_company_id", + "crm_expediente_id", + "folio", + "storage_token" + ], + "status_exito": [200, 201], + "_nota_status": "201 si el provisional es nuevo, 200 si ya existia. Las dos son exito: el carril es idempotente." + }, + "expediente_completar": { + "metodo": "POST", + "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/" + }, + "expediente_detalle": { + "metodo": "GET", + "path": "/api/v1/customs/integrations/crm/expedientes/{folio}/" + }, + "documento_subir": { + "metodo": "POST", + "path": "/api/v1/record/integrations/crm/documentos/", + "content_type": "multipart/form-data", + "_nota_content_type": "Multipart, nunca base64: EFC declara parser_classes = [MultiPartParser].", + "form_claves": [ + "organizacion_id", + "crm_company_id", + "crm_expediente_id", + "tipo", + "crm_document_ref" + ], + "archivo_campo": "file", + "status_exito": [200, 201] + }, + "documentos_listar": { + "metodo": "GET", + "path": "/api/v1/record/integrations/crm/documentos/list/" + }, + "documento_descargar": { + "metodo": "GET", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/" + }, + "documento_eliminar": { + "metodo": "DELETE", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/" + }, + "documento_reemplazar": { + "metodo": "PUT", + "path": "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/" + } + }, + + "formato_error": { + "forma": {"error": {"code": "", "message": ""}}, + "_nota": "El worker del CRM ramifica por error.code, NUNCA por el texto del mensaje: un mensaje cambia con cualquier refactor del otro repo y con el se caeria la politica de reintentos sin que nada se vea roto." + }, + + "codigos_error": { + "400": [ + "payload_invalido", + "pedimento_app_reservado", + "storage_token_invalido", + "efc_pedimento_app_incompleto", + "tipo_invalido", + "archivo_faltante", + "extension_no_permitida", + "archivo_demasiado_grande", + "espacio_insuficiente" + ], + "403": ["documento_no_eliminable"], + "404": ["expediente_no_encontrado", "documento_no_encontrado"], + "409": [ + "organizacion_no_utilizable", + "licencia_sin_espacio", + "expediente_ya_completado", + "pedimento_real_ya_existe", + "conflicto" + ], + "502": ["error_storage"] + }, + + "codigos_con_significado_para_el_crm": { + "expediente_no_encontrado": { + "http": 404, + "efecto": "dispara el ensure-then-upload: el CRM crea el provisional y reintenta la subida UNA vez", + "_nota": "Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan pendientes para siempre sin que nada falle a gritos. Es el code mas fragil del carril." + } + }, + + "reintentos": { + "reintentables": ["5xx", "timeout", "error_de_red"], + "no_reintentables": ["4xx"], + "_nota": "Un 4xx reintentado tres veces es tres veces el mismo error mas latencia. El corte esta en 500, no en 400." + } + }, + + "api_crm": { + "_nota": "Endpoints de usuario que expone el CRM. Todos con company_id obligatorio en query y usuario autenticado.", + "query_obligatorio": "company_id", + "endpoints": [ + {"metodo": "GET", "path": "/expedientes"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}"}, + {"metodo": "POST", "path": "/expedientes/ensure"}, + {"metodo": "POST", "path": "/expedientes/{expediente_id}/completar"}, + {"metodo": "DELETE", "path": "/expedientes/{expediente_id}"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos"}, + {"metodo": "POST", "path": "/expedientes/{expediente_id}/documentos"}, + {"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos/{document_id}/archivo"}, + {"metodo": "DELETE", "path": "/expedientes/{expediente_id}/documentos/{document_id}"}, + {"metodo": "GET", "path": "/expediente-gateway/outbox"}, + {"metodo": "POST", "path": "/expediente-gateway/outbox/{outbox_id}/retry"}, + {"metodo": "GET", "path": "/expediente-gateway/metrics"} + ], + + "documento_response_prohibido": ["file_key", "file_url"], + "_nota_prohibido": "La copia local es de transito y se borra al confirmar la entrega a EFC. Exponerla invitaria al frontend a guardarse una referencia que va a dejar de existir; para abrir el archivo esta el proxy de descarga.", + + "documento_response_claves_minimas": [ + "expediente_id", + "doc_type", + "name", + "content_type", + "size_bytes", + "efc_sync_state", + "efc_document_ref" + ], + + "estados_sincronizacion": ["PENDING", "SYNCED", "FAILED"], + + "descarga_documento": { + "tipo_respuesta": "streaming", + "traduccion_errores": { + "404_de_efc": 404, + "cualquier_otro_fallo_de_efc": 502, + "documento_de_otro_tenant": 404, + "documento_aun_no_entregado": 409 + }, + "_nota": "La asimetria es deliberada: un 404 de EFC significa que el documento realmente no esta; cualquier otro fallo es de la integracion, no del usuario. Y la traduccion tiene que ocurrir ANTES de que la respuesta empiece a salir, o llega tarde." + }, + + "reintento_outbox": { + "exito": {"status": "requeued", "id": ""}, + "fila_inexistente_o_de_otro_tenant": 404, + "_nota": "404 y no un 200 silencioso: es contrato con el frontend, que distingue 'no se pudo reencolar' de 'reencolado'." + }, + + "metricas_outbox_claves": ["pending", "sent", "failed"], + + "folio": { + "formato": "EXP{YYYY}-{MM}-{NNN}", + "alcance_consecutivo": ["tenant", "company", "mes"], + "reinicia": "cada mes", + "_nota": "Al pasar de 999 crece a 4 digitos en vez de desbordar." + } + } +} 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_contrato_efc.py b/backend/tests/test_contrato_efc.py new file mode 100644 index 0000000..6deddfd --- /dev/null +++ b/backend/tests/test_contrato_efc.py @@ -0,0 +1,296 @@ +"""Afirmación del lado CRM del contrato con EFC. + +``tests/contracts/efc_crm_contract.json`` es el contrato; esto comprueba que **este** repo lo +cumple. EFC debe afirmar su mitad contra una copia idéntica del mismo archivo. + +Por qué existe, y por qué no basta con las otras pruebas: CRM y EFC se despliegan por separado. Las +pruebas de `test_efc_client.py` verifican que el cliente se comporta bien contra el EFC que el +cliente **cree** que existe; si EFC renombra una ruta o una clave del payload, esas pruebas siguen +verdes y el carril se rompe en producción. Lo único que atrapa esa deriva es un contrato escrito +aparte y afirmado desde los dos lados. + +Nada aquí toca la red: las peticiones se capturan con ``httpx.MockTransport``. +""" + +import json +from pathlib import Path + +import httpx +import pytest + +from core.efc_client import EfcClient + +CONTRATO = json.loads( + (Path(__file__).parent / "contracts" / "efc_crm_contract.json").read_text(encoding="utf-8") +) +CARRIL = CONTRATO["carril_efc"] +API_CRM = CONTRATO["api_crm"] + + +@pytest.fixture() +def capturadas(): + """Cliente contra EFC simulado que va guardando las peticiones que salen.""" + peticiones: list[httpx.Request] = [] + + def handler(request): + peticiones.append(request) + if request.method == "GET" and request.url.path.endswith("/list/"): + return httpx.Response(200, json=[]) + return httpx.Response(200, json={"id": "org-1"}) + + cliente = EfcClient( + base_url="https://efc.example.test", + api_key="llave-de-prueba", + timeout_ms=500, + upload_timeout_ms=500, + verify_ssl=False, + transport=httpx.MockTransport(handler), + ) + return cliente, peticiones + + +# ── Las rutas del carril ───────────────────────────────────────────────────── + +@pytest.mark.parametrize( + "nombre,llamada", + [ + ("organizaciones_buscar", lambda c: c.buscar_organizaciones("temex")), + ("organizaciones_resolver", lambda c: c.resolve_organizacion("temex", "TEMEX")), + ("expediente_crear", lambda c: c.ingest_expediente({"folio": "EXP2026-08-001"})), + ("expediente_completar", lambda c: c.completar_expediente("EXP2026-08-001", {})), + ("expediente_detalle", lambda c: c.get_expediente("EXP2026-08-001", "org-1")), + ("documentos_listar", lambda c: c.list_documentos("org-1", 42)), + ], +) +def test_cada_llamada_del_cliente_pega_en_la_ruta_del_contrato(capturadas, nombre, llamada): + cliente, peticiones = capturadas + esperado = CARRIL["endpoints"][nombre] + + llamada(cliente) + + peticion = peticiones[-1] + assert peticion.method == esperado["metodo"] + assert peticion.url.path == _resolver(esperado["path"]) + + +def test_la_descarga_apunta_a_la_ruta_del_contrato(capturadas): + """``download_url`` la arma a mano para el proxy async, así que se comprueba aparte.""" + cliente, _ = capturadas + esperado = CARRIL["endpoints"]["documento_descargar"] + + url = httpx.URL(cliente.download_url("doc-1")) + assert url.path == _resolver(esperado["path"], doc_id="doc-1") + + +def test_la_subida_de_documento_pega_en_su_ruta_y_va_en_multipart(capturadas): + cliente, peticiones = capturadas + esperado = CARRIL["endpoints"]["documento_subir"] + + cliente.upload_documento("org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf") + + peticion = peticiones[-1] + assert peticion.method == esperado["metodo"] + assert peticion.url.path == esperado["path"] + assert esperado["content_type"] in peticion.headers["content-type"] + + +def test_el_reemplazo_de_documento_pega_en_su_ruta(capturadas): + cliente, peticiones = capturadas + esperado = CARRIL["endpoints"]["documento_reemplazar"] + + cliente.replace_documento("org-1", "doc-1", "guia.pdf", b"%PDF-1.4") + + peticion = peticiones[-1] + assert peticion.method == esperado["metodo"] + assert peticion.url.path == _resolver(esperado["path"], doc_id="doc-1") + + +def test_el_cliente_NO_sabe_borrar_documentos_en_efc(): + """El endpoint de borrado existe en EFC y el CRM **deliberadamente no lo llama**. + + ``record.Document`` no tiene vigencia ni purga, así que la política implícita del sistema es + conservar, y un documento que mañana puede ser parte del expediente de un pedimento real es + riesgo de retención fiscal. La baja en el CRM es lógica. Que el método no exista es lo que + impide que alguien lo llame "porque estaba ahí": esta prueba se pone roja si aparece. + """ + metodos = {m for m in dir(EfcClient) if "elimin" in m or "delete" in m or "borrar" in m} + assert metodos == set(), f"apareció una operación de borrado hacia EFC: {metodos}" + assert "documento_eliminar" in CARRIL["endpoints"], "el endpoint existe del lado de EFC" + + +def _resolver(plantilla: str, **valores) -> str: + """Rellena los marcadores de la plantilla con los valores que usan las pruebas.""" + defaults = {"folio": "EXP2026-08-001", "doc_id": "doc-1"} + defaults.update(valores) + return plantilla.format(**defaults) + + +# ── Las formas de los payloads ─────────────────────────────────────────────── + +def test_el_alta_de_expediente_manda_exactamente_las_claves_del_contrato(capturadas): + """Ni una de más ni una de menos. + + Una clave de menos y EFC responde ``payload_invalido``; una de más y el serializer de EFC la + ignora en silencio, que es peor: el dato se cree enviado y no lo está. + """ + cliente, peticiones = capturadas + esperadas = set(CARRIL["endpoints"]["expediente_crear"]["request_claves"]) + + cliente.ingest_expediente({clave: "x" for clave in esperadas}) + + assert set(json.loads(peticiones[-1].content)) == esperadas + + +def test_la_subida_manda_los_campos_de_formulario_del_contrato(capturadas): + cliente, peticiones = capturadas + esperados = set(CARRIL["endpoints"]["documento_subir"]["form_claves"]) + archivo = CARRIL["endpoints"]["documento_subir"]["archivo_campo"] + + cliente.upload_documento( + "org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf", + crm_document_ref="CRMDOC-1-7", + ) + + cuerpo = peticiones[-1].content.decode("latin-1") + faltantes = [c for c in esperados if f'name="{c}"' not in cuerpo] + assert faltantes == [], f"el multipart no lleva {faltantes}" + assert f'name="{archivo}"' in cuerpo + + +def test_toda_peticion_del_carril_lleva_el_header_de_autenticacion(capturadas): + cliente, peticiones = capturadas + header = CARRIL["header_autenticacion"] + + cliente.resolve_organizacion("temex") + cliente.ingest_expediente({"folio": "EXP2026-08-001"}) + cliente.upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x") + + assert peticiones, "no salió ninguna petición" + for peticion in peticiones: + assert peticion.headers.get(header) == "llave-de-prueba" + + +# ── El catálogo de errores ─────────────────────────────────────────────────── + +def test_el_code_que_dispara_el_ensure_then_upload_esta_en_el_catalogo(): + """Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan + pendientes para siempre **sin que nada falle a gritos**. Es el code más frágil del carril.""" + critico = CARRIL["codigos_con_significado_para_el_crm"]["expediente_no_encontrado"] + assert critico["http"] == 404 + assert "expediente_no_encontrado" in CARRIL["codigos_error"]["404"] + + +def test_el_gateway_ramifica_por_el_code_exacto_del_contrato(): + """El código del CRM tiene ese ``code`` escrito literal. Que coincida con el contrato es lo que + esta prueba fija; que el contrato coincida con EFC lo fija la suite del otro repo.""" + from pathlib import Path as _Path + + fuente = ( + _Path(__file__).parent.parent + / "api" / "v1" / "modules" / "crm" / "expediente_gateway" / "service.py" + ).read_text(encoding="utf-8") + + assert '"expediente_no_encontrado"' in fuente + + +def test_el_cliente_extrae_el_code_del_formato_de_error_del_contrato(): + forma = CARRIL["formato_error"]["forma"] + assert set(forma) == {"error"} + assert set(forma["error"]) == {"code", "message"} + + def handler(request): + return httpx.Response(400, json={"error": {"code": "espacio_insuficiente", "message": "m"}}) + + from core.efc_client import EfcClientError + + cliente = EfcClient( + base_url="https://efc.example.test", api_key="k", timeout_ms=200, + verify_ssl=False, transport=httpx.MockTransport(handler), + ) + with pytest.raises(EfcClientError) as exc: + cliente.resolve_organizacion("temex") + + assert exc.value.code == "espacio_insuficiente" + assert exc.value.code in CARRIL["codigos_error"]["400"] + assert exc.value.retryable is False + + +# ── El API de usuario del CRM ──────────────────────────────────────────────── + +def test_las_rutas_registradas_del_crm_son_las_del_contrato(): + """Cubre las dos direcciones: ninguna del contrato sin registrar, y ninguna registrada de más + en estos dos routers. Un endpoint que aparece sin estar en el contrato es un endpoint que + nadie del otro lado sabe que existe.""" + from api.v1.modules.crm.expediente_gateway.routes import router as gateway_router + from api.v1.modules.crm.expedientes.routes import router as expedientes_router + + registradas = set() + for router in (expedientes_router, gateway_router): + for ruta in router.routes: + # ``ruta.path`` ya trae el prefijo del router aplicado: concatenarlo lo duplicaría. + for metodo in ruta.methods: + if metodo in ("HEAD", "OPTIONS"): + continue + registradas.add((metodo, ruta.path)) + + del_contrato = {(e["metodo"], e["path"]) for e in API_CRM["endpoints"]} + + assert del_contrato - registradas == set(), "el contrato declara rutas que no existen" + assert registradas - del_contrato == set(), "hay rutas fuera del contrato" + + +def test_la_respuesta_de_un_documento_nunca_expone_la_copia_local(): + """La copia local se borra al confirmar la entrega a EFC: una referencia expuesta al frontend + es una referencia que va a dejar de existir.""" + from api.v1.modules.crm.expedientes.dto import ExpedienteDocumentResponse + + campos = set(ExpedienteDocumentResponse.model_fields) + + for prohibido in API_CRM["documento_response_prohibido"]: + assert prohibido not in campos, f"la respuesta expone '{prohibido}'" + faltantes = [c for c in API_CRM["documento_response_claves_minimas"] if c not in campos] + assert faltantes == [], f"la respuesta no lleva {faltantes}" + + +def test_los_estados_de_sincronizacion_son_los_del_contrato(): + from api.v1.modules.crm.expediente_gateway.models import ( + STATUS_FAILED, + STATUS_PENDING, + STATUS_SENT, + ) + + # Los del outbox son en minúsculas; los que ve el frontend en el documento, en mayúsculas. + assert {STATUS_PENDING, STATUS_SENT, STATUS_FAILED} == {"pending", "sent", "failed"} + assert set(API_CRM["estados_sincronizacion"]) == {"PENDING", "SYNCED", "FAILED"} + + +def test_las_metricas_del_outbox_tienen_las_claves_del_contrato(db): + from api.v1.modules.crm.expediente_gateway import service as gateway + from tests.conftest import COMPANY_ID, TENANT_ID + + metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID) + + assert set(API_CRM["metricas_outbox_claves"]).issubset(set(metricas)) + + +def test_el_formato_del_folio_es_el_del_contrato(db): + """El folio es lo que el usuario ve al guardar y lo que enlaza al expediente con EFC: su forma + es contrato, no detalle.""" + import re + + # El generador es el del CRM (crm/common/folios.py), no uno del carril: el expediente y su + # consecutivo son del CRM y esta prueba solo afirma que la FORMA que produce es la que EFC + # espera. Por eso se llama a la implementación real y no se reimplementa el formato aquí. + from api.v1.modules.crm.common.folios import next_folio + from tests.conftest import COMPANY_ID, TENANT_ID + + # with_direction=False: el expediente no lleva sufijo I/E, a diferencia de la solicitud. + folio = next_folio(db, TENANT_ID, COMPANY_ID, "EXP", None, with_direction=False) + + patron = ( + API_CRM["folio"]["formato"] + .replace("{YYYY}", r"\d{4}") + .replace("{MM}", r"\d{2}") + .replace("{NNN}", r"\d{3,}") + ) + assert re.fullmatch(patron, folio), f"{folio} no cumple {API_CRM['folio']['formato']}" diff --git a/backend/tests/test_doc_types_paridad.py b/backend/tests/test_doc_types_paridad.py new file mode 100644 index 0000000..7a01dff --- /dev/null +++ b/backend/tests/test_doc_types_paridad.py @@ -0,0 +1,85 @@ +"""Paridad del catálogo de tipos de documento entre el CRM y EFC. + +``EFC_DOC_TYPES`` del CRM tiene que ser **exactamente** el juego de claves de +``TIPOS_DOCUMENTO_CRM`` de ``api/record/views_integrations_crm.py`` en EFC. La lista está duplicada +a mano en dos repos con despliegue independiente, y este archivo es lo único que la mantiene +honesta: si alguien agrega un tipo de un solo lado, esto se pone rojo **antes** de que un documento +se rechace en producción con ``tipo_invalido``. + +El juego esperado va escrito **literal** aquí y no derivado de ``doc_types.py``, porque una prueba +que se lo pregunte al mismo módulo que valida no prueba nada: pasaría con cualquier cambio. +""" + +from api.v1.modules.crm.expediente_gateway.doc_types import EFC_DOC_TYPES, is_valid_doc_type + +# Copia literal de las claves de TIPOS_DOCUMENTO_CRM (EFC, fase 3 del ticket T2026-08-046). +# Al cambiar EFC, se cambia AQUÍ y el rojo obliga a mirar los dos lados. +CLAVES_EN_EFC = { + # crm.documents — DOC_TYPES de frontend/src/lib/api/crm/format.ts + "constancia_fiscal", + "acta_constitutiva", + "identificacion", + "comprobante_domicilio", + "contrato", + "presentacion", + "certificacion", + "licencia", + "convenio", + "tarifario", + # ops.shipment_documents — SHIPMENT_DOC_TYPES del mismo archivo + "MBL", + "HBL", + "MAWB", + "HAWB", + "CMR", + "factura_comercial", + "packing_list", + "carta_encomienda", + "carta_garantia", + "certificado_permiso", + # fin.invoices + "factura_venta", + # 'otro' existe en AMBAS listas del CRM y significa lo mismo: una sola entrada + "otro", +} + + +def test_el_catalogo_del_crm_es_identico_al_de_efc(): + faltan_en_crm = CLAVES_EN_EFC - EFC_DOC_TYPES + sobran_en_crm = EFC_DOC_TYPES - CLAVES_EN_EFC + assert not faltan_en_crm, f"EFC acepta tipos que el CRM no conoce: {sorted(faltan_en_crm)}" + assert not sobran_en_crm, ( + f"El CRM mandaría tipos que EFC va a rechazar con tipo_invalido: {sorted(sobran_en_crm)}" + ) + + +def test_son_exactamente_veintidos(): + """El número está en el ticket. Si cambia, es un cambio de contrato entre dos repos.""" + assert len(EFC_DOC_TYPES) == 22 + + +def test_no_hay_duplicados_entre_las_tres_fuentes(): + """``otro`` está en las dos listas del CRM y debe colapsar a UNA entrada. + + Un ``frozenset`` lo colapsa solo; la prueba está para que una futura refactorización a lista o + a tupla no reintroduzca el duplicado en silencio. + """ + from api.v1.modules.crm.expediente_gateway import doc_types + + todas = ( + doc_types._TIPOS_DOCUMENTOS_CLIENTE + + doc_types._TIPOS_DOCUMENTOS_EMBARQUE + + doc_types._TIPOS_FACTURACION + + doc_types._TIPOS_COMUNES + ) + assert len(todas) == len(set(todas)) + + +def test_un_tipo_fuera_del_catalogo_se_rechaza(): + """El CRM valida ANTES de gastar un viaje de red, y evita que un typo cree un DocumentType + basura en el catálogo GLOBAL de EFC, que comparten todas las organizaciones.""" + assert is_valid_doc_type("MBL") is True + assert is_valid_doc_type("mbl") is False # sensible a mayúsculas, como el catálogo de EFC + assert is_valid_doc_type("factura_de_venta") is False # typo de 'factura_venta' + assert is_valid_doc_type("") is False + assert is_valid_doc_type(None) is False diff --git a/backend/tests/test_efc_client.py b/backend/tests/test_efc_client.py new file mode 100644 index 0000000..e159b56 --- /dev/null +++ b/backend/tests/test_efc_client.py @@ -0,0 +1,248 @@ +"""Pruebas del cliente HTTP hacia EFC. + +**Existen porque el carril de referencia no las tiene.** Verificado: en el gateway de Anexo22 no hay +ni una prueba de ``EfcClient._request``, así que su bucle de reintentos, su backoff, su corte en 4xx +y su header nunca se ejercitan. Ese hueco no se clona. + +Todo va contra ``httpx.MockTransport`` por el parámetro ``transport``, que existe justamente para +esto: **ninguna de estas pruebas toca la red**. +""" + +import httpx +import pytest + +from core.efc_client import EfcClient, EfcClientError + +BASE = "https://efc.example.test" +KEY = "llave-de-prueba" + + +def _client(handler, **kwargs) -> EfcClient: + return EfcClient( + base_url=kwargs.pop("base_url", BASE), + api_key=kwargs.pop("api_key", KEY), + timeout_ms=kwargs.pop("timeout_ms", 500), + upload_timeout_ms=kwargs.pop("upload_timeout_ms", 500), + verify_ssl=False, + transport=httpx.MockTransport(handler), + **kwargs, + ) + + +def test_reintenta_un_500_y_devuelve_el_exito(): + intentos = {"n": 0} + + def handler(request): + intentos["n"] += 1 + if intentos["n"] == 1: + return httpx.Response(500, json={"detail": "boom"}) + return httpx.Response(200, json={"id": "org-1"}) + + resp = _client(handler).resolve_organizacion("temex") + assert resp == {"id": "org-1"} + assert intentos["n"] == 2 + + +def test_un_500_permanente_hace_exactamente_tres_intentos_y_es_retryable(): + """``retries = 2`` significa 3 intentos: el original + 2. Ni 2 ni 4.""" + intentos = {"n": 0} + + def handler(request): + intentos["n"] += 1 + return httpx.Response(500, json={"detail": "boom"}) + + with pytest.raises(EfcClientError) as exc: + _client(handler).resolve_organizacion("temex") + + assert intentos["n"] == 3 + assert exc.value.retryable is True + + +def test_un_timeout_permanente_hace_tres_intentos_y_es_retryable(): + intentos = {"n": 0} + + def handler(request): + intentos["n"] += 1 + raise httpx.ConnectTimeout("se acabó el tiempo", request=request) + + with pytest.raises(EfcClientError) as exc: + _client(handler).resolve_organizacion("temex") + + assert intentos["n"] == 3 + assert exc.value.retryable is True + + +def test_un_400_no_se_reintenta_y_extrae_el_code_del_cuerpo(): + """El corte en 4xx es lo que evita machacar a EFC con una petición que nunca va a pasar. + + Y el ``code`` extraído es lo que permite al worker ramificar **por campo**, nunca parseando el + texto del mensaje: un texto cambia con cualquier refactor del otro repo. + """ + intentos = {"n": 0} + + def handler(request): + intentos["n"] += 1 + return httpx.Response( + 400, + json={"error": {"code": "espacio_insuficiente", "message": "La licencia no tiene espacio"}}, + ) + + with pytest.raises(EfcClientError) as exc: + _client(handler).resolve_organizacion("temex") + + assert intentos["n"] == 1 + assert exc.value.status_code == 400 + assert exc.value.code == "espacio_insuficiente" + assert exc.value.retryable is False + assert "La licencia no tiene espacio" in str(exc.value) + + +@pytest.mark.parametrize("status_code", [401, 403]) +def test_401_y_403_no_se_reintentan(status_code): + """Una key mal configurada no mejora insistiendo: reintentarla solo gasta cuota y llena logs.""" + intentos = {"n": 0} + + def handler(request): + intentos["n"] += 1 + return httpx.Response(status_code) + + with pytest.raises(EfcClientError) as exc: + _client(handler).resolve_organizacion("temex") + + assert intentos["n"] == 1 + assert exc.value.retryable is False + + +def test_un_cuerpo_de_error_que_no_es_json_no_revienta(): + def handler(request): + return httpx.Response(400, text="502 Bad Gateway") + + with pytest.raises(EfcClientError) as exc: + _client(handler).resolve_organizacion("temex") + + assert exc.value.code is None + assert exc.value.status_code == 400 + + +def test_toda_llamada_manda_el_header_x_api_key(): + visto = {} + + def handler(request): + visto["key"] = request.headers.get("X-Api-Key") + return httpx.Response(200, json={"id": "org-1"}) + + _client(handler).resolve_organizacion("temex") + assert visto["key"] == KEY + + +def test_sin_url_configurada_no_toca_la_red_y_el_error_no_es_retryable(): + """``is_configured is False`` es lo que hace que todo el carril sea best-effort. + + Si esto tocara la red, cada operación del CRM con EFC apagado pagaría un timeout. + """ + llamado = {"n": 0} + + def handler(request): + llamado["n"] += 1 + return httpx.Response(200, json={}) + + client = _client(handler, base_url="") + assert client.is_configured is False + + with pytest.raises(EfcClientError) as exc: + client.resolve_organizacion("temex") + + assert llamado["n"] == 0 + assert exc.value.retryable is False + + +def test_sin_api_key_tampoco_esta_configurado(): + def handler(request): + return httpx.Response(200, json={}) + + assert _client(handler, api_key="").is_configured is False + + +def test_la_base_url_con_y_sin_barra_final_dan_la_misma_url(): + urls = [] + + def handler(request): + urls.append(str(request.url)) + return httpx.Response(200, json={"id": "org-1"}) + + _client(handler, base_url=BASE).resolve_organizacion("temex") + _client(handler, base_url=BASE + "/").resolve_organizacion("temex") + + assert urls[0] == urls[1] + assert "//organization" not in urls[0] + + +def test_la_subida_va_multipart_y_lleva_el_crm_document_ref(): + """El ref es la tercera capa de idempotencia: EFC devuelve 200 con el que ya existía.""" + visto = {} + + def handler(request): + visto["content_type"] = request.headers.get("Content-Type", "") + visto["body"] = request.content + return httpx.Response(201, json={"id": "doc-1"}) + + resp = _client(handler).upload_documento( + "org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4 contenido", "application/pdf", + crm_document_ref="SHPDOC-1-4471", + ) + + assert resp == {"id": "doc-1"} + assert visto["content_type"].startswith("multipart/form-data") + assert b"SHPDOC-1-4471" in visto["body"] + assert b"%PDF-1.4 contenido" in visto["body"] + # Nada de base64: el archivo viaja crudo dentro del multipart. + assert b"base64" not in visto["body"] + + +def test_la_subida_usa_el_timeout_largo_y_los_metadatos_el_corto(): + """Los 8 s de los metadatos no alcanzan para un archivo de 25 MB, y un timeout de subida + demasiado largo haría que el CRM vea un 504 opaco de nginx sin saber si el documento entró.""" + client = _client(handler=lambda r: httpx.Response(200, json={}), timeout_ms=8000, upload_timeout_ms=55000) + assert client.timeout_s == 8.0 + assert client.upload_timeout_s == 55.0 + assert client.upload_timeout_s > client.timeout_s + + +def test_ensure_then_upload_puede_ramificar_por_el_code_del_404(): + """El 404 del expediente tiene que llegar al worker con su ``code`` y su ``status_code``. + + Es lo que dispara el ensure-then-upload; sin el code, el worker tendría que adivinar de qué es + el 404 y crearía provisionales por cualquier ausencia. + """ + def handler(request): + return httpx.Response( + 404, json={"error": {"code": "expediente_no_encontrado", "message": "no está"}} + ) + + with pytest.raises(EfcClientError) as exc: + _client(handler).upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x") + + assert exc.value.status_code == 404 + assert exc.value.code == "expediente_no_encontrado" + assert exc.value.retryable is False + + +def test_la_descarga_devuelve_contenido_y_nombre_del_content_disposition(): + def handler(request): + return httpx.Response( + 200, + content=b"contenido binario", + headers={"Content-Disposition": 'attachment; filename="factura.pdf"'}, + ) + + contenido, nombre = _client(handler).download_documento("org-1", "doc-1") + assert contenido == b"contenido binario" + assert nombre == "factura.pdf" + + +def test_la_descarga_sin_content_disposition_cae_al_id_del_documento(): + def handler(request): + return httpx.Response(200, content=b"x") + + _contenido, nombre = _client(handler).download_documento("org-1", "doc-9") + assert nombre == "doc-9" diff --git a/backend/tests/test_efc_entrega_documento.py b/backend/tests/test_efc_entrega_documento.py new file mode 100644 index 0000000..51ba9fa --- /dev/null +++ b/backend/tests/test_efc_entrega_documento.py @@ -0,0 +1,350 @@ +"""Pruebas de la entrega de un archivo al expediente de EFC. + +Cubren las tres cosas que hacen que este carril no pierda ni duplique archivos: el +**ensure-then-upload** cuando el provisional todavía no existe allá, el **corte directo** +(``delete_local``) que borra la copia local solo al confirmar, y la idempotencia por +``crm_document_ref`` cuando un timeout ambiguo hace reintentar. +""" + +import pytest + +from api.v1.modules.crm.expediente_gateway import service as gateway +from api.v1.modules.crm.expediente_gateway.models import ( + FILE_KIND_DOCUMENTO, + MAX_ATTEMPTS, + SOURCE_CRM_DOCUMENTS, + STATUS_FAILED, + STATUS_PENDING, + STATUS_SENT, + EfcFileOutbox, +) +from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.documents.models import Document +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from core.efc_client import EfcClientError +from tests.conftest import COMPANY_ID, TENANT_ID + +CONTENIDO = b"%PDF-1.4 guia madre" +S3_KEY = "tenants/1/companies/1/expedientes/1/guia.pdf" + + +class _ClienteFalso: + """Doble del cliente de EFC. Registra qué se le pidió, para poder afirmarlo.""" + + is_configured = True + + def __init__(self, *, upload_falla_con=None, falla_solo_la_primera=True): + self.uploads = [] + self.ingests = [] + self._upload_falla_con = upload_falla_con + self._falla_solo_la_primera = falla_solo_la_primera + + def ingest_expediente(self, payload): + self.ingests.append(payload) + return {"status": "created", "efc": {"pedimento_id": "ped-1"}} + + def upload_documento(self, org_id, company_id, expediente_id, tipo, filename, content, + content_type="application/octet-stream", crm_document_ref=None): + primera = not self.uploads + self.uploads.append({ + "org_id": org_id, "company_id": company_id, "expediente_id": expediente_id, + "tipo": tipo, "filename": filename, "content": content, + "content_type": content_type, "crm_document_ref": crm_document_ref, + }) + if self._upload_falla_con is not None and (primera or not self._falla_solo_la_primera): + raise self._upload_falla_con + return {"id": f"doc-{len(self.uploads)}"} + + +@pytest.fixture() +def entorno(db, monkeypatch): + """EFC encendido, storage simulado y organización ya resuelta.""" + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) + monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX")) + monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1") + + borrados = [] + import core.storage_s3 as storage + + monkeypatch.setattr(storage, "get_object_bytes", lambda key: CONTENIDO) + monkeypatch.setattr(storage, "delete_object_if_exists", lambda key: borrados.append(key)) + + solicitud = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" + ) + expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + return {"db": db, "expediente": expediente, "borrados": borrados} + + +def _documento_local(db, expediente) -> Document: + doc = Document( + doc_type="MBL", + name="guia.pdf", + file_key=S3_KEY, + content_type="application/pdf", + size_bytes=len(CONTENIDO), + expediente_id=expediente.id, + efc_sync_state="PENDING", + tenant_id=TENANT_ID, + company_id=COMPANY_ID, + ) + db.add(doc) + db.flush() + doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}" + db.commit() + return doc + + +def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox: + row = EfcFileOutbox( + kind=FILE_KIND_DOCUMENTO, + s3_key=S3_KEY, + file_name="guia.pdf", + content_type="application/pdf", + efc_tipo="MBL", + source_table=SOURCE_CRM_DOCUMENTS, + source_id=documento.id, + crm_document_ref=documento.efc_document_ref, + expediente_ref=expediente.id, + delete_local=kwargs.pop("delete_local", True), + status=kwargs.pop("status", STATUS_PENDING), + tenant_id=TENANT_ID, + company_id=COMPANY_ID, + **kwargs, + ) + db.add(row) + db.commit() + return row + + +# ── Camino feliz ───────────────────────────────────────────────────────────── + +def test_entrega_feliz_marca_la_fila_y_el_documento(entorno): + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + cliente = _ClienteFalso() + + gateway.deliver_file_row(db, row, cliente) + + assert row.status == STATUS_SENT + assert row.efc_document_id == "doc-1" + assert row.sent_at is not None + assert documento.efc_sync_state == "SYNCED" + assert documento.efc_document_id == "doc-1" + assert documento.efc_synced_at is not None + + +def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno): + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + cliente = _ClienteFalso() + + gateway.deliver_file_row(db, row, cliente) + + assert len(cliente.uploads) == 1 + subida = cliente.uploads[0] + assert subida["crm_document_ref"] == documento.efc_document_ref + assert subida["content"] == CONTENIDO + assert subida["tipo"] == "MBL" + assert subida["expediente_id"] == expediente.id + + +# ── Ensure-then-upload ─────────────────────────────────────────────────────── + +def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno): + """La creación del provisional y la subida son colas distintas: la subida puede llegar antes. + + Sin esto, el primer documento de cada expediente fallaría y esperaría al barrido. + """ + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + cliente = _ClienteFalso( + upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado") + ) + + gateway.deliver_file_row(db, row, cliente) + + assert len(cliente.ingests) == 1 + assert cliente.ingests[0]["folio"] == expediente.folio + assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token + assert len(cliente.uploads) == 2 # el que falló + UNO de reintento + assert row.status == STATUS_SENT + + +def test_un_404_con_OTRO_code_no_dispara_el_ensure(entorno): + """El ensure se dispara por el ``code``, no por el 404 a secas. + + Si se disparara por cualquier 404, un documento no encontrado crearía provisionales espurios. + """ + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + cliente = _ClienteFalso( + upload_falla_con=EfcClientError("otro", status_code=404, code="documento_no_encontrado"), + falla_solo_la_primera=False, + ) + + gateway.deliver_file_row(db, row, cliente) + + assert cliente.ingests == [] + assert len(cliente.uploads) == 1 + assert row.attempts == 1 + + +def test_el_ensure_reintenta_UNA_vez_y_no_entra_en_bucle(entorno): + """Si el reintento vuelve a dar 404, se registra el fallo. No se reintenta indefinidamente.""" + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + cliente = _ClienteFalso( + upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado"), + falla_solo_la_primera=False, + ) + + gateway.deliver_file_row(db, row, cliente) + + assert len(cliente.ingests) == 1 + assert len(cliente.uploads) == 2 + assert row.attempts == 1 + assert row.status == STATUS_FAILED # un 404 no es retryable + + +# ── Corte directo (delete_local) ───────────────────────────────────────────── + +def test_al_confirmar_se_borra_la_copia_local(entorno): + """«EFC es la fuente única» se cumple así: el objeto local se borra AL CONFIRMAR, no antes.""" + db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, delete_local=True) + + gateway.deliver_file_row(db, row, _ClienteFalso()) + + assert borrados == [S3_KEY] + # La key local se limpia: dejarla apuntaría a un objeto que ya no existe y la descarga se + # ramificaría por el camino equivocado. + assert documento.file_key is None + + +def test_con_delete_local_en_false_no_se_borra_nada(entorno): + db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, delete_local=False) + + gateway.deliver_file_row(db, row, _ClienteFalso()) + + assert borrados == [] + assert documento.file_key == S3_KEY + assert row.status == STATUS_SENT + + +def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monkeypatch): + """El archivo YA está en EFC. No poder borrar la copia local no invalida la entrega, y volver a + intentarla subiría el mismo documento otra vez.""" + db, expediente = entorno["db"], entorno["expediente"] + import core.storage_s3 as storage + + def _revienta(key): + raise RuntimeError("MinIO no responde") + + monkeypatch.setattr(storage, "delete_object_if_exists", _revienta) + + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, delete_local=True) + + gateway.deliver_file_row(db, row, _ClienteFalso()) + + assert row.status == STATUS_SENT + assert documento.efc_sync_state == "SYNCED" + + +def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno): + """Nunca se borra el original antes de confirmar la subida: si se borrara primero y la subida + fallara, el archivo se habría perdido.""" + db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, delete_local=True) + cliente = _ClienteFalso( + upload_falla_con=EfcClientError("500", status_code=500, retryable=True), + falla_solo_la_primera=False, + ) + + gateway.deliver_file_row(db, row, cliente) + + assert borrados == [] # la subida falló: el original sigue ahí + assert row.status == STATUS_PENDING + assert documento.file_key == S3_KEY + + +# ── Fallos ─────────────────────────────────────────────────────────────────── + +def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno): + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1) + cliente = _ClienteFalso( + upload_falla_con=EfcClientError("EFC caído", status_code=503, retryable=True), + falla_solo_la_primera=False, + ) + + gateway.deliver_file_row(db, row, cliente) # no lanza + + assert row.status == STATUS_FAILED + assert documento.efc_sync_state == "FAILED" + assert "EFC caído" in documento.efc_error_detail + assert documento.efc_attempts == MAX_ATTEMPTS + + +def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno): + """Segunda guarda de idempotencia. Un re-despacho tras un timeout ambiguo no duplica.""" + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento, status=STATUS_SENT) + cliente = _ClienteFalso() + + gateway.deliver_file_row(db, row, cliente) + + assert cliente.uploads == [] + + +def test_un_expediente_borrado_deja_la_fila_reintentable(entorno): + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + row.expediente_ref = 999999 + db.commit() + + gateway.deliver_file_row(db, row, _ClienteFalso()) + + assert row.status == STATUS_PENDING # retryable: el expediente puede reaparecer + assert row.attempts == 1 + + +def test_un_reintento_tras_timeout_manda_el_mismo_ref_y_no_duplica(entorno): + """El ``crm_document_ref`` es estable entre reintentos: EFC devuelve 200 con el que ya existía. + + Es la tercera capa de idempotencia y la que cubre el timeout ambiguo —EFC commiteó y contestó + tarde—, donde el CRM no puede saber si el documento entró. + """ + db, expediente = entorno["db"], entorno["expediente"] + documento = _documento_local(db, expediente) + row = _fila(db, expediente, documento) + + primer_cliente = _ClienteFalso( + upload_falla_con=EfcClientError("timeout", retryable=True), falla_solo_la_primera=False + ) + gateway.deliver_file_row(db, row, primer_cliente) + assert row.status == STATUS_PENDING + + segundo_cliente = _ClienteFalso() + gateway.deliver_file_row(db, row, segundo_cliente) + + assert primer_cliente.uploads[0]["crm_document_ref"] == segundo_cliente.uploads[0]["crm_document_ref"] + assert row.status == STATUS_SENT diff --git a/backend/tests/test_efc_outbox.py b/backend/tests/test_efc_outbox.py new file mode 100644 index 0000000..f87c97f --- /dev/null +++ b/backend/tests/test_efc_outbox.py @@ -0,0 +1,386 @@ +"""Pruebas de la máquina de reintentos del outbox hacia EFC. + +Lo que se fija aquí es la capa 2 de las tres del carril: el worker **nunca lanza**, registra el +fallo en la propia fila, y decide reintentar o rendirse por el campo ``retryable`` —nunca parseando +el texto del error—. +""" + +import pytest + +from api.v1.modules.crm.expediente_gateway import service as gateway +from api.v1.modules.crm.expediente_gateway.models import ( + FILE_KIND_DOCUMENTO, + KIND_EXPEDIENTE, + MAX_ATTEMPTS, + SOURCE_CRM_DOCUMENTS, + SOURCE_OPS_SHIPMENT_DOCUMENTS, + STATUS_FAILED, + STATUS_PENDING, + STATUS_SENT, + EfcFileOutbox, + EfcSyncOutbox, +) +from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from core.efc_client import EfcClientError +from tests.conftest import COMPANY_ID, TENANT_ID + +OTRO_TENANT = 99 + + +@pytest.fixture() +def efc_encendido(monkeypatch): + """Enciende la integración y evita que el encolado toque el broker o el tenant real.""" + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) + monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX")) + return settings + + +@pytest.fixture() +def efc_apagado(monkeypatch): + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False) + return settings + + +def _expediente(db): + solicitud = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" + ) + return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + + +def _fila_sync(db, expediente, **kwargs): + row = EfcSyncOutbox( + kind=kwargs.pop("kind", KIND_EXPEDIENTE), + payload=kwargs.pop("payload", {"folio": expediente.folio}), + expediente_ref=expediente.id, + status=kwargs.pop("status", STATUS_PENDING), + tenant_id=kwargs.pop("tenant_id", TENANT_ID), + company_id=kwargs.pop("company_id", COMPANY_ID), + **kwargs, + ) + db.add(row) + db.commit() + return row + + +def _fila_archivo(db, expediente, **kwargs): + row = EfcFileOutbox( + kind=kwargs.pop("kind", FILE_KIND_DOCUMENTO), + s3_key=kwargs.pop("s3_key", "tenants/1/companies/1/expedientes/1/guia.pdf"), + file_name=kwargs.pop("file_name", "guia.pdf"), + content_type="application/pdf", + efc_tipo=kwargs.pop("efc_tipo", "MBL"), + source_table=kwargs.pop("source_table", SOURCE_CRM_DOCUMENTS), + source_id=kwargs.pop("source_id", 1), + crm_document_ref=kwargs.pop("crm_document_ref", "CRMDOC-1-1"), + expediente_ref=expediente.id, + status=kwargs.pop("status", STATUS_PENDING), + tenant_id=kwargs.pop("tenant_id", TENANT_ID), + company_id=kwargs.pop("company_id", COMPANY_ID), + **kwargs, + ) + db.add(row) + db.commit() + return row + + +# ── _register_failure ──────────────────────────────────────────────────────── + +def test_un_fallo_retryable_suma_un_intento_y_deja_la_fila_pendiente(db, efc_encendido): + expediente = _expediente(db) + row = _fila_sync(db, expediente) + + gateway._register_failure(db, row, EfcClientError("EFC no responde", retryable=True), True) + + assert row.attempts == 1 + assert row.status == STATUS_PENDING + assert "EFC no responde" in row.last_error + + +def test_un_fallo_no_retryable_marca_failed_de_inmediato(db, efc_encendido): + """Un 400 no mejora insistiendo: reintentarlo ocho veces solo retrasa que alguien lo vea.""" + expediente = _expediente(db) + row = _fila_sync(db, expediente) + + gateway._register_failure(db, row, EfcClientError("tipo inválido", retryable=False), False) + + assert row.attempts == 1 + assert row.status == STATUS_FAILED + + +def test_al_llegar_a_max_attempts_la_fila_queda_failed(db, efc_encendido): + expediente = _expediente(db) + row = _fila_sync(db, expediente, attempts=MAX_ATTEMPTS - 1) + + gateway._register_failure(db, row, EfcClientError("otra vez", retryable=True), True) + + assert row.attempts == MAX_ATTEMPTS + assert row.status == STATUS_FAILED + + +def test_el_ultimo_error_se_trunca_a_2000_caracteres(db, efc_encendido): + """``last_error`` es Text, pero un traceback de 5000 caracteres por fila llena la tabla de ruido.""" + expediente = _expediente(db) + row = _fila_sync(db, expediente) + + gateway._register_failure(db, row, Exception("x" * 5000), True) + + assert len(row.last_error) == 2000 + + +# ── deliver_row: no propaga ────────────────────────────────────────────────── + +class _ClienteQueRevienta: + is_configured = True + + def __init__(self, exc): + self._exc = exc + self.llamadas = 0 + + def ingest_expediente(self, payload): + self.llamadas += 1 + raise self._exc + + def completar_expediente(self, folio, payload): + self.llamadas += 1 + raise self._exc + + +def test_deliver_row_no_propaga_la_excepcion_de_efc(db, efc_encendido, monkeypatch): + """Si esto propagara, un EFC caído mataría al worker y se perdería la cola entera.""" + expediente = _expediente(db) + row = _fila_sync(db, expediente) + monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1") + + gateway.deliver_row(db, row, _ClienteQueRevienta(EfcClientError("caído", retryable=True))) + + assert row.status == STATUS_PENDING + assert row.attempts == 1 + + +def test_deliver_row_tampoco_propaga_una_excepcion_inesperada(db, efc_encendido, monkeypatch): + expediente = _expediente(db) + row = _fila_sync(db, expediente) + monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1") + + gateway.deliver_row(db, row, _ClienteQueRevienta(RuntimeError("algo raro"))) + + assert row.attempts == 1 + # Una excepción inesperada se trata como transitoria: no se sabe que sea permanente. + assert row.status == STATUS_PENDING + + +def test_una_fila_ya_enviada_no_vuelve_a_llamar_a_efc(db, efc_encendido): + """Segunda guarda de idempotencia. Sin ella, un re-despacho duplicaría el expediente en EFC.""" + expediente = _expediente(db) + row = _fila_sync(db, expediente, status=STATUS_SENT) + cliente = _ClienteQueRevienta(EfcClientError("no debería llamarse")) + + gateway.deliver_row(db, row, cliente) + + assert cliente.llamadas == 0 + assert row.status == STATUS_SENT + + +# ── _ya_entregado: la ambigüedad de las dos secuencias ─────────────────────── + +def test_no_se_encola_dos_veces_el_mismo_archivo(db, efc_encendido): + expediente = _expediente(db) + _fila_archivo(db, expediente, source_id=7, status=STATUS_SENT) + + assert gateway._ya_entregado(db, SOURCE_CRM_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is True + + row = gateway.enqueue_file_best_effort( + db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None, + efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=7, + crm_document_ref="CRMDOC-1-7", expediente_ref=expediente.id, + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + assert row is None + + +def test_el_mismo_id_en_otra_tabla_de_origen_SI_se_encola(db, efc_encendido): + """La prueba de la ambigüedad de las dos secuencias. + + ``crm.documents.id = 7`` y ``ops.shipment_documents.id = 7`` son documentos DISTINTOS. Sin + ``source_table`` en la guarda, entregar el primero haría que el segundo se saltara para + siempre — y nadie vería un error. + """ + expediente = _expediente(db) + _fila_archivo(db, expediente, source_id=7, source_table=SOURCE_CRM_DOCUMENTS, status=STATUS_SENT) + + assert gateway._ya_entregado(db, SOURCE_OPS_SHIPMENT_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is False + + row = gateway.enqueue_file_best_effort( + db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None, + efc_tipo="MBL", source_table=SOURCE_OPS_SHIPMENT_DOCUMENTS, source_id=7, + crm_document_ref="SHPDOC-1-7", expediente_ref=expediente.id, + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + assert row is not None + assert row.source_table == SOURCE_OPS_SHIPMENT_DOCUMENTS + + +# ── retry ──────────────────────────────────────────────────────────────────── + +def test_retry_resetea_la_fila_y_la_re_despacha(db, efc_encendido, monkeypatch): + despachos = [] + monkeypatch.setattr( + gateway, "_dispatch_file_delivery", lambda oid, t, c: despachos.append((oid, t, c)) + ) + expediente = _expediente(db) + row = _fila_archivo(db, expediente, status=STATUS_FAILED, attempts=MAX_ATTEMPTS, + last_error="se acabaron los intentos") + + ok = gateway.retry_outbox_row(db, row.id, TENANT_ID, COMPANY_ID, "file") + + assert ok is True + assert row.status == STATUS_PENDING + assert row.attempts == 0 + assert row.last_error is None + assert despachos == [(row.id, TENANT_ID, COMPANY_ID)] + + +def test_retry_de_otro_tenant_devuelve_false(db, efc_encendido): + """Devuelve False y el llamador lo traduce a 404: un 200 le haría creer al frontend que se + reencoló algo que ni siquiera es suyo.""" + expediente = _expediente(db) + row = _fila_archivo(db, expediente, status=STATUS_FAILED) + + assert gateway.retry_outbox_row(db, row.id, OTRO_TENANT, COMPANY_ID, "file") is False + assert row.status == STATUS_FAILED # intacta + + +def test_retry_de_una_fila_inexistente_devuelve_false(db, efc_encendido): + assert gateway.retry_outbox_row(db, 999999, TENANT_ID, COMPANY_ID, "file") is False + + +# ── métricas y listado ─────────────────────────────────────────────────────── + +def test_las_metricas_cuentan_por_status_sumando_las_dos_tablas(db, efc_encendido): + expediente = _expediente(db) + _fila_sync(db, expediente, status=STATUS_SENT) + _fila_archivo(db, expediente, source_id=1, status=STATUS_PENDING) + _fila_archivo(db, expediente, source_id=2, status=STATUS_FAILED) + _fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED) + + metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID) + + # El alta del expediente encoló su propia fila pendiente al crearse la solicitud. + assert metricas["failed"] == 2 + assert metricas["sent"] == 1 + assert metricas["pending"] >= 1 + + +def test_las_metricas_no_ven_las_filas_de_otro_tenant(db, efc_encendido): + expediente = _expediente(db) + _fila_archivo(db, expediente, source_id=5, status=STATUS_FAILED, tenant_id=OTRO_TENANT) + + assert gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)["failed"] == 0 + + +def test_el_listado_marca_de_que_tabla_viene_cada_fila(db, efc_encendido): + expediente = _expediente(db) + _fila_archivo(db, expediente, source_id=1) + + filas = gateway.list_outbox(db, TENANT_ID, COMPANY_ID) + tablas = {f["tabla"] for f in filas} + assert tablas == {"sync", "file"} + + solo_archivos = gateway.list_outbox(db, TENANT_ID, COMPANY_ID, tipo="file") + assert {f["tabla"] for f in solo_archivos} == {"file"} + + +# ── huecos ─────────────────────────────────────────────────────────────────── + +def test_find_expediente_gaps_encuentra_los_que_no_tienen_fila(db, efc_apagado): + """Con EFC apagado no se encola nada, así que todos los expedientes son huecos. + + Es exactamente el caso que el barrido cubre: lo creado ANTES de activar la integración. + """ + _expediente(db) + _expediente(db) + + huecos = gateway.find_expediente_gaps(db) + assert len(huecos) == 2 + + +def test_un_expediente_con_fila_failed_NO_es_un_hueco(db, efc_encendido): + """Un ``failed`` existe como fila: es visible en el tablero y reintentable a mano. + + Tratarlo como hueco lo re-encolaría en cada barrido y escondería el fallo. + """ + expediente = _expediente(db) + fila = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).first() + assert fila is not None + fila.status = STATUS_FAILED + db.commit() + + assert gateway.find_expediente_gaps(db) == [] + + +# ── best-effort ────────────────────────────────────────────────────────────── + +def test_con_efc_apagado_no_se_encola_nada(db, efc_apagado): + """``EFC_API_URL`` vacía apaga el carril entero. El CRM sigue funcionando igual.""" + expediente = _expediente(db) + + assert db.query(EfcSyncOutbox).count() == 0 + + fila = gateway.enqueue_file_best_effort( + db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None, + efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=1, + crm_document_ref="CRMDOC-1-1", expediente_ref=expediente.id, + tenant_id=TENANT_ID, company_id=COMPANY_ID, + ) + assert fila is None + assert db.query(EfcFileOutbox).count() == 0 + + +def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_encendido): + expediente = _expediente(db) + + filas = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).all() + assert len(filas) == 1 + assert filas[0].kind == KIND_EXPEDIENTE + assert filas[0].status == STATUS_PENDING + assert filas[0].payload["folio"] == expediente.folio + assert filas[0].payload["storage_token"] == expediente.efc_storage_token + + +def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendido, monkeypatch): + """La integración NUNCA puede tumbar el alta de una solicitud del usuario.""" + def _revienta(*a, **k): + raise RuntimeError("la tabla del outbox no existe") + + monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta) + + solicitud = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" + ) + + assert solicitud.id is not None + assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None + + +def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido): + """Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica.""" + expediente = _expediente(db) + solicitud_id = expediente.service_request_id + + expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1") + expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1") + + filas = db.query(EfcSyncOutbox).filter( + EfcSyncOutbox.expediente_ref == expediente.id, + EfcSyncOutbox.kind == KIND_EXPEDIENTE, + ).all() + assert len(filas) == 1 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_gateway_rutas.py b/backend/tests/test_gateway_rutas.py new file mode 100644 index 0000000..40494f4 --- /dev/null +++ b/backend/tests/test_gateway_rutas.py @@ -0,0 +1,134 @@ +"""Contrato del tablero de ops del carril CRM -> EFC. + +Lo que se fija aquí es lo que el frontend espera recibir: el 404 del reintento sobre una fila que no +existe (y **no** un 200 silencioso), la forma exacta de la respuesta de éxito, y el aislamiento por +tenant/company. +""" + +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.expediente_gateway import routes +from api.v1.modules.crm.expediente_gateway import service as gateway +from api.v1.modules.crm.expediente_gateway.models import ( + FILE_KIND_DOCUMENTO, + SOURCE_CRM_DOCUMENTS, + STATUS_FAILED, + STATUS_PENDING, + STATUS_SENT, + EfcFileOutbox, +) +from api.v1.modules.crm.expedientes import service as expedientes_service +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from tests.conftest import COMPANY_ID, TENANT_ID + +OTRO_TENANT = 99 +OTRA_COMPANY = 77 +USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"} + + +@pytest.fixture() +def entorno(db, monkeypatch): + from core.config import settings + + monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False) + monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None) + monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX")) + + solicitud = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1" + ) + expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) + return {"db": db, "expediente": expediente} + + +def _fila_archivo(db, expediente, **kwargs) -> EfcFileOutbox: + row = EfcFileOutbox( + kind=FILE_KIND_DOCUMENTO, + s3_key="k", + file_name="guia.pdf", + content_type="application/pdf", + efc_tipo="MBL", + source_table=SOURCE_CRM_DOCUMENTS, + source_id=kwargs.pop("source_id", 1), + crm_document_ref="CRMDOC-1-1", + expediente_ref=expediente.id, + status=kwargs.pop("status", STATUS_PENDING), + tenant_id=kwargs.pop("tenant_id", TENANT_ID), + company_id=kwargs.pop("company_id", COMPANY_ID), + **kwargs, + ) + db.add(row) + db.commit() + return row + + +def test_retry_de_una_fila_inexistente_da_404_con_mensaje_especifico(entorno): + """**Es contrato con el frontend.** Un 200 le haría pintar «reencolado» cuando no hay nada que + entregar, y el usuario esperaría un badge que nunca va a cambiar.""" + with pytest.raises(HTTPException) as exc: + routes.retry_outbox(999999, COMPANY_ID, "file", USUARIO, entorno["db"]) + + assert exc.value.status_code == 404 + assert exc.value.detail == "Fila de outbox no encontrada" + + +def test_retry_exitoso_devuelve_requeued_con_el_id(entorno): + row = _fila_archivo(entorno["db"], entorno["expediente"], status=STATUS_FAILED) + + resp = routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"]) + + assert resp == {"status": "requeued", "id": row.id} + assert row.status == STATUS_PENDING + + +def test_retry_de_una_fila_de_otra_company_da_404(entorno): + """No se filtra la existencia: para ese usuario la fila simplemente no existe.""" + row = _fila_archivo(entorno["db"], entorno["expediente"], company_id=OTRA_COMPANY) + + with pytest.raises(HTTPException) as exc: + routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"]) + assert exc.value.status_code == 404 + + +def test_metrics_cuenta_por_status(entorno): + db, expediente = entorno["db"], entorno["expediente"] + _fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED) + _fila_archivo(db, expediente, source_id=2, status=STATUS_SENT) + + metricas = routes.metrics(COMPANY_ID, USUARIO, db) + + assert set(metricas) == {"pending", "sent", "failed"} + assert metricas["failed"] == 1 + assert metricas["sent"] == 1 + + +def test_metrics_no_ve_otro_tenant(entorno): + db, expediente = entorno["db"], entorno["expediente"] + _fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED, tenant_id=OTRO_TENANT) + + assert routes.metrics(COMPANY_ID, USUARIO, db)["failed"] == 0 + + +def test_el_listado_solo_devuelve_lo_del_tenant_y_la_company(entorno): + db, expediente = entorno["db"], entorno["expediente"] + _fila_archivo(db, expediente, source_id=1) + _fila_archivo(db, expediente, source_id=2, tenant_id=OTRO_TENANT) + _fila_archivo(db, expediente, source_id=3, company_id=OTRA_COMPANY) + + filas = routes.list_outbox(COMPANY_ID, "file", None, 100, USUARIO, db) + + assert len(filas) == 1 + assert filas[0]["source_id"] == 1 + + +def test_el_listado_filtra_por_status(entorno): + db, expediente = entorno["db"], entorno["expediente"] + _fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED) + _fila_archivo(db, expediente, source_id=2, status=STATUS_SENT) + + fallidas = routes.list_outbox(COMPANY_ID, "file", STATUS_FAILED, 100, USUARIO, db) + + assert [f["source_id"] for f in fallidas] == [1] 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/backend/tests/test_uploads_alcance.py b/backend/tests/test_uploads_alcance.py new file mode 100644 index 0000000..915d0fe --- /dev/null +++ b/backend/tests/test_uploads_alcance.py @@ -0,0 +1,85 @@ +"""Alcance de ``GET /uploads/url``: qué objetos puede firmar este endpoint y cuáles no. + +**Cierra una fuga real.** Antes bastaba con que la key empezara por +``tenants/{tid}/companies/{cid}/`` para firmar una URL de lectura, lo que permitía firmar +**cualquier** objeto de esa company —incluidos los certificados de la FIEL— con solo el permiso de +módulo ``crm.access``. El alcance de este endpoint es «los archivos que el CRM subió», no «todo el +almacén de la company». +""" + +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension +from tests.conftest import COMPANY_ID, TENANT_ID + +USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"} +PREFIJO = f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/" + + +@pytest.fixture(autouse=True) +def _sin_s3(monkeypatch): + import api.v1.modules.crm.uploads.routes as uploads + + monkeypatch.setattr(uploads, "presigned_get_url", lambda key, **k: f"https://firmada/{key}") + + +@pytest.mark.parametrize( + "sufijo", + [ + "certificates/fiel_20260101.key", # llave privada de la FIEL + "certificates/fiel_20260101.cer", + "invoices/9/cove/cove.xml", + "imports/csv/invoice/job-1.csv", + "branding/logo.png", + "doda/1/report/doda_report.pdf", + "signatures/1/photo_x.png", + ], +) +def test_no_se_puede_firmar_nada_fuera_de_los_documentos_del_crm(sufijo): + with pytest.raises(HTTPException) as exc: + get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +@pytest.mark.parametrize( + "sufijo", + [ + "crm-docs/abc123/contrato.pdf", + "expedientes/1/documents/abc123_guia.pdf", + ], +) +def test_los_documentos_del_crm_si_se_pueden_firmar(sufijo): + resp = get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO) + assert resp["url"].endswith(sufijo) + + +def test_no_se_puede_firmar_nada_de_otro_tenant_ni_de_otra_company(): + """El aislamiento previo sigue en pie: es una guarda adicional, no un reemplazo.""" + for key in ( + "tenants/999/companies/1/crm-docs/a/b.pdf", + f"tenants/{TENANT_ID}/companies/999/crm-docs/a/b.pdf", + ): + with pytest.raises(HTTPException) as exc: + get_upload_url(key, COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +def test_una_key_que_solo_CONTIENE_el_prefijo_no_pasa(): + """La comprobación es de prefijo, no de subcadena: ``startswith`` y no ``in``.""" + with pytest.raises(HTTPException) as exc: + get_upload_url(f"otro/{PREFIJO}crm-docs/a/b.pdf", COMPANY_ID, USUARIO) + assert exc.value.status_code == 403 + + +def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable(): + for nombre in ("virus.exe", "script.sh", "macro.bat", "lib.dll", "sin_extension"): + with pytest.raises(HTTPException) as exc: + validar_extension(nombre) + assert exc.value.status_code == 422 + assert exc.value.detail == "Ese tipo de archivo no está permitido." + + +def test_la_allowlist_acepta_los_formatos_de_documento(): + for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"): + validar_extension(nombre) # no lanza 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/docker-compose.e2e.yml b/docker-compose.e2e.yml index 9bdb1dd..db8b832 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -7,12 +7,28 @@ # - Sin `container_name` ni `restart` (efímero, COMPOSE_PROJECT_NAME aísla los nombres). # - Sin `celery_worker` / `celery_beat` (no se ejercitan en los specs actuales). # - Imágenes vía env vars: la del frontend se rebuildea localmente con -# VITE_API_URL=http://localhost:8000/api/ (la prod tiene la URL de dev bakeada). -# - Puertos fijos: frontend 5173 / backend 8000 (URIs ya registradas en Workspace). +# VITE_API_URL=http://localhost:3468/api/ (la prod tiene la URL de dev bakeada). +# +# PUERTOS DEL HOST — 2026-08-10 +# frontend 5173 NO SE MUEVE. Está registrado en Workspace: `http://localhost:5173/auth/callback` +# es la redirect URI permitida del cliente OIDC, y es además el `baseURL` de +# Playwright, el `CORS_ORIGINS` y el `APP_PUBLIC_URL` del backend. Moverlo rompe +# el login antes de que corra el primer spec. +# backend 3468 Movido desde 8000. En la máquina de desarrollo el 8000 del host lo ocupa +# `EFC_backend_dev` (stack de EFC), así que este servicio no arrancaba: +# "port is already allocated". 3468 es vecino del 3467 de docker-compose.prod.yml, +# para que los puertos del CRM se mantengan en un mismo rango reconocible. +# Dentro del contenedor sigue siendo 8000: el bind de gunicorn, el healthcheck +# y `INTERNAL_API_URL=http://backend:8000` NO cambian, son de la red interna. +# +# ⚠️ SI SE REACTIVA EL STAGE E2E DEL JENKINSFILE: la imagen del frontend hay que reconstruirla +# con VITE_API_URL=http://localhost:3468/api/. Si se hornea con 8000, el navegador de Playwright +# pegaría contra el backend de EFC y los specs pasarían contra el sistema equivocado — no falla, +# miente. Es el modo de fallo caro de este cambio. # # Variables requeridas en el entorno al invocar docker compose: # E2E_BACKEND_IMAGE — imagen del backend recién pusheada a Harbor -# E2E_FRONTEND_IMAGE — imagen temporal del frontend con VITE_API_URL=localhost +# E2E_FRONTEND_IMAGE — imagen temporal del frontend con VITE_API_URL=localhost:3468 services: postgres-app: @@ -95,7 +111,9 @@ services: - S3_USE_SSL=false - S3_FILE_STORAGE=true ports: - - "8000:8000" + # 3468 en el HOST, 8000 dentro del contenedor. Ver la nota de puertos del encabezado: + # el 8000 del host lo ocupa EFC_backend_dev en la maquina de desarrollo. + - "3468:8000" depends_on: postgres-app: condition: service_healthy @@ -131,7 +149,11 @@ services: - NODE_ENV=production # VITE_API_URL ya está bakeada en E2E_FRONTEND_IMAGE; este valor solo sirve # para fallback server-side en frontend/src/lib/server/api.ts. - - VITE_API_URL=http://localhost:8000/api/ + # + # ⚠️ El puerto del host es 3468, no 8000 (ver el encabezado). Y como el valor que usa + # el NAVEGADOR va bakeado en la imagen, cambiar solo esta línea NO basta: quien + # reconstruya E2E_FRONTEND_IMAGE tiene que hornear la misma URL. + - VITE_API_URL=http://localhost:3468/api/ - INTERNAL_API_URL=http://backend:8000/api/ - INTERNAL_HUB_URL=https://workspace.aduanasoft.com - HUB_URL=https://workspace.aduanasoft.com diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c3f08ee..7330562 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -84,6 +84,31 @@ services: - S3_USE_SSL=${S3_USE_SSL:-false} - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} + + # ── Carril hacia EFC, el expediente electronico (T2026-08-046) ────────────────────── + # EFC_API_URL VACIA = CARRIL APAGADO. No es un error de configuracion: con la URL vacia + # `EfcClient.is_configured` es False, todo el enganche hace no-op y el CRM guarda sus + # archivos solo en su propio MinIO. Encenderlo exige las dos primeras. + # + # EFC_API_KEY debe ser IDENTICA a CRM_INTEGRATION_API_KEY del lado de EFC. Alla el permiso + # es fail-closed: si no coinciden, EFC responde 403 "API key invalida o no configurada" y + # esa respuesta NO distingue entre clave equivocada y clave ausente, a proposito. + # + # Las cuatro ultimas van declaradas aunque hoy queden vacias. La razon no es simetria: si + # una variable no se declara aqui, editarla en el .env NO surte efecto y el sintoma parece + # un problema de red. Ya paso con EFC_API_VERIFY_SSL en el compose de desarrollo. + - EFC_API_URL=${EFC_API_URL:-} + - EFC_API_KEY=${EFC_API_KEY:-} + - EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true} + - EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000} + # Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO + # del proxy_read_timeout del nginx de EFC. + - EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000} + # Scaffolding de mTLS: activarlo es configuracion, no codigo. Es el endurecimiento que la + # deuda de la API key estatica pide antes de produccion real. + - EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-} + - EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-} + - EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-} ports: - "3467:8000" depends_on: @@ -148,6 +173,19 @@ services: - S3_REGION=${S3_REGION:-us-east-1} - S3_USE_SSL=${S3_USE_SSL:-false} - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + + # Carril hacia EFC (T2026-08-046). EL WORKER LAS NECESITA IGUAL QUE EL BACKEND: la entrega + # de expedientes y archivos la hace `deliver_*_row` AQUI, no en el proceso web. Si solo el + # backend las tuviera, el encolado se veria perfecto y nada se entregaria nunca. + # Ver la nota completa en el servicio `backend`. + - EFC_API_URL=${EFC_API_URL:-} + - EFC_API_KEY=${EFC_API_KEY:-} + - EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true} + - EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000} + - EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000} + - EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-} + - EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-} + - EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-} depends_on: - backend - valkey @@ -186,6 +224,20 @@ services: - S3_REGION=${S3_REGION:-us-east-1} - S3_USE_SSL=${S3_USE_SSL:-false} - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + + # Carril hacia EFC (T2026-08-046). El BEAT las necesita porque de aqui salen los tres + # barridos —`sweep_outbox`, `sweep_file_outbox` y `sweep_expediente_gaps`—, que son la red + # que atrapa lo que el despacho inmediato no alcanzo a entregar. Sin ellas el carril + # depende solo del despacho inmediato y una caida de EFC deja la cola detenida para + # siempre. Ver la nota completa en el servicio `backend`. + - EFC_API_URL=${EFC_API_URL:-} + - EFC_API_KEY=${EFC_API_KEY:-} + - EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true} + - EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000} + - EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000} + - EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-} + - EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-} + - EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-} depends_on: - backend - valkey 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()); };