El Nivel 3 ahora acepta un rango: pasillo + bahías, para ir limpiando un tramo cada día.
Borra el marcador anterior y arrastra este:
R06 o solo 6) y las bahías, por ejemplo 108 a 110.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.
(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, '<')}</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, '<')}</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')} 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);
})();