nexusflex-mcp 3.69.0 → 3.70.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.
Files changed (3) hide show
  1. package/docs.mjs +339 -373
  2. package/package.json +1 -1
  3. package/server.mjs +53 -50
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexusflex-mcp",
3
- "version": "3.69.0",
3
+ "version": "3.70.0",
4
4
  "description": "MCP de Nexus Flex: operá tu nodo/cuenta desde un asistente de IA (altas de clientes, stock, KPIs). Login por autorización web (device-flow). NO toca dinero.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  // ============================================================================
26
26
  import { runDeviceFlow, saveToken, clearToken, tokenFilePath } from "./device-auth.mjs";
27
27
  import { api, log, API_URL } from "./api.mjs";
28
- import { renderAyuda, guiaOnboarding, renderFlujo } from "./docs.mjs";
28
+ import { renderAyuda, guiaOnboarding, renderFlujo, FLUJOS } from "./docs.mjs";
29
29
 
30
30
  // --- Subcomandos de línea de comando (login/logout) antes de arrancar el server ---
31
31
  const cmd = process.argv[2];
@@ -54,8 +54,12 @@ const truthy = (v) => /^(1|true|yes|si|sí)$/i.test(v ?? "");
54
54
  const ALLOW_WRITE = truthy(process.env.NEXUSFLEX_MCP_ALLOW_WRITE);
55
55
  const ALLOW_PRECIOS = truthy(process.env.NEXUSFLEX_MCP_ALLOW_PRECIOS);
56
56
 
57
- const MCP_VERSION = "3.69.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
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.70.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
58
60
  const NOVEDADES = [
61
+ "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).",
62
+ "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.",
59
63
  "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.",
60
64
  "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.",
61
65
  "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.",
@@ -123,7 +127,7 @@ const NOVEDADES = [
123
127
  "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.",
124
128
  "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.",
125
129
  ];
126
- const INSTRUCCIONES = "MCP de Nexus Flex. Para arrancar rápido llamá al tool `guia` (metodología según tu rol) y `ayuda` (índice). El MCP NUNCA mueve dinero.\n\nFLUJOS GUIADOS: cuando el usuario quiera hacer algo que tenga un flujo (alta de cliente/operador/chofer, planilla de paquetes ML, etc.), llamá PRIMERO a `flujo` con ese objetivo y SEGUÍ el paso a paso: preguntá lo que falte y NO des la tarea por terminada hasta completar todos los pasos (perfil de zona, lista de precios, colecta/geoposición, lo que corresponda).\n\nCONSTRUCTOR DE FLUJOS: si tuviste que hacer VARIAS preguntas para descubrir qué quería el usuario (no lo pudo pedir directo), registralo con `flujo_registrar` (objetivo + pasos) para convertirlo en un flujo directo.";
130
+ 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.";
127
131
  const server = new McpServer({ name: "nexusflex", version: MCP_VERSION }, { instructions: INSTRUCCIONES });
128
132
 
129
133
  /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
@@ -222,8 +226,8 @@ tool("guia", {
222
226
  // Flujo guiado: paso a paso de un objetivo para no dejar nada incompleto (Fase A).
223
227
  tool("flujo", {
224
228
  title: "Flujo guiado paso a paso",
225
- 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` (ej. alta_cliente, alta_operador, alta_chofer, control_por_foto). Usalo para GUIAR al usuario que no sabe qué datos faltan.",
226
- inputSchema: { objetivo: z.string().optional().describe("alta_cliente | alta_operador | alta_chofer | control_por_foto (vacío = lista los flujos)") },
229
+ 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.",
230
+ inputSchema: { objetivo: z.string().optional().describe(`Vacío = lista los flujos disponibles. Claves: ${Object.keys(FLUJOS).join(", ")}.`) },
227
231
  }, async ({ objetivo }) => ({ content: [{ type: "text", text: renderFlujo(objetivo) }] }));
228
232
 
229
233
  // Sugerencias: cualquier usuario (todos los roles) puede proponer funciones/mejoras.
@@ -239,7 +243,7 @@ tool("mis_sugerencias", {
239
243
  }, async () => run(() => api("GET", "/feedback/mis-sugerencias")));
240
244
  tool("flujo_registrar", {
241
245
  title: "Registrar un flujo aprendido (indagación)",
242
- description: "Llamalo cuando el usuario NO pudo pedir algo directo y tuviste que hacerle VARIAS preguntas para descubrir qué quería, hasta lograrlo. Registrás `objetivo` (lo que terminó queriendo) y `pasos` (las preguntas/decisiones que lo aclararon + herramientas usadas). Sirve para construir un FLUJO DIRECTO. No mueve dinero.",
246
+ 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.",
243
247
  inputSchema: { objetivo: z.string().min(1), pasos: z.string().min(1) },
244
248
  }, async ({ objetivo, pasos }) => run(() => api("POST", "/feedback/sugerencias", { categoria: "mejora", mensaje: `[FLUJO APRENDIDO] Objetivo: ${objetivo}\nPasos que lo aclararon: ${pasos}` })));
245
249
 
@@ -249,14 +253,14 @@ tool("flujo_registrar", {
249
253
  if (isCliente) {
250
254
  tool("mis_envios", {
251
255
  title: "Mis envíos",
252
- description: "Tus envíos/paquetes (solo los tuyos). No incluye datos de otros clientes ni del nodo.",
253
- inputSchema: { estado: z.string().optional().describe("Filtrar por estado (opcional)") },
256
+ 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.",
257
+ inputSchema: { estado: z.string().optional().describe("Estado: 'A retirar', 'Colectado', 'En centro de distribución', 'En camino', 'Entregado' (o una novedad, ej. 'Comprador ausente'). Ojo: hoy el backend no aplica este filtro y devuelve todos; filtrá vos por el campo `estado` de cada envío.") },
254
258
  }, async ({ estado }) => run(() => api("GET", `/portal/envios${q({ estado })}`)));
255
259
 
256
260
  tool("mis_kpis", {
257
261
  title: "Mis métricas",
258
262
  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).",
259
- inputSchema: { desde: z.string().optional(), hasta: z.string().optional() },
263
+ 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") },
260
264
  }, async ({ desde, hasta }) => run(() => api("GET", `/kpi/vendedor${q({ desde, hasta })}`)));
261
265
 
262
266
  // --- Control de stock del vendedor (WMS Fase 1). Se registran SIEMPRE para
@@ -330,7 +334,7 @@ if (isStaff) {
330
334
  description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
331
335
  inputSchema: {},
332
336
  }, async () => run(() => api("GET", "/gestion/formularios")));
333
- 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: z.number().optional() } },
337
+ 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() } },
334
338
  async ({ nodo }) => run(() => api("GET", `/sin-vendedor${q({ nodo })}`)));
335
339
  if (ALLOW_WRITE)
336
340
  tool("asignar_sin_vendedor", {
@@ -366,7 +370,7 @@ if (isStaff) {
366
370
  tool("kpi_nodo", {
367
371
  title: "Métricas del nodo",
368
372
  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.",
369
- inputSchema: { desde: z.string().optional(), hasta: z.string().optional(), nodo: z.number().optional().describe("Solo para admin global: elegir nodo") },
373
+ 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() },
370
374
  }, async ({ desde, hasta, nodo }) => run(() => api("GET", `/kpi/nodo${q({ desde, hasta, nodo })}`)));
371
375
  }
372
376
 
@@ -374,19 +378,19 @@ if (isStaff) {
374
378
  tool("stock_nodo", {
375
379
  title: "Stock del nodo",
376
380
  description: "Stock del depósito del nodo. Opcional: filtrar por un cliente.",
377
- inputSchema: { cliente: z.string().optional().describe("idCliente para filtrar (opcional)") },
381
+ inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
378
382
  }, async ({ cliente }) => run(() => api("GET", `/wms/stock${q({ cliente })}`)));
379
383
 
380
384
  tool("productos_nodo", {
381
385
  title: "Catálogo del nodo",
382
386
  description: "Catálogo de productos del depósito del nodo. Opcional: filtrar por cliente.",
383
- inputSchema: { cliente: z.string().optional() },
387
+ inputSchema: { cliente: z.string().optional().describe("Id del cliente de tu nodo (de `clientes_del_nodo`); no acepta nombre") },
384
388
  }, async ({ cliente }) => run(() => api("GET", `/wms/productos${q({ cliente })}`)));
385
389
 
386
390
  tool("top_productos", {
387
391
  title: "Productos más despachados del nodo",
388
392
  description: "Ranking de productos más despachados del nodo en un rango (default: mes en curso). Opcional: filtrar por un cliente.",
389
- inputSchema: { desde: z.string().optional().describe("YYYY-MM-DD"), hasta: z.string().optional().describe("YYYY-MM-DD"), cliente: z.string().optional() },
393
+ 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") },
390
394
  }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/wms/top-productos${q({ desde, hasta, cliente })}`)));
391
395
  }
392
396
  }
@@ -459,8 +463,14 @@ if (esGlobal) {
459
463
  tool("logo_subir", {
460
464
  title: "Subir logo / marca de un nodo (marca blanca)",
461
465
  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.",
462
- inputSchema: { nodo: z.number().int().positive(), 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() },
466
+ 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() },
463
467
  }, async ({ nodo, logo, marca, slug, color }) => run(() => api("PUT", `/logisticas/${nodo}/branding`, { logo, marca, slug, color })));
468
+
469
+ tool("sugerencias_listar", {
470
+ title: "Sugerencias de usuarios (admin)",
471
+ 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.",
472
+ inputSchema: { rol: z.string().optional(), estado: z.string().optional().describe("nueva | vista | en_evaluacion | implementada | descartada") },
473
+ }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
464
474
  }
465
475
 
466
476
  // ============================================================================
@@ -471,10 +481,10 @@ if (esGlobal) {
471
481
  if (isCliente || (isStaff && puede("gestion"))) {
472
482
  tool("generar_enlace_vinculacion", {
473
483
  title: "Generar enlace de vinculación de tienda",
474
- 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' (idCliente de 'clientes_del_nodo') y mandarle ese enlace para que lo autorice desde su propia cuenta de la tienda. El enlace vence en 15 minutos. No toca dinero.",
484
+ 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.",
475
485
  inputSchema: {
476
486
  proveedor: z.enum(["ml", "tiendanube", "tiendanegocio"]).describe("ml = Mercado Libre · tiendanube · tiendanegocio"),
477
- cliente: z.string().optional().describe("Solo staff: idCliente del vendedor para el que generás el enlace. El backend valida que sea de TU nodo (si no, 403)."),
487
+ cliente: z.string().optional().describe("Solo staff: nombre o id del cliente de tu nodo"),
478
488
  },
479
489
  }, async ({ proveedor, cliente }) => run(() => api("GET", `/${proveedor}/auth${q({ cliente })}`)));
480
490
  }
@@ -498,13 +508,6 @@ if (isCliente || isStaff) {
498
508
  inputSchema: { estado: z.string().optional().describe("abierto | resuelto") },
499
509
  }, async ({ estado }) => run(() => api("GET", `/feedback/reclamos${q({ estado })}`)));
500
510
  }
501
- if (isStaff) {
502
- tool("sugerencias_listar", {
503
- title: "Sugerencias de usuarios (admin)",
504
- description: "Sugerencias entrantes de los usuarios (funciones nuevas/mejoras/bugs) con usuario, rol, nodo, estado. Filtrá por `rol` y/o `estado`. Operador: las de su nodo; admin global: todas. Solo lectura.",
505
- inputSchema: { rol: z.string().optional(), estado: z.string().optional().describe("nueva | vista | en_evaluacion | implementada | descartada") },
506
- }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
507
- }
508
511
 
509
512
  // ── ADMIN DE GRUPO ── Los nodos originales de un grupo son sus admins; gestionan quién está
510
513
  // adentro y quién administra. Ver miembros lo puede cualquier miembro; sumar/expulsar/permiso
@@ -517,7 +520,7 @@ if (isStaff) {
517
520
  }, async () => run(() => api("GET", "/logisticas/grupos")));
518
521
  tool("grupo_miembros", {
519
522
  title: "Miembros de un grupo logístico",
520
- 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 lo ves en `mis_datos` o cuando vinculás por QR. Solo miembros del grupo. Solo lectura.",
523
+ 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.",
521
524
  inputSchema: { grupo: z.number().int().positive().describe("id del grupo") },
522
525
  }, async ({ grupo }) => run(() => api("GET", `/qr/grupo/${grupo}/miembros`)));
523
526
  tool("grupo_sumar_nodo", {
@@ -838,9 +841,9 @@ if (ALLOW_WRITE) {
838
841
  title: "Cargar un envío (y etiqueta)",
839
842
  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).",
840
843
  inputSchema: {
841
- cliente: zStr().describe("Solo staff: nombre del cliente/vendedor de tu nodo (el vendedor NO lo manda)"),
844
+ 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."),
842
845
  destinatario: z.string().min(1).describe("Nombre de quien recibe"),
843
- telefono: zStr().describe("Teléfono de contacto"),
846
+ 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)"),
844
847
  direccion: z.string().min(1),
845
848
  localidad: z.string().min(1),
846
849
  cp: zStr(),
@@ -856,7 +859,7 @@ if (ALLOW_WRITE) {
856
859
  if (isStaff || rol === "mensajero") {
857
860
  tool("envio_desde_etiqueta_ml", {
858
861
  title: "Registrar envío desde una etiqueta de Mercado Libre (foto)",
859
- description: "Registra un envío a partir de los datos que VOS (Claude) leíste de la foto de una etiqueta de Mercado Libre. Leé la etiqueta: sacá el CÓDIGO/QR (mlShipmentId y, si podés, el contenido crudo del QR en mlQr), el vendedor (mlSenderId si figura) y el destino (destinatario, dirección, localidad, CP). El vendedor se mapea por `mlSenderId` (cuenta ML vinculada) o pasás `cliente` por nombre; staff = de tu nodo o grupo; MENSAJERO = solo clientes de tu nodo o que hayas colectado (marketplace incluido). CONTROL POR FOTO Y CARGA DE PLANILLA (una habilidad): si te dejan una CARPETA de fotos de etiquetas, llamá este tool UNA VEZ POR FOTO; podés cerrar el circuito EN EL MISMO PASO con `avanzarA` ('colectado'/'procesado'/'entregado' — recorre TODOS los estados A retirar→Colectado→En centro→[grupo=En camino]→Entregado, cada uno registrado a TU nombre, el usuario MCP), `autoRutear` (manda cada paquete al nodo/mensajero RESPONSABLE de su zona) y/o `grupo` (lo despacha por ese grupo, ej. 'Bonorino'). 'entregado' es la vía RÁPIDA para el clearing entre nodos. DÍGITOS ILEGIBLES: si en la dirección un número está tapado, cargá la parte legible + un '*' por cada dígito que NO se lee (ej. 'Julian Aguirre 31**') — NO inventes; y mandá `fotoRuta` (la ruta local de la foto) para reencontrarla. Devuelve tracking + link de etiqueta PROVISORIA + `estado` final + `historial` + `hechoPor`. PLANILLA DE DÍAS ANTERIORES: si las fotos son de paquetes que ya se movieron, pasá `fecha` (dd/mm/aaaa) — sin eso todo queda con la fecha de hoy y se liquida en la semana equivocada. Si NO hay etiqueta pero tenés los datos, usá `envio_cargar`. No mueve dinero.",
862
+ 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`.",
860
863
  inputSchema: {
861
864
  cliente: zStr().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
862
865
  mlSenderId: zStr().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
@@ -883,7 +886,7 @@ if (ALLOW_WRITE) {
883
886
  }, async ({ tracking }) => run(() => api("POST", "/flujo/escanear", { codigo: tracking, accion: "procesar" })));
884
887
  tool("envio_entregar", {
885
888
  title: "Marcar ENTREGADO un envío ya cargado",
886
- description: "Marca como ENTREGADO uno o varios envíos que YA están en el sistema, por TRACKING. Es la contraparte de `avanzarA:'entregado'` de `envio_desde_etiqueta_ml`, que SOLO corre en altas nuevas: si el paquete ya entró por otro lado (lo cargó el vendedor, o una integración como ML/TiendaNube), ese parámetro se ignora y el envío queda 'A retirar' para siempre. Usá este para cerrarle el ciclo — típico del CONTROL POR FOTO cuando la foto es de un paquete que ya estaba cargado. 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. 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.",
889
+ 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.",
887
890
  inputSchema: {
888
891
  trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a marcar entregados (ej. ['TN-2057600163','NFABC123'])"),
889
892
  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`."),
@@ -891,7 +894,7 @@ if (ALLOW_WRITE) {
891
894
  }, async (args) => run(() => api("POST", "/envios/entregar-ref", args)));
892
895
  tool("envio_completar_ciclo", {
893
896
  title: "Reconstruir los pasos que le faltan a un envío ya cerrado",
894
- 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. Es para arreglar el CONTROL POR FOTO cerrado en lote: `envio_entregar` marca ENTREGADO y nada más, así que el envío salta de 'A retirar' al cierre sin registro de quién lo colectó, quién lo recibió en el centro y quién lo despachó — y `avanzarA` no lo arregla porque solo avanza HACIA ADELANTE, y un envío entregado ya está al final del ciclo. 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`).",
897
+ 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`).",
895
898
  inputSchema: {
896
899
  trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a completar (ej. ['NFABC123','TN-2057600163'])"),
897
900
  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."),
@@ -902,7 +905,7 @@ if (ALLOW_WRITE) {
902
905
  }, async (args) => run(() => api("POST", "/envios/completar-ciclo", args)));
903
906
  tool("envio_pago_mensajero", {
904
907
  title: "Cargar lo que se le paga al mensajero por esos envíos",
905
- 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. IMPORTANTE: al cadete se le paga por el NOMBRE (`Envio.mensajero`), no por el vínculo — `envio_asignar_mensajero` setea solo el vínculo para no mover dinero, así que un envío puede verse asignado en todas las pantallas y NO entrar en el resumen de pago. Este tool cierra esa brecha: usa el nombre con el que la persona está cargada (el apodo del resumen, ej. 'Maxi', no 'Maxi Sagarzazu'), y en un VENDEDOR con doble rol usa el de su cliente, que es lo que netea su ganancia contra la cuenta corriente. Existe porque cuando el envío NO sale por un grupo no hay tarifa que estampar —el caso del cadete directo del nodo— y el valor manual era el único mecanismo, sin herramienta que lo escribiera. 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.",
908
+ 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.",
906
909
  inputSchema: {
907
910
  trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a pagar (ej. ['NFABC123','TN-2057600163'])"),
908
911
  valor: zNum().describe("Lo que se le paga POR ENVÍO, en pesos (ej. 2150). Se escribe igual en todos los que mandes."),
@@ -912,12 +915,12 @@ if (ALLOW_WRITE) {
912
915
  }, async (args) => run(() => api("POST", "/envios/pago-mensajero", args)));
913
916
  tool("envio_corregir_estado", {
914
917
  title: "Corregir el estado de un envío",
915
- 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'), que hasta ahora solo podía escribir la sincronización de ML o el circuito de devolución — si algo dejaba un envío ahí, no había cómo volverlo atrás. 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.",
918
+ 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.",
916
919
  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.") },
917
920
  }, async (args) => run(() => api("POST", "/envios/corregir-estado", args)));
918
921
  tool("envio_asignar_mensajero", {
919
922
  title: "Asignar / quitar el mensajero de un envío",
920
- description: "Asigna —o QUITA con `quitar:true`— el MENSAJERO de envíos ya cargados, por tracking. Existe porque el parámetro `mensajero` de `envio_desde_etiqueta_ml` solo resuelve usuarios con rol 'mensajero', y hay gente que reparte sin ese rol: un VENDEDOR que además hace reparto, o un operador con reparto habilitado. Al no encontrarlos, aquella resolución elegía a OTRA persona de nombre parecido y le atribuía entregas ajenas. Acá se busca entre TODOS los que pueden repartir (rol mensajero o `reparte`), y podés pasar el ID para no depender del nombre; si el nombre matchea a varios te los lista con su id en vez de elegir por vos. No mueve dinero: el pago al mensajero se liquida por NOMBRE, no por este vínculo.",
923
+ 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`).",
921
924
  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") },
922
925
  }, async (args) => run(() => api("POST", "/envios/asignar-mensajero", args)));
923
926
  tool("envio_estado", {
@@ -927,7 +930,7 @@ if (ALLOW_WRITE) {
927
930
  }, async ({ tracking }) => run(() => api("GET", `/flujo/envio-estado?tracking=${encodeURIComponent(tracking)}`)));
928
931
  tool("envios_trabados", {
929
932
  title: "Envíos trabados del nodo (para destrabar / cerrar)",
930
- 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 → revisalo y marcalo entregado). Ordenados por severidad. Es la misma detección que alimenta el aviso proactivo a los operativos y admins del nodo. Read-only.",
933
+ 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.",
931
934
  inputSchema: {},
932
935
  }, async () => run(() => api("GET", "/flujo/envios-trabados")));
933
936
  tool("planilla_reporte", {
@@ -944,7 +947,7 @@ if (ALLOW_WRITE) {
944
947
  title: "Conciliar un nodo provisorio con el nodo real",
945
948
  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).",
946
949
  inputSchema: {
947
- provisorioId: z.number().int().positive().describe("Id del nodo provisorio a conciliar (de `nodos_listar`)"),
950
+ provisorioId: z.number().int().positive().describe("Id del nodo provisorio a conciliar (lo devolvió `nodo_provisorio_crear`, o lo ves en `grupos_tarifas`)"),
948
951
  nodoReal: z.string().min(1).describe("Nodo real destino: nombre o id (nodo ya dado de alta)"),
949
952
  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."),
950
953
  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."),
@@ -1014,12 +1017,12 @@ if (ALLOW_WRITE) {
1014
1017
  tool("ruta_publicar", {
1015
1018
  title: "Publicar una ruta/colecta en el marketplace",
1016
1019
  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).",
1017
- 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() },
1020
+ 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)") },
1018
1021
  }, async (args) => run(() => api("POST", "/rutas-publicas", args)));
1019
1022
  tool("ruta_ofertar", {
1020
1023
  title: "Ofertar por una ruta pública",
1021
1024
  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.",
1022
- inputSchema: { publicacionId: z.number().int().positive(), precio: z.number(), nota: z.string().optional() },
1025
+ inputSchema: { publicacionId: z.number().int().positive(), precio: z.number().describe("En pesos (ARS)"), nota: z.string().optional() },
1023
1026
  }, async (args) => run(() => api("POST", `/rutas-publicas/${args.publicacionId}/ofertar`, { precio: args.precio, nota: args.nota })));
1024
1027
  tool("ruta_adjudicar", {
1025
1028
  title: "Adjudicar una ruta pública (elegir ganador)",
@@ -1036,7 +1039,7 @@ if (ALLOW_WRITE) {
1036
1039
  tool("envio_mensajero_externo", {
1037
1040
  title: "Asignar un mensajero EXTERNO a envíos",
1038
1041
  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).",
1039
- inputSchema: { envioIds: z.array(z.number().int().positive()).describe("ids de los envíos"), 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() },
1042
+ 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() },
1040
1043
  }, async ({ nodo, ...rest }) => run(() => api("POST", "/envios/mensajero-externo", { ...rest, logisticaId: nodo })));
1041
1044
 
1042
1045
  tool("envio_editar_zona", {
@@ -1066,12 +1069,12 @@ if (ALLOW_WRITE) {
1066
1069
  tool("cobro_corregir", {
1067
1070
  title: "Corregir el monto de un cobro",
1068
1071
  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).",
1069
- inputSchema: { envioId: z.number().int().positive(), monto: z.number(), motivo: z.string().optional() },
1072
+ 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() },
1070
1073
  }, async ({ envioId, monto, motivo }) => run(() => api("POST", `/envios/${envioId}/corregir-cobro`, { monto, motivo })));
1071
1074
  tool("rendicion_revertir", {
1072
1075
  title: "Revertir una rendición (marcada por error)",
1073
1076
  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.",
1074
- inputSchema: { envioId: z.number().int().positive(), tipo: z.enum(["cobro", "cambio"]), motivo: z.string().optional() },
1077
+ 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() },
1075
1078
  }, async (args) => run(() => api("POST", "/flujo/rendicion/revertir", args)));
1076
1079
  }
1077
1080
  if (puede("gestion")) {
@@ -1090,7 +1093,7 @@ if (ALLOW_WRITE) {
1090
1093
 
1091
1094
  tool("colecta_fija", {
1092
1095
  title: "Colecta fija de un vendedor (días)",
1093
- 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 (antes había que acordarse de mandarlos a colectar a mano). `dias` acepta números (0=domingo … 6=sábado) o nombres (lunes, martes…); `dias:[]` quita la colecta fija. Config de AGENDA, no de dinero.",
1096
+ 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.",
1094
1097
  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() },
1095
1098
  }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/fija", { ...rest, logisticaId: nodo })));
1096
1099
 
@@ -1106,7 +1109,7 @@ if (ALLOW_WRITE) {
1106
1109
 
1107
1110
  tool("retiros_cargar", {
1108
1111
  title: "Cargar la lista de retiros del día",
1109
- 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. Es el reemplazo del Excel: antes esta lista se subía en la planilla Maestro. 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). Las direcciones pueden venir numeradas como en la planilla ('1. Helguera 936, CABA'): el número se toma como ORDEN DE RUTA y no ensucia la dirección — pegado adelante hacía que el geocoder devolviera un cuartel en Gualeguaychú. `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.",
1112
+ 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.",
1110
1113
  inputSchema: {
1111
1114
  cliente: z.string().min(1).describe("Cliente al que se le cargan los retiros (nombre o id, de tu nodo)"),
1112
1115
  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."),
@@ -1158,7 +1161,7 @@ if (ALLOW_WRITE) {
1158
1161
 
1159
1162
  tool("cliente_editar", {
1160
1163
  title: "Editar cliente (solo lo que pasás)",
1161
- description: "Edita un cliente de TU nodo cambiando SOLO lo que pasás: lo que no mandás queda como está (antes se borraban la lista, el DNI y el nombre con que cobra). 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`.",
1164
+ 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`.",
1162
1165
  inputSchema: {
1163
1166
  id: z.number().int().positive().describe("ID del cliente (de clientes_del_nodo)"),
1164
1167
  nombre: z.string().optional().describe("Solo si lo querés cambiar"),
@@ -1189,7 +1192,7 @@ if (ALLOW_WRITE) {
1189
1192
  tool("liquidacion_excluir_envio", {
1190
1193
  title: "No cobrar un envío de una liquidación",
1191
1194
  description:
1192
- "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.",
1195
+ "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. Hoy el filtro de dinero del backend bloquea esta ruta vía MCP (403): se hace desde la PWA.",
1193
1196
  inputSchema: {
1194
1197
  id: z.number().int().positive().describe("id de la liquidación"),
1195
1198
  tracking: z.string().min(1),
@@ -1199,7 +1202,7 @@ if (ALLOW_WRITE) {
1199
1202
  tool("liquidacion_reincluir_envio", {
1200
1203
  title: "Volver a cobrar un envío de una liquidación",
1201
1204
  description:
1202
- "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.",
1205
+ "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. Hoy el filtro de dinero del backend bloquea esta ruta vía MCP (403): se hace desde la PWA.",
1203
1206
  inputSchema: {
1204
1207
  id: z.number().int().positive().describe("id de la liquidación"),
1205
1208
  tracking: z.string().min(1),
@@ -1259,12 +1262,12 @@ if (ALLOW_PRECIOS && puede("precios")) {
1259
1262
  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`.",
1260
1263
  inputSchema: {
1261
1264
  idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
1262
- cercana: z.number().optional().describe("Precio zona cercana"),
1263
- media: z.number().optional().describe("Precio zona media"),
1264
- lejana: z.number().optional().describe("Precio zona lejana"),
1265
- muyLejana: z.number().optional().describe("Precio zona muy lejana"),
1265
+ cercana: z.number().optional().describe("Precio zona cercana, en pesos (ARS)"),
1266
+ media: z.number().optional().describe("Precio zona media, en pesos (ARS)"),
1267
+ lejana: z.number().optional().describe("Precio zona lejana, en pesos (ARS)"),
1268
+ muyLejana: z.number().optional().describe("Precio zona muy lejana, en pesos (ARS)"),
1266
1269
  referencia: z.string().optional(),
1267
- vigenciaDesde: z.string().optional().describe("YYYY-MM-DD desde cuándo rige (default: hoy)"),
1270
+ vigenciaDesde: z.string().optional().describe("Fecha YYYY-MM-DD desde cuándo rige (default: hoy)"),
1268
1271
  nodo: zNodo(),
1269
1272
  },
1270
1273
  }, async ({ nodo, ...rest }) => run(() => api("POST", "/precios/clientes/version", { ...rest, logisticaId: nodo })));