nexusflex-mcp 3.6.0 → 3.67.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 +526 -322
  2. package/package.json +1 -1
  3. package/server.mjs +592 -54
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 } from "./docs.mjs";
28
+ import { renderAyuda, guiaOnboarding, renderFlujo } 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,14 +54,74 @@ 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.6.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
57
+ const MCP_VERSION = "3.67.0"; // bumpear junto con package.json + backend/services/mcp-remote.ts
58
58
  const NOVEDADES = [
59
+ "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.",
60
+ "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.",
61
+ "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.",
62
+ "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`.",
63
+ "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.",
64
+ "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.",
65
+ "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.",
66
+ "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.",
67
+ "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.",
68
+ "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.",
69
+ "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.",
70
+ "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.",
71
+ "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.",
72
+ "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.",
73
+ "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.",
74
+ "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.",
75
+ "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`.",
76
+ "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.",
77
+ "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.",
78
+ "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.",
79
+ "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.",
80
+ "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.",
81
+ "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.",
82
+ "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.",
83
+ "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.",
84
+ "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.",
85
+ "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.",
86
+ "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.",
87
+ "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.",
88
+ "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.",
89
+ "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.",
90
+ "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`.",
91
+ "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.",
92
+ "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.",
93
+ "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).",
94
+ "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.",
95
+ "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.",
96
+ "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.",
97
+ "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.",
98
+ "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`.",
99
+ "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.",
100
+ "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.",
101
+ "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.",
102
+ "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.)",
103
+ "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.",
104
+ "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).",
105
+ "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.",
106
+ "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.",
107
+ "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.)",
108
+ "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.",
109
+ "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.",
110
+ "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.",
111
+ "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.",
112
+ "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.",
113
+ "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.",
114
+ "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.",
115
+ "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.",
116
+ "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.",
117
+ "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.",
118
+ "3.6.1 — Guía (`guia`) con intro motivador que engancha a hacer el tutorial (qué podés automatizar en 5 min).",
59
119
  "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).",
60
120
  "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).",
61
121
  "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.",
62
122
  "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.",
63
123
  ];
64
- const INSTRUCCIONES = "MCP de Nexus Flex. Para arrancar rápido llamá al tool `guia` (metodología según tu rol: mensajero/vendedor/nodo) y `ayuda` (índice de herramientas + temas). El MCP NUNCA mueve dinero.";
124
+ 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.";
65
125
  const server = new McpServer({ name: "nexusflex", version: MCP_VERSION }, { instructions: INSTRUCCIONES });
66
126
 
67
127
  /** Formatea un ApiResult como respuesta de tool MCP, con mensajes claros y SIN
@@ -86,6 +146,20 @@ async function run(fn) {
86
146
  }
87
147
  }
88
148
 
149
+ // Un modelo con visión suele mandar campos "de texto" como NÚMERO en el JSON
150
+ // (ej. cp: 2804, mlShipmentId: 47890557180). Con z.string() eso reventaba en la
151
+ // validación del SDK ANTES del handler → el cliente sólo veía "MCP tool call
152
+ // failed" sin detalle. Estos helpers aceptan string|number y normalizan, así el
153
+ // tool corre y, si algo falla, devuelve un mensaje útil del backend.
154
+ const zStr = () => z.union([z.string(), z.number()]).transform((x) => String(x)).optional();
155
+ const zNum = () => z.union([z.number(), z.string()]).optional();
156
+
157
+ // Override de nodo: SOLO lo usa el admin global para elegir sobre qué nodo opera. El
158
+ // operador de nodo lo ignora (el backend lo fuerza a SU nodo con nodoDe → sin escalada
159
+ // cross-nodo). Se manda al backend como `logisticaId`. Ver sugerencia MCP #20.
160
+ 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).";
161
+ const zNodo = () => z.number().int().positive().optional().describe(NODO_OVERRIDE_DESC);
162
+
89
163
  const q = (params) => {
90
164
  const s = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
91
165
  return s ? `?${s}` : "";
@@ -121,7 +195,7 @@ const puede = (p) => esGlobal || (isStaff && (esAdminNodo || permisos.length ===
121
195
  // ============================================================================
122
196
  tool("mis_datos", {
123
197
  title: "Mis datos / alcance",
124
- description: "Devuelve tu usuario, rol y nodo/cliente al que está atado este MCP. Usalo para confirmar tu alcance antes de operar.",
198
+ 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.",
125
199
  inputSchema: {},
126
200
  }, async () => run(() => api("GET", "/auth/me")));
127
201
 
@@ -143,12 +217,29 @@ tool("guia", {
143
217
  return { content: [{ type: "text", text: guiaOnboarding(target) }] };
144
218
  });
145
219
 
220
+ // Flujo guiado: paso a paso de un objetivo para no dejar nada incompleto (Fase A).
221
+ tool("flujo", {
222
+ title: "Flujo guiado paso a paso",
223
+ 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.",
224
+ inputSchema: { objetivo: z.string().optional().describe("alta_cliente | alta_operador | alta_chofer | control_por_foto (vacío = lista los flujos)") },
225
+ }, async ({ objetivo }) => ({ content: [{ type: "text", text: renderFlujo(objetivo) }] }));
226
+
146
227
  // Sugerencias: cualquier usuario (todos los roles) puede proponer funciones/mejoras.
147
228
  tool("sugerencia_crear", {
148
229
  title: "Enviar una sugerencia",
149
230
  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.",
150
231
  inputSchema: { mensaje: z.string().min(1), categoria: z.enum(["funcionalidad", "mejora", "bug", "otro"]).optional() },
151
232
  }, async (args) => run(() => api("POST", "/feedback/sugerencias", args)));
233
+ tool("mis_sugerencias", {
234
+ title: "Mis sugerencias y su estado",
235
+ 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.",
236
+ inputSchema: {},
237
+ }, async () => run(() => api("GET", "/feedback/mis-sugerencias")));
238
+ tool("flujo_registrar", {
239
+ title: "Registrar un flujo aprendido (indagación)",
240
+ 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.",
241
+ inputSchema: { objetivo: z.string().min(1), pasos: z.string().min(1) },
242
+ }, async ({ objetivo, pasos }) => run(() => api("POST", "/feedback/sugerencias", { categoria: "mejora", mensaje: `[FLUJO APRENDIDO] Objetivo: ${objetivo}\nPasos que lo aclararon: ${pasos}` })));
152
243
 
153
244
  // ============================================================================
154
245
  // ROL CLIENTE (vendedor) — SOLO lo suyo. El backend lo fuerza a su idCliente.
@@ -237,6 +328,28 @@ if (isStaff) {
237
328
  description: "Clientes/vendedores del nodo con su lista de precio asignada, más las listas disponibles. Scopeado a tu nodo.",
238
329
  inputSchema: {},
239
330
  }, async () => run(() => api("GET", "/gestion/formularios")));
331
+ 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() } },
332
+ async ({ nodo }) => run(() => api("GET", `/sin-vendedor${q({ nodo })}`)));
333
+ if (ALLOW_WRITE)
334
+ tool("asignar_sin_vendedor", {
335
+ title: "Asignar cuentas de ML / envíos sin vendedor a un vendedor (+ enlaces)",
336
+ 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.",
337
+ inputSchema: {
338
+ idCliente: z.number().optional().describe("Vendedor existente de tu nodo"),
339
+ nuevoVendedor: z.string().optional().describe("Nombre de un vendedor NUEVO (se crea en tu nodo)"),
340
+ telefono: z.string().optional().describe("Teléfono del vendedor nuevo (opcional)"),
341
+ cuentasML: z.array(z.string()).optional().describe("Cuentas de ML (el número de usuario, de `sin_vendedor`)"),
342
+ envioIds: z.array(z.number()).optional().describe("Envíos cargados con foto (ids de `sin_vendedor`)"),
343
+ usuarioEmail: z.string().optional().describe("Si el vendedor no tiene usuario: email de login de la persona"),
344
+ usuarioNombre: z.string().optional(),
345
+ usuarioApellido: z.string().optional(),
346
+ nodo: z.number().optional().describe("Solo admin global"),
347
+ },
348
+ }, async (a) => run(() => api("POST", "/sin-vendedor/asignar", {
349
+ idCliente: a.idCliente, nuevo: a.nuevoVendedor ? { nombre: a.nuevoVendedor, telefono: a.telefono } : undefined,
350
+ usuario: a.usuarioEmail ? { email: a.usuarioEmail, nombre: a.usuarioNombre, apellido: a.usuarioApellido } : undefined,
351
+ cuentasML: a.cuentasML, envioIds: a.envioIds, nodo: a.nodo,
352
+ })));
240
353
  }
241
354
 
242
355
  if (puede("precios")) {
@@ -276,21 +389,76 @@ if (isStaff) {
276
389
  }
277
390
  }
278
391
 
392
+ if (isStaff && puede("finanzas")) {
393
+ tool("cuenta_semanal_nodos", {
394
+ title: "Cuenta semanal con otros nodos (juntada)",
395
+ 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`.",
396
+ inputSchema: {
397
+ cierre: z.number().int().positive().optional().describe("id del cierre semanal (de `lista`)"),
398
+ lista: z.boolean().optional().describe("true = listar tus cierres semanales"),
399
+ nodo: zNodo(),
400
+ },
401
+ }, async ({ cierre, lista, nodo }) =>
402
+ run(() => api("GET", lista ? `/logisticas/mis-cierres${q({ nodo })}` : cierre ? `/logisticas/mis-cierres/${cierre}${q({ nodo })}` : `/logisticas/mis-cierres/sin-cerrar${q({ nodo })}`)));
403
+ }
404
+
279
405
  // ============================================================================
280
406
  // ROL GLOBAL (superadmin / superoperador)
281
407
  // ============================================================================
282
408
  if (esGlobal) {
409
+ tool("cierre_semanal_links", {
410
+ title: "Links sin login del cierre semanal",
411
+ 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.",
412
+ inputSchema: { cierre: z.number().int().positive().optional().describe("id del cierre (default: el último)") },
413
+ }, async ({ cierre }) => run(() => api("GET", `/logisticas/cierres-semanales/links${q({ cierre })}`)));
414
+
283
415
  tool("nodos_listar", {
284
416
  title: "Listar nodos",
285
417
  description: "Lista todas las logísticas (nodos) con sus conteos. Solo admin global.",
286
418
  inputSchema: {},
287
419
  }, async () => run(() => api("GET", "/logisticas")));
288
420
 
421
+ tool("nodo_renombrar", {
422
+ title: "Cambiar el nombre real de un nodo",
423
+ 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.",
424
+ inputSchema: {
425
+ nodo: z.string().min(1).describe("Nombre actual (o tu apodo) del nodo"),
426
+ nombreNuevo: z.string().min(1).describe("Nombre nuevo"),
427
+ },
428
+ }, async ({ nodo: nodoQ, nombreNuevo }) => run(async () => {
429
+ const lr = await api("GET", "/flujo/logisticas-select");
430
+ if (!lr.ok) return lr;
431
+ const lista = lr.data ?? [];
432
+ const q2 = nodoQ.trim().toLowerCase();
433
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
434
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
435
+ 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.` } };
436
+ return api("PUT", `/logisticas/${cands[0].id}`, { nombre: nombreNuevo });
437
+ }));
438
+
289
439
  tool("kpi_red", {
290
440
  title: "Métricas de la red (SaaS)",
291
441
  description: "KPIs globales de toda la red de nodos (crecimiento, operacional, volumen de clearing). Solo admin global.",
292
442
  inputSchema: {},
293
443
  }, async () => run(() => api("GET", "/kpi/saas")));
444
+
445
+ tool("usuario_habilitar_rol", {
446
+ title: "Habilitar/deshabilitar un rol de administración",
447
+ 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.",
448
+ inputSchema: { id: z.number().int().positive(), esSuperoperador: z.boolean().optional(), esAdminNodo: z.boolean().optional() },
449
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
450
+
451
+ tool("grupo_crear", {
452
+ title: "Crear grupo logístico",
453
+ description: "Crea un grupo logístico nuevo (para clearing entre nodos; los nodos se agregan/tarifan después). Solo admin global. No toca dinero.",
454
+ inputSchema: { nombre: z.string().min(1) },
455
+ }, async ({ nombre }) => run(() => api("POST", "/logisticas/grupos", { nombre })));
456
+
457
+ tool("logo_subir", {
458
+ title: "Subir logo / marca de un nodo (marca blanca)",
459
+ 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.",
460
+ 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() },
461
+ }, async ({ nodo, logo, marca, slug, color }) => run(() => api("PUT", `/logisticas/${nodo}/branding`, { logo, marca, slug, color })));
294
462
  }
295
463
 
296
464
  // ============================================================================
@@ -336,6 +504,37 @@ if (isStaff) {
336
504
  }, async ({ rol, estado }) => run(() => api("GET", `/feedback/sugerencias${q({ rol, estado })}`)));
337
505
  }
338
506
 
507
+ // ── ADMIN DE GRUPO ── Los nodos originales de un grupo son sus admins; gestionan quién está
508
+ // adentro y quién administra. Ver miembros lo puede cualquier miembro; sumar/expulsar/permiso
509
+ // solo un admin del grupo (o admin global). Nunca se deja un grupo sin admin. No mueve dinero.
510
+ if (isStaff) {
511
+ tool("grupos_tarifas", {
512
+ title: "Grupos logísticos con sus tarifas de clearing",
513
+ 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.",
514
+ inputSchema: {},
515
+ }, async () => run(() => api("GET", "/logisticas/grupos")));
516
+ tool("grupo_miembros", {
517
+ title: "Miembros de un grupo logístico",
518
+ 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.",
519
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo") },
520
+ }, async ({ grupo }) => run(() => api("GET", `/qr/grupo/${grupo}/miembros`)));
521
+ tool("grupo_sumar_nodo", {
522
+ title: "Sumar un nodo al grupo (admin de grupo)",
523
+ 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.",
524
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a sumar") },
525
+ }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-sumar", { grupoId: grupo, logisticaId: nodo })));
526
+ tool("grupo_expulsar_nodo", {
527
+ title: "Expulsar un nodo del grupo (admin de grupo)",
528
+ 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.",
529
+ inputSchema: { grupo: z.number().int().positive().describe("id del grupo"), nodo: z.number().int().positive().describe("id del nodo a expulsar") },
530
+ }, async ({ grupo, nodo }) => run(() => api("POST", "/qr/grupo-expulsar", { grupoId: grupo, logisticaId: nodo })));
531
+ tool("grupo_admin_permiso", {
532
+ title: "Dar / sacar permiso de admin de grupo",
533
+ 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.",
534
+ 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") },
535
+ }, async ({ grupo, nodo, admin }) => run(() => api("POST", "/qr/grupo-permiso", { grupoId: grupo, logisticaId: nodo, esAdminGrupo: admin })));
536
+ }
537
+
339
538
  // Consultar UN envío por tracking/código (cuando un vendedor pregunta "¿dónde está mi
340
539
  // envío X?"). Vendedor: solo entre SUS envíos; staff: dentro de su nodo.
341
540
  if (isCliente || isStaff) {
@@ -436,6 +635,26 @@ if (isStaff) {
436
635
  }, async ({ publicacionId }) => run(() => api("GET", `/rutas-publicas/${publicacionId}/ofertas`)));
437
636
  }
438
637
 
638
+ if (isStaff) {
639
+ tool("nodo_alias_poner", {
640
+ title: "Ponerle un apodo personal a un nodo",
641
+ 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.",
642
+ inputSchema: {
643
+ nodo: z.string().min(1).describe("Nombre (o tu apodo actual) del nodo"),
644
+ alias: z.string().describe("El apodo nuevo (vacío para sacarlo)"),
645
+ },
646
+ }, async ({ nodo: nodoQ, alias }) => run(async () => {
647
+ const lr = await api("GET", "/flujo/logisticas-select");
648
+ if (!lr.ok) return lr;
649
+ const lista = lr.data ?? [];
650
+ const q = nodoQ.trim().toLowerCase();
651
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q) || (l.alias ?? "").toLowerCase().includes(q));
652
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
653
+ 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.` } };
654
+ return api("POST", "/flujo/nodo-alias", { logisticaId: cands[0].id, alias });
655
+ }));
656
+ }
657
+
439
658
  if (isStaff && puede("gestion")) {
440
659
  tool("zonas_reparto", {
441
660
  title: "Zonas de reparto (mensajeros por zona)",
@@ -443,6 +662,25 @@ if (isStaff && puede("gestion")) {
443
662
  inputSchema: {},
444
663
  }, async () => run(() => api("GET", "/zonificacion/zonas-reparto")));
445
664
 
665
+ tool("zonas_simetria", {
666
+ title: "Chequear simetria de distancias entre zonas",
667
+ 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'.",
668
+ inputSchema: {},
669
+ }, async () => run(() => api("GET", "/zonificacion/simetria")));
670
+
671
+ tool("zona_barrios", {
672
+ title: "Barrios/localidades de una zona",
673
+ 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>?'.",
674
+ inputSchema: { zona: z.string().min(1).describe("Nombre de la zona, ej. 'Matanza Norte' o 'CABA'") },
675
+ }, async ({ zona }) => run(() => api("GET", `/zonificacion/zona-barrios${q({ zona })}`)));
676
+
677
+ tool("facturacion_estado", {
678
+ title: "¿Qué falta para facturar?",
679
+ description:
680
+ "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').",
681
+ inputSchema: { cliente: z.string().optional().describe("Nombre o id de un cliente puntual; vacío = los ya habilitados") },
682
+ }, async ({ cliente }) => run(() => api("GET", `/afip/estado${q({ cliente })}`)));
683
+
446
684
  tool("colecta_ver", {
447
685
  title: "Ver config de colectas",
448
686
  description: "Resumen de valores de colecta del nodo: pago default del nodo, cobros por cliente y pagos pactados por cliente+mensajero. Solo lectura.",
@@ -451,10 +689,20 @@ if (isStaff && puede("gestion")) {
451
689
 
452
690
  tool("colecta_pendientes", {
453
691
  title: "Colectas del día y de mañana",
454
- 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. Solo lectura. Sirve para «¿quién levanta a tal cliente?» y «¿el cliente X tiene envíos para mañana?».",
692
+ 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?».",
455
693
  inputSchema: {},
456
694
  }, async () => run(() => api("GET", "/colecta/panel")));
457
695
 
696
+ tool("colecta_historial", {
697
+ title: "Historial de colectas (paquetes por día y cliente)",
698
+ 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?».",
699
+ inputSchema: {
700
+ desde: z.string().describe("aaaa-mm-dd"),
701
+ hasta: z.string().optional().describe("aaaa-mm-dd; vacío = el mismo día"),
702
+ cliente: z.string().optional().describe("nombre (o parte) o id del cliente"),
703
+ },
704
+ }, async ({ desde, hasta, cliente }) => run(() => api("GET", `/colecta/historial${q({ desde, hasta, cliente })}`)));
705
+
458
706
  tool("envios_por_zona", {
459
707
  title: "Envíos que otros nodos te rutearon (por zona)",
460
708
  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.",
@@ -463,23 +711,95 @@ if (isStaff && puede("gestion")) {
463
711
 
464
712
  tool("asignar_mensajero_zona", {
465
713
  title: "Asignar un mensajero a una zona",
466
- 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.",
714
+ 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`.",
467
715
  inputSchema: {
468
716
  mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo)"),
469
717
  metazona: z.string().min(1).describe("Metazona/zona, ej. 'Palermo' o 'CABA'"),
470
718
  nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
719
+ nodo: zNodo(),
471
720
  },
472
- }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-mensajero", args)));
721
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-mensajero", { ...rest, logisticaId: nodo })));
473
722
 
474
723
  tool("asignar_nodo_zona", {
475
724
  title: "Asignar un NODO a una zona (grupo logístico)",
476
- description: "Asigna un NODO COMPLETO (por nombre, de tu grupo logístico) a una metazona — para cuando ese nodo cubre toda una localidad (ej. «que RL cubra Portela»). Suma el nodo como opción de esa zona (nodoIds). El nodo debe compartir grupo logístico con el tuyo. No cruza fuera del grupo.",
725
+ 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`.",
477
726
  inputSchema: {
478
- nodo: z.string().min(1).describe("Nombre del nodo de tu grupo logístico que cubre la zona"),
727
+ nodo: z.string().min(1).describe("Nombre del nodo (de tu grupo logístico) que cubre la zona"),
479
728
  metazona: z.string().min(1).describe("Metazona/zona, ej. 'Portela' o 'CABA'"),
480
729
  nombre: z.string().optional().describe("Nombre de la zona (opcional)"),
730
+ 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"),
731
+ 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."),
481
732
  },
482
733
  }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/asignar-nodo", args)));
734
+
735
+ tool("zona_dejar", {
736
+ title: "Dejar de ser responsable de una zona de grupo",
737
+ 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`.",
738
+ inputSchema: {
739
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
740
+ metazona: z.string().min(1).describe("Metazona/zona que dejás"),
741
+ nodo: zNodo(),
742
+ },
743
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/dejar", { ...rest, logisticaId: nodo })));
744
+
745
+ tool("nodo_link_confirmacion", {
746
+ title: "Link de confirmación sin login para un nodo",
747
+ 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).",
748
+ inputSchema: { nodo: z.string().min(1).describe("Nombre o apodo del nodo") },
749
+ }, async ({ nodo: nodoQ }) => run(async () => {
750
+ const lr = await api("GET", "/flujo/logisticas-select");
751
+ if (!lr.ok) return lr;
752
+ const lista = lr.data ?? [];
753
+ const q2 = nodoQ.trim().toLowerCase();
754
+ const cands = lista.filter((l) => l.nombre.toLowerCase().includes(q2) || (l.alias ?? "").toLowerCase().includes(q2));
755
+ if (cands.length === 0) return { ok: false, status: 404, data: { message: `No encontré un nodo "${nodoQ}".` } };
756
+ 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.` } };
757
+ return api("POST", "/logisticas/link-confirmacion", { logisticaId: cands[0].id });
758
+ }));
759
+
760
+ tool("zonas_grupo", {
761
+ title: "Zonas de un grupo logístico y quién las cubre",
762
+ 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.",
763
+ inputSchema: { grupo: z.string().min(1).describe("Nombre o id del grupo") },
764
+ }, async ({ grupo }) => run(() => api("GET", `/zonificacion/zonas-reparto-grupo${q({ grupo })}`)));
765
+
766
+ tool("zona_mover_metazona", {
767
+ title: "Mover una metazona entre zonas de un grupo",
768
+ 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.",
769
+ inputSchema: {
770
+ grupo: z.string().min(1).describe("Nombre o id del grupo (ej. 'Portela')"),
771
+ metazona: z.string().min(1).describe("Metazona/localidad a mover (ej. 'El Palomar')"),
772
+ zonaDestino: z.string().min(1).describe("Zona destino: nombre, id, o una metazona que ya tenga (ej. 'Tres de Febrero')"),
773
+ nodo: zNodo(),
774
+ },
775
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/zonificacion/zonas-reparto/metazona-mover", { ...rest, logisticaId: nodo })));
776
+
777
+ tool("zona_componer_grupo", {
778
+ title: "Componer una zona de grupo (metazonas + nodo)",
779
+ 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.",
780
+ inputSchema: {
781
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
782
+ zona: z.string().min(1).describe("Zona: nombre o id. Si no existe en el grupo, se crea con ese nombre."),
783
+ nodo: z.string().optional().describe("Nombre del nodo responsable a sumar a la zona (opcional)"),
784
+ agregar: z.array(z.string()).optional().describe("Metazonas a AGREGAR a la zona (ej. ['CABA · Flores','Flores'])"),
785
+ quitar: z.array(z.string()).optional().describe("Metazonas a QUITAR de la zona"),
786
+ nombre: z.string().optional().describe("Nombre para la zona si se crea (default: el valor de `zona`)"),
787
+ 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."),
788
+ },
789
+ }, async (args) => run(() => api("POST", "/zonificacion/zonas-reparto/grupo-componer", args)));
790
+
791
+ tool("grupo_tarifa_set", {
792
+ title: "Setear la tarifa de clearing de un grupo",
793
+ 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.",
794
+ inputSchema: {
795
+ grupo: z.string().min(1).describe("Nombre o id del grupo"),
796
+ cercana: z.number().optional().describe("Tarifa zona cercana"),
797
+ media: z.number().optional().describe("Tarifa zona media"),
798
+ lejana: z.number().optional().describe("Tarifa zona lejana"),
799
+ muyLejana: z.number().optional().describe("Tarifa zona muy lejana"),
800
+ soloDef: z.boolean().optional().describe("true = solo el default del grupo, sin tocar los nodos miembro"),
801
+ },
802
+ }, async (args) => run(() => api("POST", "/qr/grupo-tarifa-grupal", args)));
483
803
  }
484
804
 
485
805
  // ============================================================================
@@ -514,35 +834,119 @@ if (ALLOW_WRITE) {
514
834
  if (isCliente || (isStaff && puede("gestion")) || rol === "mensajero") {
515
835
  tool("envio_cargar", {
516
836
  title: "Cargar un envío (y etiqueta)",
517
- 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. No toca dinero (montoCobro es el cobro contra entrega, no un movimiento).",
837
+ 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).",
518
838
  inputSchema: {
519
- cliente: z.string().optional().describe("Solo staff: nombre del cliente/vendedor de tu nodo (el vendedor NO lo manda)"),
839
+ cliente: zStr().describe("Solo staff: nombre del cliente/vendedor de tu nodo (el vendedor NO lo manda)"),
520
840
  destinatario: z.string().min(1).describe("Nombre de quien recibe"),
521
- telefono: z.string().min(1),
841
+ telefono: zStr().describe("Teléfono de contacto"),
522
842
  direccion: z.string().min(1),
523
843
  localidad: z.string().min(1),
524
- cp: z.string().optional(),
525
- montoCobro: z.number().optional().describe("Cobro contra entrega (opcional)"),
844
+ cp: zStr(),
845
+ montoCobro: zNum().describe("Cobro contra entrega (opcional)"),
526
846
  esCambio: z.boolean().optional(),
527
847
  detalleCambio: z.string().optional(),
528
848
  comentarios: z.string().optional(),
849
+ 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."),
850
+ 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."),
529
851
  },
530
852
  }, async (args) => run(() => api("POST", "/envios/cargar-mcp", args)));
531
853
  }
532
854
  if (isStaff || rol === "mensajero") {
533
855
  tool("envio_desde_etiqueta_ml", {
534
856
  title: "Registrar envío desde una etiqueta de Mercado Libre (foto)",
535
- 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). Crea el envío ('A retirar') y devuelve tracking + link de etiqueta PROVISORIA (con el QR de ML si mandaste mlQr, reimprimible). Si NO hay etiqueta pero tenés los datos del paquete, usá `envio_cargar`. Podés pasar varias etiquetas llamando el tool una vez por cada una. No mueve dinero.",
857
+ 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.",
536
858
  inputSchema: {
537
- cliente: z.string().optional().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
538
- mlSenderId: z.string().optional().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
539
- mlShipmentId: z.string().optional().describe("id de envío/tracking de ML leído de la etiqueta"),
540
- mlQr: z.string().optional().describe("Contenido CRUDO del QR de ML (para reimprimir la etiqueta idéntica)"),
541
- destinatario: z.string().optional(), telefono: z.string().optional(),
542
- direccion: z.string().optional(), localidad: z.string().optional(), cp: z.string().optional(), zona: z.string().optional(), barrio: z.string().optional(),
543
- montoCobro: z.number().optional(), mensajero: z.string().optional().describe("Mensajero que hizo el paquete (de tu nodo)"),
859
+ cliente: zStr().describe("Vendedor por nombre/id (si no mandás mlSenderId)"),
860
+ mlSenderId: zStr().describe("sender_id del vendedor en ML (mapea a su cuenta vinculada)"),
861
+ mlShipmentId: zStr().describe("id de envío/tracking de ML leído de la etiqueta (se limpian espacios OCR)"),
862
+ mlQr: zStr().describe("Contenido CRUDO del QR de ML (para reimprimir la etiqueta idéntica)"),
863
+ destinatario: zStr(), telefono: zStr(),
864
+ 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"),
865
+ localidad: zStr(), cp: zStr(), zona: zStr(), barrio: zStr(),
866
+ 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."),
867
+ 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`."),
868
+ 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)."),
869
+ 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 '*'."),
870
+ montoCobro: zNum(), mensajero: zStr().describe("Mensajero que hizo el paquete (de tu nodo)"),
871
+ 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."),
872
+ 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."),
873
+ 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."),
544
874
  },
545
875
  }, async (args) => run(() => api("POST", "/envios/desde-etiqueta", args)));
876
+ tool("envio_procesar", {
877
+ title: "Procesar / recibir un envío en el centro",
878
+ 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`).",
879
+ inputSchema: { tracking: zStr().describe("Tracking / código del envío a procesar") },
880
+ }, async ({ tracking }) => run(() => api("POST", "/flujo/escanear", { codigo: tracking, accion: "procesar" })));
881
+ tool("envio_entregar", {
882
+ title: "Marcar ENTREGADO un envío ya cargado",
883
+ 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.",
884
+ inputSchema: {
885
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a marcar entregados (ej. ['TN-2057600163','NFABC123'])"),
886
+ 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`."),
887
+ },
888
+ }, async (args) => run(() => api("POST", "/envios/entregar-ref", args)));
889
+ tool("envio_completar_ciclo", {
890
+ title: "Reconstruir los pasos que le faltan a un envío ya cerrado",
891
+ 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`).",
892
+ inputSchema: {
893
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a completar (ej. ['NFABC123','TN-2057600163'])"),
894
+ 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."),
895
+ colectado: zStr().describe("Quién COLECTÓ (nombre o id, del nodo). Si no lo pasás, ese paso no se agrega."),
896
+ 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."),
897
+ 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."),
898
+ },
899
+ }, async (args) => run(() => api("POST", "/envios/completar-ciclo", args)));
900
+ tool("envio_pago_mensajero", {
901
+ title: "Cargar lo que se le paga al mensajero por esos envíos",
902
+ 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.",
903
+ inputSchema: {
904
+ trackings: z.array(zStr()).min(1).describe("Tracking(s) de los envíos a pagar (ej. ['NFABC123','TN-2057600163'])"),
905
+ valor: zNum().describe("Lo que se le paga POR ENVÍO, en pesos (ej. 2150). Se escribe igual en todos los que mandes."),
906
+ 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."),
907
+ 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."),
908
+ },
909
+ }, async (args) => run(() => api("POST", "/envios/pago-mensajero", args)));
910
+ tool("envio_corregir_estado", {
911
+ title: "Corregir el estado de un envío",
912
+ 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.",
913
+ 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.") },
914
+ }, async (args) => run(() => api("POST", "/envios/corregir-estado", args)));
915
+ tool("envio_asignar_mensajero", {
916
+ title: "Asignar / quitar el mensajero de un envío",
917
+ 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.",
918
+ 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") },
919
+ }, async (args) => run(() => api("POST", "/envios/asignar-mensajero", args)));
920
+ tool("envio_estado", {
921
+ title: "Estado + ETA de un envío (¿cuándo llega?)",
922
+ 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.",
923
+ inputSchema: { tracking: z.string().min(1).describe("Tracking del envío a consultar") },
924
+ }, async ({ tracking }) => run(() => api("GET", `/flujo/envio-estado?tracking=${encodeURIComponent(tracking)}`)));
925
+ tool("envios_trabados", {
926
+ title: "Envíos trabados del nodo (para destrabar / cerrar)",
927
+ 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.",
928
+ inputSchema: {},
929
+ }, async () => run(() => api("GET", "/flujo/envios-trabados")));
930
+ tool("planilla_reporte", {
931
+ title: "Reporte de la planilla ML (control por foto)",
932
+ 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.",
933
+ inputSchema: { desde: zStr().describe("Desde (YYYY-MM-DD), opcional"), hasta: zStr().describe("Hasta (YYYY-MM-DD), opcional") },
934
+ }, async ({ desde, hasta }) => run(() => api("GET", `/flujo/planilla-reporte${desde || hasta ? `?${new URLSearchParams({ ...(desde ? { desde } : {}), ...(hasta ? { hasta } : {}) }).toString()}` : ""}`)));
935
+ tool("nodo_provisorio_crear", {
936
+ title: "Crear un nodo PROVISORIO en un grupo (planilla ML)",
937
+ 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.",
938
+ 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)") },
939
+ }, async (args) => run(() => api("POST", "/flujo/nodo-provisorio", args)));
940
+ tool("provisorio_conciliar", {
941
+ title: "Conciliar un nodo provisorio con el nodo real",
942
+ 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).",
943
+ inputSchema: {
944
+ provisorioId: z.number().int().positive().describe("Id del nodo provisorio a conciliar (de `nodos_listar`)"),
945
+ nodoReal: z.string().min(1).describe("Nodo real destino: nombre o id (nodo ya dado de alta)"),
946
+ 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."),
947
+ 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."),
948
+ },
949
+ }, async (args) => run(() => api("POST", "/flujo/conciliar-provisorio", args)));
546
950
  tool("afiliado_crear", {
547
951
  title: "Crear afiliado",
548
952
  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.",
@@ -569,14 +973,34 @@ if (ALLOW_WRITE) {
569
973
  if (puede("usuarios")) {
570
974
  tool("chofer_crear", {
571
975
  title: "Alta de chofer (mensajero) con clave temporal",
572
- 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, teléfono y email.",
573
- inputSchema: { nombre: 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)") },
574
- }, async (args) => run(() => api("POST", "/usuarios/chofer", args)));
976
+ 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`.",
977
+ 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() },
978
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/chofer", { ...rest, logisticaId: nodo })));
575
979
  tool("chofer_editar", {
576
980
  title: "Editar un chofer",
577
- description: "Edita un chofer (mensajero) de TU nodo: nombre, teléfono, mensajeroNombre y/o activo (desactivar/activar). No toca credenciales.",
578
- inputSchema: { id: z.number().int().positive(), nombre: z.string().optional(), telefono: z.string().optional(), mensajeroNombre: z.string().optional(), activo: z.boolean().optional() },
981
+ description: "Edita un chofer (mensajero) de TU nodo: nombre, apellido, teléfono, mensajeroNombre y/o activo (desactivar/activar). No toca credenciales.",
982
+ 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() },
579
983
  }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/chofer/${id}`, body)));
984
+ tool("cliente_generar_usuario", {
985
+ title: "Generar usuario de login para un cliente/vendedor",
986
+ 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`.",
987
+ 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() },
988
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente", { ...rest, logisticaId: nodo })));
989
+ tool("cliente_resetear_clave", {
990
+ title: "Resetear la clave de un cliente/vendedor",
991
+ 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`.",
992
+ inputSchema: { cliente: z.string().min(1).describe("Nombre o id del cliente/vendedor de tu nodo (de `clientes_del_nodo`)"), nodo: zNodo() },
993
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/cliente/resetear", { ...rest, logisticaId: nodo })));
994
+ tool("operador_crear", {
995
+ title: "Alta de operador de nodo con clave temporal",
996
+ 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.",
997
+ 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.") },
998
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/usuarios/operador", { ...rest, logisticaId: nodo })));
999
+ tool("usuario_editar", {
1000
+ title: "Editar datos de un usuario",
1001
+ 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.",
1002
+ 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)") },
1003
+ }, async ({ id, ...body }) => run(() => api("PUT", `/usuarios/${id}`, body)));
580
1004
  tool("usuario_habilitar_reparto", {
581
1005
  title: "Habilitar a un usuario como también mensajero",
582
1006
  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.",
@@ -606,6 +1030,36 @@ if (ALLOW_WRITE) {
606
1030
  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.",
607
1031
  inputSchema: { tracking: z.string().min(1), cliente: z.string().min(1).describe("nombre o id del cliente destino (de tu nodo)") },
608
1032
  }, async (args) => run(() => api("POST", "/envios/reasignar-cliente", args)));
1033
+ tool("envio_mensajero_externo", {
1034
+ title: "Asignar un mensajero EXTERNO a envíos",
1035
+ 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).",
1036
+ 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() },
1037
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/envios/mensajero-externo", { ...rest, logisticaId: nodo })));
1038
+
1039
+ tool("envio_editar_zona", {
1040
+ title: "Corregir zona/localidad/partido/CP de un envío",
1041
+ 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.",
1042
+ inputSchema: {
1043
+ tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1044
+ localidad: z.string().optional().describe("Localidad real del destino (ej. 'Lanús')"),
1045
+ zona: z.string().optional().describe("Metazona para cobrar/rutear; si no la pasás se deriva de la localidad"),
1046
+ cp: z.string().optional().describe("Código postal real (se guarda solo con dígitos: '1.832,00' → '1832'). Vacío = borrarlo."),
1047
+ partido: z.string().optional().describe("Partido/municipio del destino (para la etiqueta)"),
1048
+ },
1049
+ }, async (args) => run(() => api("POST", "/envios/editar-zona", args)));
1050
+ tool("envio_editar_fecha", {
1051
+ title: "Corregir la fecha de un envío ya cargado",
1052
+ 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.",
1053
+ inputSchema: {
1054
+ tracking: z.string().min(1).describe("Tracking del envío a corregir"),
1055
+ fecha: z.string().min(1).describe("Fecha real del envío (dd/mm/aaaa o aaaa-mm-dd). No se admite futura."),
1056
+ },
1057
+ }, async (args) => run(() => api("POST", "/envios/editar-fecha", args)));
1058
+ tool("envio_asignar_grupo", {
1059
+ title: "Asignar un envío a un grupo logístico (despachar)",
1060
+ 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).",
1061
+ 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')") },
1062
+ }, async (args) => run(() => api("POST", "/envios/asignar-grupo-ref", args)));
609
1063
  tool("cobro_corregir", {
610
1064
  title: "Corregir el monto de un cobro",
611
1065
  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).",
@@ -620,73 +1074,155 @@ if (ALLOW_WRITE) {
620
1074
  if (puede("gestion")) {
621
1075
  tool("colecta_configurar", {
622
1076
  title: "Configurar valor de colecta",
623
- 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. No mueve dinero (config de tarifa).",
1077
+ 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).",
624
1078
  inputSchema: {
625
1079
  alcance: z.enum(["nodo", "mensajero", "cliente", "par"]),
626
1080
  valor: z.number(),
627
1081
  idCliente: z.string().optional(),
628
1082
  mensajero: z.string().optional().describe("Nombre del mensajero (de tu nodo)"),
629
1083
  minEnvios: z.number().optional().describe("Solo alcance=cliente: mínimo de envíos para colecta sin cargo"),
1084
+ nodo: zNodo(),
630
1085
  },
631
- }, async (args) => run(() => api("POST", "/colecta/config", args)));
1086
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/config", { ...rest, logisticaId: nodo })));
632
1087
 
633
- tool("colecta_asignar", {
1088
+ tool("colecta_fija", {
1089
+ title: "Colecta fija de un vendedor (días)",
1090
+ 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.",
1091
+ 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() },
1092
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/fija", { ...rest, logisticaId: nodo })));
1093
+
1094
+ tool("colecta_asignar", {
634
1095
  title: "Asignar una colecta a un mensajero",
635
- 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. Si el corte venció, la programa para el próximo día hábil. No mueve dinero (es logística).",
1096
+ 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`.",
636
1097
  inputSchema: {
637
1098
  cliente: z.string().min(1).describe("Nombre del cliente/vendedor a colectar"),
638
1099
  mensajero: z.string().min(1).describe("Nombre del mensajero (de tu nodo) que hace el retiro"),
1100
+ nodo: zNodo(),
1101
+ },
1102
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/asignar-por-nombre", { ...rest, logisticaId: nodo })));
1103
+
1104
+ tool("retiros_cargar", {
1105
+ title: "Cargar la lista de retiros del día",
1106
+ 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.",
1107
+ inputSchema: {
1108
+ cliente: z.string().min(1).describe("Cliente al que se le cargan los retiros (nombre o id, de tu nodo)"),
1109
+ 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."),
1110
+ zona: z.string().optional().describe("'Retiro en CABA' (default) o 'Retiro en GBA'. Es la metazona que cobra y paga."),
1111
+ 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."),
1112
+ 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."),
1113
+ nodo: zNodo(),
639
1114
  },
640
- }, async (args) => run(() => api("POST", "/colecta/asignar-por-nombre", args)));
1115
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/retiros", { ...rest, logisticaId: nodo })));
641
1116
 
642
1117
  tool("colecta_desasignar", {
643
1118
  title: "Desasignar una colecta",
644
- description: "Quita la asignación de una colecta (los envíos vuelven a 'sin colecta'). El colectaId lo devuelve «colecta_pendientes». No mueve dinero.",
645
- inputSchema: { colectaId: z.number().int().positive() },
646
- }, async ({ colectaId }) => run(() => api("POST", "/colecta/desasignar", { colectaId })));
1119
+ 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.",
1120
+ inputSchema: { colectaId: z.number().int().positive(), nodo: zNodo() },
1121
+ }, async ({ colectaId, nodo }) => run(() => api("POST", "/colecta/desasignar", { colectaId, logisticaId: nodo })));
647
1122
 
648
1123
  tool("procesar_zona", {
649
1124
  title: "Aceptar envíos ruteados por zona",
650
- 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).",
651
- inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), mensajeroId: z.number().int().positive().optional() },
652
- }, async (args) => run(() => api("POST", "/colecta/procesar-zona", args)));
1125
+ 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`.",
1126
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), mensajeroId: z.number().int().positive().optional(), nodo: zNodo() },
1127
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/procesar-zona", { ...rest, logisticaId: nodo })));
653
1128
 
654
1129
  tool("rechazar_zona", {
655
1130
  title: "Rechazar envíos ruteados por zona",
656
- 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».",
657
- inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), motivo: z.string().optional() },
658
- }, async (args) => run(() => api("POST", "/colecta/rechazar-zona", args)));
1131
+ 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`.",
1132
+ inputSchema: { envioIds: z.array(z.number().int().positive()).min(1), motivo: z.string().optional(), nodo: zNodo() },
1133
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/colecta/rechazar-zona", { ...rest, logisticaId: nodo })));
659
1134
 
660
1135
  tool("cliente_crear", {
661
1136
  title: "Crear cliente / vendedor",
662
- 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.",
1137
+ 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`.",
663
1138
  inputSchema: {
664
1139
  nombre: z.string().min(1).describe("Nombre del cliente"),
665
1140
  telefono: z.string().optional(),
666
1141
  dni: z.string().optional(),
667
1142
  direccion: z.string().optional(),
668
- idLista: z.string().optional().describe("ID de la lista de precios a asignar (ej. 'B'). Consultá 'clientes_del_nodo'."),
1143
+ 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."),
1144
+ nodo: zNodo(),
669
1145
  },
670
- }, async (args) => run(() => api("POST", "/gestion/clientes", args)));
1146
+ }, async ({ nodo, ...rest }) => {
1147
+ const r = await api("POST", "/gestion/clientes", { ...rest, logisticaId: nodo });
1148
+ const base = toResult(r);
1149
+ // Hint de completitud (Fase A): sin lista de precios el alta está a medias → guío el próximo paso.
1150
+ if (r.ok && !rest.idLista) {
1151
+ 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`." });
1152
+ }
1153
+ return base;
1154
+ });
671
1155
 
672
1156
  tool("cliente_editar", {
673
- title: "Editar cliente (incluye cambiar su lista)",
674
- description: "Edita un cliente de TU nodo. Para cambiarle la lista de precio pasá idLista. El nombre es obligatorio (traelo de 'clientes_del_nodo'). No toca dinero.",
1157
+ title: "Editar cliente (solo lo que pasás)",
1158
+ 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`.",
675
1159
  inputSchema: {
676
- id: z.number().int().positive().describe("ID del cliente"),
677
- nombre: z.string().min(1).describe("Nombre actual del cliente (obligatorio)"),
678
- idLista: z.string().optional().describe("Nueva lista de precio a asignar"),
1160
+ id: z.number().int().positive().describe("ID del cliente (de clientes_del_nodo)"),
1161
+ nombre: z.string().optional().describe("Solo si lo querés cambiar"),
1162
+ nombreFantasia: z.string().optional(),
1163
+ idLista: z.string().optional().describe("Lista de precios a asignar"),
679
1164
  telefono: z.string().optional(),
1165
+ email: z.string().optional().describe("Email con el que entra su usuario"),
680
1166
  dni: z.string().optional(),
681
1167
  direccion: z.string().optional(),
1168
+ mensajeroNombre: z.string().optional().describe("Nombre con el que cobra si también reparte"),
1169
+ activo: z.boolean().optional(),
1170
+ nodo: zNodo(),
1171
+ },
1172
+ }, async ({ id, nodo, ...body }) => run(() => api("PATCH", `/gestion/clientes/${id}`, { ...body, ...(nodo !== undefined ? { logisticaId: nodo } : {}) })));
1173
+ tool("sucursales_cliente", {
1174
+ title: "Sucursales de un cliente (con su perfil de zona)",
1175
+ 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.",
1176
+ inputSchema: { cliente: z.number().int().positive().describe("id del cliente (de `clientes_del_nodo`)") },
1177
+ }, async ({ cliente }) => run(() => api("GET", `/zonificacion/sucursales${q({ cliente })}`)));
1178
+ tool("sucursal_perfil", {
1179
+ title: "Setear el perfil de zona de una sucursal",
1180
+ 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.",
1181
+ 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.") },
1182
+ }, async ({ sucursalId, perfilZona }) => run(() => api("POST", "/zonificacion/sucursal-perfil", { sucursalId, perfilZona: perfilZona ?? null })));
1183
+ }
1184
+
1185
+ if (puede("finanzas")) {
1186
+ tool("liquidacion_excluir_envio", {
1187
+ title: "No cobrar un envío de una liquidación",
1188
+ description:
1189
+ "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.",
1190
+ inputSchema: {
1191
+ id: z.number().int().positive().describe("id de la liquidación"),
1192
+ tracking: z.string().min(1),
1193
+ motivo: z.string().min(10).describe("Por qué no se le cobra. Lo lee el vendedor."),
1194
+ },
1195
+ }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/excluir`, { tracking, motivo })));
1196
+ tool("liquidacion_reincluir_envio", {
1197
+ title: "Volver a cobrar un envío de una liquidación",
1198
+ description:
1199
+ "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.",
1200
+ inputSchema: {
1201
+ id: z.number().int().positive().describe("id de la liquidación"),
1202
+ tracking: z.string().min(1),
1203
+ motivo: z.string().min(10).describe("Por qué sí se le cobra."),
1204
+ },
1205
+ }, async ({ id, tracking, motivo }) => run(() => api("POST", `/liquidaciones/anteriores/${id}/reincluir`, { tracking, motivo })));
1206
+ tool("facturacion_preparar_cliente", {
1207
+ title: "Dejar un cliente listo para facturar",
1208
+ description:
1209
+ "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'.",
1210
+ inputSchema: {
1211
+ cliente: z.string().min(1).describe("Nombre o id del cliente"),
1212
+ cuit: z.string().min(1).describe("CUIT del cliente, 11 dígitos"),
1213
+ razonSocial: z.string().min(1).describe("Razón social / titular del CUIT, como figura en ARCA"),
1214
+ condicionIVA: z.string().min(1).describe("RI | MONOTRIBUTO | EXENTO | CONSUMIDOR_FINAL"),
1215
+ cuentaCobro: z.string().optional().describe("Nombre o id de la cuenta de dinero donde cobra (define quién factura)"),
1216
+ frecuencia: z.string().optional().describe("Semanal (default) o Mensual"),
1217
+ desde: z.string().optional().describe("Corte YYYY-MM-DD; vacío = hoy"),
682
1218
  },
683
- }, async ({ id, ...body }) => run(() => api("PUT", `/gestion/clientes/${id}`, body)));
1219
+ }, async (a) => run(() => api("POST", "/afip/preparar-cliente", a)));
684
1220
  }
685
1221
 
686
1222
  if (puede("wms") && wmsActivo) {
687
1223
  tool("producto_crear", {
688
1224
  title: "Crear producto (WMS)",
689
- description: "Alta de un producto en el catálogo del depósito. Staff puede indicar el cliente dueño con idCliente. No toca dinero.",
1225
+ 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`.",
690
1226
  inputSchema: {
691
1227
  nombre: z.string().min(1),
692
1228
  sku: z.string().optional(),
@@ -694,8 +1230,9 @@ if (ALLOW_WRITE) {
694
1230
  peso: z.number().optional(),
695
1231
  volumen: z.number().optional(),
696
1232
  idCliente: z.string().optional().describe("idCliente dueño del producto"),
1233
+ nodo: zNodo(),
697
1234
  },
698
- }, async (args) => run(() => api("POST", "/wms/productos", args)));
1235
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/wms/productos", { ...rest, logisticaId: nodo })));
699
1236
  }
700
1237
 
701
1238
  if (esGlobal) {
@@ -716,7 +1253,7 @@ if (ALLOW_WRITE) {
716
1253
  if (ALLOW_PRECIOS && puede("precios")) {
717
1254
  tool("precio_actualizar", {
718
1255
  title: "Actualizar precio de una lista (versionado)",
719
- 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.",
1256
+ 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`.",
720
1257
  inputSchema: {
721
1258
  idLista: z.string().min(1).describe("ID de la lista (ej. 'B'). Ver 'precios_ver'."),
722
1259
  cercana: z.number().optional().describe("Precio zona cercana"),
@@ -725,8 +1262,9 @@ if (ALLOW_PRECIOS && puede("precios")) {
725
1262
  muyLejana: z.number().optional().describe("Precio zona muy lejana"),
726
1263
  referencia: z.string().optional(),
727
1264
  vigenciaDesde: z.string().optional().describe("YYYY-MM-DD desde cuándo rige (default: hoy)"),
1265
+ nodo: zNodo(),
728
1266
  },
729
- }, async (args) => run(() => api("POST", "/precios/clientes/version", args)));
1267
+ }, async ({ nodo, ...rest }) => run(() => api("POST", "/precios/clientes/version", { ...rest, logisticaId: nodo })));
730
1268
  }
731
1269
 
732
1270
  // Ayuda integrada — se registra al final para que el índice conozca TODOS los tools