Files
ubw-requisition-filler/ubw-requisition-filler.user.js
T
2026-09-14 16:18:28 +01:00

304 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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);
}
})();