Initial commit

This commit is contained in:
2026-09-14 16:18:28 +01:00
commit 7ea93f9958
4 changed files with 411 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
// ==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) =>
'<label style="display:block;margin:6px 0 2px;font-weight:bold">' + label + '</label>' +
'<input id="' + id + '" value="' + (value || '') + '" ' +
'style="width:100%;box-sizing:border-box;padding:4px;border:1px solid #ccc;border-radius:3px">';
wrap.innerHTML =
'<div style="background:#f2f4f6;padding:8px 10px;font-weight:bold;border-bottom:1px solid #e0e0e0">' +
'UBW Requisition Filler' +
'<span id="ubw-close" style="float:right;cursor:pointer;color:#888">×</span></div>' +
'<div style="padding:10px">' +
field('Request name', 'ubw-name', DEFAULTS.requestName) +
field('Default product code', 'ubw-prod', DEFAULTS.productCode) +
'<label style="display:block;margin:6px 0 2px;font-weight:bold">Cart CSV</label>' +
'<input type="file" id="ubw-file" accept=".csv,text/csv" style="width:100%">' +
'<button id="ubw-fill" style="margin-top:10px;width:100%;padding:7px;border:0;border-radius:3px;' +
'background:#1f6fbf;color:#fff;font-weight:bold;cursor:pointer">Fill requisition</button>' +
'<div id="ubw-log" style="margin-top:8px;max-height:180px;overflow:auto;white-space:pre-wrap;' +
'font:11px/1.35 monospace;color:#333;background:#f7f7f7;border:1px solid #eee;border-radius:3px;padding:6px">' +
'Ready. Set the Header supplier/budget by hand, then pick a CSV and Fill.</div>' +
'<div style="margin-top:6px;color:#a33;font-size:11px">Set the Header (supplier, budget) first. This does not Save or Submit.</div>' +
'</div>';
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);
}
})();