Procurement Approval Workflow, Node Reference and Operator Runbook
FieldOps Co. procurement pilot. This is the maintainer's companion to
the n8n workflow: every node, what it does, and what you would change to
make it yours. It pairs with the architecture
whiteboard (the six-zone flow) and the design notes in
ARCHITECTURE.md.

The one principle: config over canvas
The volatile parts of this workflow, the thresholds, the approvers, the vendor prices, the lead-time tolerances, live in editable Data Tables, not in the node graph. One workflow serves every business unit; the business units differ by rows, not by nodes. When this runbook says "what to change," the answer is almost always a table row, not a code node. That is deliberate: your platform team should own the policy without touching the automation.
Two things are derived, never asserted:
- The amount is computed from the vendor catalogs. A requester never types a price. Approving a typed-in figure is a green execution that means nothing.
- The route is computed from the policy table. A missing or ambiguous rule default-denies to a human; it never silently auto-approves.
Execution flow
+-- Web form ---------+
INTAKE | v
+-- Email --> Parse --> Normalize Request
|
POLICY DoA Rule Lookup --> Load BU Policy
|
PRICE Vendor Catalog Lookup --> Best Price + Maverick --> Catalog Match?
| | |
| (no match) Notify #requests
v v
ROUTE Threshold Eval + Self-Check --> Route Decision
| |
(auto) Auto-Approved| (single / dual / default-deny)
APPROVE | Build Approval Card --> Post #approvals
| --> Wait --> Apply Decision
v |
RECORD Build Outcome <-------------+
| |
Post #orders Build Event Row --> Write Event Row (pr_events)
Config tables (change these, not the canvas)
Four Data Tables carry everything a platform team tunes. IDs are the live pilot values.
doa_rules, the policy
master
One row per business unit, category, and spend band. This single table decides routing, approvers, cost center, lead-time tolerance, escalation, and the auto-approve floor. A business unit with no rows here default-denies by design (that is exactly what BU-02 demonstrates in the pilot).
| Column | Meaning | Change it to |
|---|---|---|
business_unit |
BU code the band applies to | scope a band to a BU |
category |
spend category (pilot: MRO) |
scope a band to a category |
threshold_min / threshold_max |
band lower (inclusive) and upper (exclusive) in EUR | move where a band starts or ends |
approval_mode |
single or dual |
require two approvers on a band |
auto_approve_under |
auto-approve floor in EUR; 0 disables |
let small spend skip a human |
approver_primary |
who the card routes to | change the approver |
approver_fallback |
who a timeout escalates to | change the escalation target |
cost_center |
default cost center for the BU | correct the GL coding |
max_lead_time_days |
slowest delivery this BU tolerates | tighten or relax sourcing speed |
escalation_hours |
hours before a no-decision escalates | change the SLA clock |
Seeded pilot rows:
| business_unit | category | min | max | mode | auto_under | primary | fallback | cost_center | max_lead | esc_h |
|---|---|---|---|---|---|---|---|---|---|---|
| BU-01 | MRO | 0 | 5000 | single | 1000 | cost.owner | plant.controller | CC-4400 | 30 | 24 |
| BU-01 | MRO | 5000 | 25000 | single | 0 | production.manager | plant.controller | CC-4400 | 30 | 24 |
| BU-01 | MRO | 25000 | 100000 | dual | 0 | plant.manager | bu.controller | CC-4400 | 30 | 48 |
| BU-03 | MRO | 0 | 5000 | single | 750 | hub.supervisor | ops.controller | CC-6100 | 20 | 12 |
| BU-03 | MRO | 5000 | 25000 | single | 0 | hub.manager | ops.controller | CC-6100 | 20 | 12 |
| BU-03 | MRO | 25000 | 100000 | dual | 0 | regional.director | bu.controller | CC-6100 | 20 | 24 |
(Approver values shown short; live rows carry full
@fieldops.example addresses. BU-02 has no rows and
default-denies.)
business_units, the
BU registry
| Column | Meaning |
|---|---|
code |
BU code (BU-01) |
name |
display name (Rheinfeld Plant) |
active |
in service |
The thin registry of valid business units and their names. Policy
does not live here; it lives in doa_rules.
Pilot rows: BU-01 Rheinfeld Plant, BU-02 Ostwerk Assembly, BU-03
Nordhafen Logistics.
vendor_catalog,
the approved supplier list
One row per SKU per vendor. Pricing reads this; the requester never does.
| Column | Meaning |
|---|---|
sku / description /
category |
the item |
vendor_id / vendor_name |
the supplier (VEND-A is the incumbent) |
unit_price / currency /
uom |
the contracted or spot price |
contract_flag |
contract or spot |
lead_time_days |
delivery time; drives lead-time-aware selection |
asl_flag |
on the approved supplier list |
Sample (same SKU, three vendors, different price and lead time):
| sku | vendor | unit_price | contract | lead_time_days |
|---|---|---|---|---|
| MRO-1001 | Rheinwerk (VEND-A) | 18.50 | contract | 3 |
| MRO-1001 | Nordmann (VEND-B) | 14.20 | contract | 5 |
| MRO-1001 | Kessler (VEND-C) | 16.90 | spot | 2 |
| MRO-1014 | Rheinwerk (VEND-A) | 3180.00 | contract | 35 |
That last row is the lead-time story: a BU that tolerates only 20 days cannot take the single 35-day source, so the request breaches tolerance and default-denies to a human instead of ordering something that will not arrive in time.
pr_events, the audit
log
| Column | Meaning |
|---|---|
request_id |
the requisition |
event |
the state it transitioned to |
detail |
JSON snapshot (route, amount, vendor, saving, flags) |
actor |
who caused it |
ts |
when |
One row per state transition. This is the instrument the 90-day review and rule-drift detection read from. In production you would also branch this to a database or warehouse.
Email request template
The email door is deterministic: the template is the contract. A field outside the schema is ignored; a missing required field bounces back with a form link. Keep the block at the top of the message; anything below a signature or a quoted reply is cut before parsing.
Copy-paste template
Business Unit: BU-01
SKU: MRO-1001
Item Description: Deep groove ball bearing 6204-2RS
Quantity: 10
Category: MRO
Cost Center: CC-4400
Preferred Vendor:
Justification: Line 3 conveyor bearing, scheduled PM
Filled example
Subject: Procurement Request: MRO-1002 x24
Business Unit: BU-03
SKU: MRO-1002
Item Description: Nitrile gloves, box of 100
Quantity: 24
Category: MRO
Cost Center: CC-6100
Preferred Vendor: VEND-C
Justification: PPE restock, Nordhafen line
Field reference
| Label (canonical) | Required | Schema field | Allowed values | Notes |
|---|---|---|---|---|
| Business Unit | yes | business_unit |
BU-01, BU-02, BU-03 | must have doa_rules rows or it default-denies |
| SKU | yes | sku |
any vendor_catalog SKU |
unknown SKU routes to sourcing |
| Quantity | yes | quantity |
positive number | two boxes or 0 bounces, never
defaults |
| Item Description | no | item_description |
free text | display only |
| Category | no (defaults MRO) | category |
MRO | pilot scope |
| Cost Center | no | cost_center |
e.g. CC-4400 | falls back to the BU default |
| Preferred Vendor | no | preferred_vendor |
VEND-A/B/C | if not the best source, flagged as maverick, not honored |
| Justification | no | justification |
free text | carried to the audit row |
The sender address becomes requester_email
automatically. Accepted label synonyms (so real mail parses):
BU, Part Number / Material /
Item Number for SKU, Qty,
Cost Centre / CC, Vendor /
Supplier, Reason / Notes. Add
more in the ALIASES map of the
Parse Request Email node.
Node reference
Nodes in execution order, grouped by zone.
Zone: INTAKE
1. Procurement Request
Form formTrigger
Front door 1. The company-wide web form that raises a requisition. Fields map one-to-one to the normalized schema.
What to change: Add a business unit or category by
adding a dropdown option here, and add the matching
rows to doa_rules (see the cookbook). The form is the only
place the BU list is hard-typed; everything downstream reads the value,
not a list.
{
"formTitle": "FieldOps Procurement Request"
}2. Procurement Mailbox
(IMAP) emailReadImap
Front door 2. Polls a monitored procurement mailbox. Currently
disabled, pending an IMAP credential. Both doors
converge on Normalize Request, so the rest of the workflow
never knows which door a request came through.
What to change: Enable by attaching an IMAP credential and toggling the node active. Nothing else changes: the parser already produces the normalized schema. See the email template section for the shape it expects.
3. Parse Request Email
code
Deterministic regex parse of a templated request email into the normalized schema. No LLM. Cuts quoted reply history so a forwarded thread cannot inject stale values, accepts common label variants, and refuses to guess.
What to change: The label aliases live in the
ALIASES map. Add a synonym your requesters actually type
(for example another word for cost_center) as a new array
entry. The canonical labels are documented in the email template
section.
// Deterministic regex parse of a templated procurement email. No LLM.
const m = $input.first().json;
const subject = String(m.subject || '');
const from = String(m.from || m.fromEmail || '');
let body = String(m.text || m.textPlain || m.snippet || '');
// Cut quoted reply history so a forwarded thread can't yield stale values.
const cut = body.search(/^\s*(>|On .+wrote:|-----Original Message-----|_{5,}|From:\s)/m);
if (cut > 0) body = body.slice(0, cut);
// Accept "Key: value" lines, case-insensitive, with common label variants.
const ALIASES = {
business_unit: ['business unit', 'business_unit', 'bu'],
sku: ['sku', 'part number', 'part no', 'material', 'item number'],
item_description: ['description', 'item', 'item description'],
quantity: ['quantity', 'qty'],
category: ['category'],
cost_center: ['cost center', 'cost centre', 'cost_center', 'cc'],
preferred_vendor: ['preferred vendor', 'vendor', 'supplier'],
justification: ['justification', 'reason', 'notes'],
requester_email: ['requester email', 'requester', 'requested by'],
};
const found = {};
for (const raw of body.split(/\r?\n/)) {
const mm = raw.match(/^\s*\*?\s*([A-Za-z _\/-]{2,30}?)\s*\*?\s*[:\-]\s*(.+?)\s*$/);
if (!mm) continue;
const label = mm[1].trim().toLowerCase();
const value = mm[2].trim();
for (const [field, names] of Object.entries(ALIASES)) {
if (names.includes(label) && !(field in found)) { found[field] = value; break; }
}
}
// Sender is the requester unless the template overrides.
const senderMatch = from.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (!found.requester_email && senderMatch) found.requester_email = senderMatch[0];
// Parseable only if the fields the pipeline can't invent are present.
const REQUIRED = ['business_unit', 'sku', 'quantity'];
const missing = REQUIRED.filter(f => !found[f] || String(found[f]).trim() === '');
// "two boxes" strips to "" and Number("") is 0: require non-empty and > 0.
const qtyRaw = String(found.quantity || '').replace(/[^\d.]/g, '');
const qtyNum = qtyRaw === '' ? NaN : Number(qtyRaw);
const qtyValid = Number.isFinite(qtyNum) && qtyNum > 0;
if (!missing.includes('quantity') && !qtyValid) missing.push('quantity');
return [{ json: {
...found,
quantity: qtyValid ? qtyNum : null,
category: found.category || 'MRO',
intake_channel: 'email',
parse_ok: missing.length === 0,
parse_missing: missing,
source_subject: subject,
source_from: from,
}}];4. Parse OK? if
Gate. Routes a clean parse into Normalize Request and a
non-conforming one to Bounce Back. Parseable means the
fields the pipeline cannot invent (business_unit,
sku, quantity) are present and valid.
What to change: Nothing normally. This reads
parse_ok from the parser; change the required-field policy
in Parse Request Email, not here.
5. Bounce Back (form link)
set
Replies to a non-conforming email with a link to the web form, rather than guessing at the missing fields. Cheap, and it cannot be wrong.
What to change: Point form_url at your
form endpoint and wire this to whatever mail-send node you use.
Currently a stub that carries the bounce reason.
6. Normalize Request
code
The convergence point. Every intake channel becomes one
purchase-requisition object here, so downstream sees one shape. Mints a
deterministic request_id (content + minute) for dedup, and
throws on an invalid quantity rather than defaulting
it.
What to change: Nothing for a normal engagement. The
defaults (category MRO, cost_center CC-4400,
business_unit BU-01) are fallbacks for a form that omits an
optional field; change them only if your form guarantees those
fields.
// Normalize every intake channel to one purchase-requisition shape.
const f = $input.first().json;
const now = new Date().toISOString();
const norm = (v) => (v === undefined || v === null ? '' : String(v).trim());
// request_id: deterministic on content + minute, so a same-minute resubmit collides for dedup.
const seed = [norm(f.business_unit), norm(f.sku), norm(f.quantity),
norm(f.requester_email), now.slice(0, 16)].join('|');
let h = 0;
for (let i = 0; i < seed.length; i++) { h = (h * 31 + seed.charCodeAt(i)) >>> 0; }
const request_id = 'PR-' + now.slice(0, 10).replace(/-/g, '') + '-' + h.toString(36).toUpperCase().padStart(7, '0');
return [{ json: {
request_id,
intake_channel: norm(f.intake_channel) || 'form',
business_unit: norm(f.business_unit) || 'BU-01',
sku: norm(f.sku).toUpperCase(),
item_description: norm(f.item_description),
quantity: (() => {
// Throw on bad quantity; defaulting to 1 is a silent wrong order.
const q = Number(f.quantity);
if (!Number.isFinite(q) || q <= 0) {
throw new Error('Invalid quantity: ' + JSON.stringify(f.quantity) +
' (request ' + request_id + '). Refusing to default.');
}
return q;
})(),
category: norm(f.category) || 'MRO',
cost_center: norm(f.cost_center) || 'CC-4400',
preferred_vendor: norm(f.preferred_vendor),
justification: norm(f.justification),
requester_email: norm(f.requester_email),
created_at: now,
state: 'RECEIVED',
}}];Zone: POLICY
7. DoA Rule Lookup
dataTable
POLICY, step 1. Reads every doa_rules row for this
request's business unit and category. doa_rules is the
policy master: thresholds, approval mode, approvers, cost center,
lead-time tolerance, escalation window, and the auto-approve floor all
live in its rows.
What to change: This is a table read. To change any
routing behaviour, edit doa_rules (cookbook below), never
this node. The node only supplies the filter.
8. Load BU Policy code
POLICY, step 2. Collapses the business unit's several
doa_rules rows into one item carrying the
request plus its policy, and extracts max_lead_time_days.
This exists to stop a fan-out: a Data Table read runs once per input
item, so letting three band rows flow into the catalog lookup returned
three duplicate copies of every vendor.
What to change: Nothing. If a BU's bands ever disagree on lead-time tolerance, it takes the strictest, which is the safe default.
// Collapse the BU's rule rows to ONE item (req + policy) so pricing doesn't fan out.
const req = $('Normalize Request').first().json;
const rules = $input.all().map(i => i.json).filter(r => r && r.business_unit);
// max_lead_time_days is per BU+category; take the strictest if rows disagree.
const leads = rules.map(r => Number(r.max_lead_time_days)).filter(n => Number.isFinite(n) && n > 0);
const maxLead = leads.length ? Math.min(...leads) : null;
return [{ json: { ...req,
policy_rule_count: rules.length,
max_lead_time_days: maxLead,
has_policy: rules.length > 0,
}}];Zone: PRICE
9. Vendor Catalog Lookup
dataTable
PRICE, step 1. Reads every vendor_catalog row for the
requested SKU and category, one row per vendor that lists the item.
What to change: A table read. Add or reprice a
vendor line in vendor_catalog, not here.
10. Best Price + Maverick
Flag code
PRICE, step 2. Derives the amount from the catalog rows, never from the requester. Picks the cheapest source that can still deliver inside the BU's lead-time tolerance, computes the saving versus the incumbent contract, flags an off-contract (maverick) request, and reports the lead-time premium. No vendor in tolerance flags a breach and forces a human.
What to change: Nothing normally. The incumbent is
identified as VEND-A; if your incumbent is a different
vendor id, change that one constant.
// Derive amount from vendor catalogs; pick cheapest source within the BU lead-time tolerance.
const req = $('Normalize Request').first().json;
const qty = Number(req.quantity) || 1;
// BU lead-time tolerance, loaded before pricing.
const policy = $('Load BU Policy').first().json;
const maxLead = Number(policy.max_lead_time_days);
const hasTolerance = Number.isFinite(maxLead) && maxLead > 0;
const rows = $input.all().map(i => i.json).filter(r => r && r.sku && r.vendor_id);
if (rows.length === 0) {
// No catalog match: route to sourcing, never auto-approve.
return [{ json: { ...req, catalog_match: false, match_count: 0,
derived_amount: null, state: 'NEEDS_SOURCING' }}];
}
const priced = rows
.map(r => ({ ...r, unit_price: Number(r.unit_price), lead_time_days: Number(r.lead_time_days) }))
.filter(r => Number.isFinite(r.unit_price))
.sort((a, b) => a.unit_price - b.unit_price);
// Keep only vendors that can deliver within tolerance.
const eligible = hasTolerance
? priced.filter(r => Number.isFinite(r.lead_time_days) && r.lead_time_days <= maxLead)
: priced;
// None in tolerance: take the fastest, flag a breach, force a human.
const breach = hasTolerance && eligible.length === 0;
const pool = breach
? [...priced].sort((a, b) => a.lead_time_days - b.lead_time_days)
: eligible;
const best = pool[0];
const excluded = priced
.filter(r => !pool.includes(r))
.map(r => ({ vendor: r.vendor_name, price: r.unit_price, lead_time_days: r.lead_time_days }));
// VEND-A is the incumbent; delta vs it is the contract-leakage number.
const incumbent = priced.find(r => r.vendor_id === 'VEND-A') || null;
const derived_amount = Number((best.unit_price * qty).toFixed(2));
const incumbent_amount = incumbent ? Number((incumbent.unit_price * qty).toFixed(2)) : null;
const savings = incumbent_amount !== null
? Number((incumbent_amount - derived_amount).toFixed(2)) : 0;
// Lead-time premium: cost of not taking the cheapest source.
const cheapest = priced[0];
const lead_time_premium = (best.vendor_id !== cheapest.vendor_id)
? Number(((best.unit_price - cheapest.unit_price) * qty).toFixed(2)) : 0;
const maverick_flag = Boolean(req.preferred_vendor) && req.preferred_vendor !== best.vendor_id;
return [{ json: { ...req,
catalog_match: true,
match_count: priced.length,
best_vendor_id: best.vendor_id,
best_vendor_name: best.vendor_name,
best_unit_price: best.unit_price,
contract_flag: best.contract_flag,
lead_time_days: best.lead_time_days,
max_lead_time_days: hasTolerance ? maxLead : null,
lead_time_breach: breach,
lead_time_premium,
excluded_on_lead_time: excluded,
currency: best.currency || 'EUR',
derived_amount,
incumbent_amount,
savings_vs_incumbent: savings,
maverick_flag,
alternatives: priced.map(p => ({
vendor: p.vendor_name, price: p.unit_price,
contract: p.contract_flag, lead_time_days: p.lead_time_days,
})),
state: 'PRICED',
}}];11. Catalog Match? if
Gate. If the SKU priced against at least one vendor, the request
proceeds to routing and posts to #requests. If nothing
matched, it goes to sourcing.
What to change: Nothing. Reads
catalog_match set by the pricing node.
12. Needs Sourcing (RFQ)
set
Terminal state for an item no approved vendor lists. Marks
NEEDS_SOURCING and routes to the outcome post rather than
auto-approving an un-priced request. The three-quote sourcing event
itself is roadmap; detection ships in the pilot.
What to change: Wire this to your RFQ or sourcing process when it exists. Today it records the state and notifies.
13. Notify #requests
code
Builds the Slack Block Kit message announcing a raised-and-priced
requisition. #requests is a separate channel and audience
from #approvals.
What to change: Edit the message layout here. The destination channel is set by the webhook env var on the next node, not here.
// Post to #requests: requisition received and priced. Separate channel/audience from #approvals.
const j = $input.first().json;
const money = (n) => (n === null || n === undefined) ? 'n/a'
: (j.currency || 'EUR') + ' ' + Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
return [{ json: { ...j, slack_payload: {
text: 'New requisition ' + j.request_id,
blocks: [
{type: 'section', text: {type: 'mrkdwn',
text: '*New requisition* `' + j.request_id + '`\n' + j.sku +
(j.item_description ? ' - ' + j.item_description : '') + ' ×' + j.quantity}},
{type: 'context', elements: [{type: 'mrkdwn',
text: j.business_unit + ' | ' + money(j.derived_amount) + ' via ' + (j.best_vendor_name || 'n/a') +
' | raised by ' + (j.requester_email || 'unknown') + ' (' + (j.intake_channel || 'form') + ')'}]},
]}}}];14. Post to #requests
httpRequest
Posts the #requests message. A parallel branch (a side
effect), never inline in the pipeline, so a Slack response can never be
mistaken for the request object downstream.
What to change: The channel is whatever
REQUESTS_SLACK_WEBHOOK points at. Rotate the channel by
rotating the secret, never by editing the workflow.
"url": "={{ $env.REQUESTS_SLACK_WEBHOOK }}"Zone: ROUTE
15. Threshold Eval +
Self-Check code
ROUTE. Deterministic evaluation against the doa_rules
bands, plus a self-check that grades its own output. Default-denies on
any gap: no rule row, a lead-time breach, an amount outside every band,
or overlapping bands. A missing config row reaches a human, it never
auto-approves.
What to change: Behaviour is entirely
doa_rules data. To move a threshold, change an approver, or
switch a band to dual approval, edit the table. The self-check (exactly
one band must match) is a guardrail; leave it in place.
// Route on DoA bands; self-check grades its own output. Deterministic, no LLM.
const req = $input.first().json;
const amount = Number(req.derived_amount);
const rules = $('DoA Rule Lookup').all().map(i => i.json).filter(r => r && r.business_unit && r.category);
const base = { ...req, evaluated_at: new Date().toISOString() };
// Default-deny: no rule row for this BU/category.
if (rules.length === 0) {
return [{ json: { ...base, route: 'default_deny',
route_reason: 'no_rule_row_for_bu_category', approver: null,
self_check: 'pass', state: 'AWAITING_APPROVAL' }}];
}
// Default-deny: no vendor within lead-time tolerance.
if (req.lead_time_breach) {
const r0 = rules[0];
return [{ json: { ...base, route: 'default_deny',
route_reason: 'lead_time_breach', approver: r0.approver_primary || null,
approver_fallback: r0.approver_fallback, escalation_hours: Number(r0.escalation_hours),
self_check: 'pass', state: 'AWAITING_APPROVAL' }}];
}
const bands = rules.filter(r =>
amount >= Number(r.threshold_min) && amount < Number(r.threshold_max));
// Default-deny: amount outside every band.
if (bands.length === 0) {
return [{ json: { ...base, route: 'default_deny',
route_reason: 'amount_outside_all_bands', approver: null,
self_check: 'pass', state: 'AWAITING_APPROVAL' }}];
}
// Self-check: exactly one band must match; overlapping bands are a config error.
const self_check = bands.length === 1 ? 'pass' : 'FAIL_overlapping_bands';
if (self_check !== 'pass') {
return [{ json: { ...base, route: 'default_deny',
route_reason: 'overlapping_rule_bands', matched_bands: bands.length,
approver: null, self_check, state: 'AWAITING_APPROVAL' }}];
}
const band = bands[0];
const autoUnder = Number(band.auto_approve_under) || 0;
// approval_mode is config, not code.
let route;
if (autoUnder > 0 && amount < autoUnder) route = 'auto_approve';
else if (String(band.approval_mode).toLowerCase() === 'dual') route = 'dual_approver';
else route = 'single_approver';
return [{ json: { ...base,
route, route_reason: 'matched_band',
band_min: Number(band.threshold_min), band_max: Number(band.threshold_max),
approval_mode: band.approval_mode,
approver: route === 'auto_approve' ? null : band.approver_primary,
approver_fallback: band.approver_fallback,
escalation_hours: Number(band.escalation_hours),
auto_approve_under: autoUnder,
self_check,
state: route === 'auto_approve' ? 'APPROVED' : 'AWAITING_APPROVAL',
}}];16. Route Decision
switch
Switch on the computed route. auto_approve goes to the
auto-approved state; single_approver,
dual_approver, and default_deny all converge
on the one approval gate. Same shape, different data.
What to change: Nothing. The route is decided by the eval node from table data; this only fans the four outcomes out.
17. Auto-Approved set
Terminal state for a request under the BU's auto-approve floor.
Records APPROVED and routes straight to the outcome post,
no human.
What to change: The floor is
auto_approve_under in doa_rules. Set it to 0
to disable auto-approval for a band.
Zone: APPROVE
18. Build Approval Card
code
Builds the Slack approval card with decision-grade context (best
vendor, saving, lead time, contract-vs-spot, priced alternatives, flags)
and both decision links. Approve and Reject are the same
execution's resume URL with a different decision
value, so the same button works from Slack or email.
What to change: Edit the card layout here. The
signed resume URL is generated by n8n; do not hand-build it. Note the
query is joined with & because the resume URL already
carries ?signature=.
// Build the Slack approval card and both decision links (same resume URL, different decision).
const j = $input.first().json;
const resume = $execution.resumeUrl;
const money = (n) => (n === null || n === undefined)
? 'n/a'
: (j.currency || 'EUR') + ' ' + Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
// resumeUrl already carries ?signature=; join with & or the resume 401s.
const join = (u, kv) => u + (u.includes('?') ? '&' : '?') + kv;
const approveUrl = join(resume, 'decision=approve');
const rejectUrl = join(resume, 'decision=reject');
const alts = Array.isArray(j.alternatives) ? j.alternatives : [];
const altLines = alts.slice(0, 4)
.map(a => '• ' + a.vendor + ' ' + money(a.price) + ' _' + a.contract + '_')
.join('\n') || '_no alternatives_';
const flags = [];
if (j.maverick_flag) flags.push(':warning: *Off-contract request* - requester named ' + (j.preferred_vendor || 'another vendor') + ', not the best contracted source');
if (j.route === 'default_deny') flags.push(':no_entry: *Default-deny* - ' + (j.route_reason || 'no matching rule') + '. This did NOT auto-approve.');
if (j.route === 'dual_approver') flags.push(':busts_in_silhouette: *Dual approval* - this is gate 1 of 2');
const blocks = [
{type: 'header', text: {type: 'plain_text', text: 'Purchase requisition ' + j.request_id}},
{type: 'section', fields: [
{type: 'mrkdwn', text: '*Business unit*\n' + j.business_unit},
{type: 'mrkdwn', text: '*Cost center*\n' + (j.cost_center || 'n/a')},
{type: 'mrkdwn', text: '*Item*\n' + j.sku + (j.item_description ? ' - ' + j.item_description : '')},
{type: 'mrkdwn', text: '*Quantity*\n' + j.quantity},
{type: 'mrkdwn', text: '*Amount (derived)*\n' + money(j.derived_amount)},
{type: 'mrkdwn', text: '*Best source*\n' + (j.best_vendor_name || 'n/a')},
]},
{type: 'section', text: {type: 'mrkdwn',
text: '*Savings vs incumbent:* ' + money(j.savings_vs_incumbent) +
'\n*Lead time:* ' + (j.lead_time_days ?? 'n/a') + ' days *Pricing:* ' + (j.contract_flag || 'n/a')}},
{type: 'section', text: {type: 'mrkdwn', text: '*Priced against*\n' + altLines}},
];
if (flags.length) blocks.push({type: 'section', text: {type: 'mrkdwn', text: flags.join('\n')}});
blocks.push({type: 'context', elements: [{type: 'mrkdwn',
text: 'Requested by ' + (j.requester_email || 'unknown') + ' via ' + (j.intake_channel || 'form') +
' | routes to ' + (j.approver || 'a human') + ' | escalates in ' + (j.escalation_hours ?? 24) + 'h'}]});
blocks.push({type: 'actions', elements: [
{type: 'button', style: 'primary', text: {type: 'plain_text', text: 'Approve'}, url: approveUrl},
{type: 'button', style: 'danger', text: {type: 'plain_text', text: 'Reject'}, url: rejectUrl},
]});
return [{ json: { ...j,
slack_payload: {
text: 'Approval needed: ' + j.request_id + ' ' + money(j.derived_amount),
blocks,
},
approve_url: approveUrl,
reject_url: rejectUrl,
awaiting_since: new Date().toISOString(),
}}];19. Post to #approvals
httpRequest
Posts the approval card to the reviewer channel and hands off to the Wait node.
What to change: Channel is
APPROVALS_SLACK_WEBHOOK. This is the only channel that
should carry work that needs a human.
"url": "={{ $env.APPROVALS_SLACK_WEBHOOK }}"20. Wait for Decision
wait
Pauses the execution until a decision link is clicked (webhook resume) or the timeout elapses. The paused execution is the state; no external store needed.
What to change: The timeout is driven by
escalation_hours from doa_rules. A resume with
no decision is the escalation signal.
21. Apply Decision
code
Resolves the outcome from the resumed webhook query: approve, reject, or (on timeout, no decision) escalate to the configured fallback approver. A dual-approval approve records gate 1 of 2.
What to change: Nothing. The fallback approver is
approver_fallback in doa_rules, so escalation
targets are config, not code.
// Resume from Slack button, email link, or Wait timeout. Timeout = no decision = escalate.
const prior = $('Build Approval Card').first().json;
// Webhook resume exposes the caller's query; timeout has none.
const first = $input.first().json || {};
const q = first.query || (first.body && first.body.query) || {};
const decision = String(q.decision || '').toLowerCase();
const base = { ...prior, decided_at: new Date().toISOString() };
if (decision === 'approve') {
return [{ json: { ...base, decision: 'approved', decided_by: prior.approver || 'approver',
state: prior.route === 'dual_approver' ? 'GATE1_APPROVED' : 'APPROVED' }}];
}
if (decision === 'reject') {
return [{ json: { ...base, decision: 'rejected', decided_by: prior.approver || 'approver',
state: 'REJECTED' }}];
}
// No decision: wait elapsed, escalate to configured fallback.
return [{ json: { ...base, decision: 'timed_out', decided_by: null,
escalated_to: prior.approver_fallback || null,
state: 'ESCALATED' }}];Zone: RECORD
22. Build Outcome code
Builds the #orders post for every
terminal state: approved, rejected, escalated, and no-catalog-match. A
good-news-only channel is not an audit trail. Computes time-to-decision.
Deliberately does not mint a PO number.
What to change: Edit the outcome message here. The no-PO stance is intentional: the ERP owns that sequence, and ERP write-back is the roadmap next step. Do not invent a PO number in this workflow.
// Build the #orders post for every terminal state (approved/rejected/escalated/needs-sourcing).
const j = $input.first().json;
const money = (n) => (n === null || n === undefined) ? 'n/a'
: (j.currency || 'EUR') + ' ' + Number(n).toLocaleString('de-DE',
{minimumFractionDigits: 2, maximumFractionDigits: 2});
// Time-to-decision: capture now, while both timestamps exist.
let elapsed = null;
if (j.awaiting_since && j.decided_at) {
const ms = new Date(j.decided_at) - new Date(j.awaiting_since);
if (Number.isFinite(ms) && ms >= 0) {
const mins = Math.round(ms / 60000);
elapsed = mins < 60 ? mins + ' min'
: (mins < 1440 ? (mins / 60).toFixed(1) + ' h' : (mins / 1440).toFixed(1) + ' days');
}
}
const state = String(j.state || '').toUpperCase();
const MAP = {
APPROVED: [':white_check_mark:', 'Approved'],
GATE1_APPROVED: [':white_check_mark:', 'Approved at gate 1 of 2'],
REJECTED: [':x:', 'Rejected'],
ESCALATED: [':alarm_clock:', 'Escalated, no decision in time'],
NEEDS_SOURCING: [':mag:', 'No catalog match, sourcing required'],
};
const [icon, label] = MAP[state] || [':grey_question:', state || 'Unknown'];
const lines = [];
lines.push(icon + ' *' + label + '* - `' + j.request_id + '`');
lines.push('*' + (j.sku || 'n/a') + '*' + (j.item_description ? ' - ' + j.item_description : '') +
' ×' + (j.quantity ?? 'n/a'));
if (state === 'NEEDS_SOURCING') {
lines.push('_No approved vendor lists this item. Routed for RFQ / three quotes rather than auto-approved._');
} else {
lines.push(money(j.derived_amount) + ' via *' + (j.best_vendor_name || 'n/a') + '*' +
(j.savings_vs_incumbent ? ' (' + money(j.savings_vs_incumbent) + ' under the incumbent contract price)' : ''));
}
if (j.maverick_flag) {
lines.push(':warning: Off-contract request corrected at intake - requester named ' +
(j.preferred_vendor || 'another vendor') + '.');
}
if (state === 'ESCALATED') {
lines.push(':arrow_right: Escalated to *' + (j.escalated_to || 'fallback approver') +
'* after ' + (j.escalation_hours ?? 24) + 'h with no decision.');
}
if (j.route === 'default_deny') {
lines.push(':no_entry: Reached a human by *default-deny* (' + (j.route_reason || 'no matching rule') +
'). It did not auto-approve.');
}
const ctx = [];
ctx.push(j.business_unit + (j.cost_center ? ' / ' + j.cost_center : ''));
ctx.push('raised by ' + (j.requester_email || 'unknown') + ' via ' + (j.intake_channel || 'form'));
if (j.decided_by) ctx.push('decided by ' + j.decided_by);
if (elapsed) ctx.push('time to decision ' + elapsed);
// No PO number invented; the ERP owns that sequence (write-back is roadmap).
const poLine = (state === 'APPROVED' || state === 'GATE1_APPROVED')
? '_Ready for PO creation. ERP write-back is roadmap, so the purchase order number still comes from your ERP. Internal reference `' + j.request_id + '`._'
: null;
const blocks = [
{type: 'section', text: {type: 'mrkdwn', text: lines.join('\n')}},
];
if (poLine) blocks.push({type: 'section', text: {type: 'mrkdwn', text: poLine}});
blocks.push({type: 'context', elements: [{type: 'mrkdwn', text: ctx.join(' | ')}]});
return [{ json: { ...j,
time_to_decision: elapsed,
slack_payload: { text: label + ': ' + j.request_id, blocks },
}}];23. Post to #orders
httpRequest
Posts the outcome to the orders channel. Third channel, third audience: decided.
What to change: Channel is
ORDERS_SLACK_WEBHOOK.
"url": "={{ $env.ORDERS_SLACK_WEBHOOK }}"24. Build Event Row
code
Flattens the request into one audit row: request id, event (state), a JSON detail blob, actor, timestamp.
What to change: Add a field to the
detail blob if you want it queryable at the 90-day review.
Keep one row per state transition.
// RECORD: one row per state transition (the audit + drift instrument).
const j = $input.first().json;
return [{ json: {
request_id: j.request_id || 'UNKNOWN',
event: j.state || 'UNKNOWN',
detail: JSON.stringify({
route: j.route || null,
route_reason: j.route_reason || null,
self_check: j.self_check || null,
derived_amount: j.derived_amount ?? null,
currency: j.currency || 'EUR',
best_vendor: j.best_vendor_name || null,
savings_vs_incumbent: j.savings_vs_incumbent ?? null,
maverick_flag: j.maverick_flag ?? null,
catalog_match: j.catalog_match ?? null,
approver: j.approver || null,
}),
actor: j.requester_email || 'system',
ts: new Date().toISOString(),
}}];25. Write Event Row
dataTable
Appends the row to pr_events. This table is the
measurement instrument the whole 90-day value story reads from, and the
input to rule-drift detection.
What to change: Point at your pr_events
table id. In production this is also where you would branch to a
database or warehouse for real reporting.
Change cookbook
Common changes, and the one place each is made. None of these touch a code node.
| You want to | Change | Where |
|---|---|---|
| Add business unit BU-04 | add a dropdown option, add doa_rules rows (one per
band), add a business_units row |
form + 2 tables |
| Move an approval threshold | edit threshold_min / threshold_max on the
band row |
doa_rules |
| Change who approves | edit approver_primary /
approver_fallback |
doa_rules |
| Require two approvers on a band | set approval_mode = dual |
doa_rules |
| Let small spend auto-approve | raise auto_approve_under (0 disables) |
doa_rules |
| Tighten or relax delivery speed | edit max_lead_time_days |
doa_rules |
| Change the escalation clock | edit escalation_hours |
doa_rules |
| Add or reprice a vendor | add / edit the SKU-vendor row | vendor_catalog |
| Add a new item | add vendor_catalog rows, one per vendor |
vendor_catalog |
| Move a Slack channel | rotate the *_SLACK_WEBHOOK secret |
secret store |
Golden set (regression tests)
The workflow has no LLM, so its correctness is a test
suite, not a model evaluation. The golden set
(golden/) is 18 known requests with known-correct outcomes.
It pulls the four decision nodes' actual source from
the workflow export and replays them against a snapshot of the config
tables, so it runs the deployed logic and cannot drift from it.
Run it:
node golden/run_golden.mjs
18 passed, 0 failed, 18 total
It covers every route and every default-deny reason: auto-approve
floors, single and dual bands, lead-time-aware vendor selection (the
same SKU picks a different vendor per business unit, sometimes at a
premium), default-deny on a missing rule row, a lead-time breach, and an
out-of-band amount, sourcing on no catalog match, the maverick flag, and
the quantity guard that throws on 0 or
"two boxes" rather than silently ordering one. Detail in
golden/README.md.
This is the concrete answer to "how do you know green means correct": after any change to the workflow or the tables, re-run the set.
The same test, inside n8n (test before you deploy)
The golden set also runs in n8n, so your team can test a change without leaving the platform. Two workflows, a dev/prod split:
- A DEV copy of this workflow. You edit and test here.
- A Regression Test Runner: a form where you pick a business unit (or ALL) and click run. It sends the golden cases through the DEV workflow's real decision logic, shows pass or fail on the completion page, and posts the findings to a #regression Slack channel so the team sees every run.
The loop: edit DEV, open the Regression Test form, pick the business unit, green means every request still routes to the right place for the right amount, then promote DEV to production. Run it per business unit so an owner can check just their own rules.
The DEV workflow carries an inert test hook: when a
case arrives with _mode set to test, it
returns the decision and short-circuits before Slack, the approval wait,
and the event write, so a test run has no side effects and leaves
nothing to clean up. In normal use the hook never fires.
Secrets
The three Slack destinations are referenced as environment variables, never written into the workflow. An export of this workflow leaks nothing.
| Variable | Channel |
|---|---|
REQUESTS_SLACK_WEBHOOK |
#requests, raised and priced |
APPROVALS_SLACK_WEBHOOK |
#approvals, needs a human |
ORDERS_SLACK_WEBHOOK |
#orders, decided |
They are injected into the container from the secret store at runtime. Rotating a channel is a secret change and a restart, not a workflow edit.
What is deliberately not built (roadmap)
- ERP write-back and the PO number. The ERP owns that sequence; a PO number that exists only here is a reconciliation problem. The workflow stops at "ready for PO."
- The RFQ / three-quote sourcing event. Detection of an un-listed item ships in the pilot; the sourcing process itself is spec'd, not built.
- A real Slack app with signed interactivity. The pilot uses signed, single-use resume links (the same model as a password-reset link) because a Slack button cannot carry an Access cookie. A first-class Slack app is the hardening step.
- Dedup enforcement.
request_idis deterministic and ready; a singlepr_eventslookup before routing closes it.