Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user