🧹 Clear Bins v5.1

El Nivel 3 ahora acepta un rango: pasillo + bahías, para ir limpiando un tramo cada día.

Instalar

Borra el marcador anterior y arrastra este:

🧹 Clear Bins v5.1

Uso diario

  1. Con Providence abierto, dale clic al marcador → Limpiar por API (Nivel 3). Se abre en la pestaña Rango.
  2. Escribe el pasillo (R06 o solo 6) y las bahías, por ejemplo 108 a 110.
  3. Primero en Modo prueba: te dice cuántas locaciones tienen bines y cuántos fantasmas hay. El rango se queda igual para que lo repitas.
  4. Desmarca Modo prueba y dale Empezar. Cuando termina la corrida real, mañana el formulario ya propone el siguiente tramo (111 a 113).
  5. Abajo ves el historial de los últimos días: tramo, fecha y bines quitados.

Niveles y posiciones

Por defecto revisa niveles G01-G06, B01-B08 y posiciones 01-10 en cada bahía: unas 140 combinaciones por bahía. Las que no existen en Providence se saltan solas sin ensuciar el log. Si algún pasillo tiene más niveles o posiciones, ábrelo en "Niveles y posiciones" y ajústalo (por ejemplo G01-G08 o 01-12). Se recuerda para la próxima vez.

La pestaña Lista sigue funcionando igual para pegar códigos sueltos.

Las mismas reglas de seguridad: solo filas sin producto, sin pallet y sin cantidad; usa tu sesión abierta; se pausa si la sesión vence o se acumulan errores; log CSV de todo.

Código fuente
(async () => {
  if (window.__cb3) { window.__cb3.focus(); return; }
  let auth = window.__cbAuth || null;
  const F0 = window.fetch, XH0 = XMLHttpRequest.prototype.setRequestHeader;
  XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (/^authorization$/i.test(k) && v) window.__cbAuth = auth = v; return XH0.apply(this, arguments); };
  window.fetch = function (i, init) { try { const a = new Headers((init && init.headers) || (i && i.headers) || {}).get('authorization'); if (a) window.__cbAuth = auth = a; } catch (e) {} return F0.apply(this, arguments); };
  const KEY = 'clearbins_v3_state';
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const visible = el => !!el && el.offsetParent !== null;
  const BIN_RE = /BN-[A-Z0-9]+-[A-Z0-9]+/;
  const LOC_RE = /NYC1-[A-Z0-9]+(?:-[A-Z0-9]+)*/;
  const load = () => { try { return JSON.parse(localStorage.getItem(KEY)) || null; } catch (e) { return null; } };
  const save = s => { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) {} };
  let stop = false, wake = null;

  /* ---------- Panel ---------- */
  const box = document.createElement('div');
  box.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:2147483647;background:#111;color:#fff;font:13px/1.45 system-ui,sans-serif;padding:12px 14px;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.35);width:340px;max-height:80vh;overflow:auto';
  document.body.appendChild(box);
  window.__cb3 = { focus: () => box.animate([{ outline: '3px solid #f5a623' }, { outline: 'none' }], 800) };
  const close = () => { box.remove(); delete window.__cb3; XMLHttpRequest.prototype.setRequestHeader = XH0; window.fetch = F0; if (wake) wake.release().catch(() => {}); };
  const btn = (txt, color = '#3b4cf5') => `<button data-a="${txt}" style="display:block;width:100%;margin-top:6px;padding:7px 10px;border:0;border-radius:6px;background:${color};color:#fff;cursor:pointer;text-align:left;font:inherit">${txt}</button>`;
  const ui = (html) => { box.innerHTML = '<b style="font-size:14px">🧹 Clear Bins v5.1</b><div style="margin-top:6px">' + html + '</div>'; };
  const msg = t => { const m = box.querySelector('#cb-msg'); if (m) m.textContent = t; };
  const on = (txt, fn) => { const b = box.querySelector(`[data-a="${txt}"]`); if (b) b.onclick = fn; };

  /* ---------- Mantener despierta la pantalla ---------- */
  const keepAwake = async () => { try { if ('wakeLock' in navigator && document.visibilityState === 'visible') wake = await navigator.wakeLock.request('screen'); } catch (e) {} };
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && box.isConnected) keepAwake(); });

  /* ---------- Lectura de la tabla ---------- */
  const curLoc = () => { for (const h of document.querySelectorAll('h1,h2,h3')) { const m = h.textContent.match(LOC_RE); if (m && visible(h)) return m[0]; } return null; };
  const totalText = () => { const m = document.body.innerText.match(/(\d+)\s+results?\s+in\s+total/i); return m ? +m[1] : null; };
  const colIdx = () => {
    const ths = [...document.querySelectorAll('thead th')].filter(visible);
    const f = re => ths.findIndex(th => re.test(th.textContent.trim()));
    return { item: f(/^item$/i), bin: f(/^bin id$/i), qty: f(/^qty$/i) };
  };
  const rows = () => {
    const c = colIdx(); const out = [];
    document.querySelectorAll('tbody tr').forEach(tr => {
      if (!visible(tr)) return;
      const tds = tr.querySelectorAll('td');
      const binTd = c.bin >= 0 ? tds[c.bin] : [...tds].find(td => BIN_RE.test(td.textContent));
      if (!binTd) return;
      const m = binTd.textContent.match(BIN_RE); if (!m) return;
      const item = c.item >= 0 && tds[c.item] ? tds[c.item].textContent.trim() : '';
      const qty = c.qty >= 0 && tds[c.qty] ? tds[c.qty].textContent.trim() : '';
      const ghost = item === '' && /^(-|0|)$/.test(qty);
      const el = binTd.querySelector('button,[role="button"],.anticon,svg,img,i');
      out.push({ bin: m[0], ghost, item, qty, icon: el ? (el.closest('button,[role="button"],.anticon') || el) : null });
    });
    return out;
  };
  const tableReady = async (ms = 12000) => {
    const t0 = Date.now();
    while (Date.now() - t0 < ms) {
      const spinning = [...document.querySelectorAll('.ant-spin-spinning,.ant-table-placeholder .ant-spin')].some(visible);
      if (!spinning && (rows().length || [...document.querySelectorAll('.ant-empty,.ant-table-placeholder')].some(visible))) return true;
      await sleep(250);
    }
    return false;
  };

  /* ---------- Acciones de UI ---------- */
  const click = el => ['mousedown', 'mouseup', 'click'].forEach(type => el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })));
  const CONFIRM_RE = /^(yes|confirm|ok|remove|delete|detach|s[ií]|aceptar|eliminar|quitar)$/i;
  const findConfirm = () => {
    for (const s of document.querySelectorAll('.ant-modal,.ant-popover,[role="dialog"],[role="alertdialog"]')) {
      if (!visible(s)) continue;
      for (const b of s.querySelectorAll('button')) if (visible(b) && !b.disabled && CONFIRM_RE.test(b.textContent.trim())) return b;
    }
    return null;
  };
  const errEls = () => [...document.querySelectorAll('.ant-message-error,.ant-notification-notice-error,.ant-alert-error')].filter(e => visible(e) && !/success/i.test(e.textContent));
  const toastFor = bin => [...document.querySelectorAll('.ant-notification-notice,.ant-message-notice,[role="alert"]')].find(e => visible(e) && e.textContent.includes(bin) && /detached|success/i.test(e.textContent));
  const closeToasts = () => document.querySelectorAll('.ant-notification-notice-close').forEach(x => { try { x.click(); } catch (e) {} });

  // Quita los bines fantasma de la locación abierta. Devuelve {removed, skipped, error}
  const clearCurrent = async (dry, log, loc) => {
    let removed = 0, guard = 0, pages = 0;
    const withStock = new Set();
    rows().filter(r => !r.ghost).forEach(r => withStock.add(r.bin));
    if (dry) {
      const r = rows(), g = r.filter(x => x.ghost);
      log.push({ loc, bin: g.map(x => x.bin).join(' '), result: `PRUEBA: total ${totalText() ?? r.length}, fantasmas en pág.1 ${g.length}, con producto ${r.length - g.length}` });
      return { removed: 0, skipped: withStock.size };
    }
    while (!stop && guard++ < 300) {
      const g = rows().filter(r => r.ghost && r.icon);
      if (!g.length) {
        const next = document.querySelector('.ant-pagination-next:not(.ant-pagination-disabled)');
        if (next && visible(next) && pages++ < 20) { click(next.querySelector('button') || next); await sleep(800); await tableReady(); continue; }
        break;
      }
      const target = g[0], before = rows().length, beforeTotal = totalText();
      const seen = new Set(errEls());
      msg(`${loc}: quitando ${target.bin} (${removed + 1})…`);
      closeToasts();
      click(target.icon);
      for (let i = 0; i < 12; i++) { await sleep(250); const c = findConfirm(); if (c) { click(c); break; } }
      let ok = false, err = null;
      for (let i = 0; i < 40; i++) {
        await sleep(250);
        err = errEls().find(e => !seen.has(e)); if (err) break;
        if (toastFor(target.bin)) { ok = true; break; }
        const t = totalText();
        if (t != null && beforeTotal != null && t < beforeTotal) { ok = true; break; }
      }
      if (err) { log.push({ loc, bin: target.bin, result: 'ERROR: ' + err.textContent.trim().slice(0, 150) }); return { removed, error: err.textContent.trim().slice(0, 150) }; }
      if (!ok) { log.push({ loc, bin: target.bin, result: 'SIN CONFIRMAR' }); return { removed, error: 'La tabla no cambió tras el detach' }; }
      log.push({ loc, bin: target.bin, result: 'DETACHED' });
      removed++;
      for (let i = 0; i < 20; i++) { if (rows().length < before || (totalText() ?? 1e9) < (beforeTotal ?? 0)) break; await sleep(200); }
      await sleep(300);
    }
    withStock.forEach(b => log.push({ loc, bin: b, result: 'OMITIDO: tiene producto' }));
    return { removed, skipped: withStock.size };
  };

  /* ---------- Navegación entre locaciones ---------- */
  const goTo = async (tpl, code) => {
    const url = tpl.replace('{code}', encodeURIComponent(code));
    history.pushState({}, '', url);
    window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
    for (let i = 0; i < 40; i++) { await sleep(250); if (curLoc() === code) { await sleep(400); await tableReady(); return true; } }
    return false;
  };

  const csv = log => {
    const esc = v => '"' + String(v ?? '').replace(/"/g, '""') + '"';
    const body = 'fecha_hora,locacion,bin_id,resultado\n' + log.map(r => [r.t, r.loc, r.bin, r.result].map(esc).join(',')).join('\n');
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([body], { type: 'text/csv' }));
    a.download = 'clear-bins-log-' + new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-') + '.csv';
    document.body.appendChild(a); a.click(); a.remove();
  };
  const stamp = log => { const t = new Date().toLocaleString(); log.forEach(r => { if (!r.t) r.t = t; }); };

  /* ---------- Modo: lista (Nivel 2) ---------- */
  const runList = async st => {
    await keepAwake();
    ui(`<div id="cb-msg">Iniciando…</div><div id="cb-bar" style="height:6px;background:#333;border-radius:3px;margin-top:8px"><div style="height:6px;width:0;background:#3fb950;border-radius:3px"></div></div>${btn('Detener', '#e5484d')}`);
    on('Detener', () => { stop = true; msg('Deteniendo al terminar este bin…'); });
    const bar = box.querySelector('#cb-bar div');
    while (!stop && st.idx < st.queue.length) {
      const code = st.queue[st.idx];
      bar.style.width = Math.round(st.idx / st.queue.length * 100) + '%';
      msg(`(${st.idx + 1}/${st.queue.length}) Abriendo ${code}…`);
      if (curLoc() !== code) {
        const ok = await goTo(st.tpl, code);
        if (!ok) {
          st.log.push({ loc: code, bin: '', result: 'NO SE PUDO ABRIR (código inexistente o la página no navegó sola)' });
          stamp(st.log); st.idx++; save(st);
          if (++st.navFails >= 3 && st.navFails === st.idx) { stop = true; st.note = 'La navegación automática no funciona en esta página.'; }
          continue;
        }
      } else await tableReady();
      const r = await clearCurrent(st.dry, st.log, code);
      stamp(st.log);
      if (r.error) { st.paused = true; save(st); return listDone(st, `Pausado en ${code}: ${r.error}. Revisa y dale "Continuar".`); }
      st.idx++; st.done += r.removed; save(st);
    }
    st.running = st.idx < st.queue.length; save(st);
    listDone(st, stop ? (st.note || 'Detenido. Puedes continuar luego desde el marcador.') : `Listo: ${st.queue.length} locaciones, ${st.done} bines quitados${st.dry ? ' (modo prueba, no se tocó nada)' : ''}.`);
  };
  const listDone = (st, text) => {
    if (wake) wake.release().catch(() => {});
    ui(`<div>${text}</div><div style="opacity:.7;margin-top:4px">Avance: ${st.idx}/${st.queue.length}</div>${btn('Descargar log CSV', '#2d7d46')}${st.idx < st.queue.length ? btn('Continuar') : ''}${btn('Borrar lista y cerrar', '#555')}${btn('Cerrar', '#333')}`);
    on('Descargar log CSV', () => csv(st.log));
    on('Continuar', () => { stop = false; st.paused = false; st.running = true; save(st); runList(st); });
    on('Borrar lista y cerrar', () => { if (confirm('¿Borrar la lista y el log guardados?')) { localStorage.removeItem(KEY); close(); } });
    on('Cerrar', close);
  };
  const setupList = () => {
    const here = curLoc();
    if (!here) { ui('Para recorrer una lista, abre primero el Location Detail de cualquier locación (así aprendo el formato de la URL).' + btn('Cerrar', '#333')); on('Cerrar', close); return; }
    if (!decodeURIComponent(location.href).includes(here)) {
      ui(`La URL de esta página no contiene el código <b>${here}</b>, así que no puedo saltar a otras locaciones sola. Mándale a Claude esta URL:<pre style="white-space:pre-wrap;word-break:break-all;background:#222;padding:6px;border-radius:4px">${location.href.replace(/</g, '&lt;')}</pre>` + btn('Cerrar', '#333'));
      on('Cerrar', close); return;
    }
    ui(`Pega las locaciones (una por línea o separadas por coma):<textarea id="cb-list" rows="7" style="width:100%;margin-top:6px;box-sizing:border-box;font:12px monospace;border-radius:4px;padding:6px;background:#262626 !important;color:#fff !important;border:1px solid #555 !important;-webkit-text-fill-color:#fff"></textarea>
      <label style="display:block;margin-top:6px"><input type="checkbox" id="cb-dry" checked> Modo prueba (solo contar, no quitar nada)</label>
      ${btn('Empezar')}${btn('Cancelar', '#333')}`);
    on('Cancelar', close);
    on('Empezar', () => {
      const raw = box.querySelector('#cb-list').value.toUpperCase().match(/NYC1-[A-Z0-9-]+/g) || [];
      let queue = [...new Set(raw.map(s => s.replace(/-+$/, '')))];
      const risky = queue.filter(c => !/^NYC1-R\d+-/.test(c));
      if (risky.length && confirm(`${risky.length} locaciones no son de rack (ej. ${risky.slice(0, 3).join(', ')}). Carritos o estaciones pueden estar en uso.\n\nAceptar = quitarlas de la lista\nCancelar = dejarlas`)) queue = queue.filter(c => /^NYC1-R\d+-/.test(c));
      if (!queue.length) { alert('No encontré códigos NYC1-… en la lista.'); return; }
      const dry = box.querySelector('#cb-dry').checked;
      if (!dry && !confirm(`Se van a quitar los bines vacíos (sin ítem ni cantidad) de ${queue.length} locaciones.\nLas filas con producto se omiten.\n\n¿Continuar?`)) return;
      const tpl = decodeURIComponent(location.href).replace(here, '{code}');
      const st = { queue, idx: 0, dry, tpl, log: [], done: 0, navFails: 0, running: true, started: new Date().toISOString() };
      save(st); runList(st);
    });
  };

  /* ---------- Modo: esta locación ---------- */
  const runSingle = async () => {
    const loc = curLoc();
    const r0 = rows();
    if (!loc || !r0.length) { ui('No encontré Bin IDs en esta página. Abre el Location Detail.' + btn('Cerrar', '#333')); on('Cerrar', close); return; }
    const ghosts = r0.filter(r => r.ghost).length;
    if (!confirm(`Locación: ${loc}\nTotal: ${totalText() ?? r0.length}\nVacíos en esta página: ${ghosts}  ·  Con producto: ${r0.length - ghosts} (se omiten)\n\n¿Quitar los bines vacíos?`)) { close(); return; }
    await keepAwake();
    ui(`<div id="cb-msg">Empezando…</div>${btn('Detener', '#e5484d')}`);
    on('Detener', () => { stop = true; msg('Deteniendo…'); });
    const log = [];
    const r = await clearCurrent(false, log, loc); stamp(log);
    if (wake) wake.release().catch(() => {});
    ui(`<div>${r.error ? 'Me detuve: ' + r.error : stop ? 'Detenido.' : 'Listo.'} Quitados: ${r.removed}${r.skipped ? ` · Omitidos con producto: ${r.skipped}` : ''}.</div>${btn('Descargar log CSV', '#2d7d46')}${btn('Cerrar', '#333')}`);
    on('Descargar log CSV', () => csv(log)); on('Cerrar', close);
  };

  /* ---------- Modo: capturar API (para Nivel 3) ---------- */
  const runCapture = () => {
    const cap = [];
    const SENS = /authorization|cookie|token|secret|password|api[-_]?key|session/i;
    const scrub = txt => {
      if (typeof txt !== 'string') return txt == null ? null : '[binario]';
      let s = txt.slice(0, 3000);
      try { const o = JSON.parse(txt); const walk = v => { if (v && typeof v === 'object') for (const k of Object.keys(v)) { if (SENS.test(k)) v[k] = '[OCULTO]'; else walk(v[k]); } }; walk(o); s = JSON.stringify(o).slice(0, 3000); } catch (e) {}
      return s.replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[JWT OCULTO]');
    };
    const hdrs = h => { const out = {}; try { new Headers(h || {}).forEach((v, k) => { out[k] = SENS.test(k) ? '[OCULTO]' : v; }); } catch (e) {} return out; };
    const of = window.fetch;
    window.fetch = async function (input, init = {}) {
      const url = typeof input === 'string' ? input : input.url;
      const method = (init.method || (input && input.method) || 'GET').toUpperCase();
      const res = await of.apply(this, arguments);
      try { const body = await res.clone().text(); cap.push({ via: 'fetch', method, url, reqHeaders: hdrs(init.headers || (input && input.headers)), reqBody: scrub(init.body), status: res.status, resBody: scrub(body) }); upd(); } catch (e) {}
      return res;
    };
    const XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send, XH = XMLHttpRequest.prototype.setRequestHeader;
    XMLHttpRequest.prototype.open = function (m, u) { this.__c = { via: 'xhr', method: String(m).toUpperCase(), url: u, reqHeaders: {} }; return XO.apply(this, arguments); };
    XMLHttpRequest.prototype.setRequestHeader = function (k, v) { if (this.__c) this.__c.reqHeaders[k] = SENS.test(k) ? '[OCULTO]' : v; return XH.apply(this, arguments); };
    XMLHttpRequest.prototype.send = function (b) { const c = this.__c; if (c) { c.reqBody = scrub(b); this.addEventListener('loadend', () => { c.status = this.status; try { c.resBody = scrub(this.responseText); } catch (e) {} cap.push(c); upd(); }); } return XS.apply(this, arguments); };
    const restore = () => { window.fetch = of; XMLHttpRequest.prototype.open = XO; XMLHttpRequest.prototype.send = XS; XMLHttpRequest.prototype.setRequestHeader = XH; };
    const upd = () => { const n = box.querySelector('#cb-n'); if (n) n.textContent = cap.length; };
    ui(`<b>Grabando peticiones…</b> (<span id="cb-n">0</span>)<ol style="padding-left:18px;margin:6px 0">
      <li>Quita <b>un</b> bin a mano: ⊗ → Yes.</li>
      <li>Cambia a la página 2 de la tabla (o abre otra locación desde el buscador).</li>
      <li>Dale <b>Listo</b>.</li></ol>
      <div style="opacity:.75">Tokens, cookies y contraseñas se ocultan solos.</div>${btn('Listo')}${btn('Cancelar', '#333')}`);
    on('Cancelar', () => { restore(); close(); });
    on('Listo', () => {
      restore();
      const keep = cap.filter(c => !/\.(js|css|png|svg|woff2?)(\?|$)/i.test(c.url));
      const out = JSON.stringify({ page: location.href, location: curLoc(), captured: keep }, null, 1);
      ui(`Capturé ${keep.length} peticiones. Copia esto y pégaselo a Claude:<textarea readonly rows="8" style="width:100%;margin-top:6px;box-sizing:border-box;font:11px monospace;background:#262626;color:#fff;border:1px solid #555">${out.replace(/</g, '&lt;')}</textarea>${btn('Copiar')}${btn('Cerrar', '#333')}`);
      on('Copiar', async () => { try { await navigator.clipboard.writeText(out); } catch (e) { box.querySelector('textarea').select(); document.execCommand('copy'); } box.querySelector('[data-a="Copiar"]').textContent = 'Copiado ✓'; });
      on('Cerrar', close);
    });
  };

  /* ---------- Nivel 3: API directa ---------- */
  const API = 'https://november.gobolt.com';
  const AKEY = 'clearbins_api_state';
  const aload = () => { try { return JSON.parse(localStorage.getItem(AKEY)) || null; } catch (e) { return null; } };
  const asave = s => { try { localStorage.setItem(AKEY, JSON.stringify(s)); } catch (e) {} };
  const facOf = code => (code.split('-')[0] || 'nyc1').toLowerCase();
  const api = async (method, path, fac) => {
    const res = await F0.call(window, API + path, { method, headers: { Accept: 'application/json', 'X-Facility': fac, 'X-App-Id': 'wms-web-providence', Authorization: auth } });
    let body = null; try { body = await res.json(); } catch (e) {}
    return { status: res.status, body };
  };
  const scanStorage = () => {
    for (const s of [localStorage, sessionStorage]) {
      try { for (let i = 0; i < s.length; i++) { const m = (s.getItem(s.key(i)) || '').match(/eyJ[\w-]+\.[\w-]+\.[\w-]+/); if (m) return 'Bearer ' + m[0]; } } catch (e) {}
    }
    return null;
  };
  const ensureAuth = async testCode => {
    if (auth) return true;
    const cand = scanStorage();
    if (cand) { auth = cand; const r = await api('GET', '/locations/' + encodeURIComponent(testCode), facOf(testCode)); if (r.status === 200) { window.__cbAuth = auth; return true; } auth = null; }
    ui(`<div id="cb-msg">Necesito tu sesión de Providence. <b>Abre cualquier locación o cambia de página en una tabla</b> de Providence; la tomo sola en cuanto la página haga una consulta.</div>${btn('Cancelar', '#333')}`);
    let cancel = false; on('Cancelar', () => { cancel = true; });
    for (let i = 0; i < 240 && !auth && !cancel; i++) await sleep(250);
    return !!auth;
  };
  const isGhost = (it, code) => !!it.bin_id && !it.product_id && !it.manual_item_id && !it.supply_id && !it.pallet_id && !it.product
    && (it.quantity == null || Number(it.quantity) === 0) && (!it.location_code || it.location_code === code);
  const withRetry = async fn => {
    for (let a = 0; a < 4; a++) {
      const r = await fn();
      if (r.status === 429 || r.status >= 500) { await sleep([2000, 5000, 10000, 20000][a]); continue; }
      return r;
    }
    return { status: 'reintentos agotados' };
  };
  const listItems = async code => {
    const out = []; const fac = facOf(code);
    for (let p = 1; p <= 100; p++) {
      const r = await withRetry(() => api('GET', `/location-items?location_code=${encodeURIComponent(code)}&page=${p}&statuses[]=available&statuses[]=damaged&statuses[]=hold&statuses[]=wholesale`, fac));
      if (r.status !== 200) return { error: r.status };
      out.push(...(r.body.items || []));
      if (p >= ((r.body.meta && r.body.meta.totalPages) || 1)) break;
    }
    return { items: out };
  };

  const runApi = async S => {
    await keepAwake();
    if (!(await ensureAuth(S.queue[S.idx] || S.queue[0]))) { ui('No pude obtener la sesión. Cancela y vuelve a intentar con Providence abierto.' + btn('Cerrar', '#333')); on('Cerrar', close); return; }
    ui(`<div id="cb-msg">Iniciando…</div><div id="cb-stats" style="opacity:.8;margin-top:4px"></div><div style="height:6px;background:#333;border-radius:3px;margin-top:8px"><div id="cb-bar" style="height:6px;width:0;background:#3fb950;border-radius:3px"></div></div>${btn('Detener', '#e5484d')}`);
    on('Detener', () => { stop = true; msg('Deteniendo…'); });
    const t0 = Date.now(), idx0 = S.idx;
    const stats = () => {
      const el = box.querySelector('#cb-stats'); if (!el) return;
      const doneLocs = S.idx - idx0, per = doneLocs ? (Date.now() - t0) / doneLocs : 0;
      const eta = per ? Math.round(per * (S.queue.length - S.idx) / 60000) : '…';
      el.textContent = `${S.label ? S.label + ' · ' : ''}Revisados ${S.idx}/${S.queue.length} · con bines ${S.withBins || 0} · ${S.dry ? 'fantasmas encontrados' : 'quitados'} ${S.dry ? S.found : S.done} · con producto ${S.stock} · errores ${S.errs} · faltan ~${eta} min`;
      box.querySelector('#cb-bar').style.width = Math.round(S.idx / S.queue.length * 100) + '%';
    };
    const L = (loc, bin, res) => S.log.push([new Date().toLocaleString(), loc, bin, res]);
    const pause = text => { asave(S); apiDone(S, text); };
    const pre = {}; const getList = c => pre[c] || (pre[c] = listItems(c));
    while (!stop && S.idx < S.queue.length) {
      const code = S.queue[S.idx];
      msg(`Revisando ${code}…`); stats();
      for (let j = S.idx; j < Math.min(S.queue.length, S.idx + 6); j++) getList(S.queue[j]);
      const li = await getList(code); delete pre[code];
      if (li.error === 401 || li.error === 403) { if (li.error === 401) window.__cbAuth = auth = null; } if (li.error === 401 || li.error === 403) return pause(`Providence rechazó la sesión (${li.error}) en ${code}. Abre cualquier locación en Providence para renovarla y dale Continuar.`);
      if (li.error) { L(code, '', 'ERROR al leer: ' + li.error); S.errs++; S.idx++; asave(S); continue; }
      const ghosts = li.items.filter(it => isGhost(it, code)), stock = li.items.filter(it => !isGhost(it, code));
      S.stock += stock.length;
      stock.forEach(it => L(code, it.bin_id || it.pallet_id || '', 'OMITIDO: tiene producto/pallet'));
      if (!li.items.length) { if (!S.quiet) L(code, '', 'SIN BINES'); } else S.withBins = (S.withBins || 0) + 1;
      if (S.dry) { S.found += ghosts.length; ghosts.forEach(it => L(code, it.bin_id, 'PRUEBA: se quitaría')); S.idx++; asave(S); continue; }
      // Detach en paralelo (S.conc a la vez)
      let k = 0, authFail = false;
      const worker = async () => {
        while (!stop && !authFail && k < ghosts.length) {
          const it = ghosts[k++];
          const r = await withRetry(() => api('PATCH', '/location-items/detach-bin-from-location/' + it.id, facOf(code)));
          if (r.status === 401 || r.status === 403) { authFail = true; if (r.status === 401) window.__cbAuth = auth = null; k--; return; }
          const row = Array.isArray(r.body) ? r.body[0] : r.body;
          if (r.status === 200 && row && !row.location_code) { S.done++; L(code, it.bin_id, 'DETACHED'); }
          else { S.errs++; L(code, it.bin_id, 'ERROR: ' + r.status + ' ' + JSON.stringify(r.body || '').slice(0, 120)); }
          msg(`${code}: ${it.bin_id}`); stats();
          await sleep(S.gap);
        }
      };
      await Promise.all(Array.from({ length: Math.max(1, S.conc) }, worker));
      if (authFail) return pause(`Sesión vencida en ${code}. Abre cualquier locación en Providence para renovarla y dale Continuar (retoma esta misma locación).`);
      if (stop) break;
      S.idx++; asave(S);
      if (S.errs >= 20 && S.errs > S.done) return pause(`Demasiados errores (${S.errs}). Revisa el log antes de seguir.`);
    }
    asave(S); stats();
    if (!stop && S.idx >= S.queue.length) hadd(S);
    apiDone(S, stop ? 'Detenido. Puedes continuar luego desde el marcador.' : S.dry
      ? `Prueba terminada (${S.label || ''}): ${S.withBins || 0} locaciones con bines, ${S.found} bines fantasma, ${S.stock} filas con producto (no se tocó nada).`
      : `Listo (${S.label || ''}): ${S.withBins || 0} locaciones con bines, ${S.done} bines quitados, ${S.errs} errores.`);
  };
  const apiCsv = S => {
    const esc = v => '"' + String(v ?? '').replace(/"/g, '""') + '"';
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob(['fecha_hora,locacion,bin_id,resultado\n' + S.log.map(r => r.map(esc).join(',')).join('\n')], { type: 'text/csv' }));
    a.download = 'clear-bins-api-' + (S.dry ? 'prueba-' : '') + new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-') + '.csv';
    document.body.appendChild(a); a.click(); a.remove();
  };
  const apiDone = (S, text) => {
    if (wake) wake.release().catch(() => {});
    ui(`<div>${text}</div><div style="opacity:.7;margin-top:4px">Avance: ${S.idx}/${S.queue.length}</div>${btn('Descargar log CSV', '#2d7d46')}${S.idx < S.queue.length ? btn('Continuar') : ''}${btn('Borrar lista y cerrar', '#555')}${btn('Cerrar', '#333')}`);
    on('Descargar log CSV', () => apiCsv(S));
    on('Continuar', () => { stop = false; runApi(S); });
    on('Borrar lista y cerrar', () => { if (confirm('¿Borrar la lista y el log guardados?')) { localStorage.removeItem(AKEY); close(); } });
    on('Cerrar', close);
  };
  const HKEY = 'clearbins_history';
  const hload = () => { try { return JSON.parse(localStorage.getItem(HKEY)) || []; } catch (e) { return []; } };
  const hadd = S => { try { const h = hload(); h.unshift({ d: new Date().toLocaleDateString(), label: S.label || `${S.queue.length} locaciones`, dry: S.dry, done: S.done, found: S.found, stock: S.stock, errs: S.errs, locs: S.withBins || 0 }); localStorage.setItem(HKEY, JSON.stringify(h.slice(0, 40))); } catch (e) {} };
  const expand = txt => {
    // "G01-G06, B01-B08" -> [G01..G06, B01..B08]; "01-10" -> [01..10]
    const out = [];
    String(txt).toUpperCase().split(/[,\s]+/).filter(Boolean).forEach(part => {
      const m = part.match(/^([A-Z]*)(\d+)(?:-([A-Z]*)(\d+))?$/);
      if (!m) return;
      const p = m[1], a = +m[2], b = m[4] != null ? +m[4] : a, w = m[2].length;
      for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.push(p + String(i).padStart(w, '0'));
    });
    return [...new Set(out)];
  };
  const LKEY = 'clearbins_last_range';
  const setupApi = () => {
    let last = {}; try { last = JSON.parse(localStorage.getItem(LKEY)) || {}; } catch (e) {}
    const hist = hload().slice(0, 6);
    const inp = (id, val, w) => `<input id="${id}" value="${val}" style="width:${w};background:#262626 !important;color:#fff !important;border:1px solid #555 !important;border-radius:5px;padding:4px 6px;font:inherit;-webkit-text-fill-color:#fff">`;
    ui(`<div style="display:flex;gap:6px;margin-bottom:6px"><button data-tab="r" style="flex:1;padding:5px;border:0;border-radius:5px;cursor:pointer">Rango</button><button data-tab="l" style="flex:1;padding:5px;border:0;border-radius:5px;cursor:pointer">Lista</button></div>
      <div id="tab-r">
        <div>Pasillo ${inp('cb-aisle', last.aisle || 'R06', '60px')} &nbsp; Bahías ${inp('cb-b1', last.b1 || '108', '50px')} a ${inp('cb-b2', last.b2 || '110', '50px')}</div>
        <details style="margin-top:6px"><summary style="cursor:pointer;opacity:.8">Niveles y posiciones</summary>
          <div style="margin-top:4px">Niveles ${inp('cb-lv', last.lv || 'G01-G06, B01-B08', '170px')}</div>
          <div style="margin-top:4px">Posiciones ${inp('cb-ps', last.ps || '01-10', '80px')}</div>
          <div style="opacity:.7;margin-top:4px">Las combinaciones que no existen en Providence se saltan solas.</div></details>
        <div id="cb-prev" style="opacity:.75;margin-top:6px"></div>
      </div>
      <div id="tab-l" style="display:none">Pega las locaciones (una por línea o separadas por coma):<textarea id="cb-list" rows="6" style="width:100%;margin-top:6px;box-sizing:border-box;font:12px monospace;border-radius:4px;padding:6px;background:#262626 !important;color:#fff !important;border:1px solid #555 !important;-webkit-text-fill-color:#fff"></textarea></div>
      <label style="display:block;margin-top:8px"><input type="checkbox" id="cb-dry" checked> Modo prueba (solo contar, no quitar nada)</label>
      <label style="display:block;margin-top:4px">Velocidad: <select id="cb-conc" style="background:#262626 !important;color:#fff !important;border:1px solid #555 !important;border-radius:5px;padding:4px 6px;font:inherit;-webkit-text-fill-color:#fff"><option value="1">Suave (1 a la vez)</option><option value="3" selected>Normal (3 a la vez)</option><option value="5">Rápida (5 a la vez)</option></select></label>
      ${btn('Empezar')}${btn('Cancelar', '#333')}
      ${hist.length ? '<div style="margin-top:10px;opacity:.8"><b>Historial</b>' + hist.map(h => `<div style="font-size:12px">${h.d} · ${h.label} · ${h.dry ? `prueba: ${h.found} fantasmas` : `${h.done} quitados`}${h.errs ? ` · ${h.errs} err` : ''}</div>`).join('') + '</div>' : ''}`);
    let tab = 'r';
    const setTab = t => { tab = t; box.querySelector('#tab-r').style.display = t === 'r' ? '' : 'none'; box.querySelector('#tab-l').style.display = t === 'l' ? '' : 'none';
      box.querySelectorAll('[data-tab]').forEach(b => { b.style.background = b.dataset.tab === t ? '#3b4cf5' : '#333'; b.style.color = '#fff'; }); };
    box.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => setTab(b.dataset.tab)); setTab('r');
    const fac = (curLoc() || 'NYC1-').split('-')[0];
    const build = () => {
      const v = id => box.querySelector('#' + id).value.trim().toUpperCase();
      let aisle = v('cb-aisle'); if (/^\d+$/.test(aisle)) aisle = 'R' + aisle.padStart(2, '0');
      const b1 = +v('cb-b1'), b2 = +v('cb-b2'), lv = expand(v('cb-lv')), ps = expand(v('cb-ps'));
      if (!/^[A-Z]+\d+$/.test(aisle) || !b1 || !b2 || !lv.length || !ps.length) return null;
      const q = [];
      for (let b = Math.min(b1, b2); b <= Math.max(b1, b2); b++) for (const l of lv) for (const p of ps) q.push(`${fac}-${aisle}-${String(b).padStart(3, '0')}-${l}-${p}`);
      return { q, aisle, b1: Math.min(b1, b2), b2: Math.max(b1, b2), label: `${aisle} ${String(Math.min(b1, b2)).padStart(3, '0')}–${String(Math.max(b1, b2)).padStart(3, '0')}` };
    };
    const prev = () => { const r = build(); box.querySelector('#cb-prev').textContent = r ? `${r.label}: revisará hasta ${r.q.length} códigos posibles (ej. ${r.q[0]})` : 'Revisa los campos.'; };
    box.querySelectorAll('#tab-r input').forEach(i => i.oninput = prev); prev();
    on('Cancelar', close);
    on('Empezar', () => {
      let queue, label, fromRange = tab === 'r';
      if (fromRange) {
        const r = build(); if (!r) { alert('Revisa pasillo, bahías, niveles y posiciones.'); return; }
        if (r.q.length > 3000 && !confirm(`Son ${r.q.length} códigos posibles. ¿Seguro?`)) return;
        queue = r.q; label = r.label;
        const isDry = box.querySelector('#cb-dry').checked, nb1 = isDry ? r.b1 : r.b2 + 1, nb2 = isDry ? r.b2 : r.b2 + 1 + (r.b2 - r.b1);
        try { localStorage.setItem(LKEY, JSON.stringify({ aisle: r.aisle, b1: String(nb1).padStart(3, '0'), b2: String(nb2).padStart(3, '0'), lv: box.querySelector('#cb-lv').value, ps: box.querySelector('#cb-ps').value })); } catch (e) {}
      } else {
        const raw = box.querySelector('#cb-list').value.toUpperCase().match(/[A-Z0-9]+-[A-Z0-9-]+/g) || [];
        queue = [...new Set(raw.map(s => s.replace(/-+$/, '')).filter(s => /^[A-Z]{3}\d-/.test(s)))];
        const risky = queue.filter(c => !/^[A-Z]{3}\d-R\d+-/.test(c));
        if (risky.length && confirm(`${risky.length} locaciones no son de rack (ej. ${risky.slice(0, 3).join(', ')}). Carritos o estaciones pueden estar en uso.\n\nAceptar = quitarlas de la lista\nCancelar = dejarlas`)) queue = queue.filter(c => /^[A-Z]{3}\d-R\d+-/.test(c));
        if (!queue.length) { alert('No encontré códigos de locación en la lista.'); return; }
        label = `Lista de ${queue.length}`;
      }
      const dry = box.querySelector('#cb-dry').checked, conc = +box.querySelector('#cb-conc').value;
      if (!dry && !confirm(`${label}\nSe van a quitar por API los bines vacíos (sin producto, sin pallet, sin cantidad).\nLo demás se omite.\n\n¿Continuar?`)) return;
      const S = { queue, idx: 0, dry, conc, gap: 150, log: [], done: 0, found: 0, stock: 0, errs: 0, withBins: 0, label, quiet: fromRange, started: new Date().toISOString() };
      asave(S); runApi(S);
    });
  };

  /* ---------- Menú ---------- */
  const st = load();
  const pending = st && st.idx < st.queue.length;
  const A = aload(), apending = A && A.idx < A.queue.length;
  ui(`<div style="opacity:.75">${curLoc() ? 'Locación: ' + curLoc() : 'Abre un Location Detail para empezar.'}</div>
    ${apending ? btn(`Continuar lista API (${A.idx}/${A.queue.length})`, '#2d7d46') : ''}
    ${pending ? btn(`Continuar lista (${st.idx}/${st.queue.length})`, '#2d7d46') : ''}
    ${btn('Limpiar esta locación')}${btn('Recorrer lista (Nivel 2)')}${btn('Limpiar por API (Nivel 3)', '#8a5cf6')}${btn('Capturar API', '#555')}
    ${st && st.log && st.log.length ? btn('Descargar último log', '#555') : ''}${btn('Cerrar', '#333')}`);
  if (pending) on(`Continuar lista (${st.idx}/${st.queue.length})`, () => { st.running = true; runList(st); });
  on('Limpiar esta locación', runSingle);
  on('Recorrer lista (Nivel 2)', setupList);
  on('Capturar API', runCapture);
  on('Limpiar por API (Nivel 3)', setupApi);
  if (apending) on(`Continuar lista API (${A.idx}/${A.queue.length})`, () => runApi(A));
  on('Descargar último log', () => csv(st.log));
  on('Cerrar', close);
})();