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 (extraction is roadmap).
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 |
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.