Commit Graph

68 Commits

Author SHA1 Message Date
926a75c5f8 Merge pull request 'feat(crm): carril emisor hacia EFC sobre el expediente existente (T2026-08-046)' (#6) from feature/T2026-08-046-carril-efc into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 4s
Build Producción & Push a Harbor / build (push) Has been skipped
Reviewed-on: #6
2026-08-11 14:15:53 +00:00
61af5c80fe chore(deploy): las ocho variables del carril hacia EFC en produccion
El compose de produccion no declaraba ninguna EFC_*, asi que el carril nacia muerto
alla: `EfcClient.is_configured` era False y todo el enganche hacia no-op en silencio.

Van en los TRES servicios y no solo en el backend, porque cada uno hace una parte:

  - backend       encola al crear el expediente y despacha la entrega inmediata;
  - celery_worker EJECUTA la entrega (`deliver_outbox_row` / `deliver_file_outbox_row`).
                  Si solo el backend las tuviera, el encolado se veria perfecto y nada
                  se entregaria nunca -- es exactamente el modo de fallo que ya nos
                  costo un diagnostico hoy, cuando el worker no tenia registradas las
                  tareas del carril;
  - celery_beat   dispara los tres barridos, que son la red que atrapa lo que el
                  despacho inmediato no alcanzo. Sin el, una caida de EFC deja la cola
                  detenida para siempre.

Se declaran las ocho aunque seis queden vacias por default. No es simetria: una
variable no declarada en el compose NO llega al contenedor, asi que 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, que solo pasa dos de las ocho.

EFC_API_URL vacia = carril apagado, y es el default a proposito: desplegar este cambio
no enciende nada. Encenderlo exige EFC_API_URL y una EFC_API_KEY identica a la
CRM_INTEGRATION_API_KEY del lado de EFC, cuyo permiso es fail-closed.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:23:38 -06:00
192a2d9d89 fix(crm): el worker no podia entregar, y el carril encolaba lo que EFC rechaza siempre
Tres defectos que solo aparecieron al probar con los dos sistemas cableados. Ninguno lo
habrian encontrado las pruebas: usan SQLite con su propio registro de modelos y no
ejercitan el proceso del worker.

1. REGISTRO DE MODELOS EN EL WORKER. Celery no carga la app: importa el modulo de la
   tarea y nada mas. SQLAlchemy resuelve las ForeignKey por nombre de tabla contra su
   registro global, asi que sin la clase del otro extremo importada la configuracion de
   mappers moria con "Foreign key associated with column 'cases.account_id' could not
   find table 'crm.accounts'" y la tarea con PendingRollbackError.
   El sintoma era cruel: la fila del outbox se quedaba en pending con attempts=0 y SIN
   last_error --el fallo ocurre antes de poder registrarlo--, asi que el carril se veia
   encolando bien y no entregaba nunca. En la app web no pasa porque main.py monta todos
   los routers. Se importan los cuatro modelos del juego minimo verificado con
   configure_mappers() en un proceso limpio.

2. LA MIGRACION NO RELLENABA LAS FILAS PREVIAS. Los expedientes creados antes del carril
   quedaban con efc_storage_token NULL; el barrido de reconciliacion los encolaba, EFC los
   rechazaba con {'storage_token': ['This field may not be null.']} y agotaban sus 8
   intentos hasta failed. Ruido permanente por un dato derivable. La migracion ahora
   rellena 'CRM-'||company_id||'-'||reference, con la misma condicion de longitud que la
   guarda de storage_token: lo que no cabe en los 25 de pedimento_app se queda NULL a
   proposito, porque un token recortado apuntaria a la carpeta de otro expediente.

3. SIN GUARDA DE FOLIO NULO. crm.cases.reference es nullable, y ni el encolado ni el
   barrido de huecos lo comprobaban. Ahora los dos saltan lo que no tiene folio o token,
   y el encolado lo avisa en WARNING: es una omision silenciosa --el expediente vive en el
   CRM y sus documentos no llegaran a EFC-- y merece dejar rastro.

Verificado de punta a punta. Los TRES expedientes del CRM estan en EFC, leido desde EFC:

  EXP2026-08-001 -> CRM-2-EXP2026-08-001  provisional
  EXP2026-08-002 -> CRM-2-EXP2026-08-002  provisional
  EXP2026-08-003 -> CRM-2-EXP2026-08-003  provisional

y los tres en LINKED con su outbox en sent. El 001 nacio antes del enganche y se recupero
por el camino del relleno + reintento, que es el que usaria una persona desde el tablero.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:44:12 -06:00
be45f950b8 fix(crm): registra las tareas del carril en Celery, sin lo cual nada se drenaba
Delta de core/celery_app.py que se me quedo fuera al portar el carril. El sintoma era
enganoso: el encolado se veia perfecto -- fila en crm.efc_sync_outbox, status pending,
sin error -- pero el worker rechazaba la entrega con "Received unregistered task of
type 'expediente_gateway.deliver_outbox_row'" y la fila se quedaba en pending con 0
intentos PARA SIEMPRE. Ni el despacho inmediato ni el barrido existian.

  - include: api.v1.modules.crm.expediente_gateway.tasks
  - beat: sweep_outbox y sweep_file_outbox cada 120 s, sweep_expediente_gaps cada
    300 s. Los intervalos son los del carril de referencia de Anexo22. El reintento
    NO es exponencial a proposito: el backoff corto vive en el cliente HTTP y el
    largo es este barrido de intervalo fijo.

Verificado de punta a punta con los dos sistemas cableados. Desde EFC, no desde el
CRM:

  pedimento_app : CRM-2-EXP2026-08-002        (el storage_token del CRM)
  patente/aduana/clave_pedimento/regimen: None  <- provisional de verdad
  pedimento_expediente: estado=provisional, crm_expediente_id=3, folio=EXP2026-08-002
  organizacion  : Aduanasoft (hub_tenant_slug=aduanasoft, is_verified=True)
  licencia      : 5 GB, asi que la subida no falla por cuota

El resolver mapeo tenant 11 -> organizacion por slug, que es el puente 1:1 acordado.
Las cinco tareas quedan registradas en el worker y los tres barridos en el beat.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:27:01 -06:00
bc50a7d099 feat(crm): el expediente pide su pedimento provisional a EFC al nacer el folio
Primera mitad del pegamento del carril (fase 7). El reflejo en EFC se pide en
create_case, en el instante en que se mintea el folio, porque el folio es la llave
con la que las dos mitades se reconocen: EFC no recibe ids del CRM como handle.

  - crm.cases nace con efc_storage_token = CRM-{company}-{folio}, fijado al nacer y
    nunca reescrito: es la carpeta de MinIO del lado de EFC, y que sea inmutable es
    lo que permite completar el provisional con la data aduanera real sin mover un
    solo archivo.
  - replicate_expediente_best_effort corre en la MISMA transaccion que el
    expediente. Con EFC_API_URL vacia es no-op; si el encolado o el despacho fallan
    no se propaga el error y el barrido del beat recoge lo pendiente. Un sistema de
    terceros caido no puede romper un alta.
  - El import del carril es diferido para no acoplar el arranque del modulo del
    expediente, que es de otra rama, a la integracion.
  - Se registra el tablero de ops del carril (outbox, metricas, reintento manual)
    en el router del CRM.

test_el_formato_del_folio_es_el_del_contrato se re-apunta a crm/common/folios.py y
queda VERDE: afirma contra la implementacion real que el folio del CRM tiene la forma
que EFC espera, que era el riesgo de haber rebasado sobre otro expediente.

Verificado en la base: create_case produce EXP2026-08-002 con storage_token
CRM-2-EXP2026-08-002 y link_state PENDING, 0 filas encoladas por carril apagado, y la
transaccion reversada NO deja hueco en el contador -- el with_for_update de
crm/common/folios.py revierte limpio.

PENDIENTE de la fase 7: documentos (subida de un paso, proxy de descarga, listado y
desvinculacion) y los 8 archivos del frontend. Por eso siguen rojas
test_efc_outbox, test_gateway_rutas, test_efc_entrega_documento, test_uploads_alcance
y dos de test_contrato_efc.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:12 -06:00
d1fcd236e4 fix(core): el bootstrap de permisos colgaba el rol de un tenant inexistente
Al entrar por primera vez a una compañia, /permissions/me creaba el rol super_admin
con `tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1`. La sesion de ese request no
trae contexto RLS, asi que caia en el respaldo: tenant_id = 1. En una instalacion
real ese tenant no existe -- aqui son 11 y 17 -- y el INSERT moria con
ForeignKeyViolation sobre company_roles_tenant_id_fkey.

El fallo era silencioso hacia afuera: el except del bootstrap lo registraba como
ERROR CRITICO y devolvia False, pero /permissions/me seguia respondiendo 200 con la
lista de permisos VACIA. En pantalla se leia "No tienes permisos para realizar esta
accion", que manda a revisar roles en vez de la base. Toda la API respondia 403.

Dos arreglos:

  - `_resolve_tenant_id_for_company` tenia el respaldo sin implementar (`pass` con un
    comentario de plantilla), asi que devolvia None siempre que faltara el contexto
    RLS. Con esa funcion se scopean las consultas de sus CINCO llamadores, o sea que
    la lectura de permisos tampoco resolvia. Ahora consulta a76.company, la tabla de
    companias del CRM, con SQL crudo igual que seed_crm.py.

  - bootstrap_super_admin toma el tenant de la COMPANIA, con consulta directa y no
    por el helper: el helper prefiere el contexto RLS, que es el tenant del REQUEST y
    puede no ser el de la compania. Para leer permisos esa preferencia esta bien y
    ahorra una consulta en el camino caliente; para escribir una fila atada por FK a
    a76.company y a core.tenants a la vez, no -- si difirieran, el rol naceria
    cruzado entre dos tenants. Si la compania no existe, aborta sin crear nada en vez
    de inventar un tenant.

Verificado contra la base: bootstrap devuelve True en las dos companias del usuario,
70 permisos en cada una, y las filas quedan consistentes (rol de company 1 -> tenant
17, company 2 -> tenant 11). Suite sin regresion: 151 pasan.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:58:56 -06:00
5c4df590d4 feat(crm): carril hacia EFC montado sobre el expediente existente (crm.cases)
Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.

La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:

  - crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
  - crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
    expediente_ref -> crm.cases.id;
  - core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
    clonados del gateway Anexo22 -> EFC que ya corre en produccion;
  - las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.

Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.

Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.

El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.

PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:35:44 -06:00
dfb3c8a08d chore(dev): puertos del CRM sin choque con EFC y overrides locales fuera del repo
El 8000 del host lo ocupa EFC_backend_dev, asi que el backend del stack E2E se
mueve a 3468 (vecino del 3467 de prod). Dentro del contenedor sigue siendo 8000:
gunicorn, healthcheck e INTERNAL_API_URL son de la red interna y no cambian.
El 5173 NO se mueve: es la redirect URI registrada en Workspace.

SUNRISE/, automatizacion/ y docker-compose.dev.yml quedan fuera del control de
versiones: son el corredor de la corrida autonoma y el override de dev de cada
maquina, no configuracion compartida.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:19:09 -06:00
Ernesto Herrera
afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 11s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/CRM_AGENTES_CARGA/pipeline/head There was a failure building this commit
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:00:42 -06:00
Ernesto Herrera
915bdd19fe feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
  mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).

Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.

Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).

Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).

Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:51:40 -06:00
Ernesto Herrera
87d23b3d23 feat(crm): rediseño profesional del PDF de cotización
Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de
sección en color, tabla de costos con encabezado y filas alternadas, caja de
totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1);
oculta secciones sin datos; color por defecto navy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:16 -06:00
Ernesto Herrera
e269e46d88 fix(crm): envío de cotización por correo con aiosmtplib.send (sin doble STARTTLS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:23 -06:00
Ernesto Herrera
03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00
Ernesto Herrera
e09cb02b8b feat(crm): cotización — Ver PDF, Enviar por correo y pantalla de marca por tenant
- Cliente API: quotesAPI.pdfUrl/sendEmail + quoteSettingsAPI (get/save/uploadLogo).
- Cotización: botones Ver PDF y Enviar por correo (modal con destinatario/asunto/mensaje).
- Configuración → Formato de cotización: emisor, logo, color, prefijo y textos por
  defecto (por compañía).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:22:24 -06:00
Ernesto Herrera
206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00
Ernesto Herrera
ef7e69ed57 feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- Backend: CRUD de cargos (rate_charges) por tarifario.
- Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres
  para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:46:25 -06:00
Ernesto Herrera
fa542ddf18 feat(crm): frontend del módulo Tarifario — tarifarios, import Excel y Cotizador
- Cliente API rateSheetsAPI (CRUD, plantilla, import con vista previa, /rate-quote).
- Tarifarios: listado + importar por Excel (plantilla, vista previa, validación) +
  detalle con rutas y activar/vencer.
- Cotizador: calcula opciones de costo por proveedor desde los tarifarios vigentes.
- Nav CRM: Tarifarios y Cotizador.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:35:48 -06:00
Ernesto Herrera
2ae6901b6a feat(crm): módulo Tarifario — modelos, CRUD, import Excel y motor de costeo
- Tablas rate_sheets/lanes/breaks/charges (esquema crm) + migración con down().
- Catálogos nuevos: modo_tarifario, unidad_tarifa, concepto_cargo.
- CRUD de tarifarios y rutas; descarga de plantilla Excel por modo; import con
  vista previa y validación; alta directa desde Excel.
- Motor de costeo /rate-quote: aéreo (peso facturable + quiebres + optimización),
  marítimo FCL (por contenedor), LCL (W/M) y terrestre; suma cargos adicionales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:28:49 -06:00
Ernesto Herrera
8576ec7e37 feat(crm): catálogo Tipos de equipo/contenedor con medidas (Medidas de Equipos)
Agrega el catálogo global 'tipo_equipo' (27 opciones: contenedores marítimos,
ULD aéreos y remolques terrestres) con dimensiones en el campo extra (modo,
largo/ancho/alto, capacidad m³, tara, carga máx., pallets). Base para la sección
FCL del módulo de Cotización (T2026-07-183). Expone extra en el API de catálogos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:10:19 -06:00
Ernesto Herrera
de8557dec2 feat(crm): formularios Clientes/Prospectos y Proveedores por catálogo — T2026-07-081/082
Frontend de los catálogos de referencia en BD:
- Store crm-catalogs + cliente API; selects poblados desde /v1/crm/catalogs
  (fiscal: régimen, uso CFDI, forma/método de pago SAT, moneda ISO; comercial;
  país/estado/tipo de domicilio/área). Muestran la descripción, no la clave.
- "Otro → especificar" (medio de contacto y clasificación), observaciones
  generales, y datos de auditoría (ID, fechas, usuarios) en la vista de edición.
- Botón "Editar" explícito en los listados.
- Pantalla de administración de catálogos (insertar/editar/borrar): globales
  solo activar/desactivar; los de la empresa con alta/edición/borrado.
- addresses.country ampliado a 3 (país ISO alfa-3) + migración con down().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
a2a474f8c8 chore(deploy): heap de Node en build + runtime sin red + nginx buffers/http2
Cambios de infra ya aplicados en testing: NODE_OPTIONS max-old-space-size para
evitar OOM en vite build; runtime del frontend sin apk/npm (busybox/node directo);
nginx con proxy_buffers grandes (evita 502 por header del JWT) y http2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
8aef99df9c feat(crm): catálogos de referencia en BD (SAT/ISO + cliente) — T2026-07-081/082
- Modelo crm.catalog_items (global tenant_id NULL / por tenant) + migración con
  índices únicos parciales y down().
- Seed de 18 catálogos globales (485 opciones): tipo registro/persona, estatus,
  giro, clasificaciones, medio contacto, idioma, régimen, uso CFDI, forma/método
  de pago SAT, moneda/país ISO, estados MX, tipo domicilio, área, cobertura.
- Servicio + endpoints CRUD /v1/crm/catalogs (listar/insertar/editar/borrar),
  con global solo para hub_admin y catálogos del cliente por tenant.
- Columnas nuevas: accounts.commercial_observations, accounts.preferred_contact_other,
  suppliers.classification_other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:15:52 -06:00
Ernesto Herrera
5f9c7cf000 docs(deploy): runbook de despliegue a producción (auth SIWEB + RBAC)
Pasos para que un operador con acceso a prod despliegue con seguridad: env,
build del frontend en CI, up del stack, seed + sync de permisos, smoke test y
rollback. Incluye feature-flag SESSION_STORE_ENABLED como mitigación rápida.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:34:30 -06:00
Ernesto Herrera
71be225b33 fix(crm): guardar permisos de rol y recargar roles/usuarios tras refresh
- role-permissions.assign enviaba permission_id en el body pero el backend lo
  espera como query param → 422 silencioso; el permiso no se guardaba. Ahora va
  en la query y se confirma con un toast al marcar cada permiso (auto-guardado).
- Roles y Usuarios recargaban solo con el evento companyChanged, que no dispara
  en la hidratación inicial; por eso tras refrescar no aparecían hasta re-elegir
  la compañía. Se cambia a un $effect que reacciona a la compañía activa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 06:43:35 -06:00
Ernesto Herrera
0cffd351df fix(crm): alta de usuarios carga compañías del tenant en vez de slug a mano
La organización destino salía como texto libre (o fallaba al listar todos los
tenants del Hub con un token KC caduco). Ahora se carga desde /v1/auth/my-companies
(mismo origen que el switcher) y se elige la compañía; el tenant_slug se resuelve
de la compañía seleccionada.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:03:36 -06:00
Ernesto Herrera
a14acf59e5 chore(crm): ocultar pestaña Workspace del sidebar
Las altas de organizaciones/usuarios se gestionan desde Compañías, Usuarios y
Roles y permisos; se retira la entrada Workspace (y su icono Building2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:56:59 -06:00
Ernesto Herrera
0d8c4ddf62 fix(crm): catálogo de permisos no cargaba por redirect 307 (slash final)
permissionsAPI.list pegaba a /v1/core/permissions/ (con slash); la ruta backend
es @router.get("") sin slash, así que FastAPI devolvía 307 y el cliente no lo
seguía → "No se pudieron cargar roles/permisos". Se quitan los slash finales de
list/getById/create/update/delete/getModules/getActions para que matcheen exacto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:51:18 -06:00
Ernesto Herrera
de9e35c501 feat(core): botón "dar de alta usuario" (invitación) en Usuarios + fix hub_admin
- Frontend Usuarios: formulario para dar de alta (email + rol) → invitación; muestra
  el enlace copiable por si el correo no llega.
- Backend invites: resuelve tenant_slug desde la compañía cuando el token no lo trae
  (hub_admin) y usa el token KC de la sesión (valkey) para crear el invite en el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:12:11 -06:00
Ernesto Herrera
868724d1f2 fix(core): usar token KC (valkey) para listar usuarios del tenant, no la sesión local
list_users/stats llamaban al Hub (users-with-info) con el Bearer de la app, que con
el patrón SIWEB es la sesión local (HS256) → el Hub la rechaza (401) → 0 usuarios.
Nuevo core.hub_token.get_hub_access_token: toma el token KC de la sesión (valkey vía
crm_sid) y lo refresca si está por expirar. list_users y stats lo usan para el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:03:37 -06:00
Ernesto Herrera
cd3f8c62a4 feat(core): gestión de Usuarios y Roles/permisos (pantallas)
- Roles y permisos: crear/eliminar roles por compañía y editar sus permisos
  (checkboxes agrupados por módulo) vía rolesAPI + rolePermissionsAPI + permissionsAPI.
- Usuarios: lista usuarios de la compañía activa y permite asignar/quitar roles
  vía usersAPI + userRolesAPI. Reusa los clientes API ya existentes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:31:11 -06:00
Ernesto Herrera
387b3c0e78 feat(core,crm): auto-sync de tenants Workspace→CRM + mostrar tenant de la compañía activa
- assignable-tenants: para hub_admin, sincroniza automáticamente los tenants del
  Workspace (Hub GET /hub/tenants) a core.tenants con su mismo ID, y los devuelve.
  Así los tenants creados en el Workspace aparecen solos en el CRM para asignarles
  compañías (best-effort con el token KC de la sesión). CRM→Workspace ya lo hace
  Organizaciones (POST /hub/tenants).
- my-companies devuelve tenant_name/tenant_slug (join core.tenants).
- Switcher muestra el tenant de la compañía activa (antes "Sin tenant asignado").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:20:50 -06:00
Ernesto Herrera
706da34f4a feat(core): auto-ligar usuario a todas las compañías de su tenant (sin rol)
Antes solo se ligaba al usuario que CREABA la compañía; un segundo usuario del mismo
tenant no quedaba ligado → veía "sin compañía". Ahora, al entrar, el usuario con
tenant queda como MIEMBRO de todas las compañías de su tenant (solo membresía; el rol
lo asigna un admin, salvo el primer usuario que recibe super_admin en /permissions/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:44:51 -06:00
Ernesto Herrera
ce8b042e84 fix(auth): hornear is_hub_admin (autoritativo del Hub) en la sesión local
create_company (y otros checks) usan is_hub_admin, pero la sesión local se emitía
desde el token KC crudo, que no trae ese claim → el hub_admin sin tenant recibía 403
al crear compañía. Ahora la sesión se emite con los claims de /auth/me del Hub
(is_hub_admin, roles), con fallback al decode del token KC si el Hub no responde.
Se mantiene intacto el control de autorización (solo hub_admin crea fuera de su tenant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:27:27 -06:00
Ernesto Herrera
63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00
Ernesto Herrera
0b6848bbdd fix(core): crear compañía real en a76.company por tenant (arregla FK y desbloqueo)
my-companies usaba company_id = tenant_id, pero user_tenants.company_id tiene FK a
a76.company (entidad real heredada de Anexo76). Con el tenant real (aduanasoft=11)
no existía fila de compañía → ForeignKeyViolation → sin compañía → todo bloqueado.

Ahora my-companies lista las compañías del tenant desde a76.company y, si no hay
ninguna, crea una por defecto (nombre = el del tenant) en el primer acceso, usando
su id real; luego asegura el vínculo usuario↔compañía. Base para el módulo de
gestión de compañías.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 10:31:59 -06:00
Ernesto Herrera
29170f7c8c fix(auth): validar licencia sin reenviar la sesión local al Hub (rompe el bucle 401)
El LicenseValidationMiddleware reenviaba el Bearer al Hub /verify-license, pero con
el patrón SIWEB el Bearer es un JWT HS256 local que el Hub no entiende → 401 →
silent-refresh infinito → toast "Sesión expirada" en cada página.

Ahora, para una sesión local válida, la licencia se valida con el token KC guardado
en valkey (refrescándolo si está vencido) y se cachea por tenant (TTL 10 min). Si el
Hub no es concluyente (su refresh falla), se permite el paso (la sesión se emitió tras
un login válido) evitando el bucle; los resultados concluyentes sí se cachean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:43:32 -06:00
Ernesto Herrera
b76d42be83 refactor(auth): login 100% vía Workspace/Hub — sin comunicación directa a Keycloak
Patrón SIWEB: el CRM ya NO habla directo a Keycloak. Se elimina el flujo OIDC
paralelo (que causaba el bucle e "issuer mismatch"):

- workspace-auth.ts: se quitan builders de URL de KC (authorization/login/logout)
  y getPublicKeycloakBaseUrl/Realm/ClientId. getWorkspaceLoginUrl ya no lleva
  return_to a /login?sso_verified (evita el rebote sin sesión = bucle).
- /login: sso_verified=1 → /dashboard; sin sesión → App Launcher del Workspace
  (relay). Se eliminan redirectToKeycloakAuthorization/Login.
- /auth/callback: obsoleto — ya no intercambia code con KC; redirige a /dashboard.
- /logout y /auth/post-logout: limpian sesión local y vuelven al Workspace (el
  logout completo del Hub/KC se hace desde el Workspace).
- /join: usa redirectToWorkspaceLogin en vez de KC.
- lib/auth.ts: initAuth ya no inicializa keycloak-js en el browser; getToken y
  refreshAccessToken operan por cookie + silent-refresh (backend → Hub).

Login = solo App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:01:10 -06:00
Ernesto Herrera
223395b430 feat(core): compañía por tenant (1:1) en my-companies para desbloquear el dashboard
get_my_companies devolvía [] (STUB) → sin compañía activa el CRM bloqueaba todo.
Modelo: una empresa por tenant (company_id = tenant_id, como el agente de carga).
Asegura el vínculo user↔tenant↔company; los permisos se resuelven en
/permissions/me (bootstrap de super_admin al primer usuario).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:05:44 -06:00
Ernesto Herrera
579c6e1f19 chore(deploy): activar sesión local (patrón SIWEB) en testing
Pasa SESSION_STORE_ENABLED/IDLE/MAX al backend vía environment del override
(el contenedor no lee .env). SESSION_STORE_ENABLED=false revierte el patrón.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:03:01 -06:00
Ernesto Herrera
5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/
refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad
(SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey.
La app usa esa sesión local como bearer (cookie access_token); el token KC vive en
kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub
solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle
causado por el bug "Token is not active" del relay.

Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación.

Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico).

Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta
la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id;
test unitario de local_session.
Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/
silent-refresh/switch-tenant y pantallas de workspace cableadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00
Ernesto Herrera
a613a7a6aa feat(crm,workspace): alta de organizaciones y usuarios vía Hub
Nueva sección "Workspace" (Organizaciones + Usuarios) que orquesta el Hub
reenviando el token del usuario autenticado. Sin secretos en el CRM ni
bypass: la autorización la impone el Hub (hub_admin para tenants;
hub_admin o admin del tenant para invitaciones).

- Organizaciones: listar (GET /api/v1/hub/tenants) y crear
  (POST /api/v1/hub/tenants → tenant + realm Keycloak).
- Usuarios: invitación de un solo uso (POST /api/v1/hub/invites) con
  enlace copiable si el correo no llega.
- Se descarta /auth/provision-user (PROVISION_SECRET, machine-to-machine)
  en favor del flujo de invitación con token de admin.
- Helpers puros (slug, validación, extracción de errores del Hub) con
  tests unitarios; estado "requiere permisos" ante 403 y fallback a slug
  manual para admin de tenant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:19:21 -06:00
Aduanasoft
45f128a551 fix(deploy): apk add wget best-effort en Dockerfile.prod (redes sin CDN de Alpine)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:54:57 -06:00
Aduanasoft
c6f18013b3 chore(deploy): override de testing (build de producción del frontend + loopback)
Frontend construido con Dockerfile.prod (adapter-node, VITE_* horneadas), backend
uvicorn sin reload, puertos solo en 127.0.0.1 (nginx del host hace TLS/proxy),
postgres/minio sin publicar. Para testing.crm.aduanasoft.com con auth real vía Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:50:21 -06:00
Aduanasoft
f03ac38c4f docs(deploy): env de testing con auth real vía Hub (SSO relay, realm master, client aduanasoft)
Corrige la receta de integración según el código del Hub: single-realm (master) +
single-client (aduanasoft), HUB_URL para el sso-exchange del relay, y URLs del
dominio del CRM. Secretos en blanco (los pone el operador en el server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:55:28 -06:00