🧹 Clear Bins v3

Quita los bines vacíos (sin ítem ni cantidad) de las locaciones en Providence: una sola, o una lista completa, navegando sola.

Instalar

Borra el marcador anterior y arrastra este a la barra de marcadores:

🧹 Clear Bins v3

Menú (abre un Location Detail y dale clic al marcador)

Plan de prueba

  1. Elige ~10 locaciones de rack con bines fantasma. Anota cuántos bines tiene cada una e intenta un putaway en 2 o 3 (debe dar "not empty").
  2. Nivel 2, en seco: Recorrer lista con 5 de ellas y Modo prueba marcado. Revisa que pase de una a otra sola y descarga el log.
  3. Nivel 2, real: repite con las mismas 5 y el modo prueba desmarcado. Repite el putaway: ahora debería pasar.
  4. Nivel 3: en otra de las locaciones, elige Capturar API, quita 1 bin a mano, cambia de página en la tabla, dale Listo → Copiar, y pégamelo en el chat.

Seguridad: solo toca filas sin ítem y con Qty "-" o 0. Ofrece sacar de la lista lo que no sea rack (CART, estaciones). Se pausa ante cualquier error de Providence. Si se interrumpe, el marcador ofrece Continuar lista. Deja la pestaña al frente: si la minimizas, Chrome la frena.

Si al empezar la lista te dice que la URL no contiene el código, mándame la URL que te muestra.

Código fuente
(async () => {
  if (window.__cb3) { window.__cb3.focus(); return; }
  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; 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 v3</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"></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">${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);
    });
  };

  /* ---------- Menú ---------- */
  const st = load();
  const pending = st && st.idx < st.queue.length;
  ui(`<div style="opacity:.75">${curLoc() ? 'Locación: ' + curLoc() : 'Abre un Location Detail para empezar.'}</div>
    ${pending ? btn(`Continuar lista (${st.idx}/${st.queue.length})`, '#2d7d46') : ''}
    ${btn('Limpiar esta locación')}${btn('Recorrer lista (Nivel 2)')}${btn('Capturar API (Nivel 3)', '#8a5cf6')}
    ${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 (Nivel 3)', runCapture);
  on('Descargar último log', () => csv(st.log));
  on('Cerrar', close);
})();