// ==UserScript== // @name UBW Requisition Filler // @namespace unit4.ubw.requisitions // @version 1.0 // @description Fill a UBW purchase requisition's line items from a supplier cart CSV. Fills only; never saves or submits. // @match https://ubw.unit4cloud.com/* // @run-at document-idle // @grant GM_registerMenuCommand // @grant unsafeWindow // ==/UserScript== // // Install, usage and CSV format: see README.md. The comments below are the design notes: why the // script does things the way it does, for anyone maintaining it. (function () { 'use strict'; // Any @grant puts us in Tampermonkey's sandbox, whose window lacks the page's Ext; reach the real // page window (and its event constructors) via unsafeWindow. Falls back to window in the console. const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const DEFAULTS = { productCode: '', requestName: 'Order ' + new Date().toISOString().slice(0, 10), }; // ---- Small async helpers ------------------------------------------------------------------ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // "Server is done" = no outstanding Ext Ajax. Don't watch for .x-mask nodes; UBW keeps permanent ones. async function waitIdle(timeout = 20000) { const t0 = Date.now(); await sleep(120); while (Date.now() - t0 < timeout) { if (!win.Ext.Ajax.isLoading()) { await sleep(120); if (!win.Ext.Ajax.isLoading()) return true; } await sleep(100); } return false; } const visByName = (name, label) => win.Ext.ComponentQuery.query('[name="' + name + '"]').filter( (c) => c.isVisible && c.isVisible() && (label === undefined || c.fieldLabel === label) )[0]; const detailsGrid = () => win.Ext.ComponentQuery.query('abw_requisitions_detailsgridview')[0]; // Fire the full press/release sequence, not a bare .click(): ExtJS copies the header supplier onto // a new "Add product" row on mousedown, so a lone click adds the row with Supplier left blank. const clickButton = (btn) => { if (!btn) return false; const el = btn.el.dom.querySelector('a,span') || btn.el.dom; // Page-world event constructors, to match the DOM they are dispatched onto. const opts = { bubbles: true, cancelable: true, view: win, button: 0 }; const fire = (Ctor, type) => { try { el.dispatchEvent(new win[Ctor](type, opts)); } catch (e) {} }; fire('PointerEvent', 'pointerover'); fire('MouseEvent', 'mouseover'); fire('PointerEvent', 'pointerdown'); fire('MouseEvent', 'mousedown'); fire('PointerEvent', 'pointerup'); fire('MouseEvent', 'mouseup'); el.click(); return true; }; const actionButton = (text) => win.Ext.ComponentQuery.query('u4_actionbutton').find((b) => b.text === text); // Resolve a remote-lookup field to an exact code. setValue sets only the display, not the record, // so run the field's own query and select the exact match, firing the 'select' that commits it. async function resolveCombo(field, code) { if (!field) return { ok: false, err: 'field not found' }; field.focus(); if (typeof field.doQuery === 'function') { field.doQuery(String(code), true, true); await waitIdle(); await sleep(250); let match = null; field.getStore().each((r) => { if (String(r.get(field.valueField)) === String(code)) { match = r; return false; } }); if (!match) return { ok: false, err: 'no exact match for ' + code, count: field.getStore().getCount() }; field.select(match); try { field.fireEvent('select', field, [match]); } catch (e) {} await waitIdle(); await sleep(200); if (field.isExpanded) field.collapse(); } else { field.setValue(String(code)); await waitIdle(); } return { ok: field.isValid ? field.isValid() : true, value: field.getValue && field.getValue() }; } // ---- Form operations ---------------------------------------------------------------------- // Supplier and budget live in the Header (set by hand); each line inherits the supplier from it. // The script fills only the request name and the line grid. function setRequestName(cfg) { const nameF = win.Ext.ComponentQuery.query('[name="requisitionName"]')[0]; if (nameF && cfg.requestName) nameF.setValue(cfg.requestName); } async function addLine(cfg, line, log) { const grid = detailsGrid(); const store = grid.getStore(); const ce = grid.editingPlugin; const before = store.getCount(); if (!clickButton(actionButton('Add product'))) throw new Error('Add product button not found'); await waitIdle(); await sleep(400); const rec = store.getAt(store.getCount() - 1); if (store.getCount() !== before + 1) return { ok: false, err: 'row not added' }; if (!ce.editing) { const col = grid.getColumnManager().getColumns().filter((c) => c.dataIndex === 'article')[0]; ce.startEdit(rec, col); await sleep(300); } // The product code populates the default account/tax/sub-project. Use this line's own code if // the CSV gave one, otherwise the panel default. const productCode = line.productCode || cfg.productCode; const artR = await resolveCombo(visByName('article'), productCode); if (!artR.ok) return { ok: false, err: 'product code ' + productCode + ': ' + artR.err }; await sleep(200); visByName('articleDescription').setValue(line.description); visByName('quantity').setValue(line.qty); visByName('unitPrice').setValue(line.price); await sleep(150); ce.completeEdit(); await waitIdle(); await sleep(400); if (!rec.get('supplierId')) log(' WARNING: supplier blank on this line'); // Set the line amount so the on-screen total is right for review; the server recomputes // amounts, tax and the header total authoritatively on Save/Send. rec.beginEdit(); rec.set('amount', line.qty * line.price); rec.set('currencyAmount', line.qty * line.price); rec.set('amountSet', true); rec.endEdit(); try { grid.setTotalAmount(); } catch (e) {} return { ok: true, valid: rec.isValid && rec.isValid(), supplier: rec.get('supplierId'), account: rec.get('account'), amount: rec.get('amount'), }; } async function fillOrder(cfg, lines, log) { if (!detailsGrid()) { log('ERROR: no requisition form on screen. Open Purchasing > Purchase request first.'); return; } setRequestName(cfg); log('adding ' + lines.length + ' lines... (Header supplier/budget must already be set)'); let total = 0; for (let i = 0; i < lines.length; i++) { const r = await addLine(cfg, lines[i], log); if (!r.ok) { log('ABORT at line ' + (i + 1) + ': ' + r.err); return; } total += lines[i].qty * lines[i].price; log('line ' + (i + 1) + '/' + lines.length + ' ' + lines[i].description.slice(0, 40) + ' -> ' + (r.valid ? 'valid' : 'INVALID')); } log('DONE. ' + lines.length + ' lines, computed total GBP ' + total.toFixed(2) + '.'); log('Review every line, then Save as draft / Send for approval yourself.'); } // ---- CSV parsing -------------------------------------------------------------------------- // Minimal RFC-4180-ish parser: handles quoted fields, commas inside quotes and "" escapes. function parseCSV(text) { const rows = []; let row = [], field = '', inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; } else field += c; } else if (c === '"') inQuotes = true; else if (c === ',') { row.push(field); field = ''; } else if (c === '\r') { /* skip */ } else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; } else field += c; } if (field.length || row.length) { row.push(field); rows.push(row); } return rows; } function linesFromCSV(text) { const rows = parseCSV(text).filter((r) => r.some((c) => c && c.trim() !== '')); if (!rows.length) throw new Error('empty CSV'); const header = rows[0].map((h) => h.trim().toLowerCase()); const col = (name) => header.indexOf(name); const iItem = col('item number'), iQty = col('quantity'), iDesc = col('description'), iPrice = col('unit price'), iProd = col('product'); if (iItem < 0 || iQty < 0 || iDesc < 0 || iPrice < 0) { throw new Error('CSV must have columns: Item Number, Quantity, Description, Unit Price'); } const money = (s) => parseFloat(String(s).replace(/[^0-9.\-]/g, '')); const lines = []; for (let r = 1; r < rows.length; r++) { const item = (rows[r][iItem] || '').trim(); const qty = parseFloat(rows[r][iQty]); const price = money(rows[r][iPrice]); if (!item || !qty || isNaN(qty) || isNaN(price)) continue; // skip blanks / footer text lines.push({ // Optional per-line product code; blank falls back to the panel default in addLine. productCode: iProd >= 0 ? (rows[r][iProd] || '').trim() : '', description: item + ' - ' + (rows[r][iDesc] || '').trim(), qty: qty, price: price, }); } return lines; } // ---- Control panel UI --------------------------------------------------------------------- function buildPanel() { if (document.getElementById('ubw-filler-panel')) return; const wrap = document.createElement('div'); wrap.id = 'ubw-filler-panel'; wrap.style.cssText = 'position:fixed;top:70px;right:16px;z-index:100000;width:300px;background:#fff;' + 'border:1px solid #b7bcc2;border-radius:6px;box-shadow:0 4px 14px rgba(0,0,0,.18);' + 'font:12px/1.4 Arial,sans-serif;color:#222;'; const field = (label, id, value) => '' + ''; wrap.innerHTML = '
' + 'UBW Requisition Filler' + '×
' + '
' + field('Request name', 'ubw-name', DEFAULTS.requestName) + field('Default product code', 'ubw-prod', DEFAULTS.productCode) + '' + '' + '' + '
' + 'Ready. Set the Header supplier/budget by hand, then pick a CSV and Fill.
' + '
Set the Header (supplier, budget) first. This does not Save or Submit.
' + '
'; document.body.appendChild(wrap); const logEl = wrap.querySelector('#ubw-log'); const log = (m) => { logEl.textContent += '\n' + m; logEl.scrollTop = logEl.scrollHeight; }; wrap.querySelector('#ubw-close').onclick = () => wrap.remove(); wrap.querySelector('#ubw-fill').onclick = () => { const fileInput = wrap.querySelector('#ubw-file'); const cfg = { requestName: wrap.querySelector('#ubw-name').value.trim(), productCode: wrap.querySelector('#ubw-prod').value.trim(), }; if (!fileInput.files.length) { log('Pick a CSV file first.'); return; } const reader = new FileReader(); reader.onload = () => { let lines; try { lines = linesFromCSV(reader.result); } catch (e) { log('CSV error: ' + e.message); return; } logEl.textContent = 'Parsed ' + lines.length + ' lines from CSV.'; wrap.querySelector('#ubw-fill').disabled = true; fillOrder(cfg, lines, log).finally(() => { wrap.querySelector('#ubw-fill').disabled = false; }); }; reader.readAsText(fileInput.files[0]); }; } // UBW is a SPA, so the script boots once. A Tampermonkey menu command opens the panel on demand // (so you can navigate to Purchase request first). buildPanel() no-ops if shown, rebuilds after close. const showPanel = () => { if (win.Ext && win.Ext.ComponentQuery) buildPanel(); else alert('UBW is still loading its UI. Wait a moment, then try again.'); }; if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('Show UBW Requisition Filler', showPanel); } else { // Fallback (e.g. pasted into the DevTools console, where GM_* is unavailable): show at once. const boot = setInterval(() => { if (win.Ext && win.Ext.ComponentQuery) { clearInterval(boot); buildPanel(); } }, 800); } })();