nexusflex-mcp 3.72.0 → 3.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.mjs CHANGED
@@ -1,1494 +1,1560 @@
1
- #!/usr/bin/env node
2
- // ============================================================================
3
- // MCP Nexus Flex v3 — servidor stdio (paquete publicable en npm).
4
- //
5
- // INSTALACIÓN FÁCIL: npx -y nexusflex-mcp@latest (arranca el server)
6
- // npx -y nexusflex-mcp@latest login (autoriza en el navegador)
7
- // npx -y nexusflex-mcp@latest logout (borra el token local)
8
- //
9
- // AUTENTICACIÓN por DEVICE-FLOW (autorización web): sin pegar email/contraseña.
10
- // El MCP pide un código, lo autorizás con un click desde la web ya logueado, y
11
- // recibe un token de vida larga, revocable y con TU scope exacto (nunca dinero).
12
- // Fallback: NEXUSFLEX_TOKEN o NEXUSFLEX_EMAIL+PASSWORD (compatibilidad).
13
- //
14
- // AISLAMIENTO EN 3 CAPAS:
15
- // 1) El BACKEND gatea cada endpoint (requireAuth + requirePermiso + scope por
16
- // nodo / idCliente). Un token de MCP hereda el rol/permisos FRESCOS del
17
- // usuario en cada request. Es la garantía real.
18
- // 2) Este server registra los tools SEGÚN EL ROL del usuario (leído de /auth/me).
19
- // 3) DENYLIST de dinero en api.mjs + guard server-side (mcpMoneyGuard): jamás
20
- // liquidaciones/cobros/cuentas/facturación.
21
- //
22
- // Escritura por flags (default OFF):
23
- // NEXUSFLEX_MCP_ALLOW_WRITE → altas (clientes, productos, nodos) y edición.
24
- // NEXUSFLEX_MCP_ALLOW_PRECIOS → actualizar listas de precios (aparte, sensible).
25
- // ============================================================================
26
- import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
- import { api, log, API_URL } from "./api.mjs";
28
- import { renderAyuda, guiaOnboarding, renderFlujo, FLUJOS } from "./docs.mjs";
29
-
30
- // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
31
- const cmd = process.argv[2];
32
- if (cmd === "login") {
33
- try {
34
- const token = await runDeviceFlow(API_URL, { open: true, log });
35
- const file = saveToken(token, API_URL);
36
- log(`Token guardado en ${file}. Ya podés usar el MCP en Claude Desktop.`);
37
- process.exit(0);
38
- } catch (e) {
39
- log("No se pudo autorizar:", e instanceof Error ? e.message : String(e));
40
- process.exit(1);
41
- }
42
- }
43
- if (cmd === "logout") {
44
- clearToken();
45
- log(`Token local borrado (${tokenFilePath()}). Revocá también desde la web (🔌 Conexiones) si querés cortar el acceso ya emitido.`);
46
- process.exit(0);
47
- }
48
-
49
- const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
50
- const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
51
- const { z } = await import("zod");
52
-
53
- const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
54
- const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
55
- const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
56
-
57
- // ATADO CON: backend/src/services/mcp-remote.ts (MCP_VERSION + NOVEDADES + mismos tools, salvo SOLO_REMOTO)
58
- // y mcp/package.json. Lo verifica backend/src/coherencia.test.ts. Ver CLAUDE.md → "Cosas que van juntas".
59
- const MCP_VERSION = "3.72.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
60
- const NOVEDADES = [
61
- "3.72.0 — CONTROL POR FOTO POR CARPETAS, para cualquier nodo y cualquiera de sus grupos. `control_foto_preparar` (MCP local) te crea las carpetas del día (o de varios) con los nombres exactos de tus grupos y cadetes. Armás una carpeta por día con DADOS/<grupo> (lo que pasaste a un grupo), DADOS/<cadete> (lo repartió un cadete tuyo), RECIBIDOS/…/<cadete> (te lo dio otro nodo y lo repartió tu cadete) y RECIBIDOS/<grupo> (te lo dieron y lo derivaste por ese grupo). `control_foto_carpeta` (MCP local) recorre la carpeta, lee el QR de cada foto (o la IA de etiquetas), busca o da de alta el envío SIN duplicar, lo asigna al responsable de zona / cadete / grupo, lo lleva a Entregado en el día real y le cuelga la foto (la que ven los nodos en su cuenta semanal). Primero simula y te devuelve las preguntas (cuentas de ML desconocidas, zonas con varios responsables, duplicados); se contestan con `control_foto_foto` o, más cómodo, en la app: Logística → 📷 Control por foto (ahí también quedan los envíos SIN VENDEDOR para asignarles el cliente o el nodo que te los dio; «recordar» asocia la cuenta de ML para la próxima, salvo que sea compartida entre logísticas). `control_foto_estructura` te dice qué nombres de carpeta valen. Nada sale de tu red (tu nodo + los de tus grupos). No mueve dinero.",
62
- "3.71.0 — Habilitadas por el dueño: `liquidacion_excluir_envio` / `liquidacion_reincluir_envio` (sacar o devolver UN envío del cobro de una liquidación emitida, con motivo obligatorio) y `facturacion_estado` / `facturacion_preparar_cliente` (qué falta para facturar y cargar los datos fiscales de un cliente) ya funcionan por MCP: el filtro de dinero las deja pasar con su método exacto. Siguen sin pagar, cobrar ni emitir comprobantes, y piden permiso finanzas. Además `mis_envios` ahora sí filtra por `estado`.",
63
- "3.70.0 — Descripciones al día: textos de las herramientas corregidos para que digan lo que hacen hoy (`envio_entregar`, `envio_asignar_mensajero`, `generar_enlace_vinculacion` vence en 48 hs, `grupo_miembros` y `provisorio_conciliar` apuntan a `grupos_tarifas`), `flujo` lista todos los flujos disponibles, `sugerencias_listar` solo para el superadmin, `envio_cargar` exige `telefono`, parámetros con formato (fechas YYYY-MM-DD, montos en ARS, nombre o id) y las instrucciones del conector más cortas (la guía sigue en `guia`). `liquidacion_excluir_envio`/`reincluir` avisan que hoy el filtro de dinero los bloquea por MCP (se hace desde la PWA).",
64
- "3.69.1 — AYUDA completa: `ayuda` ahora tiene la ficha (qué hace, cómo usarlo, ejemplo, qué NO hace) de los 20 tools que no la tenían (cierre semanal, zonas de grupo, tarifas de grupo, sin vendedor, corregir estado, pago al cadete, retiros…) y los 26 que no aparecían en el índice. La ayuda del paquete npm se genera desde la del conector, así que ya no se desincronizan. No mueve dinero.",
65
- "3.69.0 — Control por foto de la TANDA DE UN CADETE (propios + de otros nodos mezclados): en `envio_desde_etiqueta_ml` alcanza con el `mlQr` crudo — de ahí sale el número de ML y el vendedor, y el vendedor se reconoce igual que en el escáner (cuenta vinculada O cuenta aprendida en juntadas/escaneos; antes solo la vinculada). `nodoEntrega` ya no necesita `grupo`: si el cliente es del mismo nodo que entrega es un paquete propio (sin traspaso) y si es de otro nodo se usa el grupo que comparten. Si no conoce al vendedor, contesta 404 con el senderId para que preguntes de qué nodo/cliente es. Si el envío ya existía, no lo duplica: lo completa y lo avanza. No mueve dinero.",
66
- "3.68.0 — Control por foto: `envio_desde_etiqueta_ml` acepta `nodoEntrega` (nombre o id de un nodo del `grupo`) = el nodo que RECIBIÓ y entregó el paquete. Con `fecha` + `grupo` + `nodoEntrega` una planilla atrasada entra al CIERRE SEMANAL entre nodos en su día real (ej. 5 envíos de un cliente de Envíos Frank que entregó FastCorreo el lunes 14: fecha 14/09, grupo Portela, nodoEntrega FastCorreo). Antes solo se ponía con `autoRutear` y si la zona tenía un único responsable; si no, quedaba fuera del cierre. Si el envío ya tenía OTRO nodo que entrega no se pisa (avisa). Si la semana ya cerró, entra en el cierre siguiente marcado como tarde, con su fecha real. No mueve dinero.",
67
- "3.67.0 — TARIFA de grupo de una: `grupo_tarifa_set` fija la tarifa de clearing por zona (cercana/media/lejana/muyLejana) de TODO un grupo — el default del grupo y el precio de cada nodo miembro — en una sola llamada, sin ir nodo por nodo (ej. dejar un grupo nuevo en 2700 los 4 tramos). Con `soloDef` toca solo el default. Solo la comisión (admin) del grupo o el admin global. Es config del clearing, no mueve dinero.",
68
- "3.66.0 — COMPOSICIÓN de zonas de grupo: `zona_mover_metazona` reclasifica una metazona/localidad de una zona de grupo a otra del mismo grupo sin tocar el nodo responsable (ej. 'El Palomar' mal puesto en Morón → Tres de Febrero). `zona_componer_grupo` crea/edita una zona de grupo agregando/quitando metazonas y sumándole un nodo responsable — sirve para armar las zonas de un grupo NUEVO con su composición completa (el ruteo matchea por metazona exacta, así que una zona de CABA necesita 'CABA · Barrio' y 'Barrio'). Solo la comisión (admin) del grupo o el admin global.",
69
- "3.65.0 — SIN VENDEDOR: `sin_vendedor` muestra las cuentas de Mercado Libre y los envíos cargados con foto que tu nodo tiene sin vendedor (por ejemplo, un nodo recién dado de alta con etiquetas de cuentas no vinculadas ya escaneadas), y `asignar_sin_vendedor` los asigna a UN vendedor en un paso: asocia cada cuenta, le pasa todos sus envíos, le crea el usuario si hace falta y devuelve un enlace de vinculación por cuenta. Cuando el vendedor autoriza el enlace, lo que quedaba de esa cuenta se le engancha solo.",
70
- "3.64.1 — FRENO DE DUPLICADOS en `envio_desde_etiqueta_ml` (sin número de ML) y `envio_cargar`: si el cliente ya tiene un envío a esa dirección ese día (±1), responde POSIBLE DUPLICADO nombrándolo (15/09 se cargaron 44 paquetes a mano que ya estaban por planilla y se cobraron dos veces). Si es otro paquete, repetí con `forzarNuevo:true`. Con número de ML, si difiere en un dígito de otro envío del mismo comprador, avisa en `advertencias`.",
71
- "3.64.0 — CIERRE SEMANAL entre nodos (juntada): la semana de lunes a sábado se congela sola el miércoles siguiente a las 06:00. Cada paquete que un nodo le pasó a otro esa semana (entregado o no) vale la tarifa del que lo recibe, y por nodo se netea. Lo cerrado no se recalcula; lo que se cargue tarde entra en el cierre siguiente con su fecha. Lo que daría $0 (sin tarifa) no entra y se avisa. Nuevo `cuenta_semanal_nodos` y `cierre_semanal_links` (admin global). Arranca con la semana del 14 al 19/09.",
72
- "3.63.0 — Responsable de zona PENDIENTE → FIRME: al despachar por grupo desde el escáner, el responsable de la zona (elegido en el popup, o automático si hay uno solo) queda PENDIENTE de confirmar, y recién queda FIRME cuando ese nodo lo recibe de verdad (lo procesa). Nuevo `nodo_link_confirmacion`: link FIJO sin login para un nodo que no tiene usuarios propios (un 'feeder' que solo deja paquetes en la juntada) — ve sus pendientes y los confirma con un toque. Se lo pasás por WhatsApp a mano.",
73
- "3.62.0 — `nodo_renombrar` (solo admin global): cambia la razón social REAL de un nodo (la ve todo el mundo), a diferencia de `nodo_alias_poner` que es un apodo personal. Reusa la validación existente (nombre no vacío, sin repetir otro nodo) y renombra la marca si coincidía con el nombre viejo.",
74
- "3.61.0 — `nodo_alias_poner`: le ponés TU propio apodo a un nodo (ej. 'Flex Fácil' = 'Félix', el dueño) para reconocerlo más fácil — es personal, no lo ve otro usuario ni cambia el nombre real. De ahí en más lo podés nombrar por ese apodo en `asignar_nodo_zona` y en los buscadores de la app. Alias vacío lo saca.",
75
- "3.60.0 — Responsable de zona POR GRUPO logístico (Bonorino, Portela…): `asignar_nodo_zona` con `grupo` pasa a MODO COMISIÓN — la zona es del grupo, no de un nodo dueño, y solo un admin de ESE grupo (o el admin global) puede asignarla o reasignarla. Nuevo `zona_dejar`: el nodo responsable se saca solo (self-service) de una zona de grupo; si era el único, queda LIBERADA y se avisa a TODO el grupo, pero solo la comisión la puede volver a asignar. Nuevo `zonas_grupo`: lista las zonas de un grupo con quién las cubre y cuáles están liberadas.",
76
- "3.59.0 — `cliente_editar` ya NO pisa el cliente: cambia solo los campos que le pasás (antes, cargarle el teléfono le borraba la lista de precios, el DNI y el nombre con el que cobra). Para vaciar un campo, mandalo vacío. Nuevo: `email` cambia el email con el que entra el usuario del cliente. `nombre` dejó de ser obligatorio.",
77
- "3.58.0 — `colecta_historial`: cuántos paquetes se colectaron por día y por cliente en una fecha o rango (hasta 62 días), no solo lo de hoy/mañana. Pasale `cliente` (nombre o id) para uno puntual; dice también quién los colectó. Cuenta cada paquete una vez, el día de Argentina en que se colectó (por escaneo o en la puerta). Solo lectura.",
78
- "3.57.0 — NOMBRE Y APELLIDO OBLIGATORIOS en todos los usuarios: `chofer_crear`, `operador_crear` y `cliente_generar_usuario` ahora piden `nombre` y `apellido` por separado (en un vendedor, los de la PERSONA que entra, no el de la tienda: con \"Fumshop\" en la lista no se sabía quién era). Nuevo `usuario_editar` para corregir nombre, apellido, teléfono o email de un usuario de tu nodo. Cambiar el nombre de un cadete NO cambia el nombre con el que se le paga: queda fijo para que sus entregas no se muden de grupo en la liquidación.",
79
- "3.55.0 — `envio_editar_zona` ahora también COMPLETA destinatario, teléfono y dirección. El envío que entra por el escaneo del QR trae de quién es el paquete pero NO a dónde va: nace sin destino y se liquida en $0 sin que nadie se entere (5.554 así). En un envío ya ENTREGADO los datos del destino solo se completan si están VACÍOS —nunca se pisan— y los que no se tocaron vuelven en `noPisados`. Además el escáner ahora PIDE la zona en el momento, con el paquete en la mano, y muestra cuántos de la colecta siguen sin zona.",
80
- "3.54.0 — `retiros_cargar`: la lista de RETIROS del día se carga por acá en vez del Excel Maestro. Le pasás las direcciones (numeradas o no) y el cadete, y crea un retiro por parada con la zona que cobra y paga (Retiro en CABA/GBA). Dos cosas que la planilla hacía mal y ya no: el número de orden iba PEGADO a la dirección y el geocoder terminaba devolviendo un cuartel en Gualeguaychú (~800 envíos con coordenadas en Entre Ríos), y la localidad quedaba en 'Retiro en CABA', que no es un lugar. Escribe el nombre por el que se PAGA, no solo el vínculo. Repetir la misma dirección el mismo día no duplica el retiro ni el cobro.",
81
- "3.53.0 — `envio_pago_mensajero`: carga lo que se le paga a quien repartió, por envío. Cierra una brecha que se comía plata en silencio: al cadete se le paga por el NOMBRE (`Envio.mensajero`) y no por el vínculo, así que un envío asignado con `envio_asignar_mensajero` se veía correcto en todas las pantallas y NO entraba en su resumen de pago. Ahora, al cargar el valor, se completa el nombre con el que la persona está cargada (el apodo del resumen, no el nombre completo; en un vendedor con doble rol, el de su cliente). Sin GRUPO no hay tarifa que estampar —el cadete directo del nodo— y este valor manual era el único mecanismo, sin ninguna herramienta que lo escribiera. No paga por su cuenta un envío que no se entregó: los Cancelados/Devueltos vuelven listados salvo que lo pidas.",
82
- "3.52.0 — `envio_completar_ciclo`: reconstruye los pasos que le faltan a un envío YA cerrado (Colectado / En centro de distribución / En camino), cada uno a nombre de quien lo hizo y fechado en el día real. El control por foto cerrado en lote marca ENTREGADO y nada más, así que el envío saltaba de 'A retirar' al cierre sin registro de quién lo movió, y `avanzarA` no servía porque solo avanza hacia adelante. Solo agrega lo que falta: no duplica, no cambia el estado actual y no inventa autores (el paso del que no decís quién lo hizo, no se agrega). No mueve dinero.",
83
- "3.51.0 — `envio_entregar` ya no revive un envío que había vuelto al vendedor. Corregir una tanda mal fechada es una operación en LOTE, y un paquete en estado de EXCEPCIÓN (Cancelado, Devuelto al vendedor, En espera de reposición) que estuviera en la lista se marcaba ENTREGADO por estar ahí, en vez de que solo se le corrigiera la fecha. Ahora esos vuelven en `errores` con su estado real y no se tocan; y un envío ya cerrado por ML (`Entregado (Flex)`) entra por la corrección de fecha, no por una entrega nueva. Misma regla que la cadena de `avanzarA`.",
84
- "3.50.0 — El control por foto ya cierra la tanda EN EL DÍA QUE SE MOVIÓ. `envio_desde_etiqueta_ml` con `fecha` + `avanzarA` estampa cada estado de la cadena con su hora de ESE día (Colectado 9, En centro 12, En camino 15, Entregado 18) en vez de la hora del control, así el timeline se lee en orden y el cumplimiento del día cuenta bien. Y sobre un envío que YA estaba cargado, la `fecha` ahora también se aplica: antes `cargarEnvio` cortaba antes por idempotencia y la planilla atrasada se seguía liquidando en la semana del alta. Si ese período ya está liquidado, rechaza la llamada nombrando la liquidación en vez de dejar el envío a medio corregir.",
85
- "3.49.0 — `envio_entregar` acepta `fecha`: el día REAL en que se entregó. El control por foto se cuenta días después ('esto salió por Bonorino el 2', contado el 8) y el historial decía que se había entregado hoy. Sobre un envío que YA figura entregado, corrige la fecha del cierre ya registrado (vuelve en `fechaCorregida`) — así se arregla una tanda cerrada con el día equivocado sin tocar nada más. Toca el evento 'Entregado' del historial; la semana en que se liquida el envío sigue siendo cosa de `envio_editar_fecha`. No mueve dinero.",
86
- "3.48.0 — `envio_asignar_grupo` acepta VARIOS envíos en una llamada: separá los códigos por coma o salto de línea. Una planilla despacha decenas por el mismo grupo y de a uno eran decenas de llamadas. Devuelve el resumen (asignados / ya estaban / no encontrados) en vez de cortar en el primero que falla, porque en una planilla es normal que alguno todavía no esté cargado. Un QR de ML crudo es un JSON con comas adentro: ese nunca se parte, va como un solo código.",
87
- "3.47.0 — Quién puede repartir se resuelve en UN solo lugar. La 3.46.0 arregló a medias el `mensajero` de `envio_desde_etiqueta_ml`: miraba rol 'mensajero' y el flag `reparte`, pero seguía dejando afuera la TERCERA forma — el VENDEDOR con doble rol (su Cliente tiene `mensajeroNombre`), que es el caso típico del que vende y además reparte. Ahora tanto ese parámetro como `envio_asignar_mensajero` usan `usuariosQueReparten`, la misma fuente que las listas del panel, así que las tres formas valen igual y se puede pasar el id.",
88
- "3.46.0 — El `mensajero` de `envio_desde_etiqueta_ml` ya no le atribuye entregas a la persona equivocada. Resolvía SOLO entre usuarios con rol 'mensajero', así que a un VENDEDOR que también reparte no lo encontraba y elegía a otro de nombre parecido; y si no había ninguna coincidencia lo ignoraba EN SILENCIO, dejando el envío sin mensajero con la respuesta en success. Ahora busca entre todos los que pueden repartir (rol mensajero o `reparte`), acepta el ID, falla si no encuentra a nadie, y si el nombre matchea a varios los lista con su id en vez de elegir por vos.",
89
- "3.45.0 — Dos herramientas para DESHACER lo que quedó mal. `envio_corregir_estado` pone un envío en un estado puntual con motivo obligatorio (acepta también los terminales 'Cancelado'/'Rechazado por el comprador', que antes solo escribía ML o la devolución: si algo dejaba un envío ahí, no había forma de volverlo atrás). `envio_asignar_mensajero` asigna o QUITA el mensajero por tracking, buscando entre TODOS los que pueden repartir — rol mensajero o `reparte` habilitado — y aceptando el ID: el `mensajero` de `envio_desde_etiqueta_ml` solo mira rol 'mensajero', así que con un VENDEDOR que también reparte elegía a otra persona de nombre parecido y le atribuía entregas ajenas.",
90
- "3.44.0 — FIX de la 3.43.0: un envío en estado de EXCEPCIÓN ya no se avanza. `avanzarA` sobre un envío existente buscaba su estado en el ciclo normal, y como 'Cancelado (Flex)' / 'Devuelto al vendedor' / 'En espera de reposición' no están ahí, `indexOf` daba -1 y lo trataba como si recién arrancara: marcaba ENTREGADO un paquete que en realidad había vuelto al vendedor. Ahora solo avanza desde el ciclo normal (A retirar / Colectado / En centro / En camino) y si no, contesta `noAvanzado` con el estado real, sin escribir nada.",
91
- "3.43.0 — `avanzarA` ya no se ignora cuando el envío YA existía. La cadena de estados del control por foto vivía dentro de un `if (!yaExistia)`: si el paquete ya había entrado por otro lado (lo cargó el vendedor, o una integración ML/TiendaNube), `envio_desde_etiqueta_ml` contestaba success y lo dejaba donde estaba, sin avisar. Ahora lo avanza SOLO HACIA ADELANTE desde su estado actual (nunca retrocede, y un envío ya entregado no se toca), y devuelve `historial` + `hechoPor` como en un alta nueva para que se vea qué se movió. Sirve para cerrarle el ciclo a una planilla cuyos paquetes ya estaban cargados.",
92
- "3.42.0 — Marcar ENTREGADO un envío que YA estaba cargado (`envio_entregar`, por tracking, uno o varios). Faltaba: el `avanzarA:'entregado'` de `envio_desde_etiqueta_ml` solo corre en altas NUEVAS, así que cuando la foto era de un paquete que ya había entrado por otro lado (lo cargó el vendedor, o una integración ML/TiendaNube) el parámetro se ignoraba EN SILENCIO y el envío quedaba 'A retirar' para siempre — no había forma de cerrarle el ciclo desde el MCP. Entrega 'solo con los datos' (sin foto ni firma) y corre la entrega real: sella la logística de entrega para el clearing y le avisa al vendedor. Los ya entregados vuelven en `yaEstaban` sin romper el lote. Staff, scopeado a tu nodo.",
93
- "3.41.0 — Corregir el clearing de un envío YA ENTREGADO sin romperle el estado: `envio_asignar_grupo` nunca despachó los envíos en 'Entregado'/'En camino' (solo les setea con qué grupo salieron), pero la descripción decía que los pasaba a 'En camino' y la ruta devolvía un 409 genérico — 'No se pudo asignar el envío' — cuando en realidad el envío YA estaba en ese grupo y no había nada que hacer. Ahora eso responde ok con `yaEstaba`, y al asignar devuelve `estadoIntacto` para dejar claro que el estado no se tocó. Sirve para atribuirle el clearing a una planilla vieja ya entregada.",
94
- "3.40.0 — No cobrarle a un vendedor un envío que el nodo no movió: `liquidacion_excluir_envio` lo saca del cobro de una liquidación YA emitida (re-suma y ajusta la cuenta corriente) y `liquidacion_reincluir_envio` lo devuelve. El MOTIVO es obligatorio: queda en el historial del envío y el vendedor lo ve en su portal. Si la liquidación ya está en una factura de ARCA emitida, lo rechaza (haría falta una nota de crédito). Además el motor de liquidación ahora decide con EVIDENCIA operativa (colecta, mensajero, foto, receptor, etiqueta impresa…) en vez de un guardarraíl binario, y deja registrado por qué no se cobró cada envío. Requiere permiso finanzas.",
95
- "3.39.0 — Corregir la FECHA de un envío YA cargado (`envio_editar_fecha`): el que se subió atrasado sin `fecha` quedó con el día del alta y se liquidaría en la semana equivocada. Por tracking, mueve el envío de período y arrastra el primer estado del historial (los demás quedan como se registraron). Solo staff y solo tu nodo; no admite fecha futura; y si ese período YA está liquidado lo rechaza con el número de liquidación en vez de descuadrarla en silencio. No mueve dinero.",
96
- "3.38.0 — Cargar envíos con FECHA ANTERIOR: `envio_cargar` y `envio_desde_etiqueta_ml` aceptan `fecha` (dd/mm/aaaa o aaaa-mm-dd) para subir una planilla días después de que el paquete se movió, en vez de que todo quede con la fecha del alta. La fecha define en qué SEMANA se liquida el envío: por eso es SOLO staff (un vendedor no puede mover sus paquetes de período) y no se admite una fecha futura. Sin `fecha` se comporta igual que antes (hoy). No mueve dinero.",
97
- "3.37.0 — Guía para ponerse a facturar: nuevo flujo `flujo facturacion` (paso a paso completo: emisor por cuenta, trámite en ARCA, clientes, y cómo se emite después). `facturacion_estado` te dice qué falta — emisores incompletos, cuentas sin emisor y, por cliente, qué dato le falta (CUIT, razón social, condición IVA, emisor, habilitación). `facturacion_preparar_cliente` deja un cliente listo en un paso (datos fiscales + cuenta donde cobra + frecuencia + corte en HOY, para no re-facturar lo viejo). El MCP sigue SIN facturar: no emite comprobantes ni entra a ARCA (eso necesita la Clave Fiscal de esa persona). Requiere permiso finanzas.",
98
- "3.33.0 — Colecta a vendedores SIN envíos cargados: `colecta_asignar` crea la colecta igual aunque el cliente no tenga nada cargado, y el cadete ESCANEA cada paquete en la puerta — los que no están en el sistema se crean solos a nombre del vendedor y el mismo QR nunca se cuenta dos veces. Si el QR es de Flex, se APRENDE la cuenta de ML (sender_id) del vendedor: sus próximas etiquetas se reconocen solas, sin preguntar de quién son. `colecta_pendientes` devuelve `clientesSinEnvios`.",
99
- "3.32.0 — Alerta 'marcar entregado': los envíos que quedan 'En camino' +24h sin cerrarse ahora salen en `envios_trabados` como tipo `en_camino_sin_cerrar` ('revisá y marcá entregado'), y el aviso proactivo (opt-in ALERTAS_TRABADOS=1) le llega a los OPERATIVOS y admins del nodo (antes solo al jefe). Umbral configurable con ALERTA_EN_CAMINO_HORAS.",
100
- "3.31.0 — Consulta rápida de barrios: `zona_barrios` responde '¿qué barrios/localidades hay en <zona>?' (ej. 'Matanza Norte', 'CABA') con su tramo por perfil. Busca por nombre entre las zonas visibles a tu nodo (propias, globales o de tus grupos logísticos). Solo lectura.",
101
- "3.30.0 — Perfil de zona por SUCURSAL: un cliente con sucursales en zonas distintas ahora cotiza cada envío según la distancia de SU sucursal-origen (antes usaba un único perfil por cliente). `sucursales_cliente` lista las sucursales de un cliente con su perfil; `sucursal_perfil` setea (o limpia) el perfil de una sucursal (config de staff — el vendedor no lo toca). Perfil vacío = la sucursal hereda el del cliente (comportamiento previo, aditivo).",
102
- "3.29.0 — Alta de portal para clientes, gaps cerrados: `cliente_resetear_clave` genera una clave temporal nueva para un cliente/vendedor que YA tiene usuario pero perdió el acceso (sin pisar el alta; para el alta nueva sigue `cliente_generar_usuario`). `generar_enlace_vinculacion` ahora resuelve `cliente` por NOMBRE además de id (antes solo aceptaba el id y tiraba 'Cliente inválido'). El enlace de vinculación (ML/TiendaNube/TiendaNegocio) pasó de vencer en 15 minutos a 48hs, para que le llegue vigente al cliente por WhatsApp aunque no lo abra al instante.",
103
- "3.28.0 — Admin de grupo: los nodos ORIGINALES de un grupo (los que lo fundan por QR) son sus admins y gestionan quién está adentro. `grupo_miembros` lista los nodos marcando quién es admin; `grupo_sumar_nodo` / `grupo_expulsar_nodo` agregan o sacan nodos; `grupo_admin_permiso` da o saca el permiso de admin a otro nodo (así otros heredan la administración si los originales se van). Nunca se deja un grupo sin admin. Solo un admin del grupo (o admin global). No mueve dinero.",
104
- "3.27.0 — Reconciliación de nodos (QR onboarding etapa 3): `provisorio_conciliar` acepta `modo` — 'absorber' (default, el nodo real se queda con el historial) o 'desde_ahora' (no toca el historial; el clearing arranca de acá). En la PWA, al vincular por QR elegís el modo y cargás la tarifa inicial del grupo.",
105
- "3.26.0 — Planilla ML Fase 5: AUTO-PRECIO — `envio_desde_etiqueta_ml` devuelve `precioClearing` (lo que clearea el envío por su grupo+zona) cuando lo despachás por grupo. Nuevo `planilla_reporte`: resumen de lo cargado por control por foto (ml_manual) — total + por cliente/zona/estado + suma de valor y cobro.",
106
- "3.25.0 — 'Control por foto y carga de planilla vía MCP' (flujo `control_por_foto`): `envio_desde_etiqueta_ml` cierra TODO el ciclo en un paso con `avanzarA:'entregado'` (A retirar→Colectado→Procesado→[grupo=En camino]→Entregado, cada estado con QUIÉN lo hizo). Vía rápida del clearing entre nodos. Nuevo `fotoRuta` = ruta local de la foto (no la imagen) para reencontrarla, sobre todo con dígitos '*'. Devuelve `historial` + `hechoPor`.",
107
- "3.24.0 — Planilla por CARPETA de fotos (una habilidad): `envio_desde_etiqueta_ml` cierra el circuito en un paso con `avanzarA` ('colectado'/'procesado'), `autoRutear` (al nodo/mensajero responsable de la zona; configurás con `asignar_nodo_zona`/`asignar_mensajero_zona`) y `grupo`. Convención de dígitos ilegibles con '*' (ej. 'Aguirre 31**'), sin inventar. Flujo `alta_planilla_ml` reescrito.",
108
- "3.23.0 — Flujo de comunicación: `envio_estado` responde '¿cuándo llega?' con narrativa + ETA (mensajero asignado, cuántos envíos en la ruta, posición del envío, estimado por posición×min/parada) y marca problemas (nunca despachado, sin mensajero, mensajero detenido). `envios_trabados` lista los envíos trabados del nodo por severidad. Aviso PROACTIVO al jefe de nodo por push opt-in (ALERTAS_TRABADOS=1, desactivado por defecto). Todo read-only.",
109
- "3.22.0 — Planilla ML: `provisorio_conciliar` fusiona un NODO PROVISORIO con el nodo real cuando este ya está dado de alta — reatribuye sus envíos y su membresía de grupo al nodo real y lo marca resuelto (el clearing pasa a usar la tarifa del nodo real). Como operador solo conciliás los provisorios que creó tu nodo, contra un nodo que ya opera en el grupo; el admin global además puede definirle la tarifa. No mueve dinero.",
110
- "3.21.0 — Paridad con el escáner: `envio_asignar_grupo` asigna/rutea un envío (por tracking) a un GRUPO logístico tuyo (por nombre o id, ej. 'Portela'/'Bonorino'), define con qué grupo salió (tarifa del clearing) y lo despacha ('En camino', sale del centro). Solo tus grupos / envíos de tu nodo. No mueve dinero. (Sugerencia #23.)",
111
- "3.20.0 — Constructor de flujos: `flujo_registrar` captura cuándo el asistente tuvo que INDAGAR (varias preguntas) hasta entender qué quería el usuario → sirve para armar un FLUJO DIRECTO. Se auto-usa (instrucción al conectar). Cualquier rol.",
112
- "3.19.0 — Fix de registro: `envio_procesar` (v3.16.0) ahora queda dentro del gate staff/mensajero en el conector remoto. NOTA: si un tool nuevo 'no aparece', abrí una SESIÓN nueva del MCP (el índice de herramientas se fija al iniciar; reconectar el conector no siempre alcanza).",
113
- "3.18.0 — Ahora CUALQUIER usuario puede ver SUS propias sugerencias y en qué estado están (`mis_sugerencias`): Recibida / En evaluación / ✅ Implementada / Descartada. Cada uno ve solo lo suyo.",
114
- "3.17.0 — Planilla ML Fase 3: crear un NODO PROVISORIO en un grupo al vuelo (`nodo_provisorio_crear`), para atribuir el clearing cuando te bajan paquetes de un nodo que todavía no está dado de alta. Solo en grupos de TU nodo (el admin global en cualquiera); no tiene login hasta conciliarlo con el nodo real. Reusa el clearing por grupo. No mueve dinero.",
115
- "3.16.0 — Procesar/recibir un envío en el centro desde el MCP (`envio_procesar` por tracking), sin escanear ni entrar al panel web — igual que la acción 'procesar' del escáner (queda 'En centro de distribución'). Scopeado a tu nodo/grupo. (La consulta de liquidaciones por cliente/monto + detalle ya estaba desde 3.8.0.)",
116
- "3.15.0 — Planilla ML manual (no vinculado): `envio_desde_etiqueta_ml` con `reusarEtiqueta:true` REUSA el mismo tracking/QR/etiqueta de ML que ya viene impreso (NO genera uno nuevo), marca el envío como 'ml_manual' y es idempotente (no duplica). Nuevo flujo `flujo alta_planilla_ml`. Además `alta_cliente` ahora guía también el PERFIL DE ZONA y la COLECTA (dirección con timbre + geoposición), y los flujos se AUTO-DISPARAN (el asistente consulta `flujo` cuando querés hacer algo). No mueve dinero.",
117
- "3.14.0 — El admin global ya puede OPERAR (no solo consultar) sobre cualquier nodo: los tools de escritura scopeados por nodo aceptan un parámetro opcional `nodo` para elegir sobre qué nodo actuar (chofer_crear, cliente_crear/editar, cliente_generar_usuario, producto_crear, precio_actualizar, colecta_configurar/asignar/desasignar, procesar_zona, rechazar_zona, asignar_mensajero_zona; asignar_nodo_zona usa `logisticaId`). El operador de nodo lo ignora (siempre opera en SU nodo — aislamiento intacto). Antes chofer_crear fallaba con 'Elegí un nodo' para el admin global. De paso se cerró un IDOR: desasignar una colecta ahora valida que sea de tu nodo.",
118
- "3.13.0 — Flujo guiado (Fase A): nuevo tool `flujo` que devuelve el PASO A PASO de un objetivo (alta_cliente, alta_operador, alta_chofer) para no dejar nada incompleto. Además `cliente_crear`, si no le pasás la lista de precios, te recuerda preguntar «¿qué le cobrás?» (Oficial de Flex o una propia con nombre reusable) y cómo asignarla. Todo en español, para operar sin saber nada.",
119
- "3.12.0 — Alta de OPERADOR de nodo (`operador_crear`): crea el login de un operador (staff que administra el nodo) con email + CLAVE TEMPORAL y los permisos que le des (default 'logistica'). El operador de nodo lo crea en SU nodo; el admin global elige el nodo con `nodo`. Requiere permiso 'usuarios'. No otorga admin de nodo (eso es `usuario_habilitar_rol`) ni toca dinero.",
120
- "3.11.1 — Fix `envio_desde_etiqueta_ml` / `envio_cargar`: (a) el id de ML leído de una foto con espacios OCR (\"4789055 7180\") ahora se limpia y se guarda como NÚMERO (así lo matchea el escáner); (b) los campos numérico-ambiguos (cp, teléfono, mlShipmentId, montoCobro) aceptan número o texto, así una llamada con `cp: 2804` ya no revienta en la validación con un error genérico sin detalle.",
121
- "3.11.0 — Superadmin desde el MCP: habilitar operador/superoperador/admin de nodo (`usuario_habilitar_rol`), crear grupos logísticos (`grupo_crear`) y subir el logo/marca de un nodo (`logo_subir`, imagen en base64). Además `mis_datos` ahora muestra el nodo por su NOMBRE. Solo admin global. No toca dinero.",
122
- "3.10.0 — Login para un vendedor: `cliente_generar_usuario` le crea las credenciales de acceso a un cliente/vendedor YA existente de tu nodo y devuelve una CLAVE TEMPORAL. Requiere permiso 'usuarios', scopeado a tu nodo. No toca dinero.",
123
- "3.9.0 — Corregir el destino de un envío puntual (`envio_editar_zona`): arregla la localidad/zona/partido de UN envío (por tracking) cargado con el destino mal (ej. dos localidades pisadas en la etiqueta) para que vuelva a ser cobrable/ruteable, SIN reasignarlo de cliente y sin crear un alias global. Staff: su nodo; admin global: cualquiera. No mueve dinero.",
124
- "3.8.0 — Consulta de liquidaciones: buscar por cliente/monto (`liquidacion_buscar`), ver el detalle de envíos de una liquidación (`liquidacion_detalle`) y listar las que están sin recibir/impagas (`liquidaciones_sin_recibir`). Solo lectura, scopeadas por nodo (admin de nodo: sus clientes; admin global: cualquier nodo con `nodo`). No mueve dinero.",
125
- "3.7.0 — Atribución de marketing (Fase 1): ver/guardar los píxeles de marketing (Meta Pixel, GA4, GTM y tokens de la Conversions API) del nodo o del vendedor con `marketing_config_ver` / `marketing_config_guardar`. El backend resuelve el alcance por rol (vendedor: lo suyo; operador: su nodo). No mueve dinero.",
126
- "3.6.1 — Guía (`guia`) con intro motivador que engancha a hacer el tutorial (qué podés automatizar en 5 min).",
127
- "3.6.0 — Guía de arranque por rol (`guia` + instrucciones al conectar): metodología para empezar rápido (mensajero: foto→envío al colectar; vendedor: cargar ventas; nodo: implementar + operar + clearing). Los MENSAJEROS ya pueden registrar envíos de lo que colectan (`envio_cargar` / `envio_desde_etiqueta_ml`), scopeado a clientes de su nodo o que hayan colectado (marketplace incluido).",
128
- "3.5.0 — Ayuda integrada (`ayuda`): documentación por herramienta (qué hace, cómo usar, ejemplo, qué NO hace) + temas transversales (facturacion_marketplace, dinero, aislamiento). Aclarado que adjudicar una ruta del marketplace NO genera facturación/rendición/comisión automática (el pago entre nodos es manual).",
129
- "3.4.0 — Marketplace de rutas públicas (ruta_publicar/ofertar/adjudicar); admin también reparte; registrar envío desde foto de etiqueta ML; alta/edición de choferes; mcp_version.",
130
- "3.3.0 — Afiliados y comisión por envío; métricas por tipo; gestión de colectas (asignar/rechazar entre nodos); cuentas vinculadas; sucursales; colecta a pedido; metazonas flexibles.",
131
- ];
132
- const INSTRUCCIONES = "MCP de Nexus Flex. Si te preguntan la versión, consultá `mcp_version` (este texto puede ser anterior a una actualización). Para arrancar: `guia` (cómo trabajar según tu rol) y `ayuda` (índice de herramientas). El MCP NUNCA mueve dinero.\n\nCuando el usuario quiera completar un alta o una carga que tiene flujo guiado (ver `flujo`), traé el paso a paso y completalo entero antes de darlo por terminado: los pasos siguientes (lista de precios, perfil de zona, colecta) son los que se olvidan y dejan el alta a medias.";
133
- const server = new McpServer({ name: "nexusflex", version: MCP_VERSION }, { instructions: INSTRUCCIONES });
134
-
135
- /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
136
- * filtrar tokens ni stack traces. */
137
- function toResult(r) {
138
- if (r.ok) return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
139
- const base =
140
- r.status === 401 ? "Sesión inválida o vencida (autorizá de nuevo con `npx nexusflex-mcp login`)." :
141
- r.status === 403 ? "Tu usuario no tiene permiso para esta operación (aislamiento del backend)." :
142
- r.status === 404 ? "No encontrado." :
143
- `Error ${r.status}.`;
144
- const d = r.data;
145
- const detalle = d?.error ? ` ${d.error}` : d?.message ? ` ${d.message}` : "";
146
- return { content: [{ type: "text", text: `${base}${detalle}` }], isError: true };
147
- }
148
-
149
- async function run(fn) {
150
- try {
151
- return toResult(await fn());
152
- } catch (e) {
153
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
154
- }
155
- }
156
-
157
- // Un modelo con visión suele mandar campos "de texto" como NÚMERO en el JSON
158
- // (ej. cp: 2804, mlShipmentId: 47890557180). Con z.string() eso reventaba en la
159
- // validación del SDK ANTES del handler → el cliente sólo veía "MCP tool call
160
- // failed" sin detalle. Estos helpers aceptan string|number y normalizan, así el
161
- // tool corre y, si algo falla, devuelve un mensaje útil del backend.
162
- const zStr = () => z.union([z.string(), z.number()]).transform((x) => String(x)).optional();
163
- const zNum = () => z.union([z.number(), z.string()]).optional();
164
-
165
- // Override de nodo: SOLO lo usa el admin global para elegir sobre qué nodo opera. El
166
- // operador de nodo lo ignora (el backend lo fuerza a SU nodo con nodoDe → sin escalada
167
- // cross-nodo). Se manda al backend como `logisticaId`. Ver sugerencia MCP #20.
168
- const NODO_OVERRIDE_DESC = "Solo admin global: nodo (id) sobre el que operás. El operador de nodo lo ignora (siempre opera en el suyo).";
169
- const zNodo = () => z.number().int().positive().optional().describe(NODO_OVERRIDE_DESC);
170
-
171
- const q = (params) => {
172
- const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
173
- return s ? `?${s}` : "";
174
- };
175
- const registrados = new Set(); // para que `ayuda` liste solo lo que este usuario tiene
176
- const tool = (name, def, handler) => { registrados.add(name); server.registerTool(name, def, handler); };
177
-
178
- // ============================================================================
179
- // CONTROL POR FOTO (staff). El servidor hace el trabajo foto por foto
180
- // (/control-foto/foto); acá, en la PC, se recorre la CARPETA del día — cosa que el
181
- // conector remoto no puede (no ve tu disco). Ver `flujo control_por_foto`.
182
- // ============================================================================
183
- const EXT_FOTO = /\.(jpe?g|png|webp)$/i;
184
- /** Día de la carpeta: "Lunes 14-9", "14-09-2026", "2026-09-14"… → "aaaa-mm-dd" (o null). */
185
- function diaDeCarpeta(nombre, anioDefault = new Date().getFullYear()) {
186
- const iso = nombre.match(/(20\d{2})-(\d{1,2})-(\d{1,2})/);
187
- if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`;
188
- const m = nombre.match(/(\d{1,2})[-./](\d{1,2})(?:[-./](\d{2,4}))?/);
189
- if (!m) return null;
190
- const anio = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : String(anioDefault);
191
- return `${anio}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
192
- }
193
- /** Fotos de un día: [{ archivo, carpeta (relativa al día, sin el archivo) }]. Saltea carpetas "_…". */
194
- async function fotosDelDia(diaDir) {
195
- const fs = await import("node:fs");
196
- const path = await import("node:path");
197
- const out = [];
198
- const zips = [];
199
- const recorrer = (dir) => {
200
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
201
- if (e.name.startsWith("_")) continue;
202
- const p = path.join(dir, e.name);
203
- if (e.isDirectory()) recorrer(p);
204
- else if (EXT_FOTO.test(e.name)) out.push({ archivo: p, carpeta: path.relative(diaDir, dir).split(path.sep).join("/") });
205
- else if (/\.zip$/i.test(e.name)) zips.push(path.relative(diaDir, p));
206
- }
207
- };
208
- recorrer(diaDir);
209
- return { fotos: out.filter((f) => /^(dados|recibidos)\b/i.test(f.carpeta)), sueltas: out.filter((f) => !/^(dados|recibidos)\b/i.test(f.carpeta)).length, zips };
210
- }
211
-
212
- const DIAS_SEMANA = ["Domingo", "Lunes", "Martes", "Miercoles", "Jueves", "Viernes", "Sabado"];
213
- /** "2026-09-14" → "Lunes 14-9-2026" (la convención de las carpetas del control, con el año). */
214
- function nombreCarpetaDia(iso) {
215
- const [a, m, d] = iso.split("-").map(Number);
216
- return `${DIAS_SEMANA[new Date(Date.UTC(a, m - 1, d)).getUTCDay()]} ${d}-${m}-${a}`;
217
- }
218
- /** Un nombre de grupo/cadete como nombre de carpeta válido en Windows (sin "(operador)", sin / \ : * ? " < > |). */
219
- const nombreCarpeta = (s) => String(s).replace(/\s*\([^)]*\)\s*$/, "").replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").trim();
220
- /** Fechas pedidas: "14/09/2026", "2026-09-14", rangos "14/09/2026 a 19/09/2026" o listas separadas por coma. */
221
- function fechasPedidas(texto, anioDefault) {
222
- const una = (t) => {
223
- const s = String(t).trim();
224
- const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
225
- if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`;
226
- const m = s.match(/^(\d{1,2})[-/.](\d{1,2})(?:[-/.](\d{2,4}))?$/);
227
- if (!m) return null;
228
- const a = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : String(anioDefault);
229
- return `${a}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
230
- };
231
- const out = [];
232
- for (const parte of String(texto).split(",")) {
233
- const [desde, hasta] = parte.split(/\s+(?:a|al|hasta)\s+/i).map(una);
234
- if (!desde) return null;
235
- if (!hasta) { out.push(desde); continue; }
236
- for (let t = Date.parse(`${desde}T00:00:00Z`); t <= Date.parse(`${hasta}T00:00:00Z`) && out.length < 62; t += 864e5) out.push(new Date(t).toISOString().slice(0, 10));
237
- }
238
- return out;
239
- }
240
-
241
- function registrarControlFoto() {
242
- tool("control_foto_preparar", {
243
- title: "Control por foto: crear las carpetas del día (o de varios días)",
244
- description: "Arma en la PC las carpetas para el control por foto de uno o varios días, con los nombres EXACTOS de tus grupos y cadetes: <día>/DADOS/<cada grupo>, <día>/DADOS/<cada cadete>, <día>/RECIBIDOS/<cada cadete>, <día>/RECIBIDOS/<cada grupo> (lo que derivaste por ese grupo) + un LEEME.txt que explica qué foto va en cada una. Usalo cuando el usuario dice 'hagamos el control por foto del día tal': creás las carpetas, le decís dónde quedaron y que suelte ahí las fotos; después, `control_foto_carpeta`. No pisa nada que ya exista. No mueve dinero.",
245
- inputSchema: {
246
- ruta: zStr().describe("Carpeta donde crear los días (ej. 'C:\\\\Users\\\\...\\\\Desktop\\\\Control por fotos'); se crea si no existe"),
247
- fechas: zStr().describe("Día o días: '14/09/2026', '14/09 a 19/09', '14/09, 16/09' (sin año = el actual)"),
248
- nodo: zNodo(),
249
- },
250
- }, async (a) => run(async () => {
251
- const fs = await import("node:fs");
252
- const path = await import("node:path");
253
- const raiz = String(a.ruta ?? "").trim();
254
- if (!raiz) return { ok: false, status: 400, data: { message: "Decime en qué carpeta de tu PC armo los días (ej. el Escritorio)." } };
255
- const fechas = fechasPedidas(a.fechas ?? "", new Date().getFullYear());
256
- if (!fechas?.length) return { ok: false, status: 400, data: { message: `No entiendo las fechas "${a.fechas ?? ""}". Usá '14/09/2026' o '14/09 a 19/09'.` } };
257
- const est = await api("GET", `/control-foto/estructura${q({ nodo: a.nodo })}`);
258
- if (!est.ok) return est;
259
- const grupos = (est.data.grupos ?? []).map((g) => nombreCarpeta(g.nombre));
260
- const cadetes = (est.data.cadetes ?? []).map((c) => nombreCarpeta(c.nombre));
261
- const leeme = [
262
- "CONTROL POR FOTO — qué foto va en cada carpeta",
263
- "",
264
- "DADOS/<grupo> → los paquetes que le pasaste a ese grupo (juntada, colecta del grupo…).",
265
- "DADOS/<cadete> → los que salieron a repartir con ese cadete tuyo.",
266
- "RECIBIDOS/<cadete> → los que te dieron otros nodos y repartió ese cadete.",
267
- "RECIBIDOS/<grupo> → los que te dieron y mandaste por ese grupo a otro nodo (ej. Zárate Campana).",
268
- "",
269
- "Una foto por paquete, que se lea la etiqueta. Si un paquete llegó por un grupo en particular,",
270
- "podés poner la carpeta del cadete adentro: RECIBIDOS/<grupo>/<cadete>.",
271
- "Las carpetas que no uses quedan vacías: no pasa nada. No renombres las carpetas.",
272
- "Cuando termines de soltar las fotos, pedile a Claude: \"hacé el control por foto de esta carpeta\".",
273
- ].join("\r\n");
274
- const creadas = [];
275
- for (const f of fechas) {
276
- const dia = path.join(raiz, nombreCarpetaDia(f));
277
- const subs = [...grupos.map((g) => ["DADOS", g]), ...cadetes.map((c) => ["DADOS", c]), ...cadetes.map((c) => ["RECIBIDOS", c]), ...grupos.map((g) => ["RECIBIDOS", g])];
278
- for (const s of subs) fs.mkdirSync(path.join(dia, ...s), { recursive: true });
279
- const lm = path.join(dia, "LEEME.txt");
280
- if (!fs.existsSync(lm)) fs.writeFileSync(lm, leeme);
281
- creadas.push(dia);
282
- }
283
- return { ok: true, status: 200, data: { creadas, grupos, cadetes, siguiente: "Soltá las fotos en cada carpeta y después corré control_foto_carpeta sobre la carpeta del día (o sobre la de todos los días)." } };
284
- }));
285
-
286
- tool("control_foto_estructura", {
287
- title: "Control por foto: cómo armar las carpetas (tus grupos y cadetes)",
288
- description: "Devuelve cómo tienen que llamarse las carpetas del control por foto para TU nodo: tus grupos logísticos y quiénes reparten. Llamalo antes de `control_foto_carpeta` para explicarle al usuario la estructura o para entender por qué una carpeta no se reconoce. Solo lectura.",
289
- inputSchema: { nodo: zNodo() },
290
- }, async ({ nodo }) => run(() => api("GET", `/control-foto/estructura${q({ nodo })}`)));
291
-
292
- tool("control_foto_foto", {
293
- title: "Control por foto: procesar UNA foto de etiqueta",
294
- description: "Procesa una sola foto (por su RUTA en la PC): lee el QR (o la IA de etiquetas), busca o da de alta el envío (sin duplicar), lo asigna según la CARPETA (DADOS/<grupo>, DADOS/<cadete>, RECIBIDOS/…/<cadete>, RECIBIDOS/<grupo>), lo lleva a Entregado en el `fecha` real y le cuelga la foto. Usalo para CONTESTAR una pregunta de `control_foto_carpeta`: repetí esa foto con `nodoEntrega` (quién la llevó) o `cliente` (de quién es la cuenta de ML). `simular:true` = solo mirar. No mueve dinero.",
295
- inputSchema: {
296
- ruta: zStr().describe("Ruta de la foto en la PC"),
297
- carpeta: zStr().describe("Carpeta relativa al día, ej. 'DADOS/Bonorino' o 'RECIBIDOS/Matanza/Gerardo'"),
298
- fecha: zStr().describe("Día real (dd/mm/aaaa o aaaa-mm-dd)"),
299
- simular: z.boolean().optional(),
300
- nodoEntrega: zStr().describe("Respuesta: nodo (nombre o id) que lo llevó, cuando la zona tiene varios responsables"),
301
- cliente: zStr().describe("Respuesta: id del cliente dueño de una cuenta de ML desconocida"),
302
- nodo: zNodo(),
303
- },
304
- }, async (a) => run(async () => {
305
- const fs = await import("node:fs");
306
- const path = await import("node:path");
307
- if (!a.ruta || !fs.existsSync(a.ruta)) return { ok: false, status: 400, data: { message: `No encuentro la foto ${a.ruta ?? ""}.` } };
308
- return api("POST", "/control-foto/foto", { imagen: fs.readFileSync(a.ruta).toString("base64"), carpeta: a.carpeta, fecha: a.fecha, simular: a.simular === true, nodoEntrega: a.nodoEntrega, cliente: a.cliente, nombreArchivo: path.basename(a.ruta), nodo: a.nodo });
309
- }));
310
-
311
- tool("control_foto_carpeta", {
312
- title: "Control por foto: procesar la carpeta de un día (o de varios)",
313
- description: "Recorre la carpeta de UN DÍA (o una carpeta con varios días adentro) en la PC: cada foto bajo DADOS/… o RECIBIDOS/… se manda al servidor con su carpeta y el día (sale del nombre de la carpeta: 'Lunes 14-9', '2026-09-14'…). Por defecto SIMULA (no escribe): devuelve qué haría, cuántos paquetes, y las PREGUNTAS (cuentas de ML que no conoce, zonas con varios responsables, posibles duplicados, carpetas que no reconoce). Mostrale el resumen al usuario, resolvé las preguntas y recién ahí corré con `aplicar:true`. Al aplicar, lo que siga dudoso se SUBE a la app (Logística → 📷 Control por foto) para resolverlo mirando la foto. Es reanudable: una foto ya procesada no se repite. Las fotos dudosas se copian a `_sin_identificar` dentro de cada día y deja el detalle en `_control-foto-<día>.json`. No mueve dinero.",
314
- inputSchema: {
315
- ruta: zStr().describe("Carpeta del día (ej. 'C:\\\\Users\\\\...\\\\Control por fotos\\\\Lunes 14-9') o la carpeta que contiene varios días"),
316
- aplicar: z.boolean().optional().describe("true = escribe. Sin esto, solo simula."),
317
- anio: zNum().describe("Año, si el nombre de la carpeta no lo trae (default: el actual)"),
318
- nodo: zNodo(),
319
- },
320
- }, async (a) => run(async () => {
321
- const fs = await import("node:fs");
322
- const path = await import("node:path");
323
- const raiz = String(a.ruta ?? "");
324
- if (!raiz || !fs.existsSync(raiz)) return { ok: false, status: 400, data: { message: `No encuentro la carpeta ${raiz}.` } };
325
- if (a.aplicar === true && !ALLOW_WRITE) return { ok: false, status: 403, data: { message: "Escritura deshabilitada en este MCP (NEXUSFLEX_MCP_ALLOW_WRITE)." } };
326
- const esDia = (d) => fs.readdirSync(d, { withFileTypes: true }).some((e) => e.isDirectory() && /^(dados|recibidos)$/i.test(e.name));
327
- const dias = esDia(raiz) ? [raiz] : fs.readdirSync(raiz, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && esDia(path.join(raiz, e.name))).map((e) => path.join(raiz, e.name));
328
- if (!dias.length) return { ok: false, status: 400, data: { message: "No encontré carpetas DADOS/RECIBIDOS (¿están comprimidas en .zip? descomprimilas primero). Ver `control_foto_estructura`." } };
329
- const resumen = { modo: a.aplicar === true ? "APLICADO" : "SIMULACIÓN (no se escribió nada)", dias: [] };
330
- for (const dir of dias) {
331
- const fecha = diaDeCarpeta(path.basename(dir), Number(a.anio) || undefined);
332
- if (!fecha) { resumen.dias.push({ dia: path.basename(dir), error: "El nombre de la carpeta no tiene la fecha (ej. 'Lunes 14-9')." }); continue; }
333
- const { fotos, sueltas, zips } = await fotosDelDia(dir);
334
- const res = [];
335
- let i = 0;
336
- const trabajador = async () => {
337
- while (i < fotos.length) {
338
- const f = fotos[i++];
339
- const r = await api("POST", "/control-foto/foto", { imagen: fs.readFileSync(f.archivo).toString("base64"), carpeta: f.carpeta, fecha, simular: a.aplicar !== true, nombreArchivo: path.basename(f.archivo), nodo: a.nodo }).catch((e) => ({ ok: false, data: { message: String(e) } }));
340
- res.push({ archivo: path.relative(dir, f.archivo), carpeta: f.carpeta, ...(r.ok ? r.data : { estado: "error", error: r.data?.message ?? `HTTP ${r.status}` }) });
341
- }
342
- };
343
- await Promise.all([trabajador(), trabajador(), trabajador()]);
344
- fs.writeFileSync(path.join(dir, `_control-foto-${fecha}.json`), JSON.stringify(res, null, 1));
345
- // Lo dudoso a mano: copia en _sin_identificar (el original queda donde estaba).
346
- const dudosas = res.filter((r) => r.estado === "pregunta" || r.estado === "error");
347
- if (dudosas.length) {
348
- const destino = path.join(dir, "_sin_identificar");
349
- fs.mkdirSync(destino, { recursive: true });
350
- for (const d of dudosas) {
351
- const motivo = (d.pregunta?.tipo ?? "error") + (d.pregunta?.datos?.cuentaML ? ` ML ${d.pregunta.datos.cuentaML}` : "");
352
- try { fs.copyFileSync(path.join(dir, d.archivo), path.join(destino, `${motivo} - ${d.carpeta.replace(/\//g, " ")} - ${path.basename(d.archivo)}`)); } catch { /* no rompe el lote */ }
353
- }
354
- }
355
- const cuenta = (k) => res.filter((r) => r.estado === k).length;
356
- const porCuenta = {};
357
- for (const r of res) if (r.pregunta?.tipo === "cuenta_ml") {
358
- const c = r.pregunta.datos?.cuentaML;
359
- (porCuenta[c] ??= { cuentaML: c, vendedor: r.pregunta.datos?.vendedor ?? null, marcas: new Set(), fotos: 0, ejemplo: r.archivo, carpeta: r.carpeta });
360
- porCuenta[c].fotos++;
361
- if (r.pregunta.datos?.marcaManual) porCuenta[c].marcas.add(r.pregunta.datos.marcaManual);
362
- }
363
- const otras = res.filter((r) => r.estado === "pregunta" && r.pregunta?.tipo !== "cuenta_ml").map((r) => ({ archivo: r.archivo, carpeta: r.carpeta, tipo: r.pregunta.tipo, texto: r.pregunta.texto, opciones: r.pregunta.opciones?.map((o) => o.nombre) }));
364
- const errores = [...new Set(res.filter((r) => r.estado === "error").map((r) => `${r.carpeta}: ${r.error}`))];
365
- resumen.dias.push({
366
- dia: path.basename(dir), fecha, fotos: fotos.length,
367
- ok: cuenta("ok"), sinCambios: cuenta("sin_cambios"), yaProcesadas: cuenta("ya_procesada"), altas: res.filter((r) => r.alta).length,
368
- preguntas: cuenta("pregunta"), errores: cuenta("error"),
369
- ...(sueltas ? { fuera_de_DADOS_RECIBIDOS: sueltas } : {}), ...(zips.length ? { zipsSinDescomprimir: zips } : {}),
370
- cuentasMLDesconocidas: Object.values(porCuenta).map((c) => ({ ...c, marcas: [...c.marcas] })),
371
- otrasPreguntas: otras.slice(0, 40), ...(otras.length > 40 ? { masPreguntas: otras.length - 40 } : {}),
372
- erroresDeCarpeta: errores,
373
- ...(a.aplicar === true && cuenta("pregunta") + cuenta("error") ? { resolverEnLaApp: `Las ${cuenta("pregunta") + cuenta("error")} foto(s) dudosas quedaron subidas en la app: menú Logística → 📷 Control por foto. Ahí se ven grandes y se elige el cliente, el nodo o el cadete.` } : {}),
374
- detalle: path.join(dir, `_control-foto-${fecha}.json`),
375
- });
376
- }
377
- return { ok: true, status: 200, data: resumen };
378
- }));
379
- }
380
-
381
- // ============================================================================
382
- // CAPA 2 — Identidad: leemos /auth/me ANTES de registrar tools. Cada usuario ve
383
- // SOLO los tools de su rol. Si falla el login, se registra solo `mis_datos`.
384
- // ============================================================================
385
- let me = null;
386
- try {
387
- const r = await api("GET", "/auth/me");
388
- if (r.ok) me = r.data?.usuario ?? null;
389
- else log(`/auth/me devolvió ${r.status} — revisá el token/credenciales.`);
390
- } catch (e) {
391
- log("No se pudo contactar /auth/me:", e instanceof Error ? e.message : String(e));
392
- }
393
-
394
- const rol = me?.rol ?? "";
395
- const permisos = Array.isArray(me?.permisos) ? me.permisos : [];
396
- const esGlobal = rol === "admin" || me?.esSuperoperador === true;
397
- const esAdminNodo = me?.esAdminNodo === true;
398
- const isCliente = rol === "cliente";
399
- const isStaff = rol === "operador" || rol === "admin";
400
- const wmsActivo = me?.wmsActivo === true;
401
- // Espejo de requirePermiso del backend (solo para DECIDIR qué mostrar; el backend manda).
402
- const puede = (p) => esGlobal || (isStaff && (esAdminNodo || permisos.length === 0 || permisos.includes(p)));
403
-
404
- // ============================================================================
405
- // SIEMPRE
406
- // ============================================================================
407
- tool("mis_datos", {
408
- title: "Mis datos / alcance",
409
- description: "Devuelve tu usuario, rol y nodo/cliente al que está atado este MCP. Usalo para confirmar tu alcance antes de operar. Reportá el nodo por su NOMBRE (campo `logisticaNombre`, ej. 'estás en FastCorreo'), nunca solo por el número.",
410
- inputSchema: {},
411
- }, async () => run(() => api("GET", "/auth/me")));
412
-
413
- // Versión del MCP corriendo (cualquier rol). Sirve para saber si tenés lo último.
414
- tool("mcp_version", {
415
- title: "Versión del MCP",
416
- description: "Qué versión del MCP de Nexus Flex está corriendo y por qué vía. Cualquier rol lo puede consultar.",
417
- inputSchema: {},
418
- }, async () => ({ content: [{ type: "text", text: JSON.stringify({ version: MCP_VERSION, modo: "npx (Claude Desktop)", novedades: NOVEDADES, nota: "Para tener lo último: actualizá con «npx -y nexusflex-mcp@latest» o pasate al conector remoto (siempre al día, sin instalar/actualizar)." }) }] }));
419
-
420
- // Guía de arranque por rol (metodología). Cualquier rol; read-only.
421
- tool("guia", {
422
- title: "Guía de arranque (metodología para tu rol)",
423
- description: "Cómo empezar a usar Nexus Flex rápido según tu rol: mensajero (registrar lo que colectás sacando fotos → envío), vendedor (cargar ventas), nodo (implementar + operar + clearing). Sin argumentos = tu rol; `rol` para ver otra (mensajero|cliente|nodo).",
424
- inputSchema: { rol: z.string().optional().describe("mensajero | cliente | nodo (default: tu rol)") },
425
- }, async ({ rol: r }) => {
426
- const pedido = String(r ?? "").trim().toLowerCase();
427
- const target = pedido === "nodo" ? "operador" : pedido || rol;
428
- return { content: [{ type: "text", text: guiaOnboarding(target) }] };
429
- });
430
-
431
- // Flujo guiado: paso a paso de un objetivo para no dejar nada incompleto (Fase A).
432
- tool("flujo", {
433
- title: "Flujo guiado paso a paso",
434
- description: "Te devuelve el PASO A PASO para completar bien un objetivo, sin dejar nada a medias (ej. dar de alta un cliente con su lista de precios, o el CONTROL POR FOTO de una carpeta de etiquetas ML). Sin argumento lista los flujos disponibles; pasá `objetivo` (una de las claves de abajo). Usalo para GUIAR al usuario que no sabe qué datos faltan.",
435
- inputSchema: { objetivo: z.string().optional().describe(`Vacío = lista los flujos disponibles. Claves: ${Object.keys(FLUJOS).join(", ")}.`) },
436
- }, async ({ objetivo }) => ({ content: [{ type: "text", text: renderFlujo(objetivo) }] }));
437
-
438
- // Sugerencias: cualquier usuario (todos los roles) puede proponer funciones/mejoras.
439
- tool("sugerencia_crear", {
440
- title: "Enviar una sugerencia",
441
- description: "Proponé una función nueva, mejora o reportá un bug. mensaje obligatorio; categoria opcional (funcionalidad|mejora|bug|otro). Se registra tu usuario/rol/nodo automáticamente.",
442
- inputSchema: { mensaje: z.string().min(1), categoria: z.enum(["funcionalidad", "mejora", "bug", "otro"]).optional() },
443
- }, async (args) => run(() => api("POST", "/feedback/sugerencias", args)));
444
- tool("mis_sugerencias", {
445
- title: "Mis sugerencias y su estado",
446
- description: "Lista TUS propias sugerencias/pedidos de mejora y en qué estado están (Recibida / En evaluación / ✅ Implementada / Descartada). Cada usuario ve SOLO las suyas. Sin argumentos.",
447
- inputSchema: {},
448
- }, async () => run(() => api("GET", "/feedback/mis-sugerencias")));
449
- tool("flujo_registrar", {
450
- title: "Registrar un flujo aprendido (indagación)",
451
- description: "Usalo cuando, tras varias preguntas de aclaración, se entendió lo que el usuario quería: registra el objetivo y los pasos para convertirlo en un flujo directo. Avisale al usuario que lo registrás. Registrás `objetivo` (lo que terminó queriendo) y `pasos` (las preguntas/decisiones que lo aclararon + herramientas usadas). No mueve dinero.",
452
- inputSchema: { objetivo: z.string().min(1), pasos: z.string().min(1) },
453
- }, async ({ objetivo, pasos }) => run(() => api("POST", "/feedback/sugerencias", { categoria: "mejora", mensaje: `[FLUJO APRENDIDO] Objetivo: ${objetivo}\nPasos que lo aclararon: ${pasos}` })));
454
-
455
- // ============================================================================
456
- // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
457
- // ============================================================================
458
- if (isCliente) {
459
- tool("mis_envios", {
460
- title: "Mis envíos",
461
- description: "Tus envíos/paquetes (solo los tuyos), los 300 más recientes, cada uno con su `estado`. No incluye datos de otros clientes ni del nodo.",
462
- inputSchema: { estado: z.string().optional().describe("Estado: 'A retirar', 'Colectado', 'En centro de distribución', 'En camino', 'Entregado' (o una novedad, ej. 'Comprador ausente'). Filtra por los que EMPIEZAN con ese texto ('Entregado' incluye 'Entregado (Flex)'); los 300 más recientes de ese estado.") },
463
- }, async ({ estado }) => run(() => api("GET", `/portal/envios${q({ estado })}`)));
464
-
465
- tool("mis_kpis", {
466
- title: "Mis métricas",
467
- description: "Tus métricas: volumen, calidad de entrega, saldo, última liquidación y un benchmark ANÓNIMO contra el promedio de tu nodo (nunca ves a quién corresponde cada número).",
468
- inputSchema: { desde: z.string().optional().describe("Fecha YYYY-MM-DD (pasá desde y hasta juntas; si falta una, usa el mes en curso)"), hasta: z.string().optional().describe("Fecha YYYY-MM-DD") },
469
- }, async ({ desde, hasta }) => run(() => api("GET", `/kpi/vendedor${q({ desde, hasta })}`)));
470
-
471
- // --- Control de stock del vendedor (WMS Fase 1). Se registran SIEMPRE para
472
- // rol=cliente: el backend gatea con 403 si el vendedor no tiene depósito ni
473
- // control de stock propio. Así funciona tanto con WMS del nodo como con el
474
- // modo "soft" por vendedor (Cliente.controlStockActivo). ---
475
- tool("mi_stock", {
476
- title: "Mi stock",
477
- description: "Tu stock físico en el depósito (solo tus productos). Requiere tener depósito o control de stock habilitado.",
478
- inputSchema: {},
479
- }, async () => run(() => api("GET", "/wms/stock")));
480
-
481
- tool("mi_disponible", {
482
- title: "Mi disponible para vender",
483
- description: "Disponible-para-vender por SKU = stock físico − comprometido en pedidos pendientes. Marca ⚠️ cuando un SKU está por debajo del mínimo. Solo tus productos.",
484
- inputSchema: {},
485
- }, async () => run(() => api("GET", "/wms/stock/disponible")));
486
-
487
- tool("mi_rentabilidad", {
488
- title: "Mi rentabilidad por SKU",
489
- description: "Margen por SKU en un rango (default: mes en curso) = precio de venta − costo de envío real − COGS opcional. Solo tus productos. Fechas YYYY-MM-DD.",
490
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
491
- }, async ({ desde, hasta }) => run(() => api("GET", `/wms/rentabilidad${q({ desde, hasta })}`)));
492
-
493
- tool("mis_productos", {
494
- title: "Mi catálogo",
495
- description: "Tu catálogo de productos en el depósito.",
496
- inputSchema: {},
497
- }, async () => run(() => api("GET", "/wms/productos")));
498
-
499
- tool("mis_top_productos", {
500
- title: "Mis productos más despachados",
501
- description: "Ranking de TUS productos más despachados en un rango (default: mes en curso). Solo tus productos.",
502
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
503
- }, async ({ desde, hasta }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta })}`)));
504
- tool("mis_sucursales", {
505
- title: "Mis sucursales / puntos de retiro",
506
- description: "Tus sucursales (puntos de retiro) con dirección, horario de corte y ventanas. La sucursal principal es tu dirección de retiro. Solo lectura.",
507
- inputSchema: {},
508
- }, async () => run(() => api("GET", "/portal/sucursales")));
509
- tool("mi_colecta", {
510
- title: "Estado de mi colecta",
511
- description: "Cómo está tu colecta (retiro): si la colecta AUTOMÁTICA está prendida, si pediste una por única vez, y el aviso de costo (si te cobran cuando llevás menos de X envíos). Solo lectura.",
512
- inputSchema: {},
513
- }, async () => run(() => api("GET", "/portal/colecta")));
514
- }
515
-
516
- // ============================================================================
517
- // ROL MENSAJERO — su ruta y colectas (para saber cuántos envíos tendrá).
518
- // ============================================================================
519
- if (rol === "mensajero") {
520
- tool("mi_ruta", {
521
- title: "Mi ruta del día",
522
- description: "Tus entregas asignadas (paradas de la ruta del día). Solo lectura.",
523
- inputSchema: {},
524
- }, async () => run(() => api("GET", "/flujo/mi-ruta")));
525
- tool("mis_colectas", {
526
- title: "Mis colectas (retiros)",
527
- description: "Las colectas/retiros que tenés asignados. Solo lectura.",
528
- inputSchema: {},
529
- }, async () => run(() => api("GET", "/colecta/mis-colectas")));
530
- }
531
-
532
- // ============================================================================
533
- // ROL STAFF DE NODO — datos de SU nodo (gateado por permiso; el backend fuerza el nodo).
534
- // ============================================================================
535
- if (isStaff) {
536
- if (puede("gestion")) {
537
- tool("clientes_del_nodo", {
538
- title: "Clientes y listas del nodo",
539
- description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
540
- inputSchema: {},
541
- }, async () => run(() => api("GET", "/gestion/formularios")));
542
- tool("sin_vendedor", { title: "Cuentas de ML y envíos sin vendedor", description: "Cuentas de Mercado Libre y envíos cargados con foto que tu nodo tiene SIN VENDEDOR (típico de un nodo recién dado de alta, o de etiquetas de cuentas no vinculadas que se escanearon). Devuelve `cuentas` (por cuenta de ML: cuántos envíos, ejemplos de destinatarios, si ya está vinculada) y `envios` (los cargados con foto, con la pista de la etiqueta), y los `clientes` del nodo (con si tienen usuario). Después usá `asignar_sin_vendedor`. Admin global: pasá `nodo`. No mueve dinero.", inputSchema: { nodo: zNodo() } },
543
- async ({ nodo }) => run(() => api("GET", `/sin-vendedor${q({ nodo })}`)));
544
- if (ALLOW_WRITE)
545
- tool("asignar_sin_vendedor", {
546
- title: "Asignar cuentas de ML / envíos sin vendedor a un vendedor (+ enlaces)",
547
- description: "Asigna a UN vendedor las cuentas de ML y/o envíos que el nodo tiene sin vendedor (ver `sin_vendedor`). En un paso: cada cuenta queda asociada al vendedor y TODOS sus envíos sin vendedor pasan a ser de él; los envíos con foto elegidos también; si el vendedor no tiene usuario y pasás `usuarioEmail`/`usuarioNombre`/`usuarioApellido` (de la PERSONA que entra), se le crea con clave temporal; y devuelve UN ENLACE DE VINCULACIÓN por cada cuenta de ML para mandárselo (vence en 48 hs). Cuando el vendedor lo autoriza, lo que quede de esa cuenta se le engancha solo. Vendedor: `idCliente` de tu nodo, o `nuevoVendedor` (nombre) para crearlo. No mueve dinero.",
548
- inputSchema: {
549
- idCliente: z.number().optional().describe("Vendedor existente de tu nodo"),
550
- nuevoVendedor: z.string().optional().describe("Nombre de un vendedor NUEVO (se crea en tu nodo)"),
551
- telefono: z.string().optional().describe("Teléfono del vendedor nuevo (opcional)"),
552
- cuentasML: z.array(z.string()).optional().describe("Cuentas de ML (el número de usuario, de `sin_vendedor`)"),
553
- envioIds: z.array(z.number()).optional().describe("Envíos cargados con foto (ids de `sin_vendedor`)"),
554
- usuarioEmail: z.string().optional().describe("Si el vendedor no tiene usuario: email de login de la persona"),
555
- usuarioNombre: z.string().optional(),
556
- usuarioApellido: z.string().optional(),
557
- nodo: z.number().optional().describe("Solo admin global"),
558
- },
559
- }, async (a) => run(() => api("POST", "/sin-vendedor/asignar", {
560
- idCliente: a.idCliente, nuevo: a.nuevoVendedor ? { nombre: a.nuevoVendedor, telefono: a.telefono } : undefined,
561
- usuario: a.usuarioEmail ? { email: a.usuarioEmail, nombre: a.usuarioNombre, apellido: a.usuarioApellido } : undefined,
562
- cuentasML: a.cuentasML, envioIds: a.envioIds, nodo: a.nodo,
563
- })));
564
- }
565
-
566
- if (puede("precios")) {
567
- tool("precios_ver", {
568
- title: "Ver listas de precios",
569
- description: "Las listas de precios (por zona: cercana/media/lejana/muy lejana) de tu nodo. Solo lectura.",
570
- inputSchema: {},
571
- }, async () => run(() => api("GET", "/precios/clientes")));
572
- }
573
-
574
- if (puede("reportes")) {
575
- tool("kpi_nodo", {
576
- title: "Métricas del nodo",
577
- description: "Tablero del nodo: volumen y entregas con variación mensual, P&L real, top de clientes y clientes en caída. Solo lectura, scopeado a tu nodo.",
578
- inputSchema: { desde: z.string().optional().describe("Fecha YYYY-MM-DD (pasá desde y hasta juntas; si falta una, usa el mes en curso)"), hasta: z.string().optional().describe("Fecha YYYY-MM-DD"), nodo: zNodo() },
579
- }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/nodo${q({ desde, hasta, nodo })}`)));
580
- }
581
-
582
- if (puede("wms") && wmsActivo) {
583
- tool("stock_nodo", {
584
- title: "Stock del nodo",
585
- description: "Stock del depósito del nodo. Opcional: filtrar por un cliente.",
586
- inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
587
- }, async ({ cliente }) => run(() => api("GET", `/wms/stock${q({ cliente })}`)));
588
-
589
- tool("productos_nodo", {
590
- title: "Catálogo del nodo",
591
- description: "Catálogo de productos del depósito del nodo. Opcional: filtrar por cliente.",
592
- inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
593
- }, async ({ cliente }) => run(() => api("GET", `/wms/productos${q({ cliente })}`)));
594
-
595
- tool("top_productos", {
596
- title: "Productos más despachados del nodo",
597
- description: "Ranking de productos más despachados del nodo en un rango (default: mes en curso). Opcional: filtrar por un cliente.",
598
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD"), cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
599
- }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta, cliente })}`)));
600
- }
601
- }
602
-
603
- if (isStaff && puede("finanzas")) {
604
- tool("cuenta_semanal_nodos", {
605
- title: "Cuenta semanal con otros nodos (juntada)",
606
- description: "La cuenta de TU nodo con cada nodo con el que se pasaron paquetes por un grupo (Bonorino, Portela…): semana de lunes a sábado que se congela el miércoles siguiente. Cada paquete vale la tarifa del nodo que lo recibe; por nodo se netea (neto > 0: te pagan; < 0: pagás vos). Sin `cierre`: lo que todavía NO se cerró (en vivo). Con `cierre` (id): ese cierre congelado. `lista:true` = tus cierres con su neto. Solo lectura, no mueve dinero. El admin global elige el nodo con `nodo`.",
607
- inputSchema: {
608
- cierre: z.number().int().positive().optional().describe("id del cierre semanal (de `lista`)"),
609
- lista: z.boolean().optional().describe("true = listar tus cierres semanales"),
610
- nodo: zNodo(),
611
- },
612
- }, async ({ cierre, lista, nodo }) =>
613
- run(() => api("GET", lista ? `/logisticas/mis-cierres${q({ nodo })}` : cierre ? `/logisticas/mis-cierres/${cierre}${q({ nodo })}` : `/logisticas/mis-cierres/sin-cerrar${q({ nodo })}`)));
614
- }
615
-
616
- // ============================================================================
617
- // ROL GLOBAL (superadmin / superoperador)
618
- // ============================================================================
619
- if (esGlobal) {
620
- tool("cierre_semanal_links", {
621
- title: "Links sin login del cierre semanal",
622
- description: "Para cada nodo de un cierre semanal (el último si no pasás `cierre`), el link SIN LOGIN donde ve su cuenta de esa semana con cada nodo y la baja en PDF. Primero los que NO tienen usuario: a esos pasáselo vos por WhatsApp (no tienen teléfono cargado). Solo admin global. Solo lectura.",
623
- inputSchema: { cierre: z.number().int().positive().optional().describe("id del cierre (default: el último)") },
624
- }, async ({ cierre }) => run(() => api("GET", `/logisticas/cierres-semanales/links${q({ cierre })}`)));
625
-
626
- tool("nodos_listar", {
627
- title: "Listar nodos",
628
- description: "Lista todas las logísticas (nodos) con sus conteos. Solo admin global.",
629
- inputSchema: {},
630
- }, async () => run(() => api("GET", "/logisticas")));
631
-
632
- tool("nodo_renombrar", {
633
- title: "Cambiar el nombre real de un nodo",
634
- description: "Cambia la razón social (nombre real) de un nodo — lo ve TODO el mundo (a diferencia de `nodo_alias_poner`, que es un apodo personal tuyo). Solo admin global. No toca dinero.",
635
- inputSchema: {
636
- nodo: z.string().min(1).describe("Nombre actual (o tu apodo) del nodo"),
637
- nombreNuevo: z.string().min(1).describe("Nombre nuevo"),
638
- },
639
- }, async ({ nodo: nodoQ, nombreNuevo }) => run(async () => {
640
- const lr = await api("GET", "/flujo/logisticas-select");
641
- if (!lr.ok) return lr;
642
- const lista = lr.data ?? [];
643
- const q2 = nodoQ.trim().toLowerCase();
644
- const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
645
- if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
646
- if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
647
- return api("PUT", `/logisticas/${cands[0].id}`, { nombre: nombreNuevo });
648
- }));
649
-
650
- tool("kpi_red", {
651
- title: "Métricas de la red (SaaS)",
652
- description: "KPIs globales de toda la red de nodos (crecimiento, operacional, volumen de clearing). Solo admin global.",
653
- inputSchema: {},
654
- }, async () => run(() => api("GET", "/kpi/saas")));
655
-
656
- tool("usuario_habilitar_rol", {
657
- title: "Habilitar/deshabilitar un rol de administración",
658
- description: "Habilita o deshabilita a un usuario (por id) como superoperador (global) y/o admin de nodo. Solo admin global. El backend exige un superadmin real para otorgar superoperador. Pasá al menos uno de los flags (true = habilitar, false = quitar). No toca dinero.",
659
- inputSchema: { id: z.number().int().positive(), esSuperoperador: z.boolean().optional(), esAdminNodo: z.boolean().optional() },
660
- }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
661
-
662
- tool("grupo_crear", {
663
- title: "Crear grupo logístico",
664
- description: "Crea un grupo logístico nuevo (para clearing entre nodos; los nodos se agregan/tarifan después). Solo admin global. No toca dinero.",
665
- inputSchema: { nombre: z.string().min(1) },
666
- }, async ({ nombre }) => run(() => api("POST", "/logisticas/grupos", { nombre })));
667
-
668
- tool("logo_subir", {
669
- title: "Subir logo / marca de un nodo (marca blanca)",
670
- description: "Sube o actualiza el logo y la marca de un nodo (marca blanca). El logo va como data-URI base64 (ej. data:image/png;base64,...). Solo admin global. No toca dinero.",
671
- inputSchema: { nodo: z.number().int().positive().describe("Id del nodo (de `nodos_listar`)"), logo: z.string().min(1).describe("imagen en data-URI base64, ej. data:image/png;base64,..."), marca: z.string().optional(), slug: z.string().optional(), color: z.string().optional() },
672
- }, async ({ nodo, logo, marca, slug, color }) => run(() => api("PUT", `/logisticas/${nodo}/branding`, { logo, marca, slug, color })));
673
-
674
- tool("sugerencias_listar", {
675
- title: "Sugerencias de usuarios (admin)",
676
- description: "Solo superadmin: todas las sugerencias de la red (funciones nuevas/mejoras/bugs) con usuario, rol, nodo, estado. Filtrá por `rol` y/o `estado`. Para las propias está `mis_sugerencias`. Solo lectura.",
677
- inputSchema: { rol: z.string().optional(), estado: z.string().optional().describe("nueva | vista | en_evaluacion | implementada | descartada") },
678
- }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
679
- }
680
-
681
- // ============================================================================
682
- // VINCULACIÓN DE TIENDAS — genera el enlace de autorización (OAuth) para traer las
683
- // ventas de una tienda a Nexus Flex. Un vendedor genera el SUYO; el staff (permiso
684
- // gestion) puede generarlo PARA un cliente de SU nodo y mandárselo. No toca dinero.
685
- // ============================================================================
686
- if (isCliente || (isStaff && puede("gestion"))) {
687
- tool("generar_enlace_vinculacion", {
688
- title: "Generar enlace de vinculación de tienda",
689
- description: "Genera el enlace (link) para vincular una tienda —Mercado Libre, TiendaNube o Tienda Negocio— y traer sus ventas a Nexus Flex. Si sos vendedor genera el TUYO; si sos staff podés generarlo PARA un cliente de tu nodo pasando 'cliente' y mandarle ese enlace para que lo autorice desde su propia cuenta de la tienda. El enlace vence en 48 hs. No toca dinero.",
690
- inputSchema: {
691
- proveedor: z.enum(["ml", "tiendanube", "tiendanegocio"]).describe("ml = Mercado Libre · tiendanube · tiendanegocio"),
692
- cliente: z.string().optional().describe("Solo staff: nombre o id del cliente de tu nodo"),
693
- },
694
- }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
695
- }
696
-
697
- // ============================================================================
698
- // RENDICIONES (lectura) + ZONAS DE REPARTO (mensajeros por zona)
699
- // ============================================================================
700
- if (isCliente || isStaff || rol === "mensajero") {
701
- tool("rendiciones", {
702
- title: "Rendiciones (a recuperar / a rendir)",
703
- description: "Estado de rendiciones: cobros/cambios/devoluciones pendientes de recuperar o rendir, con totales y quién tiene cada uno. Scopeado a tu alcance (vendedor: lo tuyo; nodo: tu nodo). Solo lectura.",
704
- inputSchema: {},
705
- }, async () => run(() => api("GET", "/flujo/rendiciones")));
706
- }
707
-
708
- // Reclamos de clientes ligados a liquidaciones (vendedor: lo suyo; operador: su nodo).
709
- if (isCliente || isStaff) {
710
- tool("reclamos_listar", {
711
- title: "Reclamos de clientes (liquidaciones)",
712
- description: "Reclamos abiertos de clientes ligados a liquidaciones/rendiciones: tipo, estado, trackings en disputa, detalle. Sirve para ver qué cobros/rendiciones pendientes tienen reclamo. Vendedor ve lo suyo, operador su nodo. Solo lectura.",
713
- inputSchema: { estado: z.string().optional().describe("abierto | resuelto") },
714
- }, async ({ estado }) => run(() => api("GET", `/feedback/reclamos${q({ estado })}`)));
715
- }
716
-
717
- // ── ADMIN DE GRUPO ── Los nodos originales de un grupo son sus admins; gestionan quién está
718
- // adentro y quién administra. Ver miembros lo puede cualquier miembro; sumar/expulsar/permiso
719
- // solo un admin del grupo (o admin global). Nunca se deja un grupo sin admin. No mueve dinero.
720
- if (isStaff) {
721
- tool("grupos_tarifas", {
722
- title: "Grupos logísticos con sus tarifas de clearing",
723
- description: "Los grupos logísticos de tu nodo (admin global: todos) con los nodos que los integran y la TARIFA de clearing vigente de cada uno por zona (cercana / media / lejana / muy lejana). Es lo que hay que mirar para auditar por qué un traspaso entre nodos se cobró lo que se cobró: el tramo usa el precio del nodo en ESE grupo. `grupo_miembros` muestra quién está; esto muestra a cuánto. Solo lectura.",
724
- inputSchema: {},
725
- }, async () => run(() => api("GET", "/logisticas/grupos")));
726
- tool("grupo_miembros", {
727
- title: "Miembros de un grupo logístico",
728
- description: "Lista los nodos de un grupo (por id), marcando quién es ADMIN de grupo y si vos lo sos. El id del grupo sale de `grupos_tarifas`. Solo miembros del grupo. Solo lectura.",
729
- inputSchema: { grupo: z.number().int().positive().describe("id del grupo") },
730
- }, async ({ grupo }) => run(() => api("GET", `/qr/grupo/${grupo}/miembros`)));
731
- tool("grupo_sumar_nodo", {
732
- title: "Sumar un nodo al grupo (admin de grupo)",
733
- description: "Agrega un nodo (por id) a un grupo. Solo un ADMIN del grupo (o admin global) puede. El nodo entra como miembro normal (sin permiso de admin). No mueve dinero.",
734
- inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a sumar") },
735
- }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-sumar", { grupoId: grupo, logisticaId: nodo })));
736
- tool("grupo_expulsar_nodo", {
737
- title: "Expulsar un nodo del grupo (admin de grupo)",
738
- description: "Saca un nodo (por id) de un grupo. Solo un ADMIN del grupo (o admin global). No se puede expulsar al último admin (pasá antes el permiso a otro nodo). No mueve dinero.",
739
- inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a expulsar") },
740
- }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-expulsar", { grupoId: grupo, logisticaId: nodo })));
741
- tool("grupo_admin_permiso", {
742
- title: "Dar / sacar permiso de admin de grupo",
743
- description: "Da o saca el permiso de ADMIN de grupo a un nodo del grupo (así otros pueden administrar, ej. si los originales se van). Solo un admin del grupo (o admin global). No se puede sacar el permiso al último admin. No mueve dinero.",
744
- inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo"), admin: z.boolean().describe("true = darle admin, false = sacárselo") },
745
- }, async ({ grupo, nodo, admin }) => run(() => api("POST", "/qr/grupo-permiso", { grupoId: grupo, logisticaId: nodo, esAdminGrupo: admin })));
746
- }
747
-
748
- // Consultar UN envío por tracking/código (cuando un vendedor pregunta "¿dónde está mi
749
- // envío X?"). Vendedor: solo entre SUS envíos; staff: dentro de su nodo.
750
- if (isCliente || isStaff) {
751
- tool("envio_consultar", {
752
- title: "Consultar un envío",
753
- description: "Busca UN envío por tracking o código y devuelve su estado actual, el historial de estados (con fechas), destinatario, dirección y datos de cobro/cambio si tiene. Vendedor: solo entre SUS envíos; staff: dentro de su nodo. Úsalo cuando un vendedor pregunta por un pedido puntual.",
754
- inputSchema: { codigo: z.string().min(1).describe("Tracking o código del envío (lo que manda el vendedor)") },
755
- }, async ({ codigo }) => {
756
- const code = String(codigo ?? "").trim();
757
- if (!code) return { content: [{ type: "text", text: "Pasá un tracking o código de envío." }], isError: true };
758
- const low = code.toLowerCase();
759
- try {
760
- if (isStaff) {
761
- const lista = await api("GET", `/flujo/envios${q({ q: code })}`);
762
- const rows = Array.isArray(lista.data) ? lista.data : [];
763
- if (!rows.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" en tu nodo.` }] };
764
- const exact = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
765
- const pick = exact.length ? exact : rows;
766
- if (pick.length > 1)
767
- return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Elegí uno (pasá el tracking completo):\n${JSON.stringify(pick.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado, cliente: e.cliente, destinatario: e.destinatario })), null, 2)}` }] };
768
- return toResult(await api("GET", `/flujo/envios/${pick[0].id}`));
769
- }
770
- const lista = await api("GET", "/portal/envios");
771
- const rows = Array.isArray(lista.data) ? lista.data : [];
772
- let match = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
773
- if (!match.length) match = rows.filter((e) => String(e.tracking ?? "").toLowerCase().includes(low));
774
- if (!match.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" entre tus envíos.` }] };
775
- if (match.length > 1)
776
- return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Pasá el tracking completo:\n${JSON.stringify(match.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado })), null, 2)}` }] };
777
- return toResult(await api("GET", `/portal/envios/${match[0].id}`));
778
- } catch (e) {
779
- return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
780
- }
781
- });
782
- }
783
-
784
- // Métricas por tipo de envío (flex/tienda/manual) + % antes de 21hs + (red) por logística.
785
- if (isCliente || isStaff) {
786
- tool("metricas_por_tipo", {
787
- title: "Métricas por tipo de envío",
788
- description: "Métricas por TIPO de envío (flex/tienda/manual): total, entregados, tasa de entrega y % ENTREGADO ANTES DE LAS 21hs. Admin/red desglosa por logística (qué nodo entrega mejor). Vendedor ve lo suyo, operador su nodo. Fechas YYYY-MM-DD (default: últimos 30 días).",
789
- inputSchema: {
790
- desde: z.string().optional().describe("YYYY-MM-DD"),
791
- hasta: z.string().optional().describe("YYYY-MM-DD"),
792
- nodo: z.number().optional().describe("Solo admin/superoperador: elegir nodo (si no, la red)"),
793
- },
794
- }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/envios-por-tipo${q({ desde, hasta, nodo })}`)));
795
- }
796
-
797
- // Cuentas de tienda vinculadas (ML / TiendaNube / TiendaNegocio), scopeadas por rol.
798
- if (isCliente || isStaff) {
799
- tool("cuentas_vinculadas", {
800
- title: "Cuentas vinculadas (ML / TiendaNube / TiendaNegocio)",
801
- description: "Cuántas cuentas de tienda hay vinculadas, con desglose por proveedor (Mercado Libre / TiendaNube / TiendaNegocio). Vendedor: las suyas; operador: las de los clientes de SU nodo (agrupadas por cliente); admin/superoperador: por nodo (o pasá `nodo` para bajar al desglose por cliente de ese nodo). Solo lectura.",
802
- inputSchema: { nodo: z.number().optional().describe("Solo admin: baja al desglose por cliente de ese nodo") },
803
- }, async ({ nodo }) => run(() => api("GET", `/cuentas-vinculadas${q({ nodo })}`)));
804
- }
805
-
806
- // Afiliados: comisión recurrente por envío (staff = admin global o admin de nodo, scopeado).
807
- if (isStaff) {
808
- tool("afiliacion_listar", {
809
- title: "Listar afiliaciones",
810
- description: "Afiliaciones (afiliado↔entidad referida) con su comisión por envío, vigencia y estado. Filtrá por `afiliado` o `entidad`. Solo lectura, scopeado a tu nodo.",
811
- inputSchema: { afiliado: z.string().optional(), entidad: z.string().optional() },
812
- }, async ({ afiliado, entidad }) => run(() => api("GET", `/afiliados/afiliaciones${q({ afiliado, entidad })}`)));
813
- tool("comisiones_afiliado_ver", {
814
- title: "Comisiones de afiliados",
815
- description: "Comisiones devengadas por envío (pendiente/liquidada) con totales. Filtrá por `afiliado` y rango de fechas. Solo lectura, scopeado a tu nodo.",
816
- inputSchema: { afiliado: z.string().optional(), desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
817
- }, async ({ afiliado, desde, hasta }) => run(() => api("GET", `/afiliados/comisiones${q({ afiliado, desde, hasta })}`)));
818
- }
819
-
820
- // Choferes (mensajeros) del nodo — requiere permiso 'usuarios'.
821
- if (isStaff && puede("usuarios")) {
822
- tool("chofer_listar", {
823
- title: "Listar usuarios del nodo (choferes)",
824
- description: "Usuarios de tu nodo (incluye los choferes = rol 'mensajero') con su id, nombre, teléfono, activo. Solo lectura, scopeado a tu nodo.",
825
- inputSchema: {},
826
- }, async () => run(() => api("GET", "/usuarios")));
827
- }
828
-
829
- // Marketplace de rutas/colectas públicas (subasta abierta, cualquier nodo de la red).
830
- if (isStaff) {
831
- tool("rutas_publicas", {
832
- title: "Rutas/colectas públicas (marketplace)",
833
- description: "Publicaciones ABIERTAS de toda la red que tu nodo puede tomar (con cuántas ofertas tiene cada una). Marca las tuyas (`esMia`). Solo lectura.",
834
- inputSchema: {},
835
- }, async () => run(() => api("GET", "/rutas-publicas/publicas")));
836
- tool("mis_rutas", {
837
- title: "Mis publicaciones y ofertas (marketplace)",
838
- description: "Tus publicaciones de rutas (con su estado/adjudicación) y las ofertas que hiciste a otros nodos. Solo lectura.",
839
- inputSchema: {},
840
- }, async () => run(() => api("GET", "/rutas-publicas/mias")));
841
- tool("ruta_ofertas", {
842
- title: "Ver ofertas de una publicación mía",
843
- description: "Ofertas recibidas en una publicación TUYA (ordenadas por precio asc). Solo el que publicó. Usá el id de oferta para adjudicar.",
844
- inputSchema: { publicacionId: z.number().int().positive() },
845
- }, async ({ publicacionId }) => run(() => api("GET", `/rutas-publicas/${publicacionId}/ofertas`)));
846
- }
847
-
848
- if (isStaff) {
849
- tool("nodo_alias_poner", {
850
- title: "Ponerle un apodo personal a un nodo",
851
- description: "Le ponés TU propio apodo a un nodo (ej. 'Flex Fácil' = 'Félix', el dueño) para reconocerlo más fácil: de ahí en más lo podés nombrar por ese apodo en cualquier herramienta que pida un nodo (asignar_nodo_zona, etc.) y también aparece en los buscadores de la app. Es PERSONAL — no lo ve otro usuario ni cambia el nombre real de la logística. Mandá `alias` vacío para sacarlo.",
852
- inputSchema: {
853
- nodo: z.string().min(1).describe("Nombre (o tu apodo actual) del nodo"),
854
- alias: z.string().describe("El apodo nuevo (vacío para sacarlo)"),
855
- },
856
- }, async ({ nodo: nodoQ, alias }) => run(async () => {
857
- const lr = await api("GET", "/flujo/logisticas-select");
858
- if (!lr.ok) return lr;
859
- const lista = lr.data ?? [];
860
- const q = nodoQ.trim().toLowerCase();
861
- const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q) || (l.alias ?? "").toLowerCase().includes(q));
862
- if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
863
- if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
864
- return api("POST", "/flujo/nodo-alias", { logisticaId: cands[0].id, alias });
865
- }));
866
- }
867
-
868
- if (isStaff && puede("gestion")) {
869
- tool("zonas_reparto", {
870
- title: "Zonas de reparto (mensajeros por zona)",
871
- description: "Lista las zonas de reparto de tu nodo con sus metazonas y qué mensajeros tiene asignado cada una. Solo lectura.",
872
- inputSchema: {},
873
- }, async () => run(() => api("GET", "/zonificacion/zonas-reparto")));
874
-
875
- tool("zonas_simetria", {
876
- title: "Chequear simetria de distancias entre zonas",
877
- description: "Chequea la SIMETRIA de las distancias entre zonas: si desde el perfil de A un lugar B es 'cercana', desde el perfil de B el lugar A deberia ser 'cercana' tambien. Devuelve los pares que NO cumplen, los que faltan cargar en un sentido, y los perfiles que todavia no declararon en que lugar viven. SOLO INFORMA: no corrige ni completa nada, porque puede haber asimetrias legitimas (autopista, rio, barrera) y la decision es humana. Para que un grupo entre al chequeo hay que declararle su 'perfil de origen'.",
878
- inputSchema: {},
879
- }, async () => run(() => api("GET", "/zonificacion/simetria")));
880
-
881
- tool("zona_barrios", {
882
- title: "Barrios/localidades de una zona",
883
- description: "Consulta rápida: qué localidades y BARRIOS componen una zona con nombre (ej. 'Matanza Norte', 'CABA'), con su tramo por perfil. Buscás por nombre; devuelve las zonas que matchean (propias del nodo, globales o de tus grupos logísticos). Solo lectura. Usalo cuando te preguntan '¿qué barrios hay en <zona>?'.",
884
- inputSchema: { zona: z.string().min(1).describe("Nombre de la zona, ej. 'Matanza Norte' o 'CABA'") },
885
- }, async ({ zona }) => run(() => api("GET", `/zonificacion/zona-barrios${q({ zona })}`)));
886
-
887
- tool("facturacion_estado", {
888
- title: "¿Qué falta para facturar?",
889
- description:
890
- "Diagnóstico de facturación ARCA de tu nodo: qué emisores hay y si están completos, qué cuentas de dinero no tienen emisor propio, y por cada cliente exactamente qué dato le falta (CUIT, razón social, condición IVA, emisor, habilitación). Sin 'cliente' lista los YA habilitados; con 'cliente' (nombre o id) mira ese aunque no esté habilitado. NO devuelve importes ni emite nada: es estado de configuración. Empezá y terminá por acá cuando pongas a alguien a facturar (ver el flujo 'facturacion').",
891
- inputSchema: { cliente: z.string().optional().describe("Nombre o id de un cliente puntual; vacío = los ya habilitados") },
892
- }, async ({ cliente }) => run(() => api("GET", `/afip/estado${q({ cliente })}`)));
893
-
894
- tool("colecta_ver", {
895
- title: "Ver config de colectas",
896
- description: "Resumen de valores de colecta del nodo: pago default del nodo, cobros por cliente y pagos pactados por cliente+mensajero. Solo lectura.",
897
- inputSchema: {},
898
- }, async () => run(() => api("GET", "/colecta/config")));
899
-
900
- tool("colecta_pendientes", {
901
- title: "Colectas del día y de mañana",
902
- description: "Panel de colectas de tu nodo. `items` = clientes a retirar HOY (con cuántos envíos, el corte y qué colecta está asignada a qué mensajero). `itemsManana` = clientes con envíos que entraron después del corte → van a la colecta de MAÑANA. `clientesSinEnvios` = vendedores del nodo SIN envíos cargados, a los que igual se los puede mandar a colectar (el cadete escanea los paquetes en la puerta). Solo lectura. Sirve para «¿quién levanta a tal cliente?» y «¿el cliente X tiene envíos para mañana?».",
903
- inputSchema: {},
904
- }, async () => run(() => api("GET", "/colecta/panel")));
905
-
906
- tool("colecta_historial", {
907
- title: "Historial de colectas (paquetes por día y cliente)",
908
- description: "Paquetes COLECTADOS por día y por cliente en una fecha o rango (histórico, hasta 62 días), con quién los colectó. `desde` (aaaa-mm-dd, obligatorio), `hasta` (opcional, default = desde), `cliente` (nombre o id, opcional). Cuenta cada paquete una vez, en el día de Argentina en que se colectó (evento Colectado o colecta en la puerta). Solo tu nodo. Solo lectura. Sirve para «¿cuántos paquetes le levantamos a X el martes?».",
909
- inputSchema: {
910
- desde: z.string().describe("aaaa-mm-dd"),
911
- hasta: z.string().optional().describe("aaaa-mm-dd; vacío = el mismo día"),
912
- cliente: z.string().optional().describe("nombre (o parte) o id del cliente"),
913
- },
914
- }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/colecta/historial${q({ desde, hasta, cliente })}`)));
915
-
916
- tool("envios_por_zona", {
917
- title: "Envíos que otros nodos te rutearon (por zona)",
918
- description: "Envíos de OTROS nodos ruteados a tu nodo por zona de reparto, agrupados por zona (para aceptarlos o rechazarlos). Cada envío trae su id (usalo en procesar_zona/rechazar_zona). Solo lectura, tu nodo.",
919
- inputSchema: {},
920
- }, async () => run(() => api("GET", "/colecta/zonas-a-procesar")));
921
-
922
- tool("asignar_mensajero_zona", {
923
- title: "Asignar un mensajero a una zona",
924
- description: "Asigna un mensajero (por nombre, de tu nodo) a una metazona/zona (ej. «asigná a Maxi la zona de Palermo»). Si ya existe una zona con esa metazona, le suma el mensajero; si no, la crea. No cruza nodos. El admin global elige el nodo con `nodo`.",
925
- inputSchema: {
926
- mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo)"),
927
- metazona: z.string().min(1).describe("Metazona/zona, ej. 'Palermo' o 'CABA'"),
928
- nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
929
- nodo: zNodo(),
930
- },
931
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-mensajero", { ...rest, logisticaId: nodo })));
932
-
933
- tool("asignar_nodo_zona", {
934
- title: "Asignar un NODO a una zona (grupo logístico)",
935
- description: "Asigna un NODO COMPLETO (por nombre, de tu grupo logístico) a una metazona. Sin `grupo`: para cuando ese nodo cubre toda una localidad de TU zona (ej. «que RL cubra Portela») — suma el nodo como opción (nodoIds), sin cruzar fuera de tu grupo. CON `grupo` (ej. 'Bonorino'/'Portela'): MODO COMISIÓN — la zona es del GRUPO (no de un nodo dueño) y esto reasigna quién es el responsable; solo lo puede hacer un ADMIN de ese grupo (o el admin global). Acá `nodo` es el DESTINO (por nombre); el admin global elige el nodo DUEÑO de la zona sin-grupo con `logisticaId`.",
936
- inputSchema: {
937
- nodo: z.string().min(1).describe("Nombre del nodo (de tu grupo logístico) que cubre la zona"),
938
- metazona: z.string().min(1).describe("Metazona/zona, ej. 'Portela' o 'CABA'"),
939
- nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
940
- grupo: z.string().optional().describe("Nombre o id del grupo logístico (ej. 'Bonorino') — MODO COMISIÓN: la zona pasa a ser del grupo y solo su admin puede asignarla/reasignarla"),
941
- logisticaId: z.number().int().positive().optional().describe("Solo admin global y sin `grupo`: id del nodo DUEÑO de la zona sobre el que operás. El operador de nodo lo ignora."),
942
- },
943
- }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-nodo", args)));
944
-
945
- tool("zona_dejar", {
946
- title: "Dejar de ser responsable de una zona de grupo",
947
- description: "Te sacás (a tu nodo) de una zona de un grupo logístico (ej. Bonorino) que tenías asignada — self-service, solo podés sacarte a VOS. Si eras el único responsable, la zona pasa a LIBERADA y se avisa a TODO el grupo; solo la comisión (admin del grupo) la puede volver a asignar con `asignar_nodo_zona` + `grupo`.",
948
- inputSchema: {
949
- grupo: z.string().min(1).describe("Nombre o id del grupo"),
950
- metazona: z.string().min(1).describe("Metazona/zona que dejás"),
951
- nodo: zNodo(),
952
- },
953
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/dejar", { ...rest, logisticaId: nodo })));
954
-
955
- tool("nodo_link_confirmacion", {
956
- title: "Link de confirmación sin login para un nodo",
957
- description: "Devuelve el link FIJO (sin login) donde un nodo sin usuarios propios (ej. un integrante 'feeder' de un grupo que solo deja paquetes) ve los envíos que le asignaron como responsable de zona y los confirma con un toque. Se lo pasás por WhatsApp a mano — el nodo no tiene teléfono cargado para mandárselo solo. Mismo link siempre para ese nodo (se genera la primera vez).",
958
- inputSchema: { nodo: z.string().min(1).describe("Nombre o apodo del nodo") },
959
- }, async ({ nodo: nodoQ }) => run(async () => {
960
- const lr = await api("GET", "/flujo/logisticas-select");
961
- if (!lr.ok) return lr;
962
- const lista = lr.data ?? [];
963
- const q2 = nodoQ.trim().toLowerCase();
964
- const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
965
- if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
966
- if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
967
- return api("POST", "/logisticas/link-confirmacion", { logisticaId: cands[0].id });
968
- }));
969
-
970
- tool("zonas_grupo", {
971
- title: "Zonas de un grupo logístico y quién las cubre",
972
- description: "Lista las zonas de un grupo logístico (ej. Bonorino/Portela) con el nodo responsable de cada una, marcando las LIBERADAS (sin responsable) y si vos podés reasignarlas (`puedoAsignar`, solo comisión/admin global). Visible a cualquier miembro del grupo. Solo lectura.",
973
- inputSchema: { grupo: z.string().min(1).describe("Nombre o id del grupo") },
974
- }, async ({ grupo }) => run(() => api("GET", `/zonificacion/zonas-reparto-grupo${q({ grupo })}`)));
975
-
976
- tool("zona_mover_metazona", {
977
- title: "Mover una metazona entre zonas de un grupo",
978
- description: "Reclasifica UNA metazona/localidad: la saca de la zona de grupo que la contiene y la mete en OTRA zona del MISMO grupo, sin tocar el nodo responsable (ej. 'El Palomar' está mal puesto en Morón y va a Tres de Febrero, del que es partido). Solo la comisión (admin) del grupo o el admin global. No mueve dinero.",
979
- inputSchema: {
980
- grupo: z.string().min(1).describe("Nombre o id del grupo (ej. 'Portela')"),
981
- metazona: z.string().min(1).describe("Metazona/localidad a mover (ej. 'El Palomar')"),
982
- zonaDestino: z.string().min(1).describe("Zona destino: nombre, id, o una metazona que ya tenga (ej. 'Tres de Febrero')"),
983
- nodo: zNodo(),
984
- },
985
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/metazona-mover", { ...rest, logisticaId: nodo })));
986
-
987
- tool("zona_componer_grupo", {
988
- title: "Componer una zona de grupo (metazonas + nodo)",
989
- description: "Crea o edita una zona de un grupo logístico: la ubica por nombre/id (o la crea con ese nombre), le puede sumar un NODO responsable y AGREGA/QUITA metazonas de su composición. Sirve para armar las zonas de un grupo NUEVO con su composición completa — importante para el ruteo, que matchea por metazona exacta (ej. una zona 'Flores' que cubra 'CABA · Flores' y 'Flores'). Solo la comisión (admin) del grupo o el admin global. No mueve dinero.",
990
- inputSchema: {
991
- grupo: z.string().min(1).describe("Nombre o id del grupo"),
992
- zona: z.string().min(1).describe("Zona: nombre o id. Si no existe en el grupo, se crea con ese nombre."),
993
- nodo: z.string().optional().describe("Nombre del nodo responsable a sumar a la zona (opcional)"),
994
- agregar: z.array(z.string()).optional().describe("Metazonas a AGREGAR a la zona (ej. ['CABA · Flores','Flores'])"),
995
- quitar: z.array(z.string()).optional().describe("Metazonas a QUITAR de la zona"),
996
- nombre: z.string().optional().describe("Nombre para la zona si se crea (default: el valor de `zona`)"),
997
- logisticaId: z.number().int().positive().optional().describe("Solo admin global: id del nodo (de la comisión) sobre el que operás. El operador de nodo lo ignora."),
998
- },
999
- }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/grupo-componer", args)));
1000
-
1001
- tool("grupo_tarifa_set", {
1002
- title: "Setear la tarifa de clearing de un grupo",
1003
- description: "Fija la tarifa de clearing por zona (cercana/media/lejana/muyLejana) de TODO un grupo logístico de una: el DEFAULT del grupo y el precio de CADA nodo miembro (su fila vigente). Sirve para dejar un grupo nuevo con su tarifa sin ir nodo por nodo (ej. 2700 en los 4 tramos). Con `soloDef` toca solo el default (no los nodos). Solo la comisión (admin) del grupo o el admin global. Es CONFIG del clearing — NO mueve dinero.",
1004
- inputSchema: {
1005
- grupo: z.string().min(1).describe("Nombre o id del grupo"),
1006
- cercana: z.number().optional().describe("Tarifa zona cercana"),
1007
- media: z.number().optional().describe("Tarifa zona media"),
1008
- lejana: z.number().optional().describe("Tarifa zona lejana"),
1009
- muyLejana: z.number().optional().describe("Tarifa zona muy lejana"),
1010
- soloDef: z.boolean().optional().describe("true = solo el default del grupo, sin tocar los nodos miembro"),
1011
- },
1012
- }, async (args) => run(() => api("POST", "/qr/grupo-tarifa-grupal", args)));
1013
- }
1014
-
1015
- // ============================================================================
1016
- // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
1017
- // ============================================================================
1018
- if (ALLOW_WRITE) {
1019
- if (isCliente) {
1020
- tool("sucursal_guardar", {
1021
- title: "Crear/editar sucursal (punto de retiro)",
1022
- description: "Crea o edita una sucursal tuya (punto de retiro). Para cambiar tu DIRECCIÓN DE RETIRO editá la sucursal principal (o creá una con principal=true). id vacío = nueva sucursal. Geocodifica la dirección sola. No toca dinero.",
1023
- inputSchema: {
1024
- id: z.number().optional().describe("id de la sucursal a editar; vacío = nueva"),
1025
- nombre: z.string().min(1),
1026
- direccion: z.string().optional(),
1027
- principal: z.boolean().optional().describe("true = pasa a ser tu dirección de retiro principal"),
1028
- horarioCorte: z.string().optional().describe("HH:MM"),
1029
- ventana1Desde: z.string().optional(), ventana1Hasta: z.string().optional(),
1030
- ventana2Desde: z.string().optional(), ventana2Hasta: z.string().optional(),
1031
- },
1032
- }, async (args) => run(() => api("POST", "/portal/sucursales", args)));
1033
- tool("colecta_solicitar", {
1034
- title: "Solicitar colecta (por única vez)",
1035
- description: "Pedís que te retiren los envíos POR ÚNICA VEZ, aunque tengas la colecta automática apagada. Aparecés en el panel de colecta de tu nodo. Devuelve el aviso de costo (si te cobran cuando llevás menos de X envíos). No mueve dinero.",
1036
- inputSchema: {},
1037
- }, async () => run(() => api("POST", "/portal/colecta/solicitar")));
1038
- tool("colecta_auto", {
1039
- title: "Prender/apagar colecta automática",
1040
- description: "Activás (true) o desactivás (false) tu colecta AUTOMÁTICA. Apagada = llevás los envíos al depósito y no te retiran (salvo que pidas una por única vez con colecta_solicitar). No mueve dinero.",
1041
- inputSchema: { activa: z.boolean() },
1042
- }, async ({ activa }) => run(() => api("PUT", "/portal/colecta/auto", { activa })));
1043
- }
1044
- if (isCliente || (isStaff && puede("gestion")) || rol === "mensajero") {
1045
- tool("envio_cargar", {
1046
- title: "Cargar un envío (y etiqueta)",
1047
- description: "Registra un envío nuevo (queda 'A retirar') y devuelve el tracking + un LINK a la etiqueta imprimible + un LINK para subir una foto del envío desde el celular. El vendedor lo carga para sí mismo; el staff pasa `cliente` (nombre, de su nodo); el MENSAJERO pasa `cliente` y SOLO puede registrar clientes de su nodo o que haya colectado (marketplace incluido). Datos obligatorios: destinatario, telefono, direccion, localidad. Si el envío NO es de HOY (planilla que se carga días después), pasá `fecha` — es la que define en qué SEMANA se liquida. No toca dinero (montoCobro es el cobro contra entrega, no un movimiento).",
1048
- inputSchema: {
1049
- cliente: zStr().describe("Staff y mensajero: el vendedor del envío (staff: nombre, de tu nodo; mensajero: nombre o id, de tu nodo o que hayas colectado). El vendedor NO lo manda."),
1050
- destinatario: z.string().min(1).describe("Nombre de quien recibe"),
1051
- telefono: z.union([z.string().min(1), z.number()]).transform((x) => String(x)).describe("Teléfono del destinatario (obligatorio: el backend rechaza el envío sin él)"),
1052
- direccion: z.string().min(1),
1053
- localidad: z.string().min(1),
1054
- cp: zStr(),
1055
- montoCobro: zNum().describe("Cobro contra entrega (opcional)"),
1056
- esCambio: z.boolean().optional(),
1057
- detalleCambio: z.string().optional(),
1058
- comentarios: z.string().optional(),
1059
- fecha: zStr().describe("Solo staff — fecha del envío (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: para planillas que se cargan días después. Define en qué SEMANA se liquida el envío, así que no se admite una fecha futura."),
1060
- forzarNuevo: z.boolean().optional().describe("Solo si te respondió POSIBLE DUPLICADO (409): true = es OTRO paquete distinto (dos compras a la misma dirección) y hay que cargarlo igual. Si es el MISMO paquete, NO lo mandes: usá el tracking existente."),
1061
- },
1062
- }, async (args) => run(() => api("POST", "/envios/cargar-mcp", args)));
1063
- }
1064
- if (isStaff || rol === "mensajero") {
1065
- tool("envio_desde_etiqueta_ml", {
1066
- title: "Registrar envío desde una etiqueta de Mercado Libre (foto)",
1067
- description: "Da de alta (o completa) un envío de Mercado Libre a partir de la etiqueta. El vendedor se resuelve por `mlQr`; si no, por `mlSenderId` (cuenta vinculada o aprendida); si no, por `cliente`. Si no se conoce, devuelve 404 con el senderId: preguntale al usuario de quién es. Es idempotente: si el envío ya estaba cargado no se duplica (se completa y, con `avanzarA`, se avanza). `nodoEntrega`: el nodo que lo entregó (control por foto). Dígitos ilegibles en la altura de la dirección: un '*' por cada uno (ej. 'Aguirre 31**'), no los adivines. Devuelve {id, tracking, yaExistia, noAvanzado?}. No mueve dinero. Para una carpeta de fotos, ver `flujo control_por_foto`.",
1068
- inputSchema: {
1069
- cliente: zStr().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
1070
- mlSenderId: zStr().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
1071
- mlShipmentId: zStr().describe("id de envío/tracking de ML leído de la etiqueta (se limpian espacios OCR)"),
1072
- mlQr: zStr().describe("Contenido CRUDO del QR de ML (el JSON {id, sender_id, hash_code…}). Alcanza solo: de ahí salen el número de envío y el vendedor, y sirve para reimprimir la etiqueta idéntica. Mandalo SIEMPRE que lo leas."),
1073
- destinatario: zStr(), telefono: zStr(),
1074
- direccion: zStr().describe("Dirección; si un dígito de la altura no se lee, poné '*' por cada uno (ej. 'Julian Aguirre 31**'), NO lo inventes"),
1075
- localidad: zStr(), cp: zStr(), zona: zStr(), barrio: zStr(),
1076
- avanzarA: z.enum(["colectado", "procesado", "entregado"]).optional().describe("Control por foto: avanza el envío EN UN PASO registrando CADA estado intermedio (atribuido a vos, el usuario MCP). 'colectado' / 'procesado' (En centro) / 'entregado' (recorre A retirar→Colectado→En centro→[grupo=En camino]→Entregado). 'entregado' es la vía rápida para el clearing entre nodos."),
1077
- autoRutear: z.boolean().optional().describe("Planilla: asigna automáticamente el NODO y/o MENSAJERO responsable de la ZONA de reparto del destino. Configurá antes los responsables con `asignar_nodo_zona`/`asignar_mensajero_zona`."),
1078
- grupo: zStr().describe("Control por foto: nombre o id de un GRUPO logístico tuyo (ej. 'Bonorino') — despacha por ese grupo (queda 'En camino', su mensajero es el integrante del grupo)."),
1079
- nodoEntrega: zStr().describe("Solo staff: nodo (nombre o id) que RECIBIÓ y entregó el paquete (ej. 'FastCorreo'). Es el que cobra el traspaso en el cierre semanal entre nodos. Sin `grupo` se deduce: si el cliente es de ese mismo nodo es un paquete propio (sin traspaso); si es de otro nodo, el grupo que comparten (si comparten varios, pedí `grupo`). Si el envío ya tenía otro nodo que entrega, no se pisa."),
1080
- fotoRuta: zStr().describe("RUTA LOCAL de la foto de la etiqueta en tu compu (ej. 'C:\\\\Users\\\\...\\\\Desktop\\\\etiquetas\\\\ml_123.jpg') — NO la imagen, solo la ruta, para reencontrar la foto. Mandala SIEMPRE que la dirección tenga '*'."),
1081
- montoCobro: zNum(), mensajero: zStr().describe("Mensajero que hizo el paquete (de tu nodo)"),
1082
- reusarEtiqueta: z.boolean().optional().describe("ML MANUAL (no vinculado): true = REUSA el mismo tracking/QR/etiqueta de ML que ya viene impreso (NO genera uno nuevo), lo marca 'ml_manual' y es idempotente. Usalo cuando el paquete YA está etiquetado por ML pero no está vinculado a una cuenta."),
1083
- forzarNuevo: z.boolean().optional().describe("Solo si te respondió POSIBLE DUPLICADO (409): true = es OTRO paquete distinto (dos compras a la misma dirección) y hay que cargarlo igual. Si es el MISMO paquete, NO lo mandes: usá el tracking existente."),
1084
- fecha: zStr().describe("Solo staff — día REAL en que el paquete se movió (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: para planillas que se controlan días después. Define en qué SEMANA se liquida el envío (no se admite futura) y fecha TODA la cadena de `avanzarA`: cada estado queda con su hora de ESE día (Colectado 9, En centro 12, En camino 15, Entregado 18), no con la hora del control. Si el envío YA estaba cargado, también le mueve la fecha — y si ese período ya está liquidado, rechaza la llamada nombrando la liquidación en vez de dejarlo a medio corregir."),
1085
- },
1086
- }, async (args) => run(() => api("POST", "/envios/desde-etiqueta", args)));
1087
- if (isStaff) registrarControlFoto();
1088
- tool("envio_procesar", {
1089
- title: "Procesar / recibir un envío en el centro",
1090
- description: "Marca un envío como PROCESADO / recibido en el centro de distribución (estado 'En centro de distribución') por su TRACKING, sin escanear — igual que la acción 'procesar' del escáner. Scopeado a tu nodo/grupo. No mueve dinero (registra el tramo de clearing del grupo, como `procesar_zona`).",
1091
- inputSchema: { tracking: zStr().describe("Tracking / código del envío a procesar") },
1092
- }, async ({ tracking }) => run(() => api("POST", "/flujo/escanear", { codigo: tracking, accion: "procesar" })));
1093
- tool("envio_entregar", {
1094
- title: "Marcar ENTREGADO un envío ya cargado",
1095
- description: "Marca ENTREGADO uno o varios envíos ya cargados, por tracking, sin foto ni firma. Usalo cuando no estás cargando desde una etiqueta (para eso está `envio_desde_etiqueta_ml` con `avanzarA`). Corre la entrega REAL: sella la logística de entrega para el clearing y le avisa al vendedor. Con `fecha` el historial dice el día REAL de entrega, no la hora del control; sobre un envío que YA figura entregado CORRIGE la fecha del cierre (vuelve en `fechaCorregida`), que es como se arregla una tanda cerrada con el día equivocado. Los que ya estaban entregados y no hay nada que corregirles vuelven en `yaEstaban` y no rompen el lote; los que están en un estado de EXCEPCIÓN (Cancelado, Devuelto al vendedor…) NO se entregan y vuelven en `errores` con su estado real. Staff, y solo envíos de tu nodo (admin global: cualquiera). No mueve dinero.",
1096
- inputSchema: {
1097
- trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a marcar entregados (ej. ['TN-2057600163','NFABC123'])"),
1098
- fecha: zStr().describe("Día REAL de entrega (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: la tanda que se controla por foto días después queda en el historial con el día en que salió, no con el del control. No se admite futura. Cambia el evento 'Entregado' del historial, NO la semana en que se liquida el envío — esa se mueve con `envio_editar_fecha`."),
1099
- },
1100
- }, async (args) => run(() => api("POST", "/envios/entregar-ref", args)));
1101
- tool("envio_completar_ciclo", {
1102
- title: "Reconstruir los pasos que le faltan a un envío ya cerrado",
1103
- description: "Rellena los pasos que le FALTAN a un envío ya cerrado: 'Colectado', 'En centro de distribución' y 'En camino', cada uno a nombre de QUIEN LO HIZO y fechado en el día real. Solo AGREGA lo que falta: no duplica un paso que ya está, no cambia el estado actual del envío y NO inventa autores (el paso del que no decís quién lo hizo, no se agrega). Pasá una persona por paso (nombre o id, del nodo); si el nombre matchea a varios te devuelve los candidatos con su id en vez de elegir por vos. Staff, y solo envíos de tu nodo. No mueve dinero (el mensajero se asigna con `envio_asignar_mensajero` y el grupo con `envio_asignar_grupo`).",
1104
- inputSchema: {
1105
- trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a completar (ej. ['NFABC123','TN-2057600163'])"),
1106
- fecha: zStr().describe("Día REAL en que se hicieron esos pasos (dd/mm/aaaa o aaaa-mm-dd). Cada paso queda con su hora nominal de ese día: Colectado 9, En centro 12, En camino 15. No se admite futura."),
1107
- colectado: zStr().describe("Quién COLECTÓ (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1108
- procesado: zStr().describe("Quién lo recibió EN EL CENTRO de distribución (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1109
- enCamino: zStr().describe("Quién lo DESPACHÓ / salió a repartirlo (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1110
- },
1111
- }, async (args) => run(() => api("POST", "/envios/completar-ciclo", args)));
1112
- tool("envio_pago_mensajero", {
1113
- title: "Cargar lo que se le paga al mensajero por esos envíos",
1114
- description: "Carga LO QUE SE LE PAGA a quien repartió cada envío (el valor por envío) y, si al envío le falta el NOMBRE del mensajero, lo completa. Al mensajero se le paga por el nombre guardado en el envío (`Envio.mensajero`), no por el vínculo: este tool escribe valor + nombre. Usa el nombre con el que la persona está cargada (el apodo del resumen, ej. 'Maxi', no 'Maxi Sagarzazu'); en un VENDEDOR con doble rol, el de su cliente. Por defecto NO toca envíos que no estén entregados (un Cancelado o Devuelto vuelve en `noEntregados`); si igual querés pagarlos, pasá `incluirNoEntregados:true`. Si el envío no tiene mensajero y no pasás uno, vuelve en `sinMensajero` sin tocarse. Staff, y solo envíos de tu nodo.",
1115
- inputSchema: {
1116
- trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a pagar (ej. ['NFABC123','TN-2057600163'])"),
1117
- valor: zNum().describe("Lo que se le paga POR ENVÍO, en pesos (ej. 2150). Se escribe igual en todos los que mandes."),
1118
- mensajero: zStr().describe("Opcional: nombre o id de quien repartió. Si lo pasás, lo ASIGNA y lo paga en un solo paso. Si no, usa el mensajero que el envío ya tenga."),
1119
- incluirNoEntregados: z.boolean().optional().describe("Opcional: true = pagar también los que NO están entregados (Cancelado, Devuelto…). Por defecto NO se tocan y vuelven listados."),
1120
- },
1121
- }, async (args) => run(() => api("POST", "/envios/pago-mensajero", args)));
1122
- tool("envio_corregir_estado", {
1123
- title: "Corregir el estado de un envío",
1124
- description: "Corrige el ESTADO de un envío ya cargado, para DESHACER algo que quedó mal: un 'Entregado' que no fue, o un envío que una corrección revivió por error. Además de los estados del panel acepta los TERMINALES de excepción ('Cancelado', 'Rechazado por el comprador'). El `motivo` es OBLIGATORIO (mínimo 10 caracteres) y queda en el historial: una corrección sin explicación es indistinguible de un error. NO toca las banderas de devolución ni de cobro, y no mueve dinero. Staff, y solo envíos de tu nodo.",
1125
- inputSchema: { tracking: zStr().describe("Tracking del envío a corregir"), estado: zStr().describe("Estado destino (ej. 'Cancelado', 'En camino', 'Entregado')"), motivo: z.string().min(10).describe("Por qué se corrige. Queda en el historial del envío.") },
1126
- }, async (args) => run(() => api("POST", "/envios/corregir-estado", args)));
1127
- tool("envio_asignar_mensajero", {
1128
- title: "Asignar / quitar el mensajero de un envío",
1129
- description: "Asigna —o QUITA con `quitar:true`— el MENSAJERO de envíos ya cargados, por tracking. Busca entre todos los que pueden repartir (rol mensajero, `reparte`, o vendedor con doble rol); acepta id; si el nombre es ambiguo devuelve candidatos. No mueve dinero: el pago se liquida por NOMBRE (ver `envio_pago_mensajero`).",
1130
- inputSchema: { trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos"), mensajero: zStr().optional().describe("Nombre o ID de quien repartió (rol mensajero, o alguien con reparto habilitado)"), quitar: z.boolean().optional().describe("true = deja el envío SIN mensajero asignado") },
1131
- }, async (args) => run(() => api("POST", "/envios/asignar-mensajero", args)));
1132
- tool("envio_estado", {
1133
- title: "Estado + ETA de un envío (¿cuándo llega?)",
1134
- description: "Devuelve una NARRATIVA del estado de un envío (por tracking) para responder '¿cuándo se entrega?': si está en camino, con qué mensajero, cuántos envíos lleva en la ruta, en qué posición va este y una ETA estimada (posición en la ruta × minutos por parada). Marca PROBLEMAS si los hay (nunca despachado, sin mensajero asignado, mensajero sin reportar ubicación hace rato). Es una estimación explicable, no una promesa exacta. Read-only, scopeado a tu nodo.",
1135
- inputSchema: { tracking: z.string().min(1).describe("Tracking del envío a consultar") },
1136
- }, async ({ tracking }) => run(() => api("GET", `/flujo/envio-estado?tracking=${encodeURIComponent(tracking)}`)));
1137
- tool("envios_trabados", {
1138
- title: "Envíos trabados del nodo (para destrabar / cerrar)",
1139
- description: "Lista los envíos TRABADOS de tu nodo, con el motivo: 'estancado' (mucho tiempo en 'A retirar'/'En centro' sin avanzar, ~nunca despachado), 'sin_mensajero' (en el centro pero sin mensajero asignado), 'mensajero_detenido' (en camino pero el mensajero no reporta ubicación hace rato) o 'en_camino_sin_cerrar' (en camino hace +24h sin cerrarse: candidato a cerrar; confirmá con el usuario antes de marcarlo entregado). Ordenados por severidad. Es la misma detección que alimenta el aviso proactivo a los operativos y admins del nodo. Read-only.",
1140
- inputSchema: {},
1141
- }, async () => run(() => api("GET", "/flujo/envios-trabados")));
1142
- tool("planilla_reporte", {
1143
- title: "Reporte de la planilla ML (control por foto)",
1144
- description: "Resumen de los envíos cargados por el método de planilla/control por foto (ml_manual) de tu nodo: total + desglose por CLIENTE, por ZONA y por ESTADO + suma de valor declarado y de monto a cobrar. Opcional: rango de fechas (desde/hasta, YYYY-MM-DD). Read-only.",
1145
- inputSchema: { desde: zStr().describe("Desde (YYYY-MM-DD), opcional"), hasta: zStr().describe("Hasta (YYYY-MM-DD), opcional") },
1146
- }, async ({ desde, hasta }) => run(() => api("GET", `/flujo/planilla-reporte${desde || hasta ? `?${new URLSearchParams({ ...(desde ? { desde } : {}), ...(hasta ? { hasta } : {}) }).toString()}` : ""}`)));
1147
- tool("nodo_provisorio_crear", {
1148
- title: "Crear un nodo PROVISORIO en un grupo (planilla ML)",
1149
- description: "Crea al vuelo un nodo PROVISORIO (placeholder) dentro de un grupo logístico, para atribuirle el clearing cuando el nodo real todavía no está dado de alta. No tiene login hasta que se concilie con el nodo real. Solo en grupos a los que pertenece TU nodo (el admin global, en cualquiera). Reusa el clearing por grupo. No mueve dinero.",
1150
- inputSchema: { nombre: z.string().min(1).describe("Nombre del nodo (ej. 'Nodo Avellaneda - provisorio')"), grupoId: z.number().int().positive().describe("Grupo logístico donde crearlo (de tus grupos)") },
1151
- }, async (args) => run(() => api("POST", "/flujo/nodo-provisorio", args)));
1152
- tool("provisorio_conciliar", {
1153
- title: "Conciliar un nodo provisorio con el nodo real",
1154
- description: "Cuando el nodo real YA está dado de alta, CONCILIA (fusiona) el provisorio: reatribuye sus envíos (origen/entrega) y su membresía de grupo al nodo real, y marca el provisorio como RESUELTO (queda desactivado). El clearing de esos envíos pasa a usar la tarifa del nodo real en el grupo. Aislamiento: como operador solo conciliás los provisorios que creó TU nodo y contra un nodo real que YA opera en ese grupo; si el nodo real todavía no está en el grupo hay que definirle la tarifa → eso lo hace el admin global con `tarifa`. No mueve dinero (reordena el clearing).",
1155
- inputSchema: {
1156
- provisorioId: z.number().int().positive().describe("Id del nodo provisorio a conciliar (lo devolvió `nodo_provisorio_crear`, o lo ves en `grupos_tarifas`)"),
1157
- nodoReal: z.string().min(1).describe("Nodo real destino: nombre o id (nodo ya dado de alta)"),
1158
- tarifa: z.object({ cercana: z.number().optional(), media: z.number().optional(), lejana: z.number().optional(), muyLejana: z.number().optional() }).optional().describe("Solo admin global y solo si el nodo real todavía NO opera en el grupo: tarifa (bandas) con la que se lo agrega."),
1159
- modo: z.enum(["absorber", "desde_ahora"]).optional().describe("absorber (default) = el nodo real se queda con el HISTORIAL del provisorio (reatribuye sus envíos y su grupo). desde_ahora = NO toca el historial; el clearing arranca de acá en adelante."),
1160
- },
1161
- }, async (args) => run(() => api("POST", "/flujo/conciliar-provisorio", args)));
1162
- tool("afiliado_crear", {
1163
- title: "Crear afiliado",
1164
- description: "Alta de un afiliado (quien trae volumen nuevo a la red). tipo: nodo|mensajero|externo; refId = id del nodo/mensajero (null si externo). Admin global o admin de nodo (scopeado a tu nodo). NO toca dinero.",
1165
- inputSchema: { nombre: z.string().min(1), tipo: z.enum(["nodo", "mensajero", "externo"]), refId: z.number().int().optional(), telefono: z.string().optional(), email: z.string().optional() },
1166
- }, async (args) => run(() => api("POST", "/afiliados", args)));
1167
- tool("afiliacion_crear", {
1168
- title: "Crear afiliación (comisión por envío)",
1169
- description: "Vincula un afiliado con una entidad referida (nodo o cliente) y su comisión RECURRENTE por envío. tipoComision: porcentaje|montoFijo. `porcentaje` se calcula sobre el valorDeclarado del envío (MVP); `montoFijo` = monto por envío. fechaExpiracion opcional (null = no vence). Una entidad = un solo afiliado activo. NO toca dinero (es config).",
1170
- inputSchema: { afiliado: z.string().min(1).describe("nombre o id"), entidadTipo: z.enum(["nodo", "cliente"]), entidad: z.string().min(1).describe("nombre o id del nodo/cliente referido"), tipoComision: z.enum(["porcentaje", "montoFijo"]), valorComision: z.number(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD") },
1171
- }, async (args) => run(() => api("POST", "/afiliados/afiliaciones", args)));
1172
- tool("afiliacion_editar", {
1173
- title: "Editar afiliación",
1174
- description: "Edita una afiliación: valorComision, fechaExpiracion (renovar/extender) y/o activa (des/reactivar). NO toca dinero.",
1175
- inputSchema: { id: z.number().int().positive(), valorComision: z.number().optional(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD; vacío = quitar vencimiento"), activa: z.boolean().optional() },
1176
- }, async ({ id, ...body }) => run(() => api("PUT", `/afiliados/afiliaciones/${id}`, body)));
1177
- if (esGlobal || esAdminNodo || permisos.includes("finanzas")) { // igual que el gate del backend
1178
- tool("comision_liquidar", {
1179
- title: "Liquidar comisiones de afiliado (registra pago)",
1180
- description: "Marca comisiones devengadas como LIQUIDADAS (registra el pago al afiliado). `ids` = lista de comisiones (de comisiones_afiliado_ver). Requiere permiso de finanzas. Scopeado a tu nodo.",
1181
- inputSchema: { ids: z.array(z.number().int().positive()).min(1) },
1182
- }, async ({ ids }) => run(() => api("POST", "/afiliados/comisiones/liquidar", { ids })));
1183
- }
1184
- }
1185
- if (puede("usuarios")) {
1186
- tool("chofer_crear", {
1187
- title: "Alta de chofer (mensajero) con clave temporal",
1188
- description: "Da de alta un chofer (rol mensajero) en TU nodo. El server genera una CLAVE TEMPORAL y la devuelve (pasásela al chofer; debe cambiarla al primer ingreso). Requiere nombre, apellido (por separado, los dos obligatorios), teléfono y email. El admin global elige el nodo con `nodo`.",
1189
- inputSchema: { nombre: z.string().min(1).describe("Nombre de pila"), apellido: z.string().min(1), telefono: z.string().min(1), email: z.string().min(3), mensajeroNombre: z.string().optional().describe("Nombre para el macheo con su cta cte (default: el nombre)"), nodo: zNodo() },
1190
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/chofer", { ...rest, logisticaId: nodo })));
1191
- tool("chofer_editar", {
1192
- title: "Editar un chofer",
1193
- description: "Edita un chofer (mensajero) de TU nodo: nombre, apellido, teléfono, mensajeroNombre y/o activo (desactivar/activar). No toca credenciales.",
1194
- inputSchema: { id: z.number().int().positive(), nombre: z.string().optional(), apellido: z.string().optional(), telefono: z.string().optional(), mensajeroNombre: z.string().optional(), activo: z.boolean().optional() },
1195
- }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/chofer/${id}`, body)));
1196
- tool("cliente_generar_usuario", {
1197
- title: "Generar usuario de login para un cliente/vendedor",
1198
- description: "Le crea las credenciales de login a un cliente/vendedor YA EXISTENTE de TU nodo (para que entre a su portal). Buscás el cliente por nombre o id (usá `clientes_del_nodo`) y pasás el `email` con el que va a loguear, más `nombre` y `apellido` de la PERSONA que va a usar el acceso (obligatorios; no el nombre de la tienda). El server genera una CLAVE TEMPORAL y la devuelve (pasásela al cliente; debe cambiarla al primer ingreso). Yo nunca invento la clave. Si el cliente ya tiene usuario, avisa (no lo pisa). No da de alta el PERFIL del cliente (eso es `cliente_crear`) ni toca dinero. El admin global acota la búsqueda del cliente a un nodo con `nodo`.",
1199
- inputSchema: { cliente: z.string().min(1).describe("Nombre o id del cliente/vendedor de tu nodo (de `clientes_del_nodo`)"), email: z.string().min(3).describe("Email con el que va a loguear el cliente"), nombre: z.string().min(1).describe("Nombre de pila de la persona que va a entrar (no el de la tienda)"), apellido: z.string().min(1), telefono: z.string().optional(), nodo: zNodo() },
1200
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente", { ...rest, logisticaId: nodo })));
1201
- tool("cliente_resetear_clave", {
1202
- title: "Resetear la clave de un cliente/vendedor",
1203
- description: "Genera una CLAVE TEMPORAL NUEVA para un cliente/vendedor de TU nodo que YA tiene usuario de login pero perdió el acceso. El server la genera y la devuelve (pasásela; debe cambiarla al primer ingreso). Si el cliente todavía NO tiene usuario, avisa y sugiere `cliente_generar_usuario`. No toca el perfil del cliente ni dinero. El admin global acota la búsqueda del cliente a un nodo con `nodo`.",
1204
- inputSchema: { cliente: z.string().min(1).describe("Nombre o id del cliente/vendedor de tu nodo (de `clientes_del_nodo`)"), nodo: zNodo() },
1205
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente/resetear", { ...rest, logisticaId: nodo })));
1206
- tool("operador_crear", {
1207
- title: "Alta de operador de nodo con clave temporal",
1208
- description: "Da de alta un OPERADOR (staff que administra el nodo: recibe/escanea/despacha, liquida, etc., según los permisos que le des) en TU nodo. El server genera una CLAVE TEMPORAL y la devuelve (pasásela; debe cambiarla al primer ingreso). Requiere nombre, apellido, email y teléfono. `permisos` opcional (default 'logistica'): gestion, finanzas, mensajeros, reportes, logistica, wms, precios, usuarios. El admin global elige el nodo con `nodo`. NO es un vendedor (eso es `cliente_generar_usuario`) ni un chofer (`chofer_crear`); NO otorga jerarquía de admin de nodo (eso es `usuario_habilitar_rol`). Yo nunca invento la clave. No toca dinero.",
1209
- inputSchema: { nombre: z.string().min(1).describe("Nombre de pila"), apellido: z.string().min(1), email: z.string().min(3).describe("Email con el que va a loguear"), telefono: z.string().min(1), permisos: z.array(z.string()).optional().describe("Secciones habilitadas: gestion, finanzas, mensajeros, reportes, logistica, wms, precios, usuarios. Default: logistica."), nodo: z.number().int().positive().optional().describe("Solo admin global: nodo donde crear el operador. El operador de nodo lo crea en SU nodo.") },
1210
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/operador", { ...rest, logisticaId: nodo })));
1211
- tool("usuario_editar", {
1212
- title: "Editar datos de un usuario",
1213
- description: "Corrige nombre, apellido, teléfono y/o email (con el que entra) de un usuario de TU nodo, por id (de `chofer_listar`). Nombre y apellido son obligatorios: si el usuario todavía no los tiene separados, pasá los dos. Cambiar el nombre NO cambia el nombre con el que se le paga al cadete (queda fijo). No toca rol, permisos ni clave.",
1214
- inputSchema: { id: z.number().int().positive(), nombre: z.string().optional().describe("Nombre de pila"), apellido: z.string().optional(), telefono: z.string().optional(), email: z.string().optional().describe("Email con el que entra (no puede repetirse)") },
1215
- }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
1216
- tool("usuario_habilitar_reparto", {
1217
- title: "Habilitar a un usuario como también mensajero",
1218
- description: "Marca (o desmarca con activo=false) a un operador/admin de TU nodo como TAMBIÉN mensajero: puede escanear, autoasignarse y entregar además de administrar. No toca credenciales.",
1219
- inputSchema: { id: z.number().int().positive(), activo: z.boolean().optional().describe("default true") },
1220
- }, async ({ id, activo }) => run(() => api("PUT", `/usuarios/${id}/reparto`, { activo: activo ?? true })));
1221
- }
1222
- if (isStaff) {
1223
- tool("ruta_publicar", {
1224
- title: "Publicar una ruta/colecta en el marketplace",
1225
- description: "Publicá una ruta/colecta/viaje para que CUALQUIER nodo de la red la tome (subasta abierta). VOS le pagás al que la toma. precioMax opcional (tope que ofrecés pagar). No mueve dinero (la obligación se registra al adjudicar).",
1226
- inputSchema: { titulo: z.string().min(1), tipo: z.enum(["ruta", "colecta", "viaje"]).optional(), descripcion: z.string().optional(), zona: z.string().optional(), precioMax: z.number().optional().describe("En pesos (ARS)") },
1227
- }, async (args) => run(() => api("POST", "/rutas-publicas", args)));
1228
- tool("ruta_ofertar", {
1229
- title: "Ofertar por una ruta pública",
1230
- description: "Ofertá por una publicación de OTRO nodo: `precio` = lo que cobrás por hacerla (subasta: más barato es mejor para el que publica). Si ya ofertaste, actualiza tu oferta. No podés ofertar en la tuya.",
1231
- inputSchema: { publicacionId: z.number().int().positive(), precio: z.number().describe("En pesos (ARS)"), nota: z.string().optional() },
1232
- }, async (args) => run(() => api("POST", `/rutas-publicas/${args.publicacionId}/ofertar`, { precio: args.precio, nota: args.nota })));
1233
- tool("ruta_adjudicar", {
1234
- title: "Adjudicar una ruta pública (elegir ganador)",
1235
- description: "Elegí la oferta ganadora de una publicación TUYA (los ids salen de «ruta_ofertas»). Cierra la subasta: el nodo ganador la toma al precio de su oferta (queda registrada la obligación). NO genera facturación/rendición/comisión ni clearing automático: el pago entre nodos se salda MANUALMENTE (ver «ayuda» tema:facturacion_marketplace).",
1236
- inputSchema: { publicacionId: z.number().int().positive(), ofertaId: z.number().int().positive() },
1237
- }, async ({ publicacionId, ofertaId }) => run(() => api("POST", `/rutas-publicas/${publicacionId}/adjudicar`, { ofertaId })));
1238
- }
1239
- if (isStaff) {
1240
- tool("envio_reasignar_cliente", {
1241
- title: "Reasignar un envío a otro cliente",
1242
- description: "Mueve UN envío (por tracking) a otro cliente/vendedor de TU nodo cuando se cargó mal. Deja registro en el historial. Igual que la función del frontend.",
1243
- inputSchema: { tracking: z.string().min(1), cliente: z.string().min(1).describe("nombre o id del cliente destino (de tu nodo)") },
1244
- }, async (args) => run(() => api("POST", "/envios/reasignar-cliente", args)));
1245
- tool("envio_mensajero_externo", {
1246
- title: "Asignar un mensajero EXTERNO a envíos",
1247
- description: "Registra que uno o varios envíos los hace un mensajero EXTERNO (alguien de AFUERA de la plataforma, que no tiene usuario). Quedan En camino a nombre de esa persona con su valor manual, así la entrega queda trazada y entra igual en la liquidación de mensajeros. Pasá `valorPorEnvio` para fijar cuánto se le paga por envío; si no lo pasás, figuran como FALTA VALOR hasta completarlo. No reasigna envíos ya entregados. No mueve dinero (registra el valor a pagar, no lo paga).",
1248
- inputSchema: { envioIds: z.array(z.number().int().positive()).describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), nombre: z.string().min(1).describe("Nombre de la persona que hace la entrega"), valorPorEnvio: z.number().optional().describe("Cuánto se le paga por envío"), nodo: zNodo() },
1249
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/envios/mensajero-externo", { ...rest, logisticaId: nodo })));
1250
-
1251
- tool("envio_editar_zona", {
1252
- title: "Corregir zona/localidad/partido/CP de un envío",
1253
- description: "Corrige o COMPLETA los datos de UN envío (por tracking) para que se pueda cobrar y rutear. Dos usos: (1) CORREGIR un destino mal cargado —dos localidades pisadas ('san martin - Lanús'), el CP con la altura de la calle o con formato roto ('1.832,00')—; (2) COMPLETAR un envío que entró por el ESCANEO DEL QR del vendedor, que trae de quién es el paquete pero NO a dónde va: nace sin destinatario, sin dirección y sin zona, y así se liquida en $0 sin que nadie se entere. Acepta localidad, zona, partido, cp, destinatario, telefono y direccion; recalcula la zona y deja registro en el historial. ⚠️ En un envío YA ENTREGADO los datos del destino se COMPLETAN solo si están vacíos —nunca se pisan, porque cambiarle el destino a algo ya entregado es reescribir la historia—; lo que sí se puede corregir siempre es zona/localidad/partido/CP, que es lo que arregla la liquidación. Los campos que no se pisaron vuelven en `noPisados`. Es un arreglo PUNTUAL de ESE envío: no crea un alias global. Staff: solo envíos de tu nodo. No mueve dinero.",
1254
- inputSchema: {
1255
- tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1256
- localidad: z.string().optional().describe("Localidad real del destino (ej. 'Lanús')"),
1257
- zona: z.string().optional().describe("Metazona para cobrar/rutear; si no la pasás se deriva de la localidad"),
1258
- cp: z.string().optional().describe("Código postal real (se guarda solo con dígitos: '1.832,00' → '1832'). Vacío = borrarlo."),
1259
- partido: z.string().optional().describe("Partido/municipio del destino (para la etiqueta)"),
1260
- },
1261
- }, async (args) => run(() => api("POST", "/envios/editar-zona", args)));
1262
- tool("envio_editar_fecha", {
1263
- title: "Corregir la fecha de un envío ya cargado",
1264
- description: "Cambia la FECHA de UN envío (por tracking) que quedó con el día en que se cargó y no con el día en que el paquete se movió — el caso típico: una planilla vieja que se subió sin `fecha`. Es la que define en qué SEMANA se le liquida al vendedor, así que corregirla mueve el envío de período. No admite fecha futura, y si el período ya está liquidado te lo rechaza con el número de liquidación (rehacerla es decisión tuya, no un efecto colateral). El primer estado del historial se mueve con el envío; los demás quedan como se registraron. Solo staff, y solo envíos de tu nodo (admin global: cualquiera). No mueve dinero.",
1265
- inputSchema: {
1266
- tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1267
- fecha: z.string().min(1).describe("Fecha real del envío (dd/mm/aaaa o aaaa-mm-dd). No se admite futura."),
1268
- },
1269
- }, async (args) => run(() => api("POST", "/envios/editar-fecha", args)));
1270
- tool("envio_asignar_grupo", {
1271
- title: "Asignar un envío a un grupo logístico (despachar)",
1272
- description: "Asigna/rutea UN envío (por tracking) a un GRUPO logístico tuyo (por NOMBRE o id) — igual que la acción 'Asignar grupo' del escáner. Define con qué grupo salió (tarifa del clearing) y, si el envío TODAVÍA NO SALIÓ, lo DESPACHA: pasa a 'En camino' (sale del centro de distribución) y el vendedor lo ve así. Si el envío YA está 'Entregado' o 'En camino', NO se le toca el estado ni se le agrega un evento: solo queda registrado con qué grupo salió — por eso sirve para corregir el clearing de una planilla vieja ya entregada (mirá `estadoIntacto` en la respuesta). Si ya estaba en ese grupo te lo dice (`yaEstaba`) y no reescribe nada. Acepta VARIOS códigos separados por coma o salto de línea y devuelve el resumen. Solo grupos a los que pertenece tu nodo (el admin global, cualquiera) y envíos de tu nodo. No mueve dinero (el clearing es config).",
1273
- inputSchema: { tracking: z.string().min(1).describe("Tracking del envío. Podés mandar VARIOS separados por coma o salto de línea (un QR de ML crudo va solo, no se parte)"), grupo: z.string().min(1).describe("Nombre o id del grupo logístico de tus grupos (ej. 'Portela', 'Bonorino')") },
1274
- }, async (args) => run(() => api("POST", "/envios/asignar-grupo-ref", args)));
1275
- tool("cobro_corregir", {
1276
- title: "Corregir el monto de un cobro",
1277
- description: "Ajusta el monto a cobrar contra entrega de un envío (cuando se cobró de más o de menos), guardando el monto ORIGINAL en el historial. envioId + monto nuevo. No mueve dinero en cuentas (corrige el dato del envío).",
1278
- inputSchema: { envioId: z.number().int().positive().describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), monto: z.number().describe("En pesos (ARS)"), motivo: z.string().optional() },
1279
- }, async ({ envioId, monto, motivo }) => run(() => api("POST", `/envios/${envioId}/corregir-cobro`, { monto, motivo })));
1280
- tool("rendicion_revertir", {
1281
- title: "Revertir una rendición (marcada por error)",
1282
- description: "Revierte un cobro/cambio marcado como 'rendido' cuando en realidad NO se rindió → vuelve a 'a rendir'. Deja registro (quién/cuándo). envioId + tipo (cobro|cambio). Scopeado a tu nodo.",
1283
- inputSchema: { envioId: z.number().int().positive().describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), tipo: z.enum(["cobro", "cambio"]), motivo: z.string().optional() },
1284
- }, async (args) => run(() => api("POST", "/flujo/rendicion/revertir", args)));
1285
- }
1286
- if (puede("gestion")) {
1287
- tool("colecta_configurar", {
1288
- title: "Configurar valor de colecta",
1289
- description: "Setea un valor de colecta según alcance: 'nodo' = pago default del nodo (ej. $3000); 'mensajero' = default de ese mensajero (ej. $4000, cualquier colecta); 'cliente' = cuánto se le COBRA al vendedor (idCliente + valor, opcional minEnvios); 'par' = pago pactado a un mensajero por colectar a un cliente (idCliente + mensajero + valor). Scopeado a tu nodo (el admin global elige el nodo con `nodo`). No mueve dinero (config de tarifa).",
1290
- inputSchema: {
1291
- alcance: z.enum(["nodo", "mensajero", "cliente", "par"]),
1292
- valor: z.number(),
1293
- idCliente: z.string().optional(),
1294
- mensajero: z.string().optional().describe("Nombre del mensajero (de tu nodo)"),
1295
- minEnvios: z.number().optional().describe("Solo alcance=cliente: mínimo de envíos para colecta sin cargo"),
1296
- nodo: zNodo(),
1297
- },
1298
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/config", { ...rest, logisticaId: nodo })));
1299
-
1300
- tool("colecta_fija", {
1301
- title: "Colecta fija de un vendedor (días)",
1302
- description: "Define la COLECTA FIJA de un vendedor: los días en que se lo va a buscar SIEMPRE, tenga o no envíos cargados. Es para los que no vinculan sus cuentas ni cargan envíos — los paquetes entran cuando el cadete los escanea en la puerta. Aparecen solos en `colecta_pendientes` esos días. `dias` acepta números (0=domingo … 6=sábado) o nombres (lunes, martes…); `dias:[]` quita la colecta fija. Config de AGENDA, no de dinero.",
1303
- inputSchema: { idCliente: z.string().describe("id del cliente (de clientes_del_nodo)"), dias: z.array(z.union([z.number(), z.string()])).describe("Días: [1,2,3,4,5] o [\"lunes\",\"miércoles\"]. [] = quitar."), nodo: zNodo() },
1304
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/fija", { ...rest, logisticaId: nodo })));
1305
-
1306
- tool("colecta_asignar", {
1307
- title: "Asignar una colecta a un mensajero",
1308
- description: "Asigna la colecta (retiro de mercadería) de un cliente a un mensajero, ambos por NOMBRE de tu nodo (ej. «que Maxi levante al cliente Distri Sur»). Vincula los envíos 'A retirar' de ese cliente a la colecta y avisa al mensajero. FUNCIONA AUNQUE EL CLIENTE NO TENGA ENVÍOS CARGADOS (vendedores que no vinculan sus cuentas): se crea la colecta vacía y el cadete escanea cada paquete en la puerta — los que no están en el sistema se crean solos a nombre del vendedor, sin contar dos veces el mismo QR. Si el corte venció, la programa para el próximo día hábil. No mueve dinero (es logística). El admin global acota la resolución por nombre con `nodo`.",
1309
- inputSchema: {
1310
- cliente: z.string().min(1).describe("Nombre del cliente/vendedor a colectar"),
1311
- mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo) que hace el retiro"),
1312
- nodo: zNodo(),
1313
- },
1314
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/asignar-por-nombre", { ...rest, logisticaId: nodo })));
1315
-
1316
- tool("retiros_cargar", {
1317
- title: "Cargar la lista de retiros del día",
1318
- description: "Carga de una vez la LISTA DE RETIROS (colectas) del día: le pasás las direcciones y crea un retiro por cada una. OJO, no confundir con `colecta_asignar`, que es ir a levantarle los paquetes a UN vendedor; esto es la ronda de paradas donde el cadete va a BUSCAR (no tienen destinatario ni teléfono, solo dirección). Si vienen numeradas ('1. Helguera 936'), el número se usa como orden de ruta y se saca de la dirección. `zona` es la que cobra y paga por la tabla Retiros ('Retiro en CABA' o 'Retiro en GBA'): no la inventes. El cadete se asigna a TODA la lista y se escribe también el nombre por el que se le PAGA (el resumen de pago agrupa por nombre, no por el vínculo). Si mandás dos veces la misma dirección para el mismo cliente y día, no se duplica: vuelve en `duplicados`. Quedan en 'A retirar'; para cerrarlas usá `envio_completar_ciclo`. No mueve dinero: registra el trabajo, no lo paga.",
1319
- inputSchema: {
1320
- cliente: z.string().min(1).describe("Cliente al que se le cargan los retiros (nombre o id, de tu nodo)"),
1321
- direcciones: z.array(z.string().min(1)).min(1).describe("Una dirección por elemento, en el orden de la ruta. Pueden venir numeradas ('1. Helguera 936, CABA') o sueltas."),
1322
- zona: z.string().optional().describe("'Retiro en CABA' (default) o 'Retiro en GBA'. Es la metazona que cobra y paga."),
1323
- mensajero: zStr().describe("Nombre o id de quien hace TODA la ronda. Si el nombre matchea a varios, te los lista con su id en vez de elegir por vos."),
1324
- fecha: zStr().describe("dd/mm/aaaa o aaaa-mm-dd si NO es hoy (define la semana en que se liquida). No admite fecha futura."),
1325
- nodo: zNodo(),
1326
- },
1327
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/retiros", { ...rest, logisticaId: nodo })));
1328
-
1329
- tool("colecta_desasignar", {
1330
- title: "Desasignar una colecta",
1331
- description: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta'). El colectaId lo devuelve «colecta_pendientes». Solo colectas de tu nodo (el admin global puede acotar con `nodo`). No mueve dinero.",
1332
- inputSchema: { colectaId: z.number().int().positive(), nodo: zNodo() },
1333
- }, async ({ colectaId, nodo }) => run(() => api("POST", "/colecta/desasignar", { colectaId, logisticaId: nodo })));
1334
-
1335
- tool("procesar_zona", {
1336
- title: "Aceptar envíos ruteados por zona",
1337
- description: "ACEPTA (recibe en tu nodo) los envíos que otros nodos te rutearon por zona — los ids salen de «envios_por_zona». Opcional: mensajeroId para asignarlos a un mensajero puntual de esa zona. No mueve dinero (el clearing es config). El admin global elige el nodo receptor con `nodo`.",
1338
- inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), mensajeroId: z.number().int().positive().optional(), nodo: zNodo() },
1339
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/procesar-zona", { ...rest, logisticaId: nodo })));
1340
-
1341
- tool("rechazar_zona", {
1342
- title: "Rechazar envíos ruteados por zona",
1343
- description: "RECHAZA (declina) envíos que te rutearon por zona: dejan de aparecerte y quedan para el nodo de origen u otros nodos de la zona. No cambia el estado del envío. Los ids salen de «envios_por_zona». El admin global elige el nodo con `nodo`.",
1344
- inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), motivo: z.string().optional(), nodo: zNodo() },
1345
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/rechazar-zona", { ...rest, logisticaId: nodo })));
1346
-
1347
- tool("cliente_crear", {
1348
- title: "Crear cliente / vendedor",
1349
- description: "Da de alta un cliente en TU nodo (el backend fuerza el nodo). Podés asignarle la lista de precio con idLista. No toca dinero. El admin global elige el nodo con `nodo`.",
1350
- inputSchema: {
1351
- nombre: z.string().min(1).describe("Nombre del cliente"),
1352
- telefono: z.string().optional(),
1353
- dni: z.string().optional(),
1354
- direccion: z.string().optional(),
1355
- idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo' / 'precios_ver'. Si falta, el alta queda incompleta."),
1356
- nodo: zNodo(),
1357
- },
1358
- }, async ({ nodo, ...rest }) => {
1359
- const r = await api("POST", "/gestion/clientes", { ...rest, logisticaId: nodo });
1360
- const base = toResult(r);
1361
- // Hint de completitud (Fase A): sin lista de precios el alta está a medias → guío el próximo paso.
1362
- if (r.ok && !rest.idLista) {
1363
- base.content.push({ type: "text", text: "\n⚠️ FALTA LA LISTA DE PRECIOS — el alta está incompleta.\nPreguntale al operador: «¿qué le cobrás a este cliente?»\n • ¿La lista OFICIAL de Flex (la que usan todos)?\n • ¿Un precio PROPIO? Mirá las disponibles con `precios_ver` (el nombre está en 'referencia'); si es nueva, se crea en Precios con un nombre para reusarla.\nAsigná la lista con `cliente_editar idLista:<id>`. Ver `flujo alta_cliente`." });
1364
- }
1365
- return base;
1366
- });
1367
-
1368
- tool("cliente_editar", {
1369
- title: "Editar cliente (solo lo que pasás)",
1370
- description: "Edita un cliente de TU nodo cambiando SOLO lo que pasás: lo que no mandás queda como está. Para VACIAR un campo mandalo vacío (\"\"). `email` cambia el email con el que ENTRA el usuario del cliente (tiene que tener usuario generado; requiere permiso de usuarios; no puede repetirse). No toca dinero. El admin global puede mover el cliente a otro nodo con `nodo`.",
1371
- inputSchema: {
1372
- id: z.number().int().positive().describe("ID del cliente (de clientes_del_nodo)"),
1373
- nombre: z.string().optional().describe("Solo si lo querés cambiar"),
1374
- nombreFantasia: z.string().optional(),
1375
- idLista: z.string().optional().describe("Lista de precios a asignar"),
1376
- telefono: z.string().optional(),
1377
- email: z.string().optional().describe("Email con el que entra su usuario"),
1378
- dni: z.string().optional(),
1379
- direccion: z.string().optional(),
1380
- mensajeroNombre: z.string().optional().describe("Nombre con el que cobra si también reparte"),
1381
- activo: z.boolean().optional(),
1382
- nodo: zNodo(),
1383
- },
1384
- }, async ({ id, nodo, ...body }) => run(() => api("PATCH", `/gestion/clientes/${id}`, { ...body, ...(nodo !== undefined ? { logisticaId: nodo } : {}) })));
1385
- tool("sucursales_cliente", {
1386
- title: "Sucursales de un cliente (con su perfil de zona)",
1387
- description: "Lista las sucursales (puntos de retiro) de un cliente de TU nodo con su id, nombre, dirección y PERFIL DE ZONA. Sirve para ver/ajustar con qué perfil cotiza cada sucursal (útil cuando un cliente tiene sucursales en zonas distintas). Solo lectura.",
1388
- inputSchema: { cliente: z.number().int().positive().describe("id del cliente (de `clientes_del_nodo`)") },
1389
- }, async ({ cliente }) => run(() => api("GET", `/zonificacion/sucursales${q({ cliente })}`)));
1390
- tool("sucursal_perfil", {
1391
- title: "Setear el perfil de zona de una sucursal",
1392
- description: "Define (o limpia con perfilZona vacío) el PERFIL DE ZONA de una sucursal — con eso la liquidación cotiza los envíos que salen de esa sucursal según SU distancia (no la del cliente). El id de la sucursal sale de `sucursales_cliente`. Perfil vacío = la sucursal hereda el perfil del cliente. Config de staff (NO lo toca el vendedor); no mueve dinero, pero afecta el tramo/precio.",
1393
- inputSchema: { sucursalId: z.number().int().positive().describe("id de la sucursal (de `sucursales_cliente`)"), perfilZona: z.string().optional().describe("nombre del perfil (ej. GENERAL, MORENO). Vacío = hereda el del cliente.") },
1394
- }, async ({ sucursalId, perfilZona }) => run(() => api("POST", "/zonificacion/sucursal-perfil", { sucursalId, perfilZona: perfilZona ?? null })));
1395
- }
1396
-
1397
- if (puede("finanzas")) {
1398
- tool("liquidacion_excluir_envio", {
1399
- title: "No cobrar un envío de una liquidación",
1400
- description:
1401
- "Saca UN envío (por tracking) del cobro de una liquidación YA emitida y re-suma el total, ajustando el cargo en la cuenta corriente del cliente. El `motivo` es OBLIGATORIO: queda en el historial del envío y el vendedor lo ve en su portal. Si esa liquidación ya está en una factura de ARCA emitida, lo rechaza (haría falta una nota de crédito, que el sistema no emite). Scopeada a tu nodo.",
1402
- inputSchema: {
1403
- id: z.number().int().positive().describe("id de la liquidación"),
1404
- tracking: z.string().min(1),
1405
- motivo: z.string().min(10).describe("Por qué no se le cobra. Lo lee el vendedor."),
1406
- },
1407
- }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/excluir`, { tracking, motivo })));
1408
- tool("liquidacion_reincluir_envio", {
1409
- title: "Volver a cobrar un envío de una liquidación",
1410
- description:
1411
- "Lo inverso de `liquidacion_excluir_envio`: devuelve al cobro un envío que se había excluido, re-suma y ajusta la cuenta corriente. El `motivo` es OBLIGATORIO. Mismo guard de factura emitida.",
1412
- inputSchema: {
1413
- id: z.number().int().positive().describe("id de la liquidación"),
1414
- tracking: z.string().min(1),
1415
- motivo: z.string().min(10).describe("Por qué sí se le cobra."),
1416
- },
1417
- }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/reincluir`, { tracking, motivo })));
1418
- tool("facturacion_preparar_cliente", {
1419
- title: "Dejar un cliente listo para facturar",
1420
- description:
1421
- "Carga los datos fiscales de un cliente y lo habilita: CUIT, razón social (el TITULAR del CUIT, NO el nombre de fantasía), condición frente al IVA, en qué CUENTA cobra (eso define con qué CUIT se le factura) y si factura semanal o mensual. El corte queda en HOY salvo que pases 'desde': lo anterior se facturó por fuera del sistema y volver a emitirlo sería un comprobante duplicado. NO emite comprobantes — el MCP no factura; después se emite desde la PWA o desde el portal del cliente. Preguntá TODOS los datos antes de ejecutar: no inventes un CUIT ni una razón social. Ver el flujo 'facturacion'.",
1422
- inputSchema: {
1423
- cliente: z.string().min(1).describe("Nombre o id del cliente"),
1424
- cuit: z.string().min(1).describe("CUIT del cliente, 11 dígitos"),
1425
- razonSocial: z.string().min(1).describe("Razón social / titular del CUIT, como figura en ARCA"),
1426
- condicionIVA: z.string().min(1).describe("RI | MONOTRIBUTO | EXENTO | CONSUMIDOR_FINAL"),
1427
- cuentaCobro: z.string().optional().describe("Nombre o id de la cuenta de dinero donde cobra (define quién factura)"),
1428
- frecuencia: z.string().optional().describe("Semanal (default) o Mensual"),
1429
- desde: z.string().optional().describe("Corte YYYY-MM-DD; vacío = hoy"),
1430
- },
1431
- }, async (a) => run(() => api("POST", "/afip/preparar-cliente", a)));
1432
- }
1433
-
1434
- if (puede("wms") && wmsActivo) {
1435
- tool("producto_crear", {
1436
- title: "Crear producto (WMS)",
1437
- description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero. El admin global elige el nodo con `nodo`.",
1438
- inputSchema: {
1439
- nombre: z.string().min(1),
1440
- sku: z.string().optional(),
1441
- codigoBarra: z.string().optional(),
1442
- peso: z.number().optional(),
1443
- volumen: z.number().optional(),
1444
- idCliente: z.string().optional().describe("idCliente dueño del producto"),
1445
- nodo: zNodo(),
1446
- },
1447
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/wms/productos", { ...rest, logisticaId: nodo })));
1448
- }
1449
-
1450
- if (esGlobal) {
1451
- tool("nodo_crear", {
1452
- title: "Crear nodo (logística)",
1453
- description: "Da de alta un nodo/logística nuevo. Solo admin global. No toca dinero.",
1454
- inputSchema: {
1455
- nombre: z.string().min(1).describe("Nombre del nodo/logística"),
1456
- telefono: z.string().optional(),
1457
- },
1458
- }, async ({ nombre, telefono }) => run(() => api("POST", "/logisticas", { nombre, telefono: telefono ?? null })));
1459
- }
1460
- }
1461
-
1462
- // ============================================================================
1463
- // EDICIÓN DE PRECIOS (flag aparte · sensible pero NO mueve dinero).
1464
- // ============================================================================
1465
- if (ALLOW_PRECIOS && puede("precios")) {
1466
- tool("precio_actualizar", {
1467
- title: "Actualizar precio de una lista (versionado)",
1468
- description: "Cambia los precios por zona de una lista creando una VERSIÓN nueva (histórico exacto). Requiere permiso 'precios'; el backend impide tocar listas de otro nodo. No mueve dinero. El admin global elige el nodo de la lista con `nodo`.",
1469
- inputSchema: {
1470
- idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
1471
- cercana: z.number().optional().describe("Precio zona cercana, en pesos (ARS)"),
1472
- media: z.number().optional().describe("Precio zona media, en pesos (ARS)"),
1473
- lejana: z.number().optional().describe("Precio zona lejana, en pesos (ARS)"),
1474
- muyLejana: z.number().optional().describe("Precio zona muy lejana, en pesos (ARS)"),
1475
- referencia: z.string().optional(),
1476
- vigenciaDesde: z.string().optional().describe("Fecha YYYY-MM-DD desde cuándo rige (default: hoy)"),
1477
- nodo: zNodo(),
1478
- },
1479
- }, async ({ nodo, ...rest }) => run(() => api("POST", "/precios/clientes/version", { ...rest, logisticaId: nodo })));
1480
- }
1481
-
1482
- // Ayuda integrada — se registra al final para que el índice conozca TODOS los tools
1483
- // que este usuario tiene según su rol/permisos. Solo lectura de documentación.
1484
- tool("ayuda", {
1485
- title: "Ayuda / documentación de las herramientas",
1486
- description: "Documentación de las herramientas del MCP: sin argumentos = índice de lo que podés usar; `tool` = ficha de una herramienta (qué hace, cómo usarla, ejemplo, qué NO hace); `tema` = tópico transversal (facturacion_marketplace, dinero, aislamiento). Consultalo antes de asumir cómo funciona algo.",
1487
- inputSchema: { tool: z.string().optional().describe("Nombre de una herramienta, ej. ruta_adjudicar"), tema: z.string().optional().describe("facturacion_marketplace | dinero | aislamiento") },
1488
- }, async ({ tool: t, tema }) =>
1489
- ({ content: [{ type: "text", text: renderAyuda(t || tema, { version: MCP_VERSION, disponibles: registrados }) }] }));
1490
-
1491
- const transport = new StdioServerTransport();
1492
- await server.connect(transport);
1493
- const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";
1494
- log(`MCP Nexus Flex v3 listo. Rol: ${cap}. Escritura: ${ALLOW_WRITE ? "ON" : "off"} · Precios: ${ALLOW_PRECIOS ? "ON" : "off"}.`);
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // MCP Nexus Flex v3 — servidor stdio (paquete publicable en npm).
4
+ //
5
+ // INSTALACIÓN FÁCIL: npx -y nexusflex-mcp@latest (arranca el server)
6
+ // npx -y nexusflex-mcp@latest login (autoriza en el navegador)
7
+ // npx -y nexusflex-mcp@latest logout (borra el token local)
8
+ //
9
+ // AUTENTICACIÓN por DEVICE-FLOW (autorización web): sin pegar email/contraseña.
10
+ // El MCP pide un código, lo autorizás con un click desde la web ya logueado, y
11
+ // recibe un token de vida larga, revocable y con TU scope exacto (nunca dinero).
12
+ // Fallback: NEXUSFLEX_TOKEN o NEXUSFLEX_EMAIL+PASSWORD (compatibilidad).
13
+ //
14
+ // AISLAMIENTO EN 3 CAPAS:
15
+ // 1) El BACKEND gatea cada endpoint (requireAuth + requirePermiso + scope por
16
+ // nodo / idCliente). Un token de MCP hereda el rol/permisos FRESCOS del
17
+ // usuario en cada request. Es la garantía real.
18
+ // 2) Este server registra los tools SEGÚN EL ROL del usuario (leído de /auth/me).
19
+ // 3) DENYLIST de dinero en api.mjs + guard server-side (mcpMoneyGuard): jamás
20
+ // liquidaciones/cobros/cuentas/facturación.
21
+ //
22
+ // Escritura por flags (default OFF):
23
+ // NEXUSFLEX_MCP_ALLOW_WRITE → altas (clientes, productos, nodos) y edición.
24
+ // NEXUSFLEX_MCP_ALLOW_PRECIOS → actualizar listas de precios (aparte, sensible).
25
+ // ============================================================================
26
+ import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
+ import { api, log, API_URL } from "./api.mjs";
28
+ import { renderAyuda, guiaOnboarding, renderFlujo, FLUJOS } from "./docs.mjs";
29
+
30
+ // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
31
+ const cmd = process.argv[2];
32
+ if (cmd === "login") {
33
+ try {
34
+ const token = await runDeviceFlow(API_URL, { open: true, log });
35
+ const file = saveToken(token, API_URL);
36
+ log(`Token guardado en ${file}. Ya podés usar el MCP en Claude Desktop.`);
37
+ process.exit(0);
38
+ } catch (e) {
39
+ log("No se pudo autorizar:", e instanceof Error ? e.message : String(e));
40
+ process.exit(1);
41
+ }
42
+ }
43
+ if (cmd === "logout") {
44
+ clearToken();
45
+ log(`Token local borrado (${tokenFilePath()}). Revocá también desde la web (🔌 Conexiones) si querés cortar el acceso ya emitido.`);
46
+ process.exit(0);
47
+ }
48
+
49
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
50
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
51
+ const { z } = await import("zod");
52
+
53
+ const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
54
+ const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
55
+ const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
56
+
57
+ // ATADO CON: backend/src/services/mcp-remote.ts (MCP_VERSION + NOVEDADES + mismos tools, salvo SOLO_REMOTO)
58
+ // y mcp/package.json. Lo verifica backend/src/coherencia.test.ts. Ver CLAUDE.md → "Cosas que van juntas".
59
+ const MCP_VERSION = "3.74.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
60
+ const NOVEDADES = [
61
+ "3.74.0 — RECLAMOS DE VENDEDORES: nuevo `reclamo_responder` (responder, cerrar o reabrir el reclamo que un vendedor dejó sobre su liquidación; la respuesta la lee en su portal). Se usa junto con `liquidacion_excluir_envio` / `liquidacion_reincluir_envio`: primero corregís el cobro, después contestás con el nuevo total. Es lo mismo que la pantalla Reclamos de vendedores. Además `liquidacion_cobro_parcial`: cuando el comprador pagó en destino MENOS que el flete (ej. zona media cobrada como cercana), lo cobrado se da por pagado y el envío sigue en la liquidación por la diferencia; con `montoCobrado` 0 se quita el ajuste. Y el cierre automático de un reclamo ya no responde con un solo envío y un total viejo: espera a que estén atendidos todos los envíos del reclamo. Además la app y el MCP validan que el reclamo sea de un cliente de TU nodo. Solo texto y estado: no mueve dinero. Requiere permiso finanzas.",
62
+ "3.73.0 — CONTROL POR FOTO MÁS SEGURO: el MCP de la PC solo puede leer y crear adentro de UNA carpeta, la que elegís al instalar el plugin de Nexus Flex (si no elegís ninguna: Documentos\\Control por foto, que se crea sola). `control_foto_preparar`, `control_foto_carpeta` y `control_foto_foto` toman las rutas desde ahí (podés pasar solo 'Lunes 14-9-2026') y rechazan cualquier ruta de afuera o un acceso directo que apunte afuera. `control_foto_estructura` te dice cuál es la carpeta. No mueve dinero.",
63
+ "3.72.0 — CONTROL POR FOTO POR CARPETAS, para cualquier nodo y cualquiera de sus grupos. `control_foto_preparar` (MCP local) te crea las carpetas del día (o de varios) con los nombres exactos de tus grupos y cadetes. Armás una carpeta por día con DADOS/<grupo> (lo que pasaste a un grupo), DADOS/<cadete> (lo repartió un cadete tuyo), RECIBIDOS/…/<cadete> (te lo dio otro nodo y lo repartió tu cadete) y RECIBIDOS/<grupo> (te lo dieron y lo derivaste por ese grupo). `control_foto_carpeta` (MCP local) recorre la carpeta, lee el QR de cada foto (o la IA de etiquetas), busca o da de alta el envío SIN duplicar, lo asigna al responsable de zona / cadete / grupo, lo lleva a Entregado en el día real y le cuelga la foto (la que ven los nodos en su cuenta semanal). Primero simula y te devuelve las preguntas (cuentas de ML desconocidas, zonas con varios responsables, duplicados); se contestan con `control_foto_foto` o, más cómodo, en la app: Logística → 📷 Control por foto (ahí también quedan los envíos SIN VENDEDOR para asignarles el cliente o el nodo que te los dio; «recordar» asocia la cuenta de ML para la próxima, salvo que sea compartida entre logísticas). `control_foto_estructura` te dice qué nombres de carpeta valen. Nada sale de tu red (tu nodo + los de tus grupos). No mueve dinero.",
64
+ "3.71.0 — Habilitadas por el dueño: `liquidacion_excluir_envio` / `liquidacion_reincluir_envio` (sacar o devolver UN envío del cobro de una liquidación emitida, con motivo obligatorio) y `facturacion_estado` / `facturacion_preparar_cliente` (qué falta para facturar y cargar los datos fiscales de un cliente) ya funcionan por MCP: el filtro de dinero las deja pasar con su método exacto. Siguen sin pagar, cobrar ni emitir comprobantes, y piden permiso finanzas. Además `mis_envios` ahora sí filtra por `estado`.",
65
+ "3.70.0 — Descripciones al día: textos de las herramientas corregidos para que digan lo que hacen hoy (`envio_entregar`, `envio_asignar_mensajero`, `generar_enlace_vinculacion` vence en 48 hs, `grupo_miembros` y `provisorio_conciliar` apuntan a `grupos_tarifas`), `flujo` lista todos los flujos disponibles, `sugerencias_listar` solo para el superadmin, `envio_cargar` exige `telefono`, parámetros con formato (fechas YYYY-MM-DD, montos en ARS, nombre o id) y las instrucciones del conector más cortas (la guía sigue en `guia`). `liquidacion_excluir_envio`/`reincluir` avisan que hoy el filtro de dinero los bloquea por MCP (se hace desde la PWA).",
66
+ "3.69.1 — AYUDA completa: `ayuda` ahora tiene la ficha (qué hace, cómo usarlo, ejemplo, qué NO hace) de los 20 tools que no la tenían (cierre semanal, zonas de grupo, tarifas de grupo, sin vendedor, corregir estado, pago al cadete, retiros…) y los 26 que no aparecían en el índice. La ayuda del paquete npm se genera desde la del conector, así que ya no se desincronizan. No mueve dinero.",
67
+ "3.69.0 — Control por foto de la TANDA DE UN CADETE (propios + de otros nodos mezclados): en `envio_desde_etiqueta_ml` alcanza con el `mlQr` crudo — de ahí sale el número de ML y el vendedor, y el vendedor se reconoce igual que en el escáner (cuenta vinculada O cuenta aprendida en juntadas/escaneos; antes solo la vinculada). `nodoEntrega` ya no necesita `grupo`: si el cliente es del mismo nodo que entrega es un paquete propio (sin traspaso) y si es de otro nodo se usa el grupo que comparten. Si no conoce al vendedor, contesta 404 con el senderId para que preguntes de qué nodo/cliente es. Si el envío ya existía, no lo duplica: lo completa y lo avanza. No mueve dinero.",
68
+ "3.68.0 — Control por foto: `envio_desde_etiqueta_ml` acepta `nodoEntrega` (nombre o id de un nodo del `grupo`) = el nodo que RECIBIÓ y entregó el paquete. Con `fecha` + `grupo` + `nodoEntrega` una planilla atrasada entra al CIERRE SEMANAL entre nodos en su día real (ej. 5 envíos de un cliente de Envíos Frank que entregó FastCorreo el lunes 14: fecha 14/09, grupo Portela, nodoEntrega FastCorreo). Antes solo se ponía con `autoRutear` y si la zona tenía un único responsable; si no, quedaba fuera del cierre. Si el envío ya tenía OTRO nodo que entrega no se pisa (avisa). Si la semana ya cerró, entra en el cierre siguiente marcado como tarde, con su fecha real. No mueve dinero.",
69
+ "3.67.0 — TARIFA de grupo de una: `grupo_tarifa_set` fija la tarifa de clearing por zona (cercana/media/lejana/muyLejana) de TODO un grupo — el default del grupo y el precio de cada nodo miembro — en una sola llamada, sin ir nodo por nodo (ej. dejar un grupo nuevo en 2700 los 4 tramos). Con `soloDef` toca solo el default. Solo la comisión (admin) del grupo o el admin global. Es config del clearing, no mueve dinero.",
70
+ "3.66.0 — COMPOSICIÓN de zonas de grupo: `zona_mover_metazona` reclasifica una metazona/localidad de una zona de grupo a otra del mismo grupo sin tocar el nodo responsable (ej. 'El Palomar' mal puesto en Morón → Tres de Febrero). `zona_componer_grupo` crea/edita una zona de grupo agregando/quitando metazonas y sumándole un nodo responsable — sirve para armar las zonas de un grupo NUEVO con su composición completa (el ruteo matchea por metazona exacta, así que una zona de CABA necesita 'CABA · Barrio' y 'Barrio'). Solo la comisión (admin) del grupo o el admin global.",
71
+ "3.65.0 — SIN VENDEDOR: `sin_vendedor` muestra las cuentas de Mercado Libre y los envíos cargados con foto que tu nodo tiene sin vendedor (por ejemplo, un nodo recién dado de alta con etiquetas de cuentas no vinculadas ya escaneadas), y `asignar_sin_vendedor` los asigna a UN vendedor en un paso: asocia cada cuenta, le pasa todos sus envíos, le crea el usuario si hace falta y devuelve un enlace de vinculación por cuenta. Cuando el vendedor autoriza el enlace, lo que quedaba de esa cuenta se le engancha solo.",
72
+ "3.64.1 — FRENO DE DUPLICADOS en `envio_desde_etiqueta_ml` (sin número de ML) y `envio_cargar`: si el cliente ya tiene un envío a esa dirección ese día (±1), responde POSIBLE DUPLICADO nombrándolo (15/09 se cargaron 44 paquetes a mano que ya estaban por planilla y se cobraron dos veces). Si es otro paquete, repetí con `forzarNuevo:true`. Con número de ML, si difiere en un dígito de otro envío del mismo comprador, avisa en `advertencias`.",
73
+ "3.64.0 — CIERRE SEMANAL entre nodos (juntada): la semana de lunes a sábado se congela sola el miércoles siguiente a las 06:00. Cada paquete que un nodo le pasó a otro esa semana (entregado o no) vale la tarifa del que lo recibe, y por nodo se netea. Lo cerrado no se recalcula; lo que se cargue tarde entra en el cierre siguiente con su fecha. Lo que daría $0 (sin tarifa) no entra y se avisa. Nuevo `cuenta_semanal_nodos` y `cierre_semanal_links` (admin global). Arranca con la semana del 14 al 19/09.",
74
+ "3.63.0 — Responsable de zona PENDIENTE → FIRME: al despachar por grupo desde el escáner, el responsable de la zona (elegido en el popup, o automático si hay uno solo) queda PENDIENTE de confirmar, y recién queda FIRME cuando ese nodo lo recibe de verdad (lo procesa). Nuevo `nodo_link_confirmacion`: link FIJO sin login para un nodo que no tiene usuarios propios (un 'feeder' que solo deja paquetes en la juntada) — ve sus pendientes y los confirma con un toque. Se lo pasás por WhatsApp a mano.",
75
+ "3.62.0 — `nodo_renombrar` (solo admin global): cambia la razón social REAL de un nodo (la ve todo el mundo), a diferencia de `nodo_alias_poner` que es un apodo personal. Reusa la validación existente (nombre no vacío, sin repetir otro nodo) y renombra la marca si coincidía con el nombre viejo.",
76
+ "3.61.0 — `nodo_alias_poner`: le ponés TU propio apodo a un nodo (ej. 'Flex Fácil' = 'Félix', el dueño) para reconocerlo más fácil — es personal, no lo ve otro usuario ni cambia el nombre real. De ahí en más lo podés nombrar por ese apodo en `asignar_nodo_zona` y en los buscadores de la app. Alias vacío lo saca.",
77
+ "3.60.0 — Responsable de zona POR GRUPO logístico (Bonorino, Portela…): `asignar_nodo_zona` con `grupo` pasa a MODO COMISIÓN — la zona es del grupo, no de un nodo dueño, y solo un admin de ESE grupo (o el admin global) puede asignarla o reasignarla. Nuevo `zona_dejar`: el nodo responsable se saca solo (self-service) de una zona de grupo; si era el único, queda LIBERADA y se avisa a TODO el grupo, pero solo la comisión la puede volver a asignar. Nuevo `zonas_grupo`: lista las zonas de un grupo con quién las cubre y cuáles están liberadas.",
78
+ "3.59.0 — `cliente_editar` ya NO pisa el cliente: cambia solo los campos que le pasás (antes, cargarle el teléfono le borraba la lista de precios, el DNI y el nombre con el que cobra). Para vaciar un campo, mandalo vacío. Nuevo: `email` cambia el email con el que entra el usuario del cliente. `nombre` dejó de ser obligatorio.",
79
+ "3.58.0 — `colecta_historial`: cuántos paquetes se colectaron por día y por cliente en una fecha o rango (hasta 62 días), no solo lo de hoy/mañana. Pasale `cliente` (nombre o id) para uno puntual; dice también quién los colectó. Cuenta cada paquete una vez, el día de Argentina en que se colectó (por escaneo o en la puerta). Solo lectura.",
80
+ "3.57.0 — NOMBRE Y APELLIDO OBLIGATORIOS en todos los usuarios: `chofer_crear`, `operador_crear` y `cliente_generar_usuario` ahora piden `nombre` y `apellido` por separado (en un vendedor, los de la PERSONA que entra, no el de la tienda: con \"Fumshop\" en la lista no se sabía quién era). Nuevo `usuario_editar` para corregir nombre, apellido, teléfono o email de un usuario de tu nodo. Cambiar el nombre de un cadete NO cambia el nombre con el que se le paga: queda fijo para que sus entregas no se muden de grupo en la liquidación.",
81
+ "3.55.0 — `envio_editar_zona` ahora también COMPLETA destinatario, teléfono y dirección. El envío que entra por el escaneo del QR trae de quién es el paquete pero NO a dónde va: nace sin destino y se liquida en $0 sin que nadie se entere (5.554 así). En un envío ya ENTREGADO los datos del destino solo se completan si están VACÍOS —nunca se pisan— y los que no se tocaron vuelven en `noPisados`. Además el escáner ahora PIDE la zona en el momento, con el paquete en la mano, y muestra cuántos de la colecta siguen sin zona.",
82
+ "3.54.0 — `retiros_cargar`: la lista de RETIROS del día se carga por acá en vez del Excel Maestro. Le pasás las direcciones (numeradas o no) y el cadete, y crea un retiro por parada con la zona que cobra y paga (Retiro en CABA/GBA). Dos cosas que la planilla hacía mal y ya no: el número de orden iba PEGADO a la dirección y el geocoder terminaba devolviendo un cuartel en Gualeguaychú (~800 envíos con coordenadas en Entre Ríos), y la localidad quedaba en 'Retiro en CABA', que no es un lugar. Escribe el nombre por el que se PAGA, no solo el vínculo. Repetir la misma dirección el mismo día no duplica el retiro ni el cobro.",
83
+ "3.53.0 — `envio_pago_mensajero`: carga lo que se le paga a quien repartió, por envío. Cierra una brecha que se comía plata en silencio: al cadete se le paga por el NOMBRE (`Envio.mensajero`) y no por el vínculo, así que un envío asignado con `envio_asignar_mensajero` se veía correcto en todas las pantallas y NO entraba en su resumen de pago. Ahora, al cargar el valor, se completa el nombre con el que la persona está cargada (el apodo del resumen, no el nombre completo; en un vendedor con doble rol, el de su cliente). Sin GRUPO no hay tarifa que estampar —el cadete directo del nodo— y este valor manual era el único mecanismo, sin ninguna herramienta que lo escribiera. No paga por su cuenta un envío que no se entregó: los Cancelados/Devueltos vuelven listados salvo que lo pidas.",
84
+ "3.52.0 — `envio_completar_ciclo`: reconstruye los pasos que le faltan a un envío YA cerrado (Colectado / En centro de distribución / En camino), cada uno a nombre de quien lo hizo y fechado en el día real. El control por foto cerrado en lote marca ENTREGADO y nada más, así que el envío saltaba de 'A retirar' al cierre sin registro de quién lo movió, y `avanzarA` no servía porque solo avanza hacia adelante. Solo agrega lo que falta: no duplica, no cambia el estado actual y no inventa autores (el paso del que no decís quién lo hizo, no se agrega). No mueve dinero.",
85
+ "3.51.0 — `envio_entregar` ya no revive un envío que había vuelto al vendedor. Corregir una tanda mal fechada es una operación en LOTE, y un paquete en estado de EXCEPCIÓN (Cancelado, Devuelto al vendedor, En espera de reposición) que estuviera en la lista se marcaba ENTREGADO por estar ahí, en vez de que solo se le corrigiera la fecha. Ahora esos vuelven en `errores` con su estado real y no se tocan; y un envío ya cerrado por ML (`Entregado (Flex)`) entra por la corrección de fecha, no por una entrega nueva. Misma regla que la cadena de `avanzarA`.",
86
+ "3.50.0 — El control por foto ya cierra la tanda EN EL DÍA QUE SE MOVIÓ. `envio_desde_etiqueta_ml` con `fecha` + `avanzarA` estampa cada estado de la cadena con su hora de ESE día (Colectado 9, En centro 12, En camino 15, Entregado 18) en vez de la hora del control, así el timeline se lee en orden y el cumplimiento del día cuenta bien. Y sobre un envío que YA estaba cargado, la `fecha` ahora también se aplica: antes `cargarEnvio` cortaba antes por idempotencia y la planilla atrasada se seguía liquidando en la semana del alta. Si ese período ya está liquidado, rechaza la llamada nombrando la liquidación en vez de dejar el envío a medio corregir.",
87
+ "3.49.0 — `envio_entregar` acepta `fecha`: el día REAL en que se entregó. El control por foto se cuenta días después ('esto salió por Bonorino el 2', contado el 8) y el historial decía que se había entregado hoy. Sobre un envío que YA figura entregado, corrige la fecha del cierre ya registrado (vuelve en `fechaCorregida`) — así se arregla una tanda cerrada con el día equivocado sin tocar nada más. Toca el evento 'Entregado' del historial; la semana en que se liquida el envío sigue siendo cosa de `envio_editar_fecha`. No mueve dinero.",
88
+ "3.48.0 — `envio_asignar_grupo` acepta VARIOS envíos en una llamada: separá los códigos por coma o salto de línea. Una planilla despacha decenas por el mismo grupo y de a uno eran decenas de llamadas. Devuelve el resumen (asignados / ya estaban / no encontrados) en vez de cortar en el primero que falla, porque en una planilla es normal que alguno todavía no esté cargado. Un QR de ML crudo es un JSON con comas adentro: ese nunca se parte, va como un solo código.",
89
+ "3.47.0 — Quién puede repartir se resuelve en UN solo lugar. La 3.46.0 arregló a medias el `mensajero` de `envio_desde_etiqueta_ml`: miraba rol 'mensajero' y el flag `reparte`, pero seguía dejando afuera la TERCERA forma — el VENDEDOR con doble rol (su Cliente tiene `mensajeroNombre`), que es el caso típico del que vende y además reparte. Ahora tanto ese parámetro como `envio_asignar_mensajero` usan `usuariosQueReparten`, la misma fuente que las listas del panel, así que las tres formas valen igual y se puede pasar el id.",
90
+ "3.46.0 — El `mensajero` de `envio_desde_etiqueta_ml` ya no le atribuye entregas a la persona equivocada. Resolvía SOLO entre usuarios con rol 'mensajero', así que a un VENDEDOR que también reparte no lo encontraba y elegía a otro de nombre parecido; y si no había ninguna coincidencia lo ignoraba EN SILENCIO, dejando el envío sin mensajero con la respuesta en success. Ahora busca entre todos los que pueden repartir (rol mensajero o `reparte`), acepta el ID, falla si no encuentra a nadie, y si el nombre matchea a varios los lista con su id en vez de elegir por vos.",
91
+ "3.45.0 — Dos herramientas para DESHACER lo que quedó mal. `envio_corregir_estado` pone un envío en un estado puntual con motivo obligatorio (acepta también los terminales 'Cancelado'/'Rechazado por el comprador', que antes solo escribía ML o la devolución: si algo dejaba un envío ahí, no había forma de volverlo atrás). `envio_asignar_mensajero` asigna o QUITA el mensajero por tracking, buscando entre TODOS los que pueden repartir — rol mensajero o `reparte` habilitado — y aceptando el ID: el `mensajero` de `envio_desde_etiqueta_ml` solo mira rol 'mensajero', así que con un VENDEDOR que también reparte elegía a otra persona de nombre parecido y le atribuía entregas ajenas.",
92
+ "3.44.0 — FIX de la 3.43.0: un envío en estado de EXCEPCIÓN ya no se avanza. `avanzarA` sobre un envío existente buscaba su estado en el ciclo normal, y como 'Cancelado (Flex)' / 'Devuelto al vendedor' / 'En espera de reposición' no están ahí, `indexOf` daba -1 y lo trataba como si recién arrancara: marcaba ENTREGADO un paquete que en realidad había vuelto al vendedor. Ahora solo avanza desde el ciclo normal (A retirar / Colectado / En centro / En camino) y si no, contesta `noAvanzado` con el estado real, sin escribir nada.",
93
+ "3.43.0 — `avanzarA` ya no se ignora cuando el envío YA existía. La cadena de estados del control por foto vivía dentro de un `if (!yaExistia)`: si el paquete ya había entrado por otro lado (lo cargó el vendedor, o una integración ML/TiendaNube), `envio_desde_etiqueta_ml` contestaba success y lo dejaba donde estaba, sin avisar. Ahora lo avanza SOLO HACIA ADELANTE desde su estado actual (nunca retrocede, y un envío ya entregado no se toca), y devuelve `historial` + `hechoPor` como en un alta nueva para que se vea qué se movió. Sirve para cerrarle el ciclo a una planilla cuyos paquetes ya estaban cargados.",
94
+ "3.42.0 — Marcar ENTREGADO un envío que YA estaba cargado (`envio_entregar`, por tracking, uno o varios). Faltaba: el `avanzarA:'entregado'` de `envio_desde_etiqueta_ml` solo corre en altas NUEVAS, así que cuando la foto era de un paquete que ya había entrado por otro lado (lo cargó el vendedor, o una integración ML/TiendaNube) el parámetro se ignoraba EN SILENCIO y el envío quedaba 'A retirar' para siempre — no había forma de cerrarle el ciclo desde el MCP. Entrega 'solo con los datos' (sin foto ni firma) y corre la entrega real: sella la logística de entrega para el clearing y le avisa al vendedor. Los ya entregados vuelven en `yaEstaban` sin romper el lote. Staff, scopeado a tu nodo.",
95
+ "3.41.0 — Corregir el clearing de un envío YA ENTREGADO sin romperle el estado: `envio_asignar_grupo` nunca despachó los envíos en 'Entregado'/'En camino' (solo les setea con qué grupo salieron), pero la descripción decía que los pasaba a 'En camino' y la ruta devolvía un 409 genérico — 'No se pudo asignar el envío' — cuando en realidad el envío YA estaba en ese grupo y no había nada que hacer. Ahora eso responde ok con `yaEstaba`, y al asignar devuelve `estadoIntacto` para dejar claro que el estado no se tocó. Sirve para atribuirle el clearing a una planilla vieja ya entregada.",
96
+ "3.40.0 — No cobrarle a un vendedor un envío que el nodo no movió: `liquidacion_excluir_envio` lo saca del cobro de una liquidación YA emitida (re-suma y ajusta la cuenta corriente) y `liquidacion_reincluir_envio` lo devuelve. El MOTIVO es obligatorio: queda en el historial del envío y el vendedor lo ve en su portal. Si la liquidación ya está en una factura de ARCA emitida, lo rechaza (haría falta una nota de crédito). Además el motor de liquidación ahora decide con EVIDENCIA operativa (colecta, mensajero, foto, receptor, etiqueta impresa…) en vez de un guardarraíl binario, y deja registrado por qué no se cobró cada envío. Requiere permiso finanzas.",
97
+ "3.39.0 — Corregir la FECHA de un envío YA cargado (`envio_editar_fecha`): el que se subió atrasado sin `fecha` quedó con el día del alta y se liquidaría en la semana equivocada. Por tracking, mueve el envío de período y arrastra el primer estado del historial (los demás quedan como se registraron). Solo staff y solo tu nodo; no admite fecha futura; y si ese período YA está liquidado lo rechaza con el número de liquidación en vez de descuadrarla en silencio. No mueve dinero.",
98
+ "3.38.0 — Cargar envíos con FECHA ANTERIOR: `envio_cargar` y `envio_desde_etiqueta_ml` aceptan `fecha` (dd/mm/aaaa o aaaa-mm-dd) para subir una planilla días después de que el paquete se movió, en vez de que todo quede con la fecha del alta. La fecha define en qué SEMANA se liquida el envío: por eso es SOLO staff (un vendedor no puede mover sus paquetes de período) y no se admite una fecha futura. Sin `fecha` se comporta igual que antes (hoy). No mueve dinero.",
99
+ "3.37.0 — Guía para ponerse a facturar: nuevo flujo `flujo facturacion` (paso a paso completo: emisor por cuenta, trámite en ARCA, clientes, y cómo se emite después). `facturacion_estado` te dice qué falta — emisores incompletos, cuentas sin emisor y, por cliente, qué dato le falta (CUIT, razón social, condición IVA, emisor, habilitación). `facturacion_preparar_cliente` deja un cliente listo en un paso (datos fiscales + cuenta donde cobra + frecuencia + corte en HOY, para no re-facturar lo viejo). El MCP sigue SIN facturar: no emite comprobantes ni entra a ARCA (eso necesita la Clave Fiscal de esa persona). Requiere permiso finanzas.",
100
+ "3.33.0 — Colecta a vendedores SIN envíos cargados: `colecta_asignar` crea la colecta igual aunque el cliente no tenga nada cargado, y el cadete ESCANEA cada paquete en la puerta — los que no están en el sistema se crean solos a nombre del vendedor y el mismo QR nunca se cuenta dos veces. Si el QR es de Flex, se APRENDE la cuenta de ML (sender_id) del vendedor: sus próximas etiquetas se reconocen solas, sin preguntar de quién son. `colecta_pendientes` devuelve `clientesSinEnvios`.",
101
+ "3.32.0 — Alerta 'marcar entregado': los envíos que quedan 'En camino' +24h sin cerrarse ahora salen en `envios_trabados` como tipo `en_camino_sin_cerrar` ('revisá y marcá entregado'), y el aviso proactivo (opt-in ALERTAS_TRABADOS=1) le llega a los OPERATIVOS y admins del nodo (antes solo al jefe). Umbral configurable con ALERTA_EN_CAMINO_HORAS.",
102
+ "3.31.0 — Consulta rápida de barrios: `zona_barrios` responde '¿qué barrios/localidades hay en <zona>?' (ej. 'Matanza Norte', 'CABA') con su tramo por perfil. Busca por nombre entre las zonas visibles a tu nodo (propias, globales o de tus grupos logísticos). Solo lectura.",
103
+ "3.30.0 — Perfil de zona por SUCURSAL: un cliente con sucursales en zonas distintas ahora cotiza cada envío según la distancia de SU sucursal-origen (antes usaba un único perfil por cliente). `sucursales_cliente` lista las sucursales de un cliente con su perfil; `sucursal_perfil` setea (o limpia) el perfil de una sucursal (config de staff — el vendedor no lo toca). Perfil vacío = la sucursal hereda el del cliente (comportamiento previo, aditivo).",
104
+ "3.29.0 — Alta de portal para clientes, gaps cerrados: `cliente_resetear_clave` genera una clave temporal nueva para un cliente/vendedor que YA tiene usuario pero perdió el acceso (sin pisar el alta; para el alta nueva sigue `cliente_generar_usuario`). `generar_enlace_vinculacion` ahora resuelve `cliente` por NOMBRE además de id (antes solo aceptaba el id y tiraba 'Cliente inválido'). El enlace de vinculación (ML/TiendaNube/TiendaNegocio) pasó de vencer en 15 minutos a 48hs, para que le llegue vigente al cliente por WhatsApp aunque no lo abra al instante.",
105
+ "3.28.0 — Admin de grupo: los nodos ORIGINALES de un grupo (los que lo fundan por QR) son sus admins y gestionan quién está adentro. `grupo_miembros` lista los nodos marcando quién es admin; `grupo_sumar_nodo` / `grupo_expulsar_nodo` agregan o sacan nodos; `grupo_admin_permiso` da o saca el permiso de admin a otro nodo (así otros heredan la administración si los originales se van). Nunca se deja un grupo sin admin. Solo un admin del grupo (o admin global). No mueve dinero.",
106
+ "3.27.0 — Reconciliación de nodos (QR onboarding etapa 3): `provisorio_conciliar` acepta `modo` — 'absorber' (default, el nodo real se queda con el historial) o 'desde_ahora' (no toca el historial; el clearing arranca de acá). En la PWA, al vincular por QR elegís el modo y cargás la tarifa inicial del grupo.",
107
+ "3.26.0 — Planilla ML Fase 5: AUTO-PRECIO — `envio_desde_etiqueta_ml` devuelve `precioClearing` (lo que clearea el envío por su grupo+zona) cuando lo despachás por grupo. Nuevo `planilla_reporte`: resumen de lo cargado por control por foto (ml_manual) — total + por cliente/zona/estado + suma de valor y cobro.",
108
+ "3.25.0 — 'Control por foto y carga de planilla vía MCP' (flujo `control_por_foto`): `envio_desde_etiqueta_ml` cierra TODO el ciclo en un paso con `avanzarA:'entregado'` (A retirar→Colectado→Procesado→[grupo=En camino]→Entregado, cada estado con QUIÉN lo hizo). Vía rápida del clearing entre nodos. Nuevo `fotoRuta` = ruta local de la foto (no la imagen) para reencontrarla, sobre todo con dígitos '*'. Devuelve `historial` + `hechoPor`.",
109
+ "3.24.0 — Planilla por CARPETA de fotos (una habilidad): `envio_desde_etiqueta_ml` cierra el circuito en un paso con `avanzarA` ('colectado'/'procesado'), `autoRutear` (al nodo/mensajero responsable de la zona; configurás con `asignar_nodo_zona`/`asignar_mensajero_zona`) y `grupo`. Convención de dígitos ilegibles con '*' (ej. 'Aguirre 31**'), sin inventar. Flujo `alta_planilla_ml` reescrito.",
110
+ "3.23.0 — Flujo de comunicación: `envio_estado` responde '¿cuándo llega?' con narrativa + ETA (mensajero asignado, cuántos envíos en la ruta, posición del envío, estimado por posición×min/parada) y marca problemas (nunca despachado, sin mensajero, mensajero detenido). `envios_trabados` lista los envíos trabados del nodo por severidad. Aviso PROACTIVO al jefe de nodo por push opt-in (ALERTAS_TRABADOS=1, desactivado por defecto). Todo read-only.",
111
+ "3.22.0 — Planilla ML: `provisorio_conciliar` fusiona un NODO PROVISORIO con el nodo real cuando este ya está dado de alta — reatribuye sus envíos y su membresía de grupo al nodo real y lo marca resuelto (el clearing pasa a usar la tarifa del nodo real). Como operador solo conciliás los provisorios que creó tu nodo, contra un nodo que ya opera en el grupo; el admin global además puede definirle la tarifa. No mueve dinero.",
112
+ "3.21.0 — Paridad con el escáner: `envio_asignar_grupo` asigna/rutea un envío (por tracking) a un GRUPO logístico tuyo (por nombre o id, ej. 'Portela'/'Bonorino'), define con qué grupo salió (tarifa del clearing) y lo despacha ('En camino', sale del centro). Solo tus grupos / envíos de tu nodo. No mueve dinero. (Sugerencia #23.)",
113
+ "3.20.0 — Constructor de flujos: `flujo_registrar` captura cuándo el asistente tuvo que INDAGAR (varias preguntas) hasta entender qué quería el usuario → sirve para armar un FLUJO DIRECTO. Se auto-usa (instrucción al conectar). Cualquier rol.",
114
+ "3.19.0 — Fix de registro: `envio_procesar` (v3.16.0) ahora queda dentro del gate staff/mensajero en el conector remoto. NOTA: si un tool nuevo 'no aparece', abrí una SESIÓN nueva del MCP (el índice de herramientas se fija al iniciar; reconectar el conector no siempre alcanza).",
115
+ "3.18.0 — Ahora CUALQUIER usuario puede ver SUS propias sugerencias y en qué estado están (`mis_sugerencias`): Recibida / En evaluación / ✅ Implementada / Descartada. Cada uno ve solo lo suyo.",
116
+ "3.17.0 — Planilla ML Fase 3: crear un NODO PROVISORIO en un grupo al vuelo (`nodo_provisorio_crear`), para atribuir el clearing cuando te bajan paquetes de un nodo que todavía no está dado de alta. Solo en grupos de TU nodo (el admin global en cualquiera); no tiene login hasta conciliarlo con el nodo real. Reusa el clearing por grupo. No mueve dinero.",
117
+ "3.16.0 — Procesar/recibir un envío en el centro desde el MCP (`envio_procesar` por tracking), sin escanear ni entrar al panel web — igual que la acción 'procesar' del escáner (queda 'En centro de distribución'). Scopeado a tu nodo/grupo. (La consulta de liquidaciones por cliente/monto + detalle ya estaba desde 3.8.0.)",
118
+ "3.15.0 — Planilla ML manual (no vinculado): `envio_desde_etiqueta_ml` con `reusarEtiqueta:true` REUSA el mismo tracking/QR/etiqueta de ML que ya viene impreso (NO genera uno nuevo), marca el envío como 'ml_manual' y es idempotente (no duplica). Nuevo flujo `flujo alta_planilla_ml`. Además `alta_cliente` ahora guía también el PERFIL DE ZONA y la COLECTA (dirección con timbre + geoposición), y los flujos se AUTO-DISPARAN (el asistente consulta `flujo` cuando querés hacer algo). No mueve dinero.",
119
+ "3.14.0 — El admin global ya puede OPERAR (no solo consultar) sobre cualquier nodo: los tools de escritura scopeados por nodo aceptan un parámetro opcional `nodo` para elegir sobre qué nodo actuar (chofer_crear, cliente_crear/editar, cliente_generar_usuario, producto_crear, precio_actualizar, colecta_configurar/asignar/desasignar, procesar_zona, rechazar_zona, asignar_mensajero_zona; asignar_nodo_zona usa `logisticaId`). El operador de nodo lo ignora (siempre opera en SU nodo — aislamiento intacto). Antes chofer_crear fallaba con 'Elegí un nodo' para el admin global. De paso se cerró un IDOR: desasignar una colecta ahora valida que sea de tu nodo.",
120
+ "3.13.0 — Flujo guiado (Fase A): nuevo tool `flujo` que devuelve el PASO A PASO de un objetivo (alta_cliente, alta_operador, alta_chofer) para no dejar nada incompleto. Además `cliente_crear`, si no le pasás la lista de precios, te recuerda preguntar «¿qué le cobrás?» (Oficial de Flex o una propia con nombre reusable) y cómo asignarla. Todo en español, para operar sin saber nada.",
121
+ "3.12.0 — Alta de OPERADOR de nodo (`operador_crear`): crea el login de un operador (staff que administra el nodo) con email + CLAVE TEMPORAL y los permisos que le des (default 'logistica'). El operador de nodo lo crea en SU nodo; el admin global elige el nodo con `nodo`. Requiere permiso 'usuarios'. No otorga admin de nodo (eso es `usuario_habilitar_rol`) ni toca dinero.",
122
+ "3.11.1 — Fix `envio_desde_etiqueta_ml` / `envio_cargar`: (a) el id de ML leído de una foto con espacios OCR (\"4789055 7180\") ahora se limpia y se guarda como NÚMERO (así lo matchea el escáner); (b) los campos numérico-ambiguos (cp, teléfono, mlShipmentId, montoCobro) aceptan número o texto, así una llamada con `cp: 2804` ya no revienta en la validación con un error genérico sin detalle.",
123
+ "3.11.0 — Superadmin desde el MCP: habilitar operador/superoperador/admin de nodo (`usuario_habilitar_rol`), crear grupos logísticos (`grupo_crear`) y subir el logo/marca de un nodo (`logo_subir`, imagen en base64). Además `mis_datos` ahora muestra el nodo por su NOMBRE. Solo admin global. No toca dinero.",
124
+ "3.10.0 — Login para un vendedor: `cliente_generar_usuario` le crea las credenciales de acceso a un cliente/vendedor YA existente de tu nodo y devuelve una CLAVE TEMPORAL. Requiere permiso 'usuarios', scopeado a tu nodo. No toca dinero.",
125
+ "3.9.0 — Corregir el destino de un envío puntual (`envio_editar_zona`): arregla la localidad/zona/partido de UN envío (por tracking) cargado con el destino mal (ej. dos localidades pisadas en la etiqueta) para que vuelva a ser cobrable/ruteable, SIN reasignarlo de cliente y sin crear un alias global. Staff: su nodo; admin global: cualquiera. No mueve dinero.",
126
+ "3.8.0 — Consulta de liquidaciones: buscar por cliente/monto (`liquidacion_buscar`), ver el detalle de envíos de una liquidación (`liquidacion_detalle`) y listar las que están sin recibir/impagas (`liquidaciones_sin_recibir`). Solo lectura, scopeadas por nodo (admin de nodo: sus clientes; admin global: cualquier nodo con `nodo`). No mueve dinero.",
127
+ "3.7.0 — Atribución de marketing (Fase 1): ver/guardar los píxeles de marketing (Meta Pixel, GA4, GTM y tokens de la Conversions API) del nodo o del vendedor con `marketing_config_ver` / `marketing_config_guardar`. El backend resuelve el alcance por rol (vendedor: lo suyo; operador: su nodo). No mueve dinero.",
128
+ "3.6.1 — Guía (`guia`) con intro motivador que engancha a hacer el tutorial (qué podés automatizar en 5 min).",
129
+ "3.6.0 — Guía de arranque por rol (`guia` + instrucciones al conectar): metodología para empezar rápido (mensajero: foto→envío al colectar; vendedor: cargar ventas; nodo: implementar + operar + clearing). Los MENSAJEROS ya pueden registrar envíos de lo que colectan (`envio_cargar` / `envio_desde_etiqueta_ml`), scopeado a clientes de su nodo o que hayan colectado (marketplace incluido).",
130
+ "3.5.0 — Ayuda integrada (`ayuda`): documentación por herramienta (qué hace, cómo usar, ejemplo, qué NO hace) + temas transversales (facturacion_marketplace, dinero, aislamiento). Aclarado que adjudicar una ruta del marketplace NO genera facturación/rendición/comisión automática (el pago entre nodos es manual).",
131
+ "3.4.0 — Marketplace de rutas públicas (ruta_publicar/ofertar/adjudicar); admin también reparte; registrar envío desde foto de etiqueta ML; alta/edición de choferes; mcp_version.",
132
+ "3.3.0 — Afiliados y comisión por envío; métricas por tipo; gestión de colectas (asignar/rechazar entre nodos); cuentas vinculadas; sucursales; colecta a pedido; metazonas flexibles.",
133
+ ];
134
+ const INSTRUCCIONES = "MCP de Nexus Flex. Si te preguntan la versión, consultá `mcp_version` (este texto puede ser anterior a una actualización). Para arrancar: `guia` (cómo trabajar según tu rol) y `ayuda` (índice de herramientas). El MCP NUNCA mueve dinero.\n\nCuando el usuario quiera completar un alta o una carga que tiene flujo guiado (ver `flujo`), traé el paso a paso y completalo entero antes de darlo por terminado: los pasos siguientes (lista de precios, perfil de zona, colecta) son los que se olvidan y dejan el alta a medias.";
135
+ const server = new McpServer({ name: "nexusflex", version: MCP_VERSION }, { instructions: INSTRUCCIONES });
136
+
137
+ /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
138
+ * filtrar tokens ni stack traces. */
139
+ function toResult(r) {
140
+ if (r.ok) return { content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }] };
141
+ const base =
142
+ r.status === 401 ? "Sesión inválida o vencida (autorizá de nuevo con `npx nexusflex-mcp login`)." :
143
+ r.status === 403 ? "Tu usuario no tiene permiso para esta operación (aislamiento del backend)." :
144
+ r.status === 404 ? "No encontrado." :
145
+ `Error ${r.status}.`;
146
+ const d = r.data;
147
+ const detalle = d?.error ? ` ${d.error}` : d?.message ? ` ${d.message}` : "";
148
+ return { content: [{ type: "text", text: `${base}${detalle}` }], isError: true };
149
+ }
150
+
151
+ async function run(fn) {
152
+ try {
153
+ return toResult(await fn());
154
+ } catch (e) {
155
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
156
+ }
157
+ }
158
+
159
+ // Un modelo con visión suele mandar campos "de texto" como NÚMERO en el JSON
160
+ // (ej. cp: 2804, mlShipmentId: 47890557180). Con z.string() eso reventaba en la
161
+ // validación del SDK ANTES del handler → el cliente sólo veía "MCP tool call
162
+ // failed" sin detalle. Estos helpers aceptan string|number y normalizan, así el
163
+ // tool corre y, si algo falla, devuelve un mensaje útil del backend.
164
+ const zStr = () => z.union([z.string(), z.number()]).transform((x) => String(x)).optional();
165
+ const zNum = () => z.union([z.number(), z.string()]).optional();
166
+
167
+ // Override de nodo: SOLO lo usa el admin global para elegir sobre qué nodo opera. El
168
+ // operador de nodo lo ignora (el backend lo fuerza a SU nodo con nodoDe → sin escalada
169
+ // cross-nodo). Se manda al backend como `logisticaId`. Ver sugerencia MCP #20.
170
+ const NODO_OVERRIDE_DESC = "Solo admin global: nodo (id) sobre el que operás. El operador de nodo lo ignora (siempre opera en el suyo).";
171
+ const zNodo = () => z.number().int().positive().optional().describe(NODO_OVERRIDE_DESC);
172
+
173
+ const q = (params) => {
174
+ const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
175
+ return s ? `?${s}` : "";
176
+ };
177
+ const registrados = new Set(); // para que `ayuda` liste solo lo que este usuario tiene
178
+ const tool = (name, def, handler) => { registrados.add(name); server.registerTool(name, def, handler); };
179
+
180
+ // ============================================================================
181
+ // CONTROL POR FOTO (staff). El servidor hace el trabajo foto por foto
182
+ // (/control-foto/foto); acá, en la PC, se recorre la CARPETA del día — cosa que el
183
+ // conector remoto no puede (no ve tu disco). Ver `flujo control_por_foto`.
184
+ // ============================================================================
185
+ const EXT_FOTO = /\.(jpe?g|png|webp)$/i;
186
+
187
+ // CARPETA PERMITIDA: el MCP solo lee y crea adentro de UNA carpeta, la que el usuario elige al
188
+ // instalar el plugin (NEXUSFLEX_CARPETA_FOTOS). Sin elegir: Documentos\Control por foto (se crea
189
+ // sola). Las rutas relativas se toman desde ahí; cualquier ruta que caiga afuera se rechaza.
190
+ const CARPETA_DEFAULT = ["Documents", "Control por foto"];
191
+ async function carpetaControl() {
192
+ const fs = await import("node:fs");
193
+ const path = await import("node:path");
194
+ const os = await import("node:os");
195
+ let c = String(process.env.NEXUSFLEX_CARPETA_FOTOS ?? "").trim();
196
+ if (!c || c.includes("${")) c = path.join(os.homedir(), ...CARPETA_DEFAULT); // sin configurar (o placeholder sin reemplazar)
197
+ fs.mkdirSync(c, { recursive: true });
198
+ return fs.realpathSync(c);
199
+ }
200
+ /** Resuelve `ruta` (relativa a la carpeta permitida, o absoluta) y la rechaza si cae afuera. */
201
+ async function rutaPermitida(ruta) {
202
+ const fs = await import("node:fs");
203
+ const path = await import("node:path");
204
+ const base = await carpetaControl();
205
+ const pedida = String(ruta ?? "").trim();
206
+ let p = pedida ? path.resolve(base, pedida) : base;
207
+ if (fs.existsSync(p)) p = fs.realpathSync(p); // un acceso directo que apunta afuera, también afuera
208
+ const rel = path.relative(base, p);
209
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
210
+ return { error: { ok: false, status: 400, data: { message: `Por seguridad solo trabajo adentro de la carpeta del control por foto: ${base}. Mové las fotos ahí (o cambiá la carpeta en la configuración del plugin).`, carpetaPermitida: base } } };
211
+ }
212
+ return { ruta: p, base };
213
+ }
214
+ /** Día de la carpeta: "Lunes 14-9", "14-09-2026", "2026-09-14"… → "aaaa-mm-dd" (o null). */
215
+ function diaDeCarpeta(nombre, anioDefault = new Date().getFullYear()) {
216
+ const iso = nombre.match(/(20\d{2})-(\d{1,2})-(\d{1,2})/);
217
+ if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`;
218
+ const m = nombre.match(/(\d{1,2})[-./](\d{1,2})(?:[-./](\d{2,4}))?/);
219
+ if (!m) return null;
220
+ const anio = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : String(anioDefault);
221
+ return `${anio}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
222
+ }
223
+ /** Fotos de un día: [{ archivo, carpeta (relativa al día, sin el archivo) }]. Saltea carpetas "_…". */
224
+ async function fotosDelDia(diaDir) {
225
+ const fs = await import("node:fs");
226
+ const path = await import("node:path");
227
+ const out = [];
228
+ const zips = [];
229
+ const recorrer = (dir) => {
230
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
231
+ if (e.name.startsWith("_") || e.isSymbolicLink()) continue; // un acceso directo puede apuntar afuera de la carpeta permitida
232
+ const p = path.join(dir, e.name);
233
+ if (e.isDirectory()) recorrer(p);
234
+ else if (EXT_FOTO.test(e.name)) out.push({ archivo: p, carpeta: path.relative(diaDir, dir).split(path.sep).join("/") });
235
+ else if (/\.zip$/i.test(e.name)) zips.push(path.relative(diaDir, p));
236
+ }
237
+ };
238
+ recorrer(diaDir);
239
+ return { fotos: out.filter((f) => /^(dados|recibidos)\b/i.test(f.carpeta)), sueltas: out.filter((f) => !/^(dados|recibidos)\b/i.test(f.carpeta)).length, zips };
240
+ }
241
+
242
+ const DIAS_SEMANA = ["Domingo", "Lunes", "Martes", "Miercoles", "Jueves", "Viernes", "Sabado"];
243
+ /** "2026-09-14" → "Lunes 14-9-2026" (la convención de las carpetas del control, con el año). */
244
+ function nombreCarpetaDia(iso) {
245
+ const [a, m, d] = iso.split("-").map(Number);
246
+ return `${DIAS_SEMANA[new Date(Date.UTC(a, m - 1, d)).getUTCDay()]} ${d}-${m}-${a}`;
247
+ }
248
+ /** Un nombre de grupo/cadete como nombre de carpeta válido en Windows (sin "(operador)", sin / \ : * ? " < > |). */
249
+ const nombreCarpeta = (s) => String(s).replace(/\s*\([^)]*\)\s*$/, "").replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").trim();
250
+ /** Fechas pedidas: "14/09/2026", "2026-09-14", rangos "14/09/2026 a 19/09/2026" o listas separadas por coma. */
251
+ function fechasPedidas(texto, anioDefault) {
252
+ const una = (t) => {
253
+ const s = String(t).trim();
254
+ const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
255
+ if (iso) return `${iso[1]}-${iso[2].padStart(2, "0")}-${iso[3].padStart(2, "0")}`;
256
+ const m = s.match(/^(\d{1,2})[-/.](\d{1,2})(?:[-/.](\d{2,4}))?$/);
257
+ if (!m) return null;
258
+ const a = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : String(anioDefault);
259
+ return `${a}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
260
+ };
261
+ const out = [];
262
+ for (const parte of String(texto).split(",")) {
263
+ const [desde, hasta] = parte.split(/\s+(?:a|al|hasta)\s+/i).map(una);
264
+ if (!desde) return null;
265
+ if (!hasta) { out.push(desde); continue; }
266
+ for (let t = Date.parse(`${desde}T00:00:00Z`); t <= Date.parse(`${hasta}T00:00:00Z`) && out.length < 62; t += 864e5) out.push(new Date(t).toISOString().slice(0, 10));
267
+ }
268
+ return out;
269
+ }
270
+
271
+ function registrarControlFoto() {
272
+ tool("control_foto_preparar", {
273
+ title: "Control por foto: crear las carpetas del día (o de varios días)",
274
+ description: "Arma en la PC las carpetas para el control por foto de uno o varios días, con los nombres EXACTOS de tus grupos y cadetes: <día>/DADOS/<cada grupo>, <día>/DADOS/<cada cadete>, <día>/RECIBIDOS/<cada cadete>, <día>/RECIBIDOS/<cada grupo> (lo que derivaste por ese grupo) + un LEEME.txt que explica qué foto va en cada una. Usalo cuando el usuario dice 'hagamos el control por foto del día tal': creás las carpetas, le decís dónde quedaron y que suelte ahí las fotos; después, `control_foto_carpeta`. No pisa nada que ya exista. No mueve dinero.",
275
+ inputSchema: {
276
+ ruta: zStr().describe("Opcional: subcarpeta adentro de la carpeta del control por foto (la que se eligió al instalar; por defecto Documentos\\\\Control por foto). Vacío = ahí mismo. Afuera de esa carpeta no se puede."),
277
+ fechas: zStr().describe("Día o días: '14/09/2026', '14/09 a 19/09', '14/09, 16/09' (sin año = el actual)"),
278
+ nodo: zNodo(),
279
+ },
280
+ }, async (a) => run(async () => {
281
+ const fs = await import("node:fs");
282
+ const path = await import("node:path");
283
+ const permitida = await rutaPermitida(a.ruta);
284
+ if (permitida.error) return permitida.error;
285
+ const raiz = permitida.ruta;
286
+ const fechas = fechasPedidas(a.fechas ?? "", new Date().getFullYear());
287
+ if (!fechas?.length) return { ok: false, status: 400, data: { message: `No entiendo las fechas "${a.fechas ?? ""}". Usá '14/09/2026' o '14/09 a 19/09'.` } };
288
+ const est = await api("GET", `/control-foto/estructura${q({ nodo: a.nodo })}`);
289
+ if (!est.ok) return est;
290
+ const grupos = (est.data.grupos ?? []).map((g) => nombreCarpeta(g.nombre));
291
+ const cadetes = (est.data.cadetes ?? []).map((c) => nombreCarpeta(c.nombre));
292
+ const leeme = [
293
+ "CONTROL POR FOTO — qué foto va en cada carpeta",
294
+ "",
295
+ "DADOS/<grupo> → los paquetes que le pasaste a ese grupo (juntada, colecta del grupo…).",
296
+ "DADOS/<cadete> → los que salieron a repartir con ese cadete tuyo.",
297
+ "RECIBIDOS/<cadete> → los que te dieron otros nodos y repartió ese cadete.",
298
+ "RECIBIDOS/<grupo> → los que te dieron y mandaste por ese grupo a otro nodo (ej. Zárate Campana).",
299
+ "",
300
+ "Una foto por paquete, que se lea la etiqueta. Si un paquete llegó por un grupo en particular,",
301
+ "podés poner la carpeta del cadete adentro: RECIBIDOS/<grupo>/<cadete>.",
302
+ "Las carpetas que no uses quedan vacías: no pasa nada. No renombres las carpetas.",
303
+ "Cuando termines de soltar las fotos, pedile a Claude: \"hacé el control por foto de esta carpeta\".",
304
+ ].join("\r\n");
305
+ const creadas = [];
306
+ for (const f of fechas) {
307
+ const dia = path.join(raiz, nombreCarpetaDia(f));
308
+ const subs = [...grupos.map((g) => ["DADOS", g]), ...cadetes.map((c) => ["DADOS", c]), ...cadetes.map((c) => ["RECIBIDOS", c]), ...grupos.map((g) => ["RECIBIDOS", g])];
309
+ for (const s of subs) fs.mkdirSync(path.join(dia, ...s), { recursive: true });
310
+ const lm = path.join(dia, "LEEME.txt");
311
+ if (!fs.existsSync(lm)) fs.writeFileSync(lm, leeme);
312
+ creadas.push(dia);
313
+ }
314
+ return { ok: true, status: 200, data: { creadas, grupos, cadetes, siguiente: "Soltá las fotos en cada carpeta y después corré control_foto_carpeta sobre la carpeta del día (o sobre la de todos los días)." } };
315
+ }));
316
+
317
+ tool("control_foto_estructura", {
318
+ title: "Control por foto: cómo armar las carpetas (tus grupos y cadetes)",
319
+ description: "Devuelve cómo tienen que llamarse las carpetas del control por foto para TU nodo: tus grupos logísticos y quiénes reparten. Llamalo antes de `control_foto_carpeta` para explicarle al usuario la estructura o para entender por qué una carpeta no se reconoce. Solo lectura.",
320
+ inputSchema: { nodo: zNodo() },
321
+ }, async ({ nodo }) => run(async () => {
322
+ const r = await api("GET", `/control-foto/estructura${q({ nodo })}`);
323
+ // En la PC: además, dónde tienen que ir las carpetas (la única carpeta que este MCP puede leer).
324
+ return r.ok ? { ...r, data: { ...r.data, carpetaDelControl: await carpetaControl() } } : r;
325
+ }));
326
+
327
+ tool("control_foto_foto", {
328
+ title: "Control por foto: procesar UNA foto de etiqueta",
329
+ description: "Procesa una sola foto (por su RUTA en la PC): lee el QR (o la IA de etiquetas), busca o da de alta el envío (sin duplicar), lo asigna según la CARPETA (DADOS/<grupo>, DADOS/<cadete>, RECIBIDOS/…/<cadete>, RECIBIDOS/<grupo>), lo lleva a Entregado en el `fecha` real y le cuelga la foto. Usalo para CONTESTAR una pregunta de `control_foto_carpeta`: repetí esa foto con `nodoEntrega` (quién la llevó) o `cliente` (de quién es la cuenta de ML). `simular:true` = solo mirar. No mueve dinero.",
330
+ inputSchema: {
331
+ ruta: zStr().describe("Ruta de la foto (adentro de la carpeta del control por foto; puede ser relativa a ella)"),
332
+ carpeta: zStr().describe("Carpeta relativa al día, ej. 'DADOS/Bonorino' o 'RECIBIDOS/Matanza/Gerardo'"),
333
+ fecha: zStr().describe("Día real (dd/mm/aaaa o aaaa-mm-dd)"),
334
+ simular: z.boolean().optional(),
335
+ nodoEntrega: zStr().describe("Respuesta: nodo (nombre o id) que lo llevó, cuando la zona tiene varios responsables"),
336
+ cliente: zStr().describe("Respuesta: id del cliente dueño de una cuenta de ML desconocida"),
337
+ nodo: zNodo(),
338
+ },
339
+ }, async (a) => run(async () => {
340
+ const fs = await import("node:fs");
341
+ const path = await import("node:path");
342
+ if (!a.ruta) return { ok: false, status: 400, data: { message: "Decime la ruta de la foto." } };
343
+ const permitida = await rutaPermitida(a.ruta);
344
+ if (permitida.error) return permitida.error;
345
+ const foto = permitida.ruta;
346
+ if (!fs.existsSync(foto) || !EXT_FOTO.test(foto)) return { ok: false, status: 400, data: { message: `No encuentro la foto ${a.ruta}.` } };
347
+ return api("POST", "/control-foto/foto", { imagen: fs.readFileSync(foto).toString("base64"), carpeta: a.carpeta, fecha: a.fecha, simular: a.simular === true, nodoEntrega: a.nodoEntrega, cliente: a.cliente, nombreArchivo: path.basename(foto), nodo: a.nodo });
348
+ }));
349
+
350
+ tool("control_foto_carpeta", {
351
+ title: "Control por foto: procesar la carpeta de un día (o de varios)",
352
+ description: "Recorre la carpeta de UN DÍA (o una carpeta con varios días adentro) en la PC: cada foto bajo DADOS/… o RECIBIDOS/… se manda al servidor con su carpeta y el día (sale del nombre de la carpeta: 'Lunes 14-9', '2026-09-14'…). Por defecto SIMULA (no escribe): devuelve qué haría, cuántos paquetes, y las PREGUNTAS (cuentas de ML que no conoce, zonas con varios responsables, posibles duplicados, carpetas que no reconoce). Mostrale el resumen al usuario, resolvé las preguntas y recién ahí corré con `aplicar:true`. Al aplicar, lo que siga dudoso se SUBE a la app (Logística → 📷 Control por foto) para resolverlo mirando la foto. Es reanudable: una foto ya procesada no se repite. Las fotos dudosas se copian a `_sin_identificar` dentro de cada día y deja el detalle en `_control-foto-<día>.json`. No mueve dinero.",
353
+ inputSchema: {
354
+ ruta: zStr().describe("Carpeta del día (ej. 'Lunes 14-9-2026') o la que contiene varios días, adentro de la carpeta del control por foto. Vacío = la carpeta del control entera."),
355
+ aplicar: z.boolean().optional().describe("true = escribe. Sin esto, solo simula."),
356
+ anio: zNum().describe("Año, si el nombre de la carpeta no lo trae (default: el actual)"),
357
+ nodo: zNodo(),
358
+ },
359
+ }, async (a) => run(async () => {
360
+ const fs = await import("node:fs");
361
+ const path = await import("node:path");
362
+ const permitida = await rutaPermitida(a.ruta);
363
+ if (permitida.error) return permitida.error;
364
+ const raiz = permitida.ruta;
365
+ if (!fs.existsSync(raiz)) return { ok: false, status: 400, data: { message: `No encuentro la carpeta ${a.ruta}.` } };
366
+ if (a.aplicar === true && !ALLOW_WRITE) return { ok: false, status: 403, data: { message: "Escritura deshabilitada en este MCP (NEXUSFLEX_MCP_ALLOW_WRITE)." } };
367
+ const esDia = (d) => fs.readdirSync(d, { withFileTypes: true }).some((e) => e.isDirectory() && /^(dados|recibidos)$/i.test(e.name));
368
+ const dias = esDia(raiz) ? [raiz] : fs.readdirSync(raiz, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && esDia(path.join(raiz, e.name))).map((e) => path.join(raiz, e.name));
369
+ if (!dias.length) return { ok: false, status: 400, data: { message: "No encontré carpetas DADOS/RECIBIDOS (¿están comprimidas en .zip? descomprimilas primero). Ver `control_foto_estructura`." } };
370
+ const resumen = { modo: a.aplicar === true ? "APLICADO" : "SIMULACIÓN (no se escribió nada)", dias: [] };
371
+ for (const dir of dias) {
372
+ const fecha = diaDeCarpeta(path.basename(dir), Number(a.anio) || undefined);
373
+ if (!fecha) { resumen.dias.push({ dia: path.basename(dir), error: "El nombre de la carpeta no tiene la fecha (ej. 'Lunes 14-9')." }); continue; }
374
+ const { fotos, sueltas, zips } = await fotosDelDia(dir);
375
+ const res = [];
376
+ let i = 0;
377
+ const trabajador = async () => {
378
+ while (i < fotos.length) {
379
+ const f = fotos[i++];
380
+ const r = await api("POST", "/control-foto/foto", { imagen: fs.readFileSync(f.archivo).toString("base64"), carpeta: f.carpeta, fecha, simular: a.aplicar !== true, nombreArchivo: path.basename(f.archivo), nodo: a.nodo }).catch((e) => ({ ok: false, data: { message: String(e) } }));
381
+ res.push({ archivo: path.relative(dir, f.archivo), carpeta: f.carpeta, ...(r.ok ? r.data : { estado: "error", error: r.data?.message ?? `HTTP ${r.status}` }) });
382
+ }
383
+ };
384
+ await Promise.all([trabajador(), trabajador(), trabajador()]);
385
+ fs.writeFileSync(path.join(dir, `_control-foto-${fecha}.json`), JSON.stringify(res, null, 1));
386
+ // Lo dudoso a mano: copia en _sin_identificar (el original queda donde estaba).
387
+ const dudosas = res.filter((r) => r.estado === "pregunta" || r.estado === "error");
388
+ if (dudosas.length) {
389
+ const destino = path.join(dir, "_sin_identificar");
390
+ fs.mkdirSync(destino, { recursive: true });
391
+ for (const d of dudosas) {
392
+ const motivo = (d.pregunta?.tipo ?? "error") + (d.pregunta?.datos?.cuentaML ? ` ML ${d.pregunta.datos.cuentaML}` : "");
393
+ try { fs.copyFileSync(path.join(dir, d.archivo), path.join(destino, `${motivo} - ${d.carpeta.replace(/\//g, " ")} - ${path.basename(d.archivo)}`)); } catch { /* no rompe el lote */ }
394
+ }
395
+ }
396
+ const cuenta = (k) => res.filter((r) => r.estado === k).length;
397
+ const porCuenta = {};
398
+ for (const r of res) if (r.pregunta?.tipo === "cuenta_ml") {
399
+ const c = r.pregunta.datos?.cuentaML;
400
+ (porCuenta[c] ??= { cuentaML: c, vendedor: r.pregunta.datos?.vendedor ?? null, marcas: new Set(), fotos: 0, ejemplo: r.archivo, carpeta: r.carpeta });
401
+ porCuenta[c].fotos++;
402
+ if (r.pregunta.datos?.marcaManual) porCuenta[c].marcas.add(r.pregunta.datos.marcaManual);
403
+ }
404
+ const otras = res.filter((r) => r.estado === "pregunta" && r.pregunta?.tipo !== "cuenta_ml").map((r) => ({ archivo: r.archivo, carpeta: r.carpeta, tipo: r.pregunta.tipo, texto: r.pregunta.texto, opciones: r.pregunta.opciones?.map((o) => o.nombre) }));
405
+ const errores = [...new Set(res.filter((r) => r.estado === "error").map((r) => `${r.carpeta}: ${r.error}`))];
406
+ resumen.dias.push({
407
+ dia: path.basename(dir), fecha, fotos: fotos.length,
408
+ ok: cuenta("ok"), sinCambios: cuenta("sin_cambios"), yaProcesadas: cuenta("ya_procesada"), altas: res.filter((r) => r.alta).length,
409
+ preguntas: cuenta("pregunta"), errores: cuenta("error"),
410
+ ...(sueltas ? { fuera_de_DADOS_RECIBIDOS: sueltas } : {}), ...(zips.length ? { zipsSinDescomprimir: zips } : {}),
411
+ cuentasMLDesconocidas: Object.values(porCuenta).map((c) => ({ ...c, marcas: [...c.marcas] })),
412
+ otrasPreguntas: otras.slice(0, 40), ...(otras.length > 40 ? { masPreguntas: otras.length - 40 } : {}),
413
+ erroresDeCarpeta: errores,
414
+ ...(a.aplicar === true && cuenta("pregunta") + cuenta("error") ? { resolverEnLaApp: `Las ${cuenta("pregunta") + cuenta("error")} foto(s) dudosas quedaron subidas en la app: menú Logística → 📷 Control por foto. Ahí se ven grandes y se elige el cliente, el nodo o el cadete.` } : {}),
415
+ detalle: path.join(dir, `_control-foto-${fecha}.json`),
416
+ });
417
+ }
418
+ return { ok: true, status: 200, data: resumen };
419
+ }));
420
+ }
421
+
422
+ // ============================================================================
423
+ // CAPA 2 — Identidad: leemos /auth/me ANTES de registrar tools. Cada usuario ve
424
+ // SOLO los tools de su rol. Si falla el login, se registra solo `mis_datos`.
425
+ // ============================================================================
426
+ let me = null;
427
+ try {
428
+ const r = await api("GET", "/auth/me");
429
+ if (r.ok) me = r.data?.usuario ?? null;
430
+ else log(`/auth/me devolvió ${r.status} — revisá el token/credenciales.`);
431
+ } catch (e) {
432
+ log("No se pudo contactar /auth/me:", e instanceof Error ? e.message : String(e));
433
+ }
434
+
435
+ const rol = me?.rol ?? "";
436
+ const permisos = Array.isArray(me?.permisos) ? me.permisos : [];
437
+ const esGlobal = rol === "admin" || me?.esSuperoperador === true;
438
+ const esAdminNodo = me?.esAdminNodo === true;
439
+ const isCliente = rol === "cliente";
440
+ const isStaff = rol === "operador" || rol === "admin";
441
+ const wmsActivo = me?.wmsActivo === true;
442
+ // Espejo de requirePermiso del backend (solo para DECIDIR qué mostrar; el backend manda).
443
+ const puede = (p) => esGlobal || (isStaff && (esAdminNodo || permisos.length === 0 || permisos.includes(p)));
444
+
445
+ // ============================================================================
446
+ // SIEMPRE
447
+ // ============================================================================
448
+ tool("mis_datos", {
449
+ title: "Mis datos / alcance",
450
+ description: "Devuelve tu usuario, rol y nodo/cliente al que está atado este MCP. Usalo para confirmar tu alcance antes de operar. Reportá el nodo por su NOMBRE (campo `logisticaNombre`, ej. 'estás en FastCorreo'), nunca solo por el número.",
451
+ inputSchema: {},
452
+ }, async () => run(() => api("GET", "/auth/me")));
453
+
454
+ // Versión del MCP corriendo (cualquier rol). Sirve para saber si tenés lo último.
455
+ tool("mcp_version", {
456
+ title: "Versión del MCP",
457
+ description: "Qué versión del MCP de Nexus Flex está corriendo y por qué vía. Cualquier rol lo puede consultar.",
458
+ inputSchema: {},
459
+ }, async () => ({ content: [{ type: "text", text: JSON.stringify({ version: MCP_VERSION, modo: "npx (Claude Desktop)", novedades: NOVEDADES, nota: "Para tener lo último: actualizá con «npx -y nexusflex-mcp@latest» o pasate al conector remoto (siempre al día, sin instalar/actualizar)." }) }] }));
460
+
461
+ // Guía de arranque por rol (metodología). Cualquier rol; read-only.
462
+ tool("guia", {
463
+ title: "Guía de arranque (metodología para tu rol)",
464
+ description: "Cómo empezar a usar Nexus Flex rápido según tu rol: mensajero (registrar lo que colectás sacando fotos → envío), vendedor (cargar ventas), nodo (implementar + operar + clearing). Sin argumentos = tu rol; `rol` para ver otra (mensajero|cliente|nodo).",
465
+ inputSchema: { rol: z.string().optional().describe("mensajero | cliente | nodo (default: tu rol)") },
466
+ }, async ({ rol: r }) => {
467
+ const pedido = String(r ?? "").trim().toLowerCase();
468
+ const target = pedido === "nodo" ? "operador" : pedido || rol;
469
+ return { content: [{ type: "text", text: guiaOnboarding(target) }] };
470
+ });
471
+
472
+ // Flujo guiado: paso a paso de un objetivo para no dejar nada incompleto (Fase A).
473
+ tool("flujo", {
474
+ title: "Flujo guiado paso a paso",
475
+ description: "Te devuelve el PASO A PASO para completar bien un objetivo, sin dejar nada a medias (ej. dar de alta un cliente con su lista de precios, o el CONTROL POR FOTO de una carpeta de etiquetas ML). Sin argumento lista los flujos disponibles; pasá `objetivo` (una de las claves de abajo). Usalo para GUIAR al usuario que no sabe qué datos faltan.",
476
+ inputSchema: { objetivo: z.string().optional().describe(`Vacío = lista los flujos disponibles. Claves: ${Object.keys(FLUJOS).join(", ")}.`) },
477
+ }, async ({ objetivo }) => ({ content: [{ type: "text", text: renderFlujo(objetivo) }] }));
478
+
479
+ // Sugerencias: cualquier usuario (todos los roles) puede proponer funciones/mejoras.
480
+ tool("sugerencia_crear", {
481
+ title: "Enviar una sugerencia",
482
+ description: "Proponé una función nueva, mejora o reportá un bug. mensaje obligatorio; categoria opcional (funcionalidad|mejora|bug|otro). Se registra tu usuario/rol/nodo automáticamente.",
483
+ inputSchema: { mensaje: z.string().min(1), categoria: z.enum(["funcionalidad", "mejora", "bug", "otro"]).optional() },
484
+ }, async (args) => run(() => api("POST", "/feedback/sugerencias", args)));
485
+ tool("mis_sugerencias", {
486
+ title: "Mis sugerencias y su estado",
487
+ description: "Lista TUS propias sugerencias/pedidos de mejora y en qué estado están (Recibida / En evaluación / ✅ Implementada / Descartada). Cada usuario ve SOLO las suyas. Sin argumentos.",
488
+ inputSchema: {},
489
+ }, async () => run(() => api("GET", "/feedback/mis-sugerencias")));
490
+ tool("flujo_registrar", {
491
+ title: "Registrar un flujo aprendido (indagación)",
492
+ description: "Usalo cuando, tras varias preguntas de aclaración, se entendió lo que el usuario quería: registra el objetivo y los pasos para convertirlo en un flujo directo. Avisale al usuario que lo registrás. Registrás `objetivo` (lo que terminó queriendo) y `pasos` (las preguntas/decisiones que lo aclararon + herramientas usadas). No mueve dinero.",
493
+ inputSchema: { objetivo: z.string().min(1), pasos: z.string().min(1) },
494
+ }, async ({ objetivo, pasos }) => run(() => api("POST", "/feedback/sugerencias", { categoria: "mejora", mensaje: `[FLUJO APRENDIDO] Objetivo: ${objetivo}\nPasos que lo aclararon: ${pasos}` })));
495
+
496
+ // ============================================================================
497
+ // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
498
+ // ============================================================================
499
+ if (isCliente) {
500
+ tool("mis_envios", {
501
+ title: "Mis envíos",
502
+ description: "Tus envíos/paquetes (solo los tuyos), los 300 más recientes, cada uno con su `estado`. No incluye datos de otros clientes ni del nodo.",
503
+ inputSchema: { estado: z.string().optional().describe("Estado: 'A retirar', 'Colectado', 'En centro de distribución', 'En camino', 'Entregado' (o una novedad, ej. 'Comprador ausente'). Filtra por los que EMPIEZAN con ese texto ('Entregado' incluye 'Entregado (Flex)'); los 300 más recientes de ese estado.") },
504
+ }, async ({ estado }) => run(() => api("GET", `/portal/envios${q({ estado })}`)));
505
+
506
+ tool("mis_kpis", {
507
+ title: "Mis métricas",
508
+ description: "Tus métricas: volumen, calidad de entrega, saldo, última liquidación y un benchmark ANÓNIMO contra el promedio de tu nodo (nunca ves a quién corresponde cada número).",
509
+ inputSchema: { desde: z.string().optional().describe("Fecha YYYY-MM-DD (pasá desde y hasta juntas; si falta una, usa el mes en curso)"), hasta: z.string().optional().describe("Fecha YYYY-MM-DD") },
510
+ }, async ({ desde, hasta }) => run(() => api("GET", `/kpi/vendedor${q({ desde, hasta })}`)));
511
+
512
+ // --- Control de stock del vendedor (WMS Fase 1). Se registran SIEMPRE para
513
+ // rol=cliente: el backend gatea con 403 si el vendedor no tiene depósito ni
514
+ // control de stock propio. Así funciona tanto con WMS del nodo como con el
515
+ // modo "soft" por vendedor (Cliente.controlStockActivo). ---
516
+ tool("mi_stock", {
517
+ title: "Mi stock",
518
+ description: "Tu stock físico en el depósito (solo tus productos). Requiere tener depósito o control de stock habilitado.",
519
+ inputSchema: {},
520
+ }, async () => run(() => api("GET", "/wms/stock")));
521
+
522
+ tool("mi_disponible", {
523
+ title: "Mi disponible para vender",
524
+ description: "Disponible-para-vender por SKU = stock físico − comprometido en pedidos pendientes. Marca ⚠️ cuando un SKU está por debajo del mínimo. Solo tus productos.",
525
+ inputSchema: {},
526
+ }, async () => run(() => api("GET", "/wms/stock/disponible")));
527
+
528
+ tool("mi_rentabilidad", {
529
+ title: "Mi rentabilidad por SKU",
530
+ description: "Margen por SKU en un rango (default: mes en curso) = precio de venta − costo de envío real − COGS opcional. Solo tus productos. Fechas YYYY-MM-DD.",
531
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
532
+ }, async ({ desde, hasta }) => run(() => api("GET", `/wms/rentabilidad${q({ desde, hasta })}`)));
533
+
534
+ tool("mis_productos", {
535
+ title: "Mi catálogo",
536
+ description: "Tu catálogo de productos en el depósito.",
537
+ inputSchema: {},
538
+ }, async () => run(() => api("GET", "/wms/productos")));
539
+
540
+ tool("mis_top_productos", {
541
+ title: "Mis productos más despachados",
542
+ description: "Ranking de TUS productos más despachados en un rango (default: mes en curso). Solo tus productos.",
543
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
544
+ }, async ({ desde, hasta }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta })}`)));
545
+ tool("mis_sucursales", {
546
+ title: "Mis sucursales / puntos de retiro",
547
+ description: "Tus sucursales (puntos de retiro) con dirección, horario de corte y ventanas. La sucursal principal es tu dirección de retiro. Solo lectura.",
548
+ inputSchema: {},
549
+ }, async () => run(() => api("GET", "/portal/sucursales")));
550
+ tool("mi_colecta", {
551
+ title: "Estado de mi colecta",
552
+ description: "Cómo está tu colecta (retiro): si la colecta AUTOMÁTICA está prendida, si pediste una por única vez, y el aviso de costo (si te cobran cuando llevás menos de X envíos). Solo lectura.",
553
+ inputSchema: {},
554
+ }, async () => run(() => api("GET", "/portal/colecta")));
555
+ }
556
+
557
+ // ============================================================================
558
+ // ROL MENSAJERO — su ruta y colectas (para saber cuántos envíos tendrá).
559
+ // ============================================================================
560
+ if (rol === "mensajero") {
561
+ tool("mi_ruta", {
562
+ title: "Mi ruta del día",
563
+ description: "Tus entregas asignadas (paradas de la ruta del día). Solo lectura.",
564
+ inputSchema: {},
565
+ }, async () => run(() => api("GET", "/flujo/mi-ruta")));
566
+ tool("mis_colectas", {
567
+ title: "Mis colectas (retiros)",
568
+ description: "Las colectas/retiros que tenés asignados. Solo lectura.",
569
+ inputSchema: {},
570
+ }, async () => run(() => api("GET", "/colecta/mis-colectas")));
571
+ }
572
+
573
+ // ============================================================================
574
+ // ROL STAFF DE NODO — datos de SU nodo (gateado por permiso; el backend fuerza el nodo).
575
+ // ============================================================================
576
+ if (isStaff) {
577
+ if (puede("gestion")) {
578
+ tool("clientes_del_nodo", {
579
+ title: "Clientes y listas del nodo",
580
+ description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
581
+ inputSchema: {},
582
+ }, async () => run(() => api("GET", "/gestion/formularios")));
583
+ tool("sin_vendedor", { title: "Cuentas de ML y envíos sin vendedor", description: "Cuentas de Mercado Libre y envíos cargados con foto que tu nodo tiene SIN VENDEDOR (típico de un nodo recién dado de alta, o de etiquetas de cuentas no vinculadas que se escanearon). Devuelve `cuentas` (por cuenta de ML: cuántos envíos, ejemplos de destinatarios, si ya está vinculada) y `envios` (los cargados con foto, con la pista de la etiqueta), y los `clientes` del nodo (con si tienen usuario). Después usá `asignar_sin_vendedor`. Admin global: pasá `nodo`. No mueve dinero.", inputSchema: { nodo: zNodo() } },
584
+ async ({ nodo }) => run(() => api("GET", `/sin-vendedor${q({ nodo })}`)));
585
+ if (ALLOW_WRITE)
586
+ tool("asignar_sin_vendedor", {
587
+ title: "Asignar cuentas de ML / envíos sin vendedor a un vendedor (+ enlaces)",
588
+ description: "Asigna a UN vendedor las cuentas de ML y/o envíos que el nodo tiene sin vendedor (ver `sin_vendedor`). En un paso: cada cuenta queda asociada al vendedor y TODOS sus envíos sin vendedor pasan a ser de él; los envíos con foto elegidos también; si el vendedor no tiene usuario y pasás `usuarioEmail`/`usuarioNombre`/`usuarioApellido` (de la PERSONA que entra), se le crea con clave temporal; y devuelve UN ENLACE DE VINCULACIÓN por cada cuenta de ML para mandárselo (vence en 48 hs). Cuando el vendedor lo autoriza, lo que quede de esa cuenta se le engancha solo. Vendedor: `idCliente` de tu nodo, o `nuevoVendedor` (nombre) para crearlo. No mueve dinero.",
589
+ inputSchema: {
590
+ idCliente: z.number().optional().describe("Vendedor existente de tu nodo"),
591
+ nuevoVendedor: z.string().optional().describe("Nombre de un vendedor NUEVO (se crea en tu nodo)"),
592
+ telefono: z.string().optional().describe("Teléfono del vendedor nuevo (opcional)"),
593
+ cuentasML: z.array(z.string()).optional().describe("Cuentas de ML (el número de usuario, de `sin_vendedor`)"),
594
+ envioIds: z.array(z.number()).optional().describe("Envíos cargados con foto (ids de `sin_vendedor`)"),
595
+ usuarioEmail: z.string().optional().describe("Si el vendedor no tiene usuario: email de login de la persona"),
596
+ usuarioNombre: z.string().optional(),
597
+ usuarioApellido: z.string().optional(),
598
+ nodo: z.number().optional().describe("Solo admin global"),
599
+ },
600
+ }, async (a) => run(() => api("POST", "/sin-vendedor/asignar", {
601
+ idCliente: a.idCliente, nuevo: a.nuevoVendedor ? { nombre: a.nuevoVendedor, telefono: a.telefono } : undefined,
602
+ usuario: a.usuarioEmail ? { email: a.usuarioEmail, nombre: a.usuarioNombre, apellido: a.usuarioApellido } : undefined,
603
+ cuentasML: a.cuentasML, envioIds: a.envioIds, nodo: a.nodo,
604
+ })));
605
+ }
606
+
607
+ if (puede("precios")) {
608
+ tool("precios_ver", {
609
+ title: "Ver listas de precios",
610
+ description: "Las listas de precios (por zona: cercana/media/lejana/muy lejana) de tu nodo. Solo lectura.",
611
+ inputSchema: {},
612
+ }, async () => run(() => api("GET", "/precios/clientes")));
613
+ }
614
+
615
+ if (puede("reportes")) {
616
+ tool("kpi_nodo", {
617
+ title: "Métricas del nodo",
618
+ description: "Tablero del nodo: volumen y entregas con variación mensual, P&L real, top de clientes y clientes en caída. Solo lectura, scopeado a tu nodo.",
619
+ inputSchema: { desde: z.string().optional().describe("Fecha YYYY-MM-DD (pasá desde y hasta juntas; si falta una, usa el mes en curso)"), hasta: z.string().optional().describe("Fecha YYYY-MM-DD"), nodo: zNodo() },
620
+ }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/nodo${q({ desde, hasta, nodo })}`)));
621
+ }
622
+
623
+ if (puede("wms") && wmsActivo) {
624
+ tool("stock_nodo", {
625
+ title: "Stock del nodo",
626
+ description: "Stock del depósito del nodo. Opcional: filtrar por un cliente.",
627
+ inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
628
+ }, async ({ cliente }) => run(() => api("GET", `/wms/stock${q({ cliente })}`)));
629
+
630
+ tool("productos_nodo", {
631
+ title: "Catálogo del nodo",
632
+ description: "Catálogo de productos del depósito del nodo. Opcional: filtrar por cliente.",
633
+ inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
634
+ }, async ({ cliente }) => run(() => api("GET", `/wms/productos${q({ cliente })}`)));
635
+
636
+ tool("top_productos", {
637
+ title: "Productos más despachados del nodo",
638
+ description: "Ranking de productos más despachados del nodo en un rango (default: mes en curso). Opcional: filtrar por un cliente.",
639
+ inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD"), cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
640
+ }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta, cliente })}`)));
641
+ }
642
+ }
643
+
644
+ if (isStaff && puede("finanzas")) {
645
+ tool("cuenta_semanal_nodos", {
646
+ title: "Cuenta semanal con otros nodos (juntada)",
647
+ description: "La cuenta de TU nodo con cada nodo con el que se pasaron paquetes por un grupo (Bonorino, Portela…): semana de lunes a sábado que se congela el miércoles siguiente. Cada paquete vale la tarifa del nodo que lo recibe; por nodo se netea (neto > 0: te pagan; < 0: pagás vos). Sin `cierre`: lo que todavía NO se cerró (en vivo). Con `cierre` (id): ese cierre congelado. `lista:true` = tus cierres con su neto. Solo lectura, no mueve dinero. El admin global elige el nodo con `nodo`.",
648
+ inputSchema: {
649
+ cierre: z.number().int().positive().optional().describe("id del cierre semanal (de `lista`)"),
650
+ lista: z.boolean().optional().describe("true = listar tus cierres semanales"),
651
+ nodo: zNodo(),
652
+ },
653
+ }, async ({ cierre, lista, nodo }) =>
654
+ run(() => api("GET", lista ? `/logisticas/mis-cierres${q({ nodo })}` : cierre ? `/logisticas/mis-cierres/${cierre}${q({ nodo })}` : `/logisticas/mis-cierres/sin-cerrar${q({ nodo })}`)));
655
+ }
656
+
657
+ // ============================================================================
658
+ // ROL GLOBAL (superadmin / superoperador)
659
+ // ============================================================================
660
+ if (esGlobal) {
661
+ tool("cierre_semanal_links", {
662
+ title: "Links del cierre semanal para cada nodo",
663
+ description: "Para cada nodo de un cierre semanal (el último si no pasás `cierre`), el link de su cuenta de esa semana. El link NO muestra la cuenta sin login: si el nodo todavía no tiene usuario, le pide mail, contraseña, nombre y apellido y lo da de alta (una sola vez por nodo, con el primer link que abra); si ya tiene, lo manda a entrar y ve la cuenta con todos los nodos adentro. Primero los que NO tienen usuario: a esos pasáselo vos por WhatsApp. Solo admin global. Solo lectura.",
664
+ inputSchema: { cierre: z.number().int().positive().optional().describe("id del cierre (default: el último)") },
665
+ }, async ({ cierre }) => run(() => api("GET", `/logisticas/cierres-semanales/links${q({ cierre })}`)));
666
+
667
+ tool("nodos_listar", {
668
+ title: "Listar nodos",
669
+ description: "Lista todas las logísticas (nodos) con sus conteos. Solo admin global.",
670
+ inputSchema: {},
671
+ }, async () => run(() => api("GET", "/logisticas")));
672
+
673
+ tool("nodo_renombrar", {
674
+ title: "Cambiar el nombre real de un nodo",
675
+ description: "Cambia la razón social (nombre real) de un nodo — lo ve TODO el mundo (a diferencia de `nodo_alias_poner`, que es un apodo personal tuyo). Solo admin global. No toca dinero.",
676
+ inputSchema: {
677
+ nodo: z.string().min(1).describe("Nombre actual (o tu apodo) del nodo"),
678
+ nombreNuevo: z.string().min(1).describe("Nombre nuevo"),
679
+ },
680
+ }, async ({ nodo: nodoQ, nombreNuevo }) => run(async () => {
681
+ const lr = await api("GET", "/flujo/logisticas-select");
682
+ if (!lr.ok) return lr;
683
+ const lista = lr.data ?? [];
684
+ const q2 = nodoQ.trim().toLowerCase();
685
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
686
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
687
+ if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
688
+ return api("PUT", `/logisticas/${cands[0].id}`, { nombre: nombreNuevo });
689
+ }));
690
+
691
+ tool("kpi_red", {
692
+ title: "Métricas de la red (SaaS)",
693
+ description: "KPIs globales de toda la red de nodos (crecimiento, operacional, volumen de clearing). Solo admin global.",
694
+ inputSchema: {},
695
+ }, async () => run(() => api("GET", "/kpi/saas")));
696
+
697
+ tool("usuario_habilitar_rol", {
698
+ title: "Habilitar/deshabilitar un rol de administración",
699
+ description: "Habilita o deshabilita a un usuario (por id) como superoperador (global) y/o admin de nodo. Solo admin global. El backend exige un superadmin real para otorgar superoperador. Pasá al menos uno de los flags (true = habilitar, false = quitar). No toca dinero.",
700
+ inputSchema: { id: z.number().int().positive(), esSuperoperador: z.boolean().optional(), esAdminNodo: z.boolean().optional() },
701
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
702
+
703
+ tool("grupo_crear", {
704
+ title: "Crear grupo logístico",
705
+ description: "Crea un grupo logístico nuevo (para clearing entre nodos; los nodos se agregan/tarifan después). Solo admin global. No toca dinero.",
706
+ inputSchema: { nombre: z.string().min(1) },
707
+ }, async ({ nombre }) => run(() => api("POST", "/logisticas/grupos", { nombre })));
708
+
709
+ tool("logo_subir", {
710
+ title: "Subir logo / marca de un nodo (marca blanca)",
711
+ description: "Sube o actualiza el logo y la marca de un nodo (marca blanca). El logo va como data-URI base64 (ej. data:image/png;base64,...). Solo admin global. No toca dinero.",
712
+ inputSchema: { nodo: z.number().int().positive().describe("Id del nodo (de `nodos_listar`)"), logo: z.string().min(1).describe("imagen en data-URI base64, ej. data:image/png;base64,..."), marca: z.string().optional(), slug: z.string().optional(), color: z.string().optional() },
713
+ }, async ({ nodo, logo, marca, slug, color }) => run(() => api("PUT", `/logisticas/${nodo}/branding`, { logo, marca, slug, color })));
714
+
715
+ tool("sugerencias_listar", {
716
+ title: "Sugerencias de usuarios (admin)",
717
+ description: "Solo superadmin: todas las sugerencias de la red (funciones nuevas/mejoras/bugs) con usuario, rol, nodo, estado. Filtrá por `rol` y/o `estado`. Para las propias está `mis_sugerencias`. Solo lectura.",
718
+ inputSchema: { rol: z.string().optional(), estado: z.string().optional().describe("nueva | vista | en_evaluacion | implementada | descartada") },
719
+ }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
720
+ }
721
+
722
+ // ============================================================================
723
+ // VINCULACIÓN DE TIENDAS — genera el enlace de autorización (OAuth) para traer las
724
+ // ventas de una tienda a Nexus Flex. Un vendedor genera el SUYO; el staff (permiso
725
+ // gestion) puede generarlo PARA un cliente de SU nodo y mandárselo. No toca dinero.
726
+ // ============================================================================
727
+ if (isCliente || (isStaff && puede("gestion"))) {
728
+ tool("generar_enlace_vinculacion", {
729
+ title: "Generar enlace de vinculación de tienda",
730
+ description: "Genera el enlace (link) para vincular una tienda —Mercado Libre, TiendaNube o Tienda Negocio— y traer sus ventas a Nexus Flex. Si sos vendedor genera el TUYO; si sos staff podés generarlo PARA un cliente de tu nodo pasando 'cliente' y mandarle ese enlace para que lo autorice desde su propia cuenta de la tienda. El enlace vence en 48 hs. No toca dinero.",
731
+ inputSchema: {
732
+ proveedor: z.enum(["ml", "tiendanube", "tiendanegocio"]).describe("ml = Mercado Libre · tiendanube · tiendanegocio"),
733
+ cliente: z.string().optional().describe("Solo staff: nombre o id del cliente de tu nodo"),
734
+ },
735
+ }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
736
+ }
737
+
738
+ // ============================================================================
739
+ // RENDICIONES (lectura) + ZONAS DE REPARTO (mensajeros por zona)
740
+ // ============================================================================
741
+ if (isCliente || isStaff || rol === "mensajero") {
742
+ tool("rendiciones", {
743
+ title: "Rendiciones (a recuperar / a rendir)",
744
+ description: "Estado de rendiciones: cobros/cambios/devoluciones pendientes de recuperar o rendir, con totales y quién tiene cada uno. Scopeado a tu alcance (vendedor: lo tuyo; nodo: tu nodo). Solo lectura.",
745
+ inputSchema: {},
746
+ }, async () => run(() => api("GET", "/flujo/rendiciones")));
747
+ }
748
+
749
+ // Reclamos de clientes ligados a liquidaciones (vendedor: lo suyo; operador: su nodo).
750
+ if (isCliente || isStaff) {
751
+ tool("reclamos_listar", {
752
+ title: "Reclamos de clientes (liquidaciones)",
753
+ description: "Reclamos abiertos de clientes ligados a liquidaciones/rendiciones: tipo, estado, trackings en disputa, detalle. Sirve para ver qué cobros/rendiciones pendientes tienen reclamo. Vendedor ve lo suyo, operador su nodo. Solo lectura.",
754
+ inputSchema: { estado: z.string().optional().describe("abierto | resuelto") },
755
+ }, async ({ estado }) => run(() => api("GET", `/feedback/reclamos${q({ estado })}`)));
756
+ }
757
+
758
+ // ── ADMIN DE GRUPO ── Los nodos originales de un grupo son sus admins; gestionan quién está
759
+ // adentro y quién administra. Ver miembros lo puede cualquier miembro; sumar/expulsar/permiso
760
+ // solo un admin del grupo (o admin global). Nunca se deja un grupo sin admin. No mueve dinero.
761
+ if (isStaff) {
762
+ tool("grupos_tarifas", {
763
+ title: "Grupos logísticos con sus tarifas de clearing",
764
+ description: "Los grupos logísticos de tu nodo (admin global: todos) con los nodos que los integran y la TARIFA de clearing vigente de cada uno por zona (cercana / media / lejana / muy lejana). Es lo que hay que mirar para auditar por qué un traspaso entre nodos se cobró lo que se cobró: el tramo usa el precio del nodo en ESE grupo. `grupo_miembros` muestra quién está; esto muestra a cuánto. Solo lectura.",
765
+ inputSchema: {},
766
+ }, async () => run(() => api("GET", "/logisticas/grupos")));
767
+ tool("grupo_miembros", {
768
+ title: "Miembros de un grupo logístico",
769
+ description: "Lista los nodos de un grupo (por id), marcando quién es ADMIN de grupo y si vos lo sos. El id del grupo sale de `grupos_tarifas`. Solo miembros del grupo. Solo lectura.",
770
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo") },
771
+ }, async ({ grupo }) => run(() => api("GET", `/qr/grupo/${grupo}/miembros`)));
772
+ tool("grupo_sumar_nodo", {
773
+ title: "Sumar un nodo al grupo (admin de grupo)",
774
+ description: "Agrega un nodo (por id) a un grupo. Solo un ADMIN del grupo (o admin global) puede. El nodo entra como miembro normal (sin permiso de admin). No mueve dinero.",
775
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a sumar") },
776
+ }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-sumar", { grupoId: grupo, logisticaId: nodo })));
777
+ tool("grupo_expulsar_nodo", {
778
+ title: "Expulsar un nodo del grupo (admin de grupo)",
779
+ description: "Saca un nodo (por id) de un grupo. Solo un ADMIN del grupo (o admin global). No se puede expulsar al último admin (pasá antes el permiso a otro nodo). No mueve dinero.",
780
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a expulsar") },
781
+ }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-expulsar", { grupoId: grupo, logisticaId: nodo })));
782
+ tool("grupo_admin_permiso", {
783
+ title: "Dar / sacar permiso de admin de grupo",
784
+ description: "Da o saca el permiso de ADMIN de grupo a un nodo del grupo (así otros pueden administrar, ej. si los originales se van). Solo un admin del grupo (o admin global). No se puede sacar el permiso al último admin. No mueve dinero.",
785
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo"), admin: z.boolean().describe("true = darle admin, false = sacárselo") },
786
+ }, async ({ grupo, nodo, admin }) => run(() => api("POST", "/qr/grupo-permiso", { grupoId: grupo, logisticaId: nodo, esAdminGrupo: admin })));
787
+ }
788
+
789
+ // Consultar UN envío por tracking/código (cuando un vendedor pregunta "¿dónde está mi
790
+ // envío X?"). Vendedor: solo entre SUS envíos; staff: dentro de su nodo.
791
+ if (isCliente || isStaff) {
792
+ tool("envio_consultar", {
793
+ title: "Consultar un envío",
794
+ description: "Busca UN envío por tracking o código y devuelve su estado actual, el historial de estados (con fechas), destinatario, dirección y datos de cobro/cambio si tiene. Vendedor: solo entre SUS envíos; staff: dentro de su nodo. Úsalo cuando un vendedor pregunta por un pedido puntual.",
795
+ inputSchema: { codigo: z.string().min(1).describe("Tracking o código del envío (lo que manda el vendedor)") },
796
+ }, async ({ codigo }) => {
797
+ const code = String(codigo ?? "").trim();
798
+ if (!code) return { content: [{ type: "text", text: "Pasá un tracking o código de envío." }], isError: true };
799
+ const low = code.toLowerCase();
800
+ try {
801
+ if (isStaff) {
802
+ const lista = await api("GET", `/flujo/envios${q({ q: code })}`);
803
+ const rows = Array.isArray(lista.data) ? lista.data : [];
804
+ if (!rows.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" en tu nodo.` }] };
805
+ const exact = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
806
+ const pick = exact.length ? exact : rows;
807
+ if (pick.length > 1)
808
+ return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Elegí uno (pasá el tracking completo):\n${JSON.stringify(pick.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado, cliente: e.cliente, destinatario: e.destinatario })), null, 2)}` }] };
809
+ return toResult(await api("GET", `/flujo/envios/${pick[0].id}`));
810
+ }
811
+ const lista = await api("GET", "/portal/envios");
812
+ const rows = Array.isArray(lista.data) ? lista.data : [];
813
+ let match = rows.filter((e) => String(e.tracking ?? "").toLowerCase() === low);
814
+ if (!match.length) match = rows.filter((e) => String(e.tracking ?? "").toLowerCase().includes(low));
815
+ if (!match.length) return { content: [{ type: "text", text: `No encontré un envío "${code}" entre tus envíos.` }] };
816
+ if (match.length > 1)
817
+ return { content: [{ type: "text", text: `Varios envíos matchean "${code}". Pasá el tracking completo:\n${JSON.stringify(match.slice(0, 10).map((e) => ({ id: e.id, tracking: e.tracking, estado: e.estado })), null, 2)}` }] };
818
+ return toResult(await api("GET", `/portal/envios/${match[0].id}`));
819
+ } catch (e) {
820
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
821
+ }
822
+ });
823
+ }
824
+
825
+ // Métricas por tipo de envío (flex/tienda/manual) + % antes de 21hs + (red) por logística.
826
+ if (isCliente || isStaff) {
827
+ tool("metricas_por_tipo", {
828
+ title: "Métricas por tipo de envío",
829
+ description: "Métricas por TIPO de envío (flex/tienda/manual): total, entregados, tasa de entrega y % ENTREGADO ANTES DE LAS 21hs. Admin/red desglosa por logística (qué nodo entrega mejor). Vendedor ve lo suyo, operador su nodo. Fechas YYYY-MM-DD (default: últimos 30 días).",
830
+ inputSchema: {
831
+ desde: z.string().optional().describe("YYYY-MM-DD"),
832
+ hasta: z.string().optional().describe("YYYY-MM-DD"),
833
+ nodo: z.number().optional().describe("Solo admin/superoperador: elegir nodo (si no, la red)"),
834
+ },
835
+ }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/envios-por-tipo${q({ desde, hasta, nodo })}`)));
836
+ }
837
+
838
+ // Cuentas de tienda vinculadas (ML / TiendaNube / TiendaNegocio), scopeadas por rol.
839
+ if (isCliente || isStaff) {
840
+ tool("cuentas_vinculadas", {
841
+ title: "Cuentas vinculadas (ML / TiendaNube / TiendaNegocio)",
842
+ description: "Cuántas cuentas de tienda hay vinculadas, con desglose por proveedor (Mercado Libre / TiendaNube / TiendaNegocio). Vendedor: las suyas; operador: las de los clientes de SU nodo (agrupadas por cliente); admin/superoperador: por nodo (o pasá `nodo` para bajar al desglose por cliente de ese nodo). Solo lectura.",
843
+ inputSchema: { nodo: z.number().optional().describe("Solo admin: baja al desglose por cliente de ese nodo") },
844
+ }, async ({ nodo }) => run(() => api("GET", `/cuentas-vinculadas${q({ nodo })}`)));
845
+ }
846
+
847
+ // Afiliados: comisión recurrente por envío (staff = admin global o admin de nodo, scopeado).
848
+ if (isStaff) {
849
+ tool("afiliacion_listar", {
850
+ title: "Listar afiliaciones",
851
+ description: "Afiliaciones (afiliado↔entidad referida) con su comisión por envío, vigencia y estado. Filtrá por `afiliado` o `entidad`. Solo lectura, scopeado a tu nodo.",
852
+ inputSchema: { afiliado: z.string().optional(), entidad: z.string().optional() },
853
+ }, async ({ afiliado, entidad }) => run(() => api("GET", `/afiliados/afiliaciones${q({ afiliado, entidad })}`)));
854
+ tool("comisiones_afiliado_ver", {
855
+ title: "Comisiones de afiliados",
856
+ description: "Comisiones devengadas por envío (pendiente/liquidada) con totales. Filtrá por `afiliado` y rango de fechas. Solo lectura, scopeado a tu nodo.",
857
+ inputSchema: { afiliado: z.string().optional(), desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD") },
858
+ }, async ({ afiliado, desde, hasta }) => run(() => api("GET", `/afiliados/comisiones${q({ afiliado, desde, hasta })}`)));
859
+ }
860
+
861
+ // Choferes (mensajeros) del nodo — requiere permiso 'usuarios'.
862
+ if (isStaff && puede("usuarios")) {
863
+ tool("chofer_listar", {
864
+ title: "Listar usuarios del nodo (choferes)",
865
+ description: "Usuarios de tu nodo (incluye los choferes = rol 'mensajero') con su id, nombre, teléfono, activo. Solo lectura, scopeado a tu nodo.",
866
+ inputSchema: {},
867
+ }, async () => run(() => api("GET", "/usuarios")));
868
+ }
869
+
870
+ // Marketplace de rutas/colectas públicas (subasta abierta, cualquier nodo de la red).
871
+ if (isStaff) {
872
+ tool("rutas_publicas", {
873
+ title: "Rutas/colectas públicas (marketplace)",
874
+ description: "Publicaciones ABIERTAS de toda la red que tu nodo puede tomar (con cuántas ofertas tiene cada una). Marca las tuyas (`esMia`). Solo lectura.",
875
+ inputSchema: {},
876
+ }, async () => run(() => api("GET", "/rutas-publicas/publicas")));
877
+ tool("mis_rutas", {
878
+ title: "Mis publicaciones y ofertas (marketplace)",
879
+ description: "Tus publicaciones de rutas (con su estado/adjudicación) y las ofertas que hiciste a otros nodos. Solo lectura.",
880
+ inputSchema: {},
881
+ }, async () => run(() => api("GET", "/rutas-publicas/mias")));
882
+ tool("ruta_ofertas", {
883
+ title: "Ver ofertas de una publicación mía",
884
+ description: "Ofertas recibidas en una publicación TUYA (ordenadas por precio asc). Solo el que publicó. Usá el id de oferta para adjudicar.",
885
+ inputSchema: { publicacionId: z.number().int().positive() },
886
+ }, async ({ publicacionId }) => run(() => api("GET", `/rutas-publicas/${publicacionId}/ofertas`)));
887
+ }
888
+
889
+ if (isStaff) {
890
+ tool("nodo_alias_poner", {
891
+ title: "Ponerle un apodo personal a un nodo",
892
+ description: "Le ponés TU propio apodo a un nodo (ej. 'Flex Fácil' = 'Félix', el dueño) para reconocerlo más fácil: de ahí en más lo podés nombrar por ese apodo en cualquier herramienta que pida un nodo (asignar_nodo_zona, etc.) y también aparece en los buscadores de la app. Es PERSONAL — no lo ve otro usuario ni cambia el nombre real de la logística. Mandá `alias` vacío para sacarlo.",
893
+ inputSchema: {
894
+ nodo: z.string().min(1).describe("Nombre (o tu apodo actual) del nodo"),
895
+ alias: z.string().describe("El apodo nuevo (vacío para sacarlo)"),
896
+ },
897
+ }, async ({ nodo: nodoQ, alias }) => run(async () => {
898
+ const lr = await api("GET", "/flujo/logisticas-select");
899
+ if (!lr.ok) return lr;
900
+ const lista = lr.data ?? [];
901
+ const q = nodoQ.trim().toLowerCase();
902
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q) || (l.alias ?? "").toLowerCase().includes(q));
903
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
904
+ if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
905
+ return api("POST", "/flujo/nodo-alias", { logisticaId: cands[0].id, alias });
906
+ }));
907
+ }
908
+
909
+ if (isStaff && puede("gestion")) {
910
+ tool("zonas_reparto", {
911
+ title: "Zonas de reparto (mensajeros por zona)",
912
+ description: "Lista las zonas de reparto de tu nodo con sus metazonas y qué mensajeros tiene asignado cada una. Solo lectura.",
913
+ inputSchema: {},
914
+ }, async () => run(() => api("GET", "/zonificacion/zonas-reparto")));
915
+
916
+ tool("zonas_simetria", {
917
+ title: "Chequear simetria de distancias entre zonas",
918
+ description: "Chequea la SIMETRIA de las distancias entre zonas: si desde el perfil de A un lugar B es 'cercana', desde el perfil de B el lugar A deberia ser 'cercana' tambien. Devuelve los pares que NO cumplen, los que faltan cargar en un sentido, y los perfiles que todavia no declararon en que lugar viven. SOLO INFORMA: no corrige ni completa nada, porque puede haber asimetrias legitimas (autopista, rio, barrera) y la decision es humana. Para que un grupo entre al chequeo hay que declararle su 'perfil de origen'.",
919
+ inputSchema: {},
920
+ }, async () => run(() => api("GET", "/zonificacion/simetria")));
921
+
922
+ tool("zona_barrios", {
923
+ title: "Barrios/localidades de una zona",
924
+ description: "Consulta rápida: qué localidades y BARRIOS componen una zona con nombre (ej. 'Matanza Norte', 'CABA'), con su tramo por perfil. Buscás por nombre; devuelve las zonas que matchean (propias del nodo, globales o de tus grupos logísticos). Solo lectura. Usalo cuando te preguntan '¿qué barrios hay en <zona>?'.",
925
+ inputSchema: { zona: z.string().min(1).describe("Nombre de la zona, ej. 'Matanza Norte' o 'CABA'") },
926
+ }, async ({ zona }) => run(() => api("GET", `/zonificacion/zona-barrios${q({ zona })}`)));
927
+
928
+ tool("facturacion_estado", {
929
+ title: "¿Qué falta para facturar?",
930
+ description:
931
+ "Diagnóstico de facturación ARCA de tu nodo: qué emisores hay y si están completos, qué cuentas de dinero no tienen emisor propio, y por cada cliente exactamente qué dato le falta (CUIT, razón social, condición IVA, emisor, habilitación). Sin 'cliente' lista los YA habilitados; con 'cliente' (nombre o id) mira ese aunque no esté habilitado. NO devuelve importes ni emite nada: es estado de configuración. Empezá y terminá por acá cuando pongas a alguien a facturar (ver el flujo 'facturacion').",
932
+ inputSchema: { cliente: z.string().optional().describe("Nombre o id de un cliente puntual; vacío = los ya habilitados") },
933
+ }, async ({ cliente }) => run(() => api("GET", `/afip/estado${q({ cliente })}`)));
934
+
935
+ tool("colecta_ver", {
936
+ title: "Ver config de colectas",
937
+ description: "Resumen de valores de colecta del nodo: pago default del nodo, cobros por cliente y pagos pactados por cliente+mensajero. Solo lectura.",
938
+ inputSchema: {},
939
+ }, async () => run(() => api("GET", "/colecta/config")));
940
+
941
+ tool("colecta_pendientes", {
942
+ title: "Colectas del día y de mañana",
943
+ description: "Panel de colectas de tu nodo. `items` = clientes a retirar HOY (con cuántos envíos, el corte y qué colecta está asignada a qué mensajero). `itemsManana` = clientes con envíos que entraron después del corte → van a la colecta de MAÑANA. `clientesSinEnvios` = vendedores del nodo SIN envíos cargados, a los que igual se los puede mandar a colectar (el cadete escanea los paquetes en la puerta). Solo lectura. Sirve para «¿quién levanta a tal cliente?» y «¿el cliente X tiene envíos para mañana?».",
944
+ inputSchema: {},
945
+ }, async () => run(() => api("GET", "/colecta/panel")));
946
+
947
+ tool("colecta_historial", {
948
+ title: "Historial de colectas (paquetes por día y cliente)",
949
+ description: "Paquetes COLECTADOS por día y por cliente en una fecha o rango (histórico, hasta 62 días), con quién los colectó. `desde` (aaaa-mm-dd, obligatorio), `hasta` (opcional, default = desde), `cliente` (nombre o id, opcional). Cuenta cada paquete una vez, en el día de Argentina en que se colectó (evento Colectado o colecta en la puerta). Solo tu nodo. Solo lectura. Sirve para «¿cuántos paquetes le levantamos a X el martes?».",
950
+ inputSchema: {
951
+ desde: z.string().describe("aaaa-mm-dd"),
952
+ hasta: z.string().optional().describe("aaaa-mm-dd; vacío = el mismo día"),
953
+ cliente: z.string().optional().describe("nombre (o parte) o id del cliente"),
954
+ },
955
+ }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/colecta/historial${q({ desde, hasta, cliente })}`)));
956
+
957
+ tool("envios_por_zona", {
958
+ title: "Envíos que otros nodos te rutearon (por zona)",
959
+ description: "Envíos de OTROS nodos ruteados a tu nodo por zona de reparto, agrupados por zona (para aceptarlos o rechazarlos). Cada envío trae su id (usalo en procesar_zona/rechazar_zona). Solo lectura, tu nodo.",
960
+ inputSchema: {},
961
+ }, async () => run(() => api("GET", "/colecta/zonas-a-procesar")));
962
+
963
+ tool("asignar_mensajero_zona", {
964
+ title: "Asignar un mensajero a una zona",
965
+ description: "Asigna un mensajero (por nombre, de tu nodo) a una metazona/zona (ej. «asigná a Maxi la zona de Palermo»). Si ya existe una zona con esa metazona, le suma el mensajero; si no, la crea. No cruza nodos. El admin global elige el nodo con `nodo`.",
966
+ inputSchema: {
967
+ mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo)"),
968
+ metazona: z.string().min(1).describe("Metazona/zona, ej. 'Palermo' o 'CABA'"),
969
+ nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
970
+ nodo: zNodo(),
971
+ },
972
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-mensajero", { ...rest, logisticaId: nodo })));
973
+
974
+ tool("asignar_nodo_zona", {
975
+ title: "Asignar un NODO a una zona (grupo logístico)",
976
+ description: "Asigna un NODO COMPLETO (por nombre, de tu grupo logístico) a una metazona. Sin `grupo`: para cuando ese nodo cubre toda una localidad de TU zona (ej. «que RL cubra Portela») — suma el nodo como opción (nodoIds), sin cruzar fuera de tu grupo. CON `grupo` (ej. 'Bonorino'/'Portela'): MODO COMISIÓN — la zona es del GRUPO (no de un nodo dueño) y esto reasigna quién es el responsable; solo lo puede hacer un ADMIN de ese grupo (o el admin global). Acá `nodo` es el DESTINO (por nombre); el admin global elige el nodo DUEÑO de la zona sin-grupo con `logisticaId`.",
977
+ inputSchema: {
978
+ nodo: z.string().min(1).describe("Nombre del nodo (de tu grupo logístico) que cubre la zona"),
979
+ metazona: z.string().min(1).describe("Metazona/zona, ej. 'Portela' o 'CABA'"),
980
+ nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
981
+ grupo: z.string().optional().describe("Nombre o id del grupo logístico (ej. 'Bonorino') — MODO COMISIÓN: la zona pasa a ser del grupo y solo su admin puede asignarla/reasignarla"),
982
+ logisticaId: z.number().int().positive().optional().describe("Solo admin global y sin `grupo`: id del nodo DUEÑO de la zona sobre el que operás. El operador de nodo lo ignora."),
983
+ },
984
+ }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-nodo", args)));
985
+
986
+ tool("zona_dejar", {
987
+ title: "Dejar de ser responsable de una zona de grupo",
988
+ description: "Te sacás (a tu nodo) de una zona de un grupo logístico (ej. Bonorino) que tenías asignada — self-service, solo podés sacarte a VOS. Si eras el único responsable, la zona pasa a LIBERADA y se avisa a TODO el grupo; solo la comisión (admin del grupo) la puede volver a asignar con `asignar_nodo_zona` + `grupo`.",
989
+ inputSchema: {
990
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
991
+ metazona: z.string().min(1).describe("Metazona/zona que dejás"),
992
+ nodo: zNodo(),
993
+ },
994
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/dejar", { ...rest, logisticaId: nodo })));
995
+
996
+ tool("nodo_link_confirmacion", {
997
+ title: "Link de confirmación sin login para un nodo",
998
+ description: "Devuelve el link FIJO (sin login) donde un nodo sin usuarios propios (ej. un integrante 'feeder' de un grupo que solo deja paquetes) ve los envíos que le asignaron como responsable de zona y los confirma con un toque. Se lo pasás por WhatsApp a mano — el nodo no tiene teléfono cargado para mandárselo solo. Mismo link siempre para ese nodo (se genera la primera vez).",
999
+ inputSchema: { nodo: z.string().min(1).describe("Nombre o apodo del nodo") },
1000
+ }, async ({ nodo: nodoQ }) => run(async () => {
1001
+ const lr = await api("GET", "/flujo/logisticas-select");
1002
+ if (!lr.ok) return lr;
1003
+ const lista = lr.data ?? [];
1004
+ const q2 = nodoQ.trim().toLowerCase();
1005
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
1006
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
1007
+ if (cands.length > 1) return { ok: false, status: 409, data: { message: `Varios nodos matchean "${nodoQ}": ${cands.map((c) => c.nombre).join(", ")}. Sé más específico.` } };
1008
+ return api("POST", "/logisticas/link-confirmacion", { logisticaId: cands[0].id });
1009
+ }));
1010
+
1011
+ tool("zonas_grupo", {
1012
+ title: "Zonas de un grupo logístico y quién las cubre",
1013
+ description: "Lista las zonas de un grupo logístico (ej. Bonorino/Portela) con el nodo responsable de cada una, marcando las LIBERADAS (sin responsable) y si vos podés reasignarlas (`puedoAsignar`, solo comisión/admin global). Visible a cualquier miembro del grupo. Solo lectura.",
1014
+ inputSchema: { grupo: z.string().min(1).describe("Nombre o id del grupo") },
1015
+ }, async ({ grupo }) => run(() => api("GET", `/zonificacion/zonas-reparto-grupo${q({ grupo })}`)));
1016
+
1017
+ tool("zona_mover_metazona", {
1018
+ title: "Mover una metazona entre zonas de un grupo",
1019
+ description: "Reclasifica UNA metazona/localidad: la saca de la zona de grupo que la contiene y la mete en OTRA zona del MISMO grupo, sin tocar el nodo responsable (ej. 'El Palomar' está mal puesto en Morón y va a Tres de Febrero, del que es partido). Solo la comisión (admin) del grupo o el admin global. No mueve dinero.",
1020
+ inputSchema: {
1021
+ grupo: z.string().min(1).describe("Nombre o id del grupo (ej. 'Portela')"),
1022
+ metazona: z.string().min(1).describe("Metazona/localidad a mover (ej. 'El Palomar')"),
1023
+ zonaDestino: z.string().min(1).describe("Zona destino: nombre, id, o una metazona que ya tenga (ej. 'Tres de Febrero')"),
1024
+ nodo: zNodo(),
1025
+ },
1026
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/metazona-mover", { ...rest, logisticaId: nodo })));
1027
+
1028
+ tool("zona_componer_grupo", {
1029
+ title: "Componer una zona de grupo (metazonas + nodo)",
1030
+ description: "Crea o edita una zona de un grupo logístico: la ubica por nombre/id (o la crea con ese nombre), le puede sumar un NODO responsable y AGREGA/QUITA metazonas de su composición. Sirve para armar las zonas de un grupo NUEVO con su composición completa — importante para el ruteo, que matchea por metazona exacta (ej. una zona 'Flores' que cubra 'CABA · Flores' y 'Flores'). Solo la comisión (admin) del grupo o el admin global. No mueve dinero.",
1031
+ inputSchema: {
1032
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
1033
+ zona: z.string().min(1).describe("Zona: nombre o id. Si no existe en el grupo, se crea con ese nombre."),
1034
+ nodo: z.string().optional().describe("Nombre del nodo responsable a sumar a la zona (opcional)"),
1035
+ agregar: z.array(z.string()).optional().describe("Metazonas a AGREGAR a la zona (ej. ['CABA · Flores','Flores'])"),
1036
+ quitar: z.array(z.string()).optional().describe("Metazonas a QUITAR de la zona"),
1037
+ nombre: z.string().optional().describe("Nombre para la zona si se crea (default: el valor de `zona`)"),
1038
+ logisticaId: z.number().int().positive().optional().describe("Solo admin global: id del nodo (de la comisión) sobre el que operás. El operador de nodo lo ignora."),
1039
+ },
1040
+ }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/grupo-componer", args)));
1041
+
1042
+ tool("grupo_tarifa_set", {
1043
+ title: "Setear la tarifa de clearing de un grupo",
1044
+ description: "Fija la tarifa de clearing por zona (cercana/media/lejana/muyLejana) de TODO un grupo logístico de una: el DEFAULT del grupo y el precio de CADA nodo miembro (su fila vigente). Sirve para dejar un grupo nuevo con su tarifa sin ir nodo por nodo (ej. 2700 en los 4 tramos). Con `soloDef` toca solo el default (no los nodos). Solo la comisión (admin) del grupo o el admin global. Es CONFIG del clearing — NO mueve dinero.",
1045
+ inputSchema: {
1046
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
1047
+ cercana: z.number().optional().describe("Tarifa zona cercana"),
1048
+ media: z.number().optional().describe("Tarifa zona media"),
1049
+ lejana: z.number().optional().describe("Tarifa zona lejana"),
1050
+ muyLejana: z.number().optional().describe("Tarifa zona muy lejana"),
1051
+ soloDef: z.boolean().optional().describe("true = solo el default del grupo, sin tocar los nodos miembro"),
1052
+ },
1053
+ }, async (args) => run(() => api("POST", "/qr/grupo-tarifa-grupal", args)));
1054
+ }
1055
+
1056
+ // ============================================================================
1057
+ // ESCRITURA (opt-in por flag · NUNCA dinero). Además gateado por permiso en el backend.
1058
+ // ============================================================================
1059
+ if (ALLOW_WRITE) {
1060
+ if (isCliente) {
1061
+ tool("sucursal_guardar", {
1062
+ title: "Crear/editar sucursal (punto de retiro)",
1063
+ description: "Crea o edita una sucursal tuya (punto de retiro). Para cambiar tu DIRECCIÓN DE RETIRO editá la sucursal principal (o creá una con principal=true). id vacío = nueva sucursal. Geocodifica la dirección sola. No toca dinero.",
1064
+ inputSchema: {
1065
+ id: z.number().optional().describe("id de la sucursal a editar; vacío = nueva"),
1066
+ nombre: z.string().min(1),
1067
+ direccion: z.string().optional(),
1068
+ principal: z.boolean().optional().describe("true = pasa a ser tu dirección de retiro principal"),
1069
+ horarioCorte: z.string().optional().describe("HH:MM"),
1070
+ ventana1Desde: z.string().optional(), ventana1Hasta: z.string().optional(),
1071
+ ventana2Desde: z.string().optional(), ventana2Hasta: z.string().optional(),
1072
+ },
1073
+ }, async (args) => run(() => api("POST", "/portal/sucursales", args)));
1074
+ tool("colecta_solicitar", {
1075
+ title: "Solicitar colecta (por única vez)",
1076
+ description: "Pedís que te retiren los envíos POR ÚNICA VEZ, aunque tengas la colecta automática apagada. Aparecés en el panel de colecta de tu nodo. Devuelve el aviso de costo (si te cobran cuando llevás menos de X envíos). No mueve dinero.",
1077
+ inputSchema: {},
1078
+ }, async () => run(() => api("POST", "/portal/colecta/solicitar")));
1079
+ tool("colecta_auto", {
1080
+ title: "Prender/apagar colecta automática",
1081
+ description: "Activás (true) o desactivás (false) tu colecta AUTOMÁTICA. Apagada = llevás los envíos al depósito y no te retiran (salvo que pidas una por única vez con colecta_solicitar). No mueve dinero.",
1082
+ inputSchema: { activa: z.boolean() },
1083
+ }, async ({ activa }) => run(() => api("PUT", "/portal/colecta/auto", { activa })));
1084
+ }
1085
+ if (isCliente || (isStaff && puede("gestion")) || rol === "mensajero") {
1086
+ tool("envio_cargar", {
1087
+ title: "Cargar un envío (y etiqueta)",
1088
+ description: "Registra un envío nuevo (queda 'A retirar') y devuelve el tracking + un LINK a la etiqueta imprimible + un LINK para subir una foto del envío desde el celular. El vendedor lo carga para sí mismo; el staff pasa `cliente` (nombre, de su nodo); el MENSAJERO pasa `cliente` y SOLO puede registrar clientes de su nodo o que haya colectado (marketplace incluido). Datos obligatorios: destinatario, telefono, direccion, localidad. Si el envío NO es de HOY (planilla que se carga días después), pasá `fecha` — es la que define en qué SEMANA se liquida. No toca dinero (montoCobro es el cobro contra entrega, no un movimiento).",
1089
+ inputSchema: {
1090
+ cliente: zStr().describe("Staff y mensajero: el vendedor del envío (staff: nombre, de tu nodo; mensajero: nombre o id, de tu nodo o que hayas colectado). El vendedor NO lo manda."),
1091
+ destinatario: z.string().min(1).describe("Nombre de quien recibe"),
1092
+ telefono: z.union([z.string().min(1), z.number()]).transform((x) => String(x)).describe("Teléfono del destinatario (obligatorio: el backend rechaza el envío sin él)"),
1093
+ direccion: z.string().min(1),
1094
+ localidad: z.string().min(1),
1095
+ cp: zStr(),
1096
+ montoCobro: zNum().describe("Cobro contra entrega (opcional)"),
1097
+ esCambio: z.boolean().optional(),
1098
+ detalleCambio: z.string().optional(),
1099
+ comentarios: z.string().optional(),
1100
+ fecha: zStr().describe("Solo staff — fecha del envío (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: para planillas que se cargan días después. Define en qué SEMANA se liquida el envío, así que no se admite una fecha futura."),
1101
+ forzarNuevo: z.boolean().optional().describe("Solo si te respondió POSIBLE DUPLICADO (409): true = es OTRO paquete distinto (dos compras a la misma dirección) y hay que cargarlo igual. Si es el MISMO paquete, NO lo mandes: usá el tracking existente."),
1102
+ },
1103
+ }, async (args) => run(() => api("POST", "/envios/cargar-mcp", args)));
1104
+ }
1105
+ if (isStaff || rol === "mensajero") {
1106
+ tool("envio_desde_etiqueta_ml", {
1107
+ title: "Registrar envío desde una etiqueta de Mercado Libre (foto)",
1108
+ description: "Da de alta (o completa) un envío de Mercado Libre a partir de la etiqueta. El vendedor se resuelve por `mlQr`; si no, por `mlSenderId` (cuenta vinculada o aprendida); si no, por `cliente`. Si no se conoce, devuelve 404 con el senderId: preguntale al usuario de quién es. Es idempotente: si el envío ya estaba cargado no se duplica (se completa y, con `avanzarA`, se avanza). `nodoEntrega`: el nodo que lo entregó (control por foto). Dígitos ilegibles en la altura de la dirección: un '*' por cada uno (ej. 'Aguirre 31**'), no los adivines. Devuelve {id, tracking, yaExistia, noAvanzado?}. No mueve dinero. Para una carpeta de fotos, ver `flujo control_por_foto`.",
1109
+ inputSchema: {
1110
+ cliente: zStr().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
1111
+ mlSenderId: zStr().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
1112
+ mlShipmentId: zStr().describe("id de envío/tracking de ML leído de la etiqueta (se limpian espacios OCR)"),
1113
+ mlQr: zStr().describe("Contenido CRUDO del QR de ML (el JSON {id, sender_id, hash_code…}). Alcanza solo: de ahí salen el número de envío y el vendedor, y sirve para reimprimir la etiqueta idéntica. Mandalo SIEMPRE que lo leas."),
1114
+ destinatario: zStr(), telefono: zStr(),
1115
+ direccion: zStr().describe("Dirección; si un dígito de la altura no se lee, poné '*' por cada uno (ej. 'Julian Aguirre 31**'), NO lo inventes"),
1116
+ localidad: zStr(), cp: zStr(), zona: zStr(), barrio: zStr(),
1117
+ avanzarA: z.enum(["colectado", "procesado", "entregado"]).optional().describe("Control por foto: avanza el envío EN UN PASO registrando CADA estado intermedio (atribuido a vos, el usuario MCP). 'colectado' / 'procesado' (En centro) / 'entregado' (recorre A retirar→Colectado→En centro→[grupo=En camino]→Entregado). 'entregado' es la vía rápida para el clearing entre nodos."),
1118
+ autoRutear: z.boolean().optional().describe("Planilla: asigna automáticamente el NODO y/o MENSAJERO responsable de la ZONA de reparto del destino. Configurá antes los responsables con `asignar_nodo_zona`/`asignar_mensajero_zona`."),
1119
+ grupo: zStr().describe("Control por foto: nombre o id de un GRUPO logístico tuyo (ej. 'Bonorino') — despacha por ese grupo (queda 'En camino', su mensajero es el integrante del grupo)."),
1120
+ nodoEntrega: zStr().describe("Solo staff: nodo (nombre o id) que RECIBIÓ y entregó el paquete (ej. 'FastCorreo'). Es el que cobra el traspaso en el cierre semanal entre nodos. Sin `grupo` se deduce: si el cliente es de ese mismo nodo es un paquete propio (sin traspaso); si es de otro nodo, el grupo que comparten (si comparten varios, pedí `grupo`). Si el envío ya tenía otro nodo que entrega, no se pisa."),
1121
+ fotoRuta: zStr().describe("RUTA LOCAL de la foto de la etiqueta en tu compu (ej. 'C:\\\\Users\\\\...\\\\Desktop\\\\etiquetas\\\\ml_123.jpg') — NO la imagen, solo la ruta, para reencontrar la foto. Mandala SIEMPRE que la dirección tenga '*'."),
1122
+ montoCobro: zNum(), mensajero: zStr().describe("Mensajero que hizo el paquete (de tu nodo)"),
1123
+ reusarEtiqueta: z.boolean().optional().describe("ML MANUAL (no vinculado): true = REUSA el mismo tracking/QR/etiqueta de ML que ya viene impreso (NO genera uno nuevo), lo marca 'ml_manual' y es idempotente. Usalo cuando el paquete YA está etiquetado por ML pero no está vinculado a una cuenta."),
1124
+ forzarNuevo: z.boolean().optional().describe("Solo si te respondió POSIBLE DUPLICADO (409): true = es OTRO paquete distinto (dos compras a la misma dirección) y hay que cargarlo igual. Si es el MISMO paquete, NO lo mandes: usá el tracking existente."),
1125
+ fecha: zStr().describe("Solo staff — día REAL en que el paquete se movió (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: para planillas que se controlan días después. Define en qué SEMANA se liquida el envío (no se admite futura) y fecha TODA la cadena de `avanzarA`: cada estado queda con su hora de ESE día (Colectado 9, En centro 12, En camino 15, Entregado 18), no con la hora del control. Si el envío YA estaba cargado, también le mueve la fecha — y si ese período ya está liquidado, rechaza la llamada nombrando la liquidación en vez de dejarlo a medio corregir."),
1126
+ },
1127
+ }, async (args) => run(() => api("POST", "/envios/desde-etiqueta", args)));
1128
+ if (isStaff) registrarControlFoto();
1129
+ tool("envio_procesar", {
1130
+ title: "Procesar / recibir un envío en el centro",
1131
+ description: "Marca un envío como PROCESADO / recibido en el centro de distribución (estado 'En centro de distribución') por su TRACKING, sin escanear — igual que la acción 'procesar' del escáner. Scopeado a tu nodo/grupo. No mueve dinero (registra el tramo de clearing del grupo, como `procesar_zona`).",
1132
+ inputSchema: { tracking: zStr().describe("Tracking / código del envío a procesar") },
1133
+ }, async ({ tracking }) => run(() => api("POST", "/flujo/escanear", { codigo: tracking, accion: "procesar" })));
1134
+ tool("envio_entregar", {
1135
+ title: "Marcar ENTREGADO un envío ya cargado",
1136
+ description: "Marca ENTREGADO uno o varios envíos ya cargados, por tracking, sin foto ni firma. Usalo cuando no estás cargando desde una etiqueta (para eso está `envio_desde_etiqueta_ml` con `avanzarA`). Corre la entrega REAL: sella la logística de entrega para el clearing y le avisa al vendedor. Con `fecha` el historial dice el día REAL de entrega, no la hora del control; sobre un envío que YA figura entregado CORRIGE la fecha del cierre (vuelve en `fechaCorregida`), que es como se arregla una tanda cerrada con el día equivocado. Los que ya estaban entregados y no hay nada que corregirles vuelven en `yaEstaban` y no rompen el lote; los que están en un estado de EXCEPCIÓN (Cancelado, Devuelto al vendedor…) NO se entregan y vuelven en `errores` con su estado real. Staff, y solo envíos de tu nodo (admin global: cualquiera). No mueve dinero.",
1137
+ inputSchema: {
1138
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a marcar entregados (ej. ['TN-2057600163','NFABC123'])"),
1139
+ fecha: zStr().describe("Día REAL de entrega (dd/mm/aaaa o aaaa-mm-dd) si NO es HOY: la tanda que se controla por foto días después queda en el historial con el día en que salió, no con el del control. No se admite futura. Cambia el evento 'Entregado' del historial, NO la semana en que se liquida el envío — esa se mueve con `envio_editar_fecha`."),
1140
+ },
1141
+ }, async (args) => run(() => api("POST", "/envios/entregar-ref", args)));
1142
+ tool("envio_completar_ciclo", {
1143
+ title: "Reconstruir los pasos que le faltan a un envío ya cerrado",
1144
+ description: "Rellena los pasos que le FALTAN a un envío ya cerrado: 'Colectado', 'En centro de distribución' y 'En camino', cada uno a nombre de QUIEN LO HIZO y fechado en el día real. Solo AGREGA lo que falta: no duplica un paso que ya está, no cambia el estado actual del envío y NO inventa autores (el paso del que no decís quién lo hizo, no se agrega). Pasá una persona por paso (nombre o id, del nodo); si el nombre matchea a varios te devuelve los candidatos con su id en vez de elegir por vos. Staff, y solo envíos de tu nodo. No mueve dinero (el mensajero se asigna con `envio_asignar_mensajero` y el grupo con `envio_asignar_grupo`).",
1145
+ inputSchema: {
1146
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a completar (ej. ['NFABC123','TN-2057600163'])"),
1147
+ fecha: zStr().describe("Día REAL en que se hicieron esos pasos (dd/mm/aaaa o aaaa-mm-dd). Cada paso queda con su hora nominal de ese día: Colectado 9, En centro 12, En camino 15. No se admite futura."),
1148
+ colectado: zStr().describe("Quién COLECTÓ (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1149
+ procesado: zStr().describe("Quién lo recibió EN EL CENTRO de distribución (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1150
+ enCamino: zStr().describe("Quién lo DESPACHÓ / salió a repartirlo (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
1151
+ },
1152
+ }, async (args) => run(() => api("POST", "/envios/completar-ciclo", args)));
1153
+ tool("envio_pago_mensajero", {
1154
+ title: "Cargar lo que se le paga al mensajero por esos envíos",
1155
+ description: "Carga LO QUE SE LE PAGA a quien repartió cada envío (el valor por envío) y, si al envío le falta el NOMBRE del mensajero, lo completa. Al mensajero se le paga por el nombre guardado en el envío (`Envio.mensajero`), no por el vínculo: este tool escribe valor + nombre. Usa el nombre con el que la persona está cargada (el apodo del resumen, ej. 'Maxi', no 'Maxi Sagarzazu'); en un VENDEDOR con doble rol, el de su cliente. Por defecto NO toca envíos que no estén entregados (un Cancelado o Devuelto vuelve en `noEntregados`); si igual querés pagarlos, pasá `incluirNoEntregados:true`. Si el envío no tiene mensajero y no pasás uno, vuelve en `sinMensajero` sin tocarse. Staff, y solo envíos de tu nodo.",
1156
+ inputSchema: {
1157
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a pagar (ej. ['NFABC123','TN-2057600163'])"),
1158
+ valor: zNum().describe("Lo que se le paga POR ENVÍO, en pesos (ej. 2150). Se escribe igual en todos los que mandes."),
1159
+ mensajero: zStr().describe("Opcional: nombre o id de quien repartió. Si lo pasás, lo ASIGNA y lo paga en un solo paso. Si no, usa el mensajero que el envío ya tenga."),
1160
+ incluirNoEntregados: z.boolean().optional().describe("Opcional: true = pagar también los que NO están entregados (Cancelado, Devuelto…). Por defecto NO se tocan y vuelven listados."),
1161
+ },
1162
+ }, async (args) => run(() => api("POST", "/envios/pago-mensajero", args)));
1163
+ tool("envio_corregir_estado", {
1164
+ title: "Corregir el estado de un envío",
1165
+ description: "Corrige el ESTADO de un envío ya cargado, para DESHACER algo que quedó mal: un 'Entregado' que no fue, o un envío que una corrección revivió por error. Además de los estados del panel acepta los TERMINALES de excepción ('Cancelado', 'Rechazado por el comprador'). El `motivo` es OBLIGATORIO (mínimo 10 caracteres) y queda en el historial: una corrección sin explicación es indistinguible de un error. NO toca las banderas de devolución ni de cobro, y no mueve dinero. Staff, y solo envíos de tu nodo.",
1166
+ inputSchema: { tracking: zStr().describe("Tracking del envío a corregir"), estado: zStr().describe("Estado destino (ej. 'Cancelado', 'En camino', 'Entregado')"), motivo: z.string().min(10).describe("Por qué se corrige. Queda en el historial del envío.") },
1167
+ }, async (args) => run(() => api("POST", "/envios/corregir-estado", args)));
1168
+ tool("envio_asignar_mensajero", {
1169
+ title: "Asignar / quitar el mensajero de un envío",
1170
+ description: "Asigna —o QUITA con `quitar:true`— el MENSAJERO de envíos ya cargados, por tracking. Busca entre todos los que pueden repartir (rol mensajero, `reparte`, o vendedor con doble rol); acepta id; si el nombre es ambiguo devuelve candidatos. No mueve dinero: el pago se liquida por NOMBRE (ver `envio_pago_mensajero`).",
1171
+ inputSchema: { trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos"), mensajero: zStr().optional().describe("Nombre o ID de quien repartió (rol mensajero, o alguien con reparto habilitado)"), quitar: z.boolean().optional().describe("true = deja el envío SIN mensajero asignado") },
1172
+ }, async (args) => run(() => api("POST", "/envios/asignar-mensajero", args)));
1173
+ tool("envio_estado", {
1174
+ title: "Estado + ETA de un envío (¿cuándo llega?)",
1175
+ description: "Devuelve una NARRATIVA del estado de un envío (por tracking) para responder '¿cuándo se entrega?': si está en camino, con qué mensajero, cuántos envíos lleva en la ruta, en qué posición va este y una ETA estimada (posición en la ruta × minutos por parada). Marca PROBLEMAS si los hay (nunca despachado, sin mensajero asignado, mensajero sin reportar ubicación hace rato). Es una estimación explicable, no una promesa exacta. Read-only, scopeado a tu nodo.",
1176
+ inputSchema: { tracking: z.string().min(1).describe("Tracking del envío a consultar") },
1177
+ }, async ({ tracking }) => run(() => api("GET", `/flujo/envio-estado?tracking=${encodeURIComponent(tracking)}`)));
1178
+ tool("envios_trabados", {
1179
+ title: "Envíos trabados del nodo (para destrabar / cerrar)",
1180
+ description: "Lista los envíos TRABADOS de tu nodo, con el motivo: 'estancado' (mucho tiempo en 'A retirar'/'En centro' sin avanzar, ~nunca despachado), 'sin_mensajero' (en el centro pero sin mensajero asignado), 'mensajero_detenido' (en camino pero el mensajero no reporta ubicación hace rato) o 'en_camino_sin_cerrar' (en camino hace +24h sin cerrarse: candidato a cerrar; confirmá con el usuario antes de marcarlo entregado). Ordenados por severidad. Es la misma detección que alimenta el aviso proactivo a los operativos y admins del nodo. Read-only.",
1181
+ inputSchema: {},
1182
+ }, async () => run(() => api("GET", "/flujo/envios-trabados")));
1183
+ tool("planilla_reporte", {
1184
+ title: "Reporte de la planilla ML (control por foto)",
1185
+ description: "Resumen de los envíos cargados por el método de planilla/control por foto (ml_manual) de tu nodo: total + desglose por CLIENTE, por ZONA y por ESTADO + suma de valor declarado y de monto a cobrar. Opcional: rango de fechas (desde/hasta, YYYY-MM-DD). Read-only.",
1186
+ inputSchema: { desde: zStr().describe("Desde (YYYY-MM-DD), opcional"), hasta: zStr().describe("Hasta (YYYY-MM-DD), opcional") },
1187
+ }, async ({ desde, hasta }) => run(() => api("GET", `/flujo/planilla-reporte${desde || hasta ? `?${new URLSearchParams({ ...(desde ? { desde } : {}), ...(hasta ? { hasta } : {}) }).toString()}` : ""}`)));
1188
+ tool("nodo_provisorio_crear", {
1189
+ title: "Crear un nodo PROVISORIO en un grupo (planilla ML)",
1190
+ description: "Crea al vuelo un nodo PROVISORIO (placeholder) dentro de un grupo logístico, para atribuirle el clearing cuando el nodo real todavía no está dado de alta. No tiene login hasta que se concilie con el nodo real. Solo en grupos a los que pertenece TU nodo (el admin global, en cualquiera). Reusa el clearing por grupo. No mueve dinero.",
1191
+ inputSchema: { nombre: z.string().min(1).describe("Nombre del nodo (ej. 'Nodo Avellaneda - provisorio')"), grupoId: z.number().int().positive().describe("Grupo logístico donde crearlo (de tus grupos)") },
1192
+ }, async (args) => run(() => api("POST", "/flujo/nodo-provisorio", args)));
1193
+ tool("provisorio_conciliar", {
1194
+ title: "Conciliar un nodo provisorio con el nodo real",
1195
+ description: "Cuando el nodo real YA está dado de alta, CONCILIA (fusiona) el provisorio: reatribuye sus envíos (origen/entrega) y su membresía de grupo al nodo real, y marca el provisorio como RESUELTO (queda desactivado). El clearing de esos envíos pasa a usar la tarifa del nodo real en el grupo. Aislamiento: como operador solo conciliás los provisorios que creó TU nodo y contra un nodo real que YA opera en ese grupo; si el nodo real todavía no está en el grupo hay que definirle la tarifa → eso lo hace el admin global con `tarifa`. No mueve dinero (reordena el clearing).",
1196
+ inputSchema: {
1197
+ provisorioId: z.number().int().positive().describe("Id del nodo provisorio a conciliar (lo devolvió `nodo_provisorio_crear`, o lo ves en `grupos_tarifas`)"),
1198
+ nodoReal: z.string().min(1).describe("Nodo real destino: nombre o id (nodo ya dado de alta)"),
1199
+ tarifa: z.object({ cercana: z.number().optional(), media: z.number().optional(), lejana: z.number().optional(), muyLejana: z.number().optional() }).optional().describe("Solo admin global y solo si el nodo real todavía NO opera en el grupo: tarifa (bandas) con la que se lo agrega."),
1200
+ modo: z.enum(["absorber", "desde_ahora"]).optional().describe("absorber (default) = el nodo real se queda con el HISTORIAL del provisorio (reatribuye sus envíos y su grupo). desde_ahora = NO toca el historial; el clearing arranca de acá en adelante."),
1201
+ },
1202
+ }, async (args) => run(() => api("POST", "/flujo/conciliar-provisorio", args)));
1203
+ tool("afiliado_crear", {
1204
+ title: "Crear afiliado",
1205
+ description: "Alta de un afiliado (quien trae volumen nuevo a la red). tipo: nodo|mensajero|externo; refId = id del nodo/mensajero (null si externo). Admin global o admin de nodo (scopeado a tu nodo). NO toca dinero.",
1206
+ inputSchema: { nombre: z.string().min(1), tipo: z.enum(["nodo", "mensajero", "externo"]), refId: z.number().int().optional(), telefono: z.string().optional(), email: z.string().optional() },
1207
+ }, async (args) => run(() => api("POST", "/afiliados", args)));
1208
+ tool("afiliacion_crear", {
1209
+ title: "Crear afiliación (comisión por envío)",
1210
+ description: "Vincula un afiliado con una entidad referida (nodo o cliente) y su comisión RECURRENTE por envío. tipoComision: porcentaje|montoFijo. `porcentaje` se calcula sobre el valorDeclarado del envío (MVP); `montoFijo` = monto por envío. fechaExpiracion opcional (null = no vence). Una entidad = un solo afiliado activo. NO toca dinero (es config).",
1211
+ inputSchema: { afiliado: z.string().min(1).describe("nombre o id"), entidadTipo: z.enum(["nodo", "cliente"]), entidad: z.string().min(1).describe("nombre o id del nodo/cliente referido"), tipoComision: z.enum(["porcentaje", "montoFijo"]), valorComision: z.number(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD") },
1212
+ }, async (args) => run(() => api("POST", "/afiliados/afiliaciones", args)));
1213
+ tool("afiliacion_editar", {
1214
+ title: "Editar afiliación",
1215
+ description: "Edita una afiliación: valorComision, fechaExpiracion (renovar/extender) y/o activa (des/reactivar). NO toca dinero.",
1216
+ inputSchema: { id: z.number().int().positive(), valorComision: z.number().optional(), fechaExpiracion: z.string().optional().describe("YYYY-MM-DD; vacío = quitar vencimiento"), activa: z.boolean().optional() },
1217
+ }, async ({ id, ...body }) => run(() => api("PUT", `/afiliados/afiliaciones/${id}`, body)));
1218
+ if (esGlobal || esAdminNodo || permisos.includes("finanzas")) { // igual que el gate del backend
1219
+ tool("comision_liquidar", {
1220
+ title: "Liquidar comisiones de afiliado (registra pago)",
1221
+ description: "Marca comisiones devengadas como LIQUIDADAS (registra el pago al afiliado). `ids` = lista de comisiones (de comisiones_afiliado_ver). Requiere permiso de finanzas. Scopeado a tu nodo.",
1222
+ inputSchema: { ids: z.array(z.number().int().positive()).min(1) },
1223
+ }, async ({ ids }) => run(() => api("POST", "/afiliados/comisiones/liquidar", { ids })));
1224
+ }
1225
+ }
1226
+ if (puede("usuarios")) {
1227
+ tool("chofer_crear", {
1228
+ title: "Alta de chofer (mensajero) con clave temporal",
1229
+ description: "Da de alta un chofer (rol mensajero) en TU nodo. El server genera una CLAVE TEMPORAL y la devuelve (pasásela al chofer; debe cambiarla al primer ingreso). Requiere nombre, apellido (por separado, los dos obligatorios), teléfono y email. El admin global elige el nodo con `nodo`.",
1230
+ inputSchema: { nombre: z.string().min(1).describe("Nombre de pila"), apellido: z.string().min(1), telefono: z.string().min(1), email: z.string().min(3), mensajeroNombre: z.string().optional().describe("Nombre para el macheo con su cta cte (default: el nombre)"), nodo: zNodo() },
1231
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/chofer", { ...rest, logisticaId: nodo })));
1232
+ tool("chofer_editar", {
1233
+ title: "Editar un chofer",
1234
+ description: "Edita un chofer (mensajero) de TU nodo: nombre, apellido, teléfono, mensajeroNombre y/o activo (desactivar/activar). No toca credenciales.",
1235
+ inputSchema: { id: z.number().int().positive(), nombre: z.string().optional(), apellido: z.string().optional(), telefono: z.string().optional(), mensajeroNombre: z.string().optional(), activo: z.boolean().optional() },
1236
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/chofer/${id}`, body)));
1237
+ tool("cliente_generar_usuario", {
1238
+ title: "Generar usuario de login para un cliente/vendedor",
1239
+ description: "Le crea las credenciales de login a un cliente/vendedor YA EXISTENTE de TU nodo (para que entre a su portal). Buscás el cliente por nombre o id (usá `clientes_del_nodo`) y pasás el `email` con el que va a loguear, más `nombre` y `apellido` de la PERSONA que va a usar el acceso (obligatorios; no el nombre de la tienda). El server genera una CLAVE TEMPORAL y la devuelve (pasásela al cliente; debe cambiarla al primer ingreso). Yo nunca invento la clave. Si el cliente ya tiene usuario, avisa (no lo pisa). No da de alta el PERFIL del cliente (eso es `cliente_crear`) ni toca dinero. El admin global acota la búsqueda del cliente a un nodo con `nodo`.",
1240
+ inputSchema: { cliente: z.string().min(1).describe("Nombre o id del cliente/vendedor de tu nodo (de `clientes_del_nodo`)"), email: z.string().min(3).describe("Email con el que va a loguear el cliente"), nombre: z.string().min(1).describe("Nombre de pila de la persona que va a entrar (no el de la tienda)"), apellido: z.string().min(1), telefono: z.string().optional(), nodo: zNodo() },
1241
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente", { ...rest, logisticaId: nodo })));
1242
+ tool("cliente_resetear_clave", {
1243
+ title: "Resetear la clave de un cliente/vendedor",
1244
+ description: "Genera una CLAVE TEMPORAL NUEVA para un cliente/vendedor de TU nodo que YA tiene usuario de login pero perdió el acceso. El server la genera y la devuelve (pasásela; debe cambiarla al primer ingreso). Si el cliente todavía NO tiene usuario, avisa y sugiere `cliente_generar_usuario`. No toca el perfil del cliente ni dinero. El admin global acota la búsqueda del cliente a un nodo con `nodo`.",
1245
+ inputSchema: { cliente: z.string().min(1).describe("Nombre o id del cliente/vendedor de tu nodo (de `clientes_del_nodo`)"), nodo: zNodo() },
1246
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente/resetear", { ...rest, logisticaId: nodo })));
1247
+ tool("operador_crear", {
1248
+ title: "Alta de operador de nodo con clave temporal",
1249
+ description: "Da de alta un OPERADOR (staff que administra el nodo: recibe/escanea/despacha, liquida, etc., según los permisos que le des) en TU nodo. El server genera una CLAVE TEMPORAL y la devuelve (pasásela; debe cambiarla al primer ingreso). Requiere nombre, apellido, email y teléfono. `permisos` opcional (default 'logistica'): gestion, finanzas, mensajeros, reportes, logistica, wms, precios, usuarios. El admin global elige el nodo con `nodo`. NO es un vendedor (eso es `cliente_generar_usuario`) ni un chofer (`chofer_crear`); NO otorga jerarquía de admin de nodo (eso es `usuario_habilitar_rol`). Yo nunca invento la clave. No toca dinero.",
1250
+ inputSchema: { nombre: z.string().min(1).describe("Nombre de pila"), apellido: z.string().min(1), email: z.string().min(3).describe("Email con el que va a loguear"), telefono: z.string().min(1), permisos: z.array(z.string()).optional().describe("Secciones habilitadas: gestion, finanzas, mensajeros, reportes, logistica, wms, precios, usuarios. Default: logistica."), nodo: z.number().int().positive().optional().describe("Solo admin global: nodo donde crear el operador. El operador de nodo lo crea en SU nodo.") },
1251
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/operador", { ...rest, logisticaId: nodo })));
1252
+ tool("usuario_editar", {
1253
+ title: "Editar datos de un usuario",
1254
+ description: "Corrige nombre, apellido, teléfono y/o email (con el que entra) de un usuario de TU nodo, por id (de `chofer_listar`). Nombre y apellido son obligatorios: si el usuario todavía no los tiene separados, pasá los dos. Cambiar el nombre NO cambia el nombre con el que se le paga al cadete (queda fijo). No toca rol, permisos ni clave.",
1255
+ inputSchema: { id: z.number().int().positive(), nombre: z.string().optional().describe("Nombre de pila"), apellido: z.string().optional(), telefono: z.string().optional(), email: z.string().optional().describe("Email con el que entra (no puede repetirse)") },
1256
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
1257
+ tool("usuario_habilitar_reparto", {
1258
+ title: "Habilitar a un usuario como también mensajero",
1259
+ description: "Marca (o desmarca con activo=false) a un operador/admin de TU nodo como TAMBIÉN mensajero: puede escanear, autoasignarse y entregar además de administrar. No toca credenciales.",
1260
+ inputSchema: { id: z.number().int().positive(), activo: z.boolean().optional().describe("default true") },
1261
+ }, async ({ id, activo }) => run(() => api("PUT", `/usuarios/${id}/reparto`, { activo: activo ?? true })));
1262
+ }
1263
+ if (isStaff) {
1264
+ tool("ruta_publicar", {
1265
+ title: "Publicar una ruta/colecta en el marketplace",
1266
+ description: "Publicá una ruta/colecta/viaje para que CUALQUIER nodo de la red la tome (subasta abierta). VOS le pagás al que la toma. precioMax opcional (tope que ofrecés pagar). No mueve dinero (la obligación se registra al adjudicar).",
1267
+ inputSchema: { titulo: z.string().min(1), tipo: z.enum(["ruta", "colecta", "viaje"]).optional(), descripcion: z.string().optional(), zona: z.string().optional(), precioMax: z.number().optional().describe("En pesos (ARS)") },
1268
+ }, async (args) => run(() => api("POST", "/rutas-publicas", args)));
1269
+ tool("ruta_ofertar", {
1270
+ title: "Ofertar por una ruta pública",
1271
+ description: "Ofertá por una publicación de OTRO nodo: `precio` = lo que cobrás por hacerla (subasta: más barato es mejor para el que publica). Si ya ofertaste, actualiza tu oferta. No podés ofertar en la tuya.",
1272
+ inputSchema: { publicacionId: z.number().int().positive(), precio: z.number().describe("En pesos (ARS)"), nota: z.string().optional() },
1273
+ }, async (args) => run(() => api("POST", `/rutas-publicas/${args.publicacionId}/ofertar`, { precio: args.precio, nota: args.nota })));
1274
+ tool("ruta_adjudicar", {
1275
+ title: "Adjudicar una ruta pública (elegir ganador)",
1276
+ description: "Elegí la oferta ganadora de una publicación TUYA (los ids salen de «ruta_ofertas»). Cierra la subasta: el nodo ganador la toma al precio de su oferta (queda registrada la obligación). NO genera facturación/rendición/comisión ni clearing automático: el pago entre nodos se salda MANUALMENTE (ver «ayuda» tema:facturacion_marketplace).",
1277
+ inputSchema: { publicacionId: z.number().int().positive(), ofertaId: z.number().int().positive() },
1278
+ }, async ({ publicacionId, ofertaId }) => run(() => api("POST", `/rutas-publicas/${publicacionId}/adjudicar`, { ofertaId })));
1279
+ }
1280
+ if (isStaff) {
1281
+ tool("envio_reasignar_cliente", {
1282
+ title: "Reasignar un envío a otro cliente",
1283
+ description: "Mueve UN envío (por tracking) a otro cliente/vendedor de TU nodo cuando se cargó mal. Deja registro en el historial. Igual que la función del frontend.",
1284
+ inputSchema: { tracking: z.string().min(1), cliente: z.string().min(1).describe("nombre o id del cliente destino (de tu nodo)") },
1285
+ }, async (args) => run(() => api("POST", "/envios/reasignar-cliente", args)));
1286
+ tool("envio_mensajero_externo", {
1287
+ title: "Asignar un mensajero EXTERNO a envíos",
1288
+ description: "Registra que uno o varios envíos los hace un mensajero EXTERNO (alguien de AFUERA de la plataforma, que no tiene usuario). Quedan En camino a nombre de esa persona con su valor manual, así la entrega queda trazada y entra igual en la liquidación de mensajeros. Pasá `valorPorEnvio` para fijar cuánto se le paga por envío; si no lo pasás, figuran como FALTA VALOR hasta completarlo. No reasigna envíos ya entregados. No mueve dinero (registra el valor a pagar, no lo paga).",
1289
+ inputSchema: { envioIds: z.array(z.number().int().positive()).describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), nombre: z.string().min(1).describe("Nombre de la persona que hace la entrega"), valorPorEnvio: z.number().optional().describe("Cuánto se le paga por envío"), nodo: zNodo() },
1290
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/envios/mensajero-externo", { ...rest, logisticaId: nodo })));
1291
+
1292
+ tool("envio_editar_zona", {
1293
+ title: "Corregir zona/localidad/partido/CP de un envío",
1294
+ description: "Corrige o COMPLETA los datos de UN envío (por tracking) para que se pueda cobrar y rutear. Dos usos: (1) CORREGIR un destino mal cargado —dos localidades pisadas ('san martin - Lanús'), el CP con la altura de la calle o con formato roto ('1.832,00')—; (2) COMPLETAR un envío que entró por el ESCANEO DEL QR del vendedor, que trae de quién es el paquete pero NO a dónde va: nace sin destinatario, sin dirección y sin zona, y así se liquida en $0 sin que nadie se entere. Acepta localidad, zona, partido, cp, destinatario, telefono y direccion; recalcula la zona y deja registro en el historial. ⚠️ En un envío YA ENTREGADO los datos del destino se COMPLETAN solo si están vacíos —nunca se pisan, porque cambiarle el destino a algo ya entregado es reescribir la historia—; lo que sí se puede corregir siempre es zona/localidad/partido/CP, que es lo que arregla la liquidación. Los campos que no se pisaron vuelven en `noPisados`. Es un arreglo PUNTUAL de ESE envío: no crea un alias global. Staff: solo envíos de tu nodo. No mueve dinero.",
1295
+ inputSchema: {
1296
+ tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1297
+ localidad: z.string().optional().describe("Localidad real del destino (ej. 'Lanús')"),
1298
+ zona: z.string().optional().describe("Metazona para cobrar/rutear; si no la pasás se deriva de la localidad"),
1299
+ cp: z.string().optional().describe("Código postal real (se guarda solo con dígitos: '1.832,00' → '1832'). Vacío = borrarlo."),
1300
+ partido: z.string().optional().describe("Partido/municipio del destino (para la etiqueta)"),
1301
+ },
1302
+ }, async (args) => run(() => api("POST", "/envios/editar-zona", args)));
1303
+ tool("envio_editar_fecha", {
1304
+ title: "Corregir la fecha de un envío ya cargado",
1305
+ description: "Cambia la FECHA de UN envío (por tracking) que quedó con el día en que se cargó y no con el día en que el paquete se movió — el caso típico: una planilla vieja que se subió sin `fecha`. Es la que define en qué SEMANA se le liquida al vendedor, así que corregirla mueve el envío de período. No admite fecha futura, y si el período ya está liquidado te lo rechaza con el número de liquidación (rehacerla es decisión tuya, no un efecto colateral). El primer estado del historial se mueve con el envío; los demás quedan como se registraron. Solo staff, y solo envíos de tu nodo (admin global: cualquiera). No mueve dinero.",
1306
+ inputSchema: {
1307
+ tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1308
+ fecha: z.string().min(1).describe("Fecha real del envío (dd/mm/aaaa o aaaa-mm-dd). No se admite futura."),
1309
+ },
1310
+ }, async (args) => run(() => api("POST", "/envios/editar-fecha", args)));
1311
+ tool("envio_asignar_grupo", {
1312
+ title: "Asignar un envío a un grupo logístico (despachar)",
1313
+ description: "Asigna/rutea UN envío (por tracking) a un GRUPO logístico tuyo (por NOMBRE o id) — igual que la acción 'Asignar grupo' del escáner. Define con qué grupo salió (tarifa del clearing) y, si el envío TODAVÍA NO SALIÓ, lo DESPACHA: pasa a 'En camino' (sale del centro de distribución) y el vendedor lo ve así. Si el envío YA está 'Entregado' o 'En camino', NO se le toca el estado ni se le agrega un evento: solo queda registrado con qué grupo salió — por eso sirve para corregir el clearing de una planilla vieja ya entregada (mirá `estadoIntacto` en la respuesta). Si ya estaba en ese grupo te lo dice (`yaEstaba`) y no reescribe nada. Acepta VARIOS códigos separados por coma o salto de línea y devuelve el resumen. Solo grupos a los que pertenece tu nodo (el admin global, cualquiera) y envíos de tu nodo. No mueve dinero (el clearing es config).",
1314
+ inputSchema: { tracking: z.string().min(1).describe("Tracking del envío. Podés mandar VARIOS separados por coma o salto de línea (un QR de ML crudo va solo, no se parte)"), grupo: z.string().min(1).describe("Nombre o id del grupo logístico de tus grupos (ej. 'Portela', 'Bonorino')") },
1315
+ }, async (args) => run(() => api("POST", "/envios/asignar-grupo-ref", args)));
1316
+ tool("cobro_corregir", {
1317
+ title: "Corregir el monto de un cobro",
1318
+ description: "Ajusta el monto a cobrar contra entrega de un envío (cuando se cobró de más o de menos), guardando el monto ORIGINAL en el historial. envioId + monto nuevo. No mueve dinero en cuentas (corrige el dato del envío).",
1319
+ inputSchema: { envioId: z.number().int().positive().describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), monto: z.number().describe("En pesos (ARS)"), motivo: z.string().optional() },
1320
+ }, async ({ envioId, monto, motivo }) => run(() => api("POST", `/envios/${envioId}/corregir-cobro`, { monto, motivo })));
1321
+ tool("rendicion_revertir", {
1322
+ title: "Revertir una rendición (marcada por error)",
1323
+ description: "Revierte un cobro/cambio marcado como 'rendido' cuando en realidad NO se rindió → vuelve a 'a rendir'. Deja registro (quién/cuándo). envioId + tipo (cobro|cambio). Scopeado a tu nodo.",
1324
+ inputSchema: { envioId: z.number().int().positive().describe("El `id` del envío que devuelve `envio_consultar` (no el tracking)"), tipo: z.enum(["cobro", "cambio"]), motivo: z.string().optional() },
1325
+ }, async (args) => run(() => api("POST", "/flujo/rendicion/revertir", args)));
1326
+ }
1327
+ if (puede("gestion")) {
1328
+ tool("colecta_configurar", {
1329
+ title: "Configurar valor de colecta",
1330
+ description: "Setea un valor de colecta según alcance: 'nodo' = pago default del nodo (ej. $3000); 'mensajero' = default de ese mensajero (ej. $4000, cualquier colecta); 'cliente' = cuánto se le COBRA al vendedor (idCliente + valor, opcional minEnvios); 'par' = pago pactado a un mensajero por colectar a un cliente (idCliente + mensajero + valor). Scopeado a tu nodo (el admin global elige el nodo con `nodo`). No mueve dinero (config de tarifa).",
1331
+ inputSchema: {
1332
+ alcance: z.enum(["nodo", "mensajero", "cliente", "par"]),
1333
+ valor: z.number(),
1334
+ idCliente: z.string().optional(),
1335
+ mensajero: z.string().optional().describe("Nombre del mensajero (de tu nodo)"),
1336
+ minEnvios: z.number().optional().describe("Solo alcance=cliente: mínimo de envíos para colecta sin cargo"),
1337
+ nodo: zNodo(),
1338
+ },
1339
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/config", { ...rest, logisticaId: nodo })));
1340
+
1341
+ tool("colecta_fija", {
1342
+ title: "Colecta fija de un vendedor (días)",
1343
+ description: "Define la COLECTA FIJA de un vendedor: los días en que se lo va a buscar SIEMPRE, tenga o no envíos cargados. Es para los que no vinculan sus cuentas ni cargan envíos — los paquetes entran cuando el cadete los escanea en la puerta. Aparecen solos en `colecta_pendientes` esos días. `dias` acepta números (0=domingo … 6=sábado) o nombres (lunes, martes…); `dias:[]` quita la colecta fija. Config de AGENDA, no de dinero.",
1344
+ inputSchema: { idCliente: z.string().describe("id del cliente (de clientes_del_nodo)"), dias: z.array(z.union([z.number(), z.string()])).describe("Días: [1,2,3,4,5] o [\"lunes\",\"miércoles\"]. [] = quitar."), nodo: zNodo() },
1345
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/fija", { ...rest, logisticaId: nodo })));
1346
+
1347
+ tool("colecta_asignar", {
1348
+ title: "Asignar una colecta a un mensajero",
1349
+ description: "Asigna la colecta (retiro de mercadería) de un cliente a un mensajero, ambos por NOMBRE de tu nodo (ej. «que Maxi levante al cliente Distri Sur»). Vincula los envíos 'A retirar' de ese cliente a la colecta y avisa al mensajero. FUNCIONA AUNQUE EL CLIENTE NO TENGA ENVÍOS CARGADOS (vendedores que no vinculan sus cuentas): se crea la colecta vacía y el cadete escanea cada paquete en la puerta — los que no están en el sistema se crean solos a nombre del vendedor, sin contar dos veces el mismo QR. Si el corte venció, la programa para el próximo día hábil. No mueve dinero (es logística). El admin global acota la resolución por nombre con `nodo`.",
1350
+ inputSchema: {
1351
+ cliente: z.string().min(1).describe("Nombre del cliente/vendedor a colectar"),
1352
+ mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo) que hace el retiro"),
1353
+ nodo: zNodo(),
1354
+ },
1355
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/asignar-por-nombre", { ...rest, logisticaId: nodo })));
1356
+
1357
+ tool("retiros_cargar", {
1358
+ title: "Cargar la lista de retiros del día",
1359
+ description: "Carga de una vez la LISTA DE RETIROS (colectas) del día: le pasás las direcciones y crea un retiro por cada una. OJO, no confundir con `colecta_asignar`, que es ir a levantarle los paquetes a UN vendedor; esto es la ronda de paradas donde el cadete va a BUSCAR (no tienen destinatario ni teléfono, solo dirección). Si vienen numeradas ('1. Helguera 936'), el número se usa como orden de ruta y se saca de la dirección. `zona` es la que cobra y paga por la tabla Retiros ('Retiro en CABA' o 'Retiro en GBA'): no la inventes. El cadete se asigna a TODA la lista y se escribe también el nombre por el que se le PAGA (el resumen de pago agrupa por nombre, no por el vínculo). Si mandás dos veces la misma dirección para el mismo cliente y día, no se duplica: vuelve en `duplicados`. Quedan en 'A retirar'; para cerrarlas usá `envio_completar_ciclo`. No mueve dinero: registra el trabajo, no lo paga.",
1360
+ inputSchema: {
1361
+ cliente: z.string().min(1).describe("Cliente al que se le cargan los retiros (nombre o id, de tu nodo)"),
1362
+ direcciones: z.array(z.string().min(1)).min(1).describe("Una dirección por elemento, en el orden de la ruta. Pueden venir numeradas ('1. Helguera 936, CABA') o sueltas."),
1363
+ zona: z.string().optional().describe("'Retiro en CABA' (default) o 'Retiro en GBA'. Es la metazona que cobra y paga."),
1364
+ mensajero: zStr().describe("Nombre o id de quien hace TODA la ronda. Si el nombre matchea a varios, te los lista con su id en vez de elegir por vos."),
1365
+ fecha: zStr().describe("dd/mm/aaaa o aaaa-mm-dd si NO es hoy (define la semana en que se liquida). No admite fecha futura."),
1366
+ nodo: zNodo(),
1367
+ },
1368
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/retiros", { ...rest, logisticaId: nodo })));
1369
+
1370
+ tool("colecta_desasignar", {
1371
+ title: "Desasignar una colecta",
1372
+ description: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta'). El colectaId lo devuelve «colecta_pendientes». Solo colectas de tu nodo (el admin global puede acotar con `nodo`). No mueve dinero.",
1373
+ inputSchema: { colectaId: z.number().int().positive(), nodo: zNodo() },
1374
+ }, async ({ colectaId, nodo }) => run(() => api("POST", "/colecta/desasignar", { colectaId, logisticaId: nodo })));
1375
+
1376
+ tool("procesar_zona", {
1377
+ title: "Aceptar envíos ruteados por zona",
1378
+ description: "ACEPTA (recibe en tu nodo) los envíos que otros nodos te rutearon por zona — los ids salen de «envios_por_zona». Opcional: mensajeroId para asignarlos a un mensajero puntual de esa zona. No mueve dinero (el clearing es config). El admin global elige el nodo receptor con `nodo`.",
1379
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), mensajeroId: z.number().int().positive().optional(), nodo: zNodo() },
1380
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/procesar-zona", { ...rest, logisticaId: nodo })));
1381
+
1382
+ tool("rechazar_zona", {
1383
+ title: "Rechazar envíos ruteados por zona",
1384
+ description: "RECHAZA (declina) envíos que te rutearon por zona: dejan de aparecerte y quedan para el nodo de origen u otros nodos de la zona. No cambia el estado del envío. Los ids salen de «envios_por_zona». El admin global elige el nodo con `nodo`.",
1385
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), motivo: z.string().optional(), nodo: zNodo() },
1386
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/rechazar-zona", { ...rest, logisticaId: nodo })));
1387
+
1388
+ tool("cliente_crear", {
1389
+ title: "Crear cliente / vendedor",
1390
+ description: "Da de alta un cliente en TU nodo (el backend fuerza el nodo). Podés asignarle la lista de precio con idLista. No toca dinero. El admin global elige el nodo con `nodo`.",
1391
+ inputSchema: {
1392
+ nombre: z.string().min(1).describe("Nombre del cliente"),
1393
+ telefono: z.string().optional(),
1394
+ dni: z.string().optional(),
1395
+ direccion: z.string().optional(),
1396
+ idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo' / 'precios_ver'. Si falta, el alta queda incompleta."),
1397
+ nodo: zNodo(),
1398
+ },
1399
+ }, async ({ nodo, ...rest }) => {
1400
+ const r = await api("POST", "/gestion/clientes", { ...rest, logisticaId: nodo });
1401
+ const base = toResult(r);
1402
+ // Hint de completitud (Fase A): sin lista de precios el alta está a medias → guío el próximo paso.
1403
+ if (r.ok && !rest.idLista) {
1404
+ base.content.push({ type: "text", text: "\n⚠️ FALTA LA LISTA DE PRECIOS — el alta está incompleta.\nPreguntale al operador: «¿qué le cobrás a este cliente?»\n • ¿La lista OFICIAL de Flex (la que usan todos)?\n • ¿Un precio PROPIO? Mirá las disponibles con `precios_ver` (el nombre está en 'referencia'); si es nueva, se crea en Precios con un nombre para reusarla.\nAsigná la lista con `cliente_editar idLista:<id>`. Ver `flujo alta_cliente`." });
1405
+ }
1406
+ return base;
1407
+ });
1408
+
1409
+ tool("cliente_editar", {
1410
+ title: "Editar cliente (solo lo que pasás)",
1411
+ description: "Edita un cliente de TU nodo cambiando SOLO lo que pasás: lo que no mandás queda como está. Para VACIAR un campo mandalo vacío (\"\"). `email` cambia el email con el que ENTRA el usuario del cliente (tiene que tener usuario generado; requiere permiso de usuarios; no puede repetirse). No toca dinero. El admin global puede mover el cliente a otro nodo con `nodo`.",
1412
+ inputSchema: {
1413
+ id: z.number().int().positive().describe("ID del cliente (de clientes_del_nodo)"),
1414
+ nombre: z.string().optional().describe("Solo si lo querés cambiar"),
1415
+ nombreFantasia: z.string().optional(),
1416
+ idLista: z.string().optional().describe("Lista de precios a asignar"),
1417
+ telefono: z.string().optional(),
1418
+ email: z.string().optional().describe("Email con el que entra su usuario"),
1419
+ dni: z.string().optional(),
1420
+ direccion: z.string().optional(),
1421
+ mensajeroNombre: z.string().optional().describe("Nombre con el que cobra si también reparte"),
1422
+ activo: z.boolean().optional(),
1423
+ nodo: zNodo(),
1424
+ },
1425
+ }, async ({ id, nodo, ...body }) => run(() => api("PATCH", `/gestion/clientes/${id}`, { ...body, ...(nodo !== undefined ? { logisticaId: nodo } : {}) })));
1426
+ tool("sucursales_cliente", {
1427
+ title: "Sucursales de un cliente (con su perfil de zona)",
1428
+ description: "Lista las sucursales (puntos de retiro) de un cliente de TU nodo con su id, nombre, dirección y PERFIL DE ZONA. Sirve para ver/ajustar con qué perfil cotiza cada sucursal (útil cuando un cliente tiene sucursales en zonas distintas). Solo lectura.",
1429
+ inputSchema: { cliente: z.number().int().positive().describe("id del cliente (de `clientes_del_nodo`)") },
1430
+ }, async ({ cliente }) => run(() => api("GET", `/zonificacion/sucursales${q({ cliente })}`)));
1431
+ tool("sucursal_perfil", {
1432
+ title: "Setear el perfil de zona de una sucursal",
1433
+ description: "Define (o limpia con perfilZona vacío) el PERFIL DE ZONA de una sucursal — con eso la liquidación cotiza los envíos que salen de esa sucursal según SU distancia (no la del cliente). El id de la sucursal sale de `sucursales_cliente`. Perfil vacío = la sucursal hereda el perfil del cliente. Config de staff (NO lo toca el vendedor); no mueve dinero, pero afecta el tramo/precio.",
1434
+ inputSchema: { sucursalId: z.number().int().positive().describe("id de la sucursal (de `sucursales_cliente`)"), perfilZona: z.string().optional().describe("nombre del perfil (ej. GENERAL, MORENO). Vacío = hereda el del cliente.") },
1435
+ }, async ({ sucursalId, perfilZona }) => run(() => api("POST", "/zonificacion/sucursal-perfil", { sucursalId, perfilZona: perfilZona ?? null })));
1436
+ }
1437
+
1438
+ if (puede("finanzas")) {
1439
+ tool("liquidacion_excluir_envio", {
1440
+ title: "No cobrar un envío de una liquidación",
1441
+ description:
1442
+ "Saca UN envío (por tracking) del cobro de una liquidación YA emitida y re-suma el total, ajustando el cargo en la cuenta corriente del cliente. El `motivo` es OBLIGATORIO: queda en el historial del envío y el vendedor lo ve en su portal. Si esa liquidación ya está en una factura de ARCA emitida, lo rechaza (haría falta una nota de crédito, que el sistema no emite). Scopeada a tu nodo.",
1443
+ inputSchema: {
1444
+ id: z.number().int().positive().describe("id de la liquidación"),
1445
+ tracking: z.string().min(1),
1446
+ motivo: z.string().min(10).describe("Por qué no se le cobra. Lo lee el vendedor."),
1447
+ },
1448
+ }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/excluir`, { tracking, motivo })));
1449
+ tool("liquidacion_reincluir_envio", {
1450
+ title: "Volver a cobrar un envío de una liquidación",
1451
+ description:
1452
+ "Lo inverso de `liquidacion_excluir_envio`: devuelve al cobro un envío que se había excluido, re-suma y ajusta la cuenta corriente. El `motivo` es OBLIGATORIO. Mismo guard de factura emitida.",
1453
+ inputSchema: {
1454
+ id: z.number().int().positive().describe("id de la liquidación"),
1455
+ tracking: z.string().min(1),
1456
+ motivo: z.string().min(10).describe("Por qué sí se le cobra."),
1457
+ },
1458
+ }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/reincluir`, { tracking, motivo })));
1459
+ tool("liquidacion_cobro_parcial", {
1460
+ title: "Cobro en destino parcial de un envío",
1461
+ description: "Cuando el comprador le pagó al cadete MENOS que el flete (ej. el envío era zona media y se cobró como cercana): lo cobrado se da por pagado y el envío sigue en la liquidación YA emitida por la diferencia (flete − cobrado). `montoCobrado` 0 quita el ajuste y vuelve a cobrar el flete entero. Si lo cobrado cubre todo el flete usá `liquidacion_excluir_envio`. El `motivo` es OBLIGATORIO: queda en el historial del envío y el vendedor lo ve en su portal. Rechaza si la liquidación ya está en una factura de ARCA emitida y los viajes especiales. Ajusta el total y la cuenta corriente. No mueve dinero. Scopeada a tu nodo. Requiere permiso finanzas.",
1462
+ inputSchema: {
1463
+ id: z.number().int().positive().describe("id de la liquidación"),
1464
+ tracking: z.string().min(1),
1465
+ montoCobrado: z.number().min(0).describe("Lo que se cobró en destino, en pesos (ARS). 0 = quitar el ajuste."),
1466
+ motivo: z.string().min(10).describe("Por qué (ej. 'zona media cobrada como cercana'). Lo lee el vendedor."),
1467
+ },
1468
+ }, async ({ id, tracking, montoCobrado, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/cobro-parcial`, { tracking, montoCobrado, motivo })));
1469
+ tool("reclamo_responder", {
1470
+ title: "Responder o cerrar un reclamo de un vendedor",
1471
+ description:
1472
+ "Responde el reclamo que un vendedor dejó sobre su liquidación (el `id` sale de `reclamos_listar`). La respuesta la lee el vendedor en su portal (Mi Cuenta). `cerrar:true` además lo pasa a resuelto; `reabrir:true` lo vuelve a abierto. Es lo mismo que Reclamos de vendedores en la app. Para corregir lo cobrado primero usá `liquidacion_excluir_envio` / `liquidacion_reincluir_envio` y contale el resultado en la respuesta. Solo texto y estado: no mueve dinero. Scopeado a tu nodo. Requiere permiso finanzas.",
1473
+ inputSchema: {
1474
+ id: z.number().int().positive().describe("id del reclamo (de `reclamos_listar`)"),
1475
+ respuesta: z.string().min(5).optional().describe("Texto para el vendedor (obligatorio salvo que solo reabras)"),
1476
+ cerrar: z.boolean().optional().describe("true = dejarlo resuelto"),
1477
+ reabrir: z.boolean().optional().describe("true = volver a abierto"),
1478
+ },
1479
+ }, async ({ id, respuesta, cerrar, reabrir }) => run(async () => {
1480
+ if (!respuesta && !reabrir) throw new Error("Falta la respuesta para el vendedor.");
1481
+ if (cerrar && reabrir) throw new Error("No podés cerrar y reabrir a la vez.");
1482
+ return api("PUT", `/liquidaciones/observaciones/${id}`, { ...(respuesta ? { respuesta } : {}), ...(cerrar ? { estado: "resuelto" } : reabrir ? { estado: "abierto" } : {}) });
1483
+ }));
1484
+ tool("facturacion_preparar_cliente", {
1485
+ title: "Dejar un cliente listo para facturar",
1486
+ description:
1487
+ "Carga los datos fiscales de un cliente y lo habilita: CUIT, razón social (el TITULAR del CUIT, NO el nombre de fantasía), condición frente al IVA, en qué CUENTA cobra (eso define con qué CUIT se le factura) y si factura semanal o mensual. El corte queda en HOY salvo que pases 'desde': lo anterior se facturó por fuera del sistema y volver a emitirlo sería un comprobante duplicado. NO emite comprobantes — el MCP no factura; después se emite desde la PWA o desde el portal del cliente. Preguntá TODOS los datos antes de ejecutar: no inventes un CUIT ni una razón social. Ver el flujo 'facturacion'.",
1488
+ inputSchema: {
1489
+ cliente: z.string().min(1).describe("Nombre o id del cliente"),
1490
+ cuit: z.string().min(1).describe("CUIT del cliente, 11 dígitos"),
1491
+ razonSocial: z.string().min(1).describe("Razón social / titular del CUIT, como figura en ARCA"),
1492
+ condicionIVA: z.string().min(1).describe("RI | MONOTRIBUTO | EXENTO | CONSUMIDOR_FINAL"),
1493
+ cuentaCobro: z.string().optional().describe("Nombre o id de la cuenta de dinero donde cobra (define quién factura)"),
1494
+ frecuencia: z.string().optional().describe("Semanal (default) o Mensual"),
1495
+ desde: z.string().optional().describe("Corte YYYY-MM-DD; vacío = hoy"),
1496
+ },
1497
+ }, async (a) => run(() => api("POST", "/afip/preparar-cliente", a)));
1498
+ }
1499
+
1500
+ if (puede("wms") && wmsActivo) {
1501
+ tool("producto_crear", {
1502
+ title: "Crear producto (WMS)",
1503
+ description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero. El admin global elige el nodo con `nodo`.",
1504
+ inputSchema: {
1505
+ nombre: z.string().min(1),
1506
+ sku: z.string().optional(),
1507
+ codigoBarra: z.string().optional(),
1508
+ peso: z.number().optional(),
1509
+ volumen: z.number().optional(),
1510
+ idCliente: z.string().optional().describe("idCliente dueño del producto"),
1511
+ nodo: zNodo(),
1512
+ },
1513
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/wms/productos", { ...rest, logisticaId: nodo })));
1514
+ }
1515
+
1516
+ if (esGlobal) {
1517
+ tool("nodo_crear", {
1518
+ title: "Crear nodo (logística)",
1519
+ description: "Da de alta un nodo/logística nuevo. Solo admin global. No toca dinero.",
1520
+ inputSchema: {
1521
+ nombre: z.string().min(1).describe("Nombre del nodo/logística"),
1522
+ telefono: z.string().optional(),
1523
+ },
1524
+ }, async ({ nombre, telefono }) => run(() => api("POST", "/logisticas", { nombre, telefono: telefono ?? null })));
1525
+ }
1526
+ }
1527
+
1528
+ // ============================================================================
1529
+ // EDICIÓN DE PRECIOS (flag aparte · sensible pero NO mueve dinero).
1530
+ // ============================================================================
1531
+ if (ALLOW_PRECIOS && puede("precios")) {
1532
+ tool("precio_actualizar", {
1533
+ title: "Actualizar precio de una lista (versionado)",
1534
+ description: "Cambia los precios por zona de una lista creando una VERSIÓN nueva (histórico exacto). Requiere permiso 'precios'; el backend impide tocar listas de otro nodo. No mueve dinero. El admin global elige el nodo de la lista con `nodo`.",
1535
+ inputSchema: {
1536
+ idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
1537
+ cercana: z.number().optional().describe("Precio zona cercana, en pesos (ARS)"),
1538
+ media: z.number().optional().describe("Precio zona media, en pesos (ARS)"),
1539
+ lejana: z.number().optional().describe("Precio zona lejana, en pesos (ARS)"),
1540
+ muyLejana: z.number().optional().describe("Precio zona muy lejana, en pesos (ARS)"),
1541
+ referencia: z.string().optional(),
1542
+ vigenciaDesde: z.string().optional().describe("Fecha YYYY-MM-DD desde cuándo rige (default: hoy)"),
1543
+ nodo: zNodo(),
1544
+ },
1545
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/precios/clientes/version", { ...rest, logisticaId: nodo })));
1546
+ }
1547
+
1548
+ // Ayuda integrada — se registra al final para que el índice conozca TODOS los tools
1549
+ // que este usuario tiene según su rol/permisos. Solo lectura de documentación.
1550
+ tool("ayuda", {
1551
+ title: "Ayuda / documentación de las herramientas",
1552
+ description: "Documentación de las herramientas del MCP: sin argumentos = índice de lo que podés usar; `tool` = ficha de una herramienta (qué hace, cómo usarla, ejemplo, qué NO hace); `tema` = tópico transversal (facturacion_marketplace, dinero, aislamiento). Consultalo antes de asumir cómo funciona algo.",
1553
+ inputSchema: { tool: z.string().optional().describe("Nombre de una herramienta, ej. ruta_adjudicar"), tema: z.string().optional().describe("facturacion_marketplace | dinero | aislamiento") },
1554
+ }, async ({ tool: t, tema }) =>
1555
+ ({ content: [{ type: "text", text: renderAyuda(t || tema, { version: MCP_VERSION, disponibles: registrados }) }] }));
1556
+
1557
+ const transport = new StdioServerTransport();
1558
+ await server.connect(transport);
1559
+ const cap = isCliente ? "cliente" : esGlobal ? "admin global" : isStaff ? "staff de nodo" : "sin identidad";
1560
+ log(`MCP Nexus Flex v3 listo. Rol: ${cap}. Escritura: ${ALLOW_WRITE ? "ON" : "off"} · Precios: ${ALLOW_PRECIOS ? "ON" : "off"}.`);