Build the supplier: reorder.requested -> decide -> deliver

The service's own supplier, simulated: a fixed lead time or a 15% chance of a
stockout is the whole model, on purpose — this simulates that fulfilment can
fail, not why a given supplier would.

One service, two capacities: this workflow both raises reorder.requested (the
periodic run) and answers its own request (this workflow), under the same
Kafka account and Keycloak client, per the 2026-08-31 decision that
réapprovisionnement stays one service.

The delivery write goes through the front door — patch-product, under a new
restocker role that may PATCH a product and nothing else — so the resulting
product.stock_changed event is the only stock-update fact. No second,
n8n-emitted event claims a delivery happened with nothing to certify it.

Shipped with, and fixed, a bug where the delivery-application node spread the
wrong upstream , silently dropping reorder_id/productcode/quantity two
nodes before the failure surfaced. Verified separately: the write+event chain
(a real bug, now fixed) and the state machine (persists correctly on a real
trigger; a manual test run does not persist staticData, which is a testing
limitation, not a production one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-31 16:41:46 +02:00
parent d11ca36764
commit 1ec9e308ba
3 changed files with 570 additions and 1 deletions
+75
View File
@@ -21,6 +21,81 @@ POST /rest/workflows/<id>/activate {"versionId": "<current>"}
`PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently:
activation has its own endpoint.
## supply-procurement
The service's own supplier, simulated. Reacts to `reorder.requested` (emitted
by the periodic run below, once it finds a reference under threshold),
decides — a fixed lead time, or a 15% chance the supplier cannot fulfil —
answers with `reorder.confirmed`/`reorder.rejected`, and on the confirmed
delivery date writes the stock itself.
```
reorder.requested {productcode, quantity, requested_at}
→ reorder.confirmed {deliver_on} — 85% of the time
→ reorder.rejected {reason: "supplier stockout"} — 15%
```
No purchase-order entity, no supplier entity, no negotiation — the whole model
is one probability and one lead time, on purpose: this simulates *that*
fulfilment can fail, not *why* a given supplier would.
### One service, two capacities — not two identities
This is still the supply agent, not a separate "procurement" component. It
both requests (the periodic run) and answers its own request (this workflow),
under the same Kafka account and the same Keycloak client — the 2026-08-31
decision that réapprovisionnement is one service, not a split into a demand
side and a supply side.
### The write goes through the front door
On the delivery date, this workflow reads the product's current stock and
PATCHes it — through Kong, through the same `patch-product` MCP tool a human
product manager would use, under a **new, narrow role**: `restocker` may PATCH
a product and nothing else, not create, not replace, not delete. It sends
`X-Stock-Change-Reason: delivery:<reorder-id>`, which `classic-models-api`
only honours in that exact shape; anything else falls back to
`manual-adjustment`. The resulting `product.stock_changed` event is the *only*
stock-update event — there is deliberately no second, n8n-emitted fact
claiming a delivery happened, because nothing would certify it against the
one that actually changed the database. See `classic-models-api`'s commit
"Accept a delivery reason on a stock PATCH" for the backend side.
### Two triggers, one shared state
`$getWorkflowStaticData('global').pendingDeliveries`, keyed by reorder id —
the same pattern the stock-watch workflow already uses for its watchlist, now
shared across **two** trigger chains in this one workflow: the Kafka-triggered
decision writes an entry when the supplier confirms, and a daily schedule
(06:15, deliberately after the periodic run's 06:00) reads what is due and
applies it. A pending entry is deleted only once the PATCH is confirmed by the
API's own response — a failed write leaves it in place so tomorrow's run
retries it, rather than losing a delivery nobody applied.
### A bug this shipped with, and how it was found
`Apply delivery` sits between `Read current stock` and `Update stock`, and its
first version wrote `{ ...$json, new_quantity }` — but `$json` at that point is
`Read current stock`'s own output, not the due-delivery record two nodes
earlier. `reorder_id`/`productcode`/`quantity` were silently dropped, and
`Update stock` failed two nodes later with `"path_productcode" is a required
property` — a symptom far from its cause. Fixed by naming the source
explicitly: `$('Due deliveries').item.json` for what the delivery *is*, `$json`
for what the catalog *says now*. The same rule `check_availability` already
follows for `Recent movement` — an implicit `$json` is only ever the
*immediately preceding* node, never "whichever upstream node has the field I
want."
### Manual test runs do not persist static data
Verified the hard way: triggering this workflow via `/rest/workflows/{id}/run`
executes for real (the write happens, the event is genuine) but the
`pendingDeliveries` mutation is **not** saved back to the workflow — the entry
reappears on the next check. Only a genuine trigger firing (a real Kafka
message, the real schedule) persists it. A manual run is fine for proving the
logic; it is not fine for proving the state machine, and the two were verified
separately here.
## supply-check-availability
Working. `POST /webhook/check-availability` with the end user's token in
+36 -1
View File
@@ -139,7 +139,7 @@
},
{
"parameters": {
"jsCode": "// Which references need a purchase order, and why.\n//\n// The proposal is computed and goes no further, because there is nowhere for it\n// to go: the classicmodels schema has no purchase order, no supplier entity \u2014\n// `productvendor` is a string on the product \u2014 and the catalog's only write\n// tool is create-product, which is the Catalogue agent's business, not this\n// one's. So this run escalates rather than writing, which is also what an\n// autonomous agent meeting a decision it cannot take is supposed to do.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\nreturn [{ json: {\n proposals,\n scanned,\n // Loud when the scan was partial: a proposal set computed from part of the\n // catalog looks exactly like one computed from all of it.\n complete: scanned >= total,\n escalate: proposals.length > 0 || scanned < total,\n reason: scanned < total\n ? `Scanned ${scanned} of ${total} references; the rest were not read.`\n : (proposals.length\n ? `${proposals.length} reference(s) below the threshold of ${THRESHOLD}. `\n + 'Nowhere to record a purchase order \u2014 the domain has no such concept \u2014 '\n + 'so this needs a human.'\n : 'Nothing below the threshold.'),\n}}];\n"
"jsCode": "// Which references need a purchase order, and why.\n//\n// A proposal becomes a request event, not a write: the domain still has no\n// purchase-order entity, and this run does not decide a delivery date or a\n// supplier's answer \u2014 that is the procurement half of this same service,\n// reacting to reorder.requested on its own schedule.\nconst THRESHOLD = 50;\nconst COVER = 3; // reorder up to three times the threshold\n\nconst item = $input.first();\nconst raw = item?.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: { proposals: [], escalate: true,\n reason: 'The catalog did not answer in the expected shape.' }}];\n}\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { proposals: [], escalate: true,\n reason: \"The catalog's reply could not be read.\" }}]; }\n\n// A tool failure is a successful result carrying isError, not a JSON-RPC error.\nif (rpc.error || rpc.result?.isError) {\n const why = rpc.error?.message ?? rpc.result?.content?.[0]?.text ?? 'unknown';\n return [{ json: { proposals: [], escalate: true,\n reason: `The catalog refused the read: ${why}` }}];\n}\n\nconst page = JSON.parse(rpc.result.content[0].text);\nconst products = page.results ?? [];\nconst scanned = products.length;\nconst total = page.count ?? scanned;\n\nconst proposals = products\n .filter((p) => Number(p.quantityinstock) < THRESHOLD)\n .map((p) => ({\n productcode: p.productcode,\n productname: p.productname,\n vendor: p.productvendor,\n on_hand: Number(p.quantityinstock),\n propose_order: THRESHOLD * COVER - Number(p.quantityinstock),\n }));\n\n// Each proposal becomes a request, not a purchase order \u2014 the domain still has\n// no such entity, and this run is still not the one that decides a delivery\n// date or a supplier's answer. It raises a fact and hands the decision to the\n// same service's own procurement half, over Kafka, exactly the way any other\n// consumer would be handed it.\nconst now = new Date().toISOString();\nconst requests = proposals.map((p) => ({\n json: {\n reorder_id: `${Date.now().toString(36)}-${p.productcode}-${Math.random().toString(36).slice(2, 8)}`,\n productcode: p.productcode,\n quantity: p.propose_order,\n requested_at: now,\n },\n}));\n\n// Nothing to request is a normal outcome, not a diagnostic \u2014 this workflow has\n// no reader downstream of an \"escalate\" field the way check_availability's\n// webhook does, so there is nowhere for a warning to usefully go beyond n8n's\n// own execution log. A partial scan (scanned < total) is visible there:\n// `results.length` on the raw page ends up smaller than `count`.\nreturn requests;\n"
},
"id": "propose-reorders",
"name": "Propose reorders",
@@ -149,6 +149,30 @@
880,
0
]
},
{
"parameters": {
"topic": "classic-models.procurement",
"sendInputData": false,
"message": "={{ JSON.stringify({ specversion: '1.0', type: 'com.sttlab.classicmodels.reorder.requested.v1', source: '/supply-agent/procurement', subject: $json.reorder_id, id: $json.reorder_id, time: $json.requested_at, datacontenttype: 'application/json', data: { reorder_id: $json.reorder_id, productcode: $json.productcode, quantity: $json.quantity, requested_at: $json.requested_at } }) }}",
"useKey": true,
"key": "={{ $json.reorder_id }}",
"options": {}
},
"id": "emit-reorder-requests",
"name": "Emit reorder requests",
"type": "n8n-nodes-base.kafka",
"typeVersion": 2,
"position": [
1100,
0
],
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
}
],
"connections": {
@@ -195,6 +219,17 @@
}
]
]
},
"Propose reorders": {
"main": [
[
{
"node": "Emit reorder requests",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
+459
View File
@@ -0,0 +1,459 @@
{
"name": "Supply \u2014 procurement",
"nodes": [
{
"parameters": {
"topic": "classic-models.procurement",
"groupId": "supply-procurement",
"resolveOffset": "onCompletion",
"allowedStatuses": [
"success"
],
"options": {
"jsonParseMessage": true,
"onlyMessage": true,
"fromBeginning": false
}
},
"type": "n8n-nodes-base.kafkaTrigger",
"typeVersion": 2,
"position": [
0,
0
],
"id": "procurement-events",
"name": "Procurement events",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// Every fact on this topic passes through here \u2014 requests, and this\n// service's own answers to them. Only a request is this node's job; an\n// answer re-arriving (a replay, or this consumer's own message) is not\n// something to decide on a second time.\nif ($json.type !== 'com.sttlab.classicmodels.reorder.requested.v1') return [];\nreturn [{ json: $json.data ?? {} }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
],
"id": "filter-requested",
"name": "Only requests"
},
{
"parameters": {
"jsCode": "// The supplier's answer to one request \u2014 decided once, here, never revisited.\n//\n// A random stockout is the honest way to simulate \"sometimes a supplier\n// cannot fulfil a request\" without pretending to model a real one: no lead\n// time table, no supplier entity, no negotiation. `STOCKOUT_CHANCE` is the\n// whole model.\nconst LEAD_TIME_DAYS = 7;\nconst STOCKOUT_CHANCE = 0.15;\n\nconst req = $json;\nif (!req.reorder_id || !req.productcode) {\n // Not our envelope, or unreadable. Every message on this topic is either a\n // request or an answer to one \u2014 this node only runs on requests (filtered\n // upstream), so a shape this far off the contract is not this run's job.\n return [];\n}\n\nconst rejected = Math.random() < STOCKOUT_CHANCE;\nconst now = new Date();\n\nif (rejected) {\n return [{ json: {\n outcome: 'rejected',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n reason: 'supplier stockout',\n }}];\n}\n\nconst deliverOn = new Date(now);\ndeliverOn.setUTCDate(deliverOn.getUTCDate() + LEAD_TIME_DAYS);\n\nreturn [{ json: {\n outcome: 'confirmed',\n reorder_id: req.reorder_id,\n productcode: req.productcode,\n quantity: req.quantity,\n decided_at: now.toISOString(),\n deliver_on: deliverOn.toISOString().slice(0, 10),\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
0
],
"id": "decide-request",
"name": "Decide the request"
},
{
"parameters": {
"jsCode": "// The envelope for whichever answer \"Decide the request\" produced. One node\n// covers both event types on purpose: they share every field except the ones\n// that differ (deliver_on vs reason), and a caller distinguishes them by\n// `type`, the way any consumer here already does.\nconst d = $json;\nconst type = d.outcome === 'confirmed'\n ? 'com.sttlab.classicmodels.reorder.confirmed.v1'\n : 'com.sttlab.classicmodels.reorder.rejected.v1';\n\nconst data = d.outcome === 'confirmed'\n ? { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, deliver_on: d.deliver_on }\n : { reorder_id: d.reorder_id, productcode: d.productcode, quantity: d.quantity, reason: d.reason };\n\nreturn [{ json: {\n envelope: JSON.stringify({\n specversion: '1.0',\n type,\n source: '/supply-agent/procurement',\n subject: d.reorder_id,\n id: `${d.reorder_id}-${d.outcome}`,\n time: d.decided_at,\n datacontenttype: 'application/json',\n data,\n }),\n reorder_id: d.reorder_id,\n}}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
-100
],
"id": "build-answer",
"name": "Build the answer"
},
{
"parameters": {
"topic": "classic-models.procurement",
"sendInputData": false,
"message": "={{ $json.envelope }}",
"useKey": true,
"key": "={{ $json.reorder_id }}",
"options": {}
},
"type": "n8n-nodes-base.kafka",
"typeVersion": 2,
"position": [
880,
-100
],
"id": "publish-answer",
"name": "Publish the answer",
"credentials": {
"kafka": {
"id": "8qNZXLzvdx5rWQ0V",
"name": "kafka-classic-models"
}
}
},
{
"parameters": {
"jsCode": "// Remembering what to deliver, and when \u2014 the same staticData pattern the\n// stock-watch workflow already uses for its own pending state, so this is not\n// a second mechanism to reason about, just a second use of one.\nif ($json.outcome !== 'confirmed') {\n // A rejection has nothing further to do: no delivery, nothing to schedule.\n return [];\n}\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\nstore.pendingDeliveries[$json.reorder_id] = {\n productcode: $json.productcode,\n quantity: $json.quantity,\n deliver_on: $json.deliver_on,\n};\nreturn [{ json: { scheduled: true, reorder_id: $json.reorder_id } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
100
],
"id": "schedule-delivery",
"name": "Schedule the delivery"
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 6,
"triggerAtMinute": 15
}
]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
300
],
"id": "every-morning-delivery",
"name": "Every morning (delivery)"
},
{
"parameters": {
"method": "POST",
"url": "https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token",
"sendBody": true,
"contentType": "form-urlencoded",
"bodyParameters": {
"parameters": [
{
"name": "grant_type",
"value": "client_credentials"
},
{
"name": "client_id",
"value": "classic-models-agent-supply"
},
{
"name": "client_secret",
"value": "={{ $env.SUPPLY_CLIENT_SECRET }}"
},
{
"name": "scope",
"value": "openid mcp-catalog-audience token-exchange-broker-audience"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
300
],
"id": "machine-token-delivery",
"name": "Machine token"
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"supply-procurement\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
300
],
"id": "mcp-initialize-delivery",
"name": "MCP initialize"
},
{
"parameters": {
"jsCode": "// What is due today or earlier \u2014 anything scheduled for a date already past\n// is delivered on the next run rather than lost, since this only runs once a\n// day and a missed day must not mean a missed delivery.\nconst store = $getWorkflowStaticData('global');\nstore.pendingDeliveries = store.pendingDeliveries ?? {};\n\nconst today = new Date().toISOString().slice(0, 10);\nconst due = Object.entries(store.pendingDeliveries)\n .filter(([, d]) => d.deliver_on <= today)\n .map(([reorder_id, d]) => ({ json: { reorder_id, ...d } }));\n\nreturn due;\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
300
],
"id": "due-deliveries",
"name": "Due deliveries"
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'get-product', arguments: { path_productcode: $json.productcode } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
880,
300
],
"id": "read-current-stock",
"name": "Read current stock",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// The new stock figure, read fresh rather than trusted from whenever this was\n// scheduled \u2014 a delivery adds to whatever is on hand *now*, not to a number\n// that may be a week stale.\n//\n// Two upstream nodes matter here, not one: \"Due deliveries\" carries what this\n// delivery IS (reorder_id, productcode, quantity), \"Read current stock\" carries\n// what the catalog says right now. `$json` at this point is the LATTER \u2014 using\n// it for the former was the bug that shipped first: it silently dropped\n// reorder_id/productcode/quantity, and the write two nodes later failed on\n// \"path_productcode is a required property\" with no due-delivery context left\n// to explain why.\nconst due = $('Due deliveries').item.json;\n\nconst raw = $json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }];\n\nlet rpc;\ntry { rpc = JSON.parse(line.slice(5).trim()); }\ncatch (e) { return [{ json: { ...due, applied: false, why: 'unreadable catalog reply' } }]; }\nif (rpc.error || rpc.result?.isError) {\n return [{ json: { ...due, applied: false, why: 'read failed' } }];\n}\n\nconst product = JSON.parse(rpc.result.content[0].text);\nconst newQuantity = Number(product.quantityinstock) + Number(due.quantity);\n\nreturn [{ json: { ...due, new_quantity: newQuantity } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1100,
300
],
"id": "apply-delivery",
"name": "Apply delivery"
},
{
"parameters": {
"method": "POST",
"url": "https://catalog-dev.sttlab.local/catalog-mcp",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('Machine token').item.json.access_token }}"
},
{
"name": "Accept",
"value": "application/json, text/event-stream"
},
{
"name": "mcp-session-id",
"value": "={{ $('MCP initialize').item.json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'patch-product', arguments: { path_productcode: $json.productcode, ['header_X-Stock-Change-Reason']: 'delivery:' + $json.reorder_id, body: { quantityinstock: $json.new_quantity } } } }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1320,
300
],
"id": "update-stock",
"name": "Update stock",
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "// Only clear the pending entry once the write is actually confirmed \u2014 the\n// classic-models-api response is the proof, never an assumption. A failed\n// PATCH here must leave the entry in place so tomorrow's run retries it,\n// rather than silently losing a delivery nobody applied.\nconst raw = $json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\n\nlet ok = false;\ntry {\n const rpc = JSON.parse(line.slice(5).trim());\n ok = !rpc.error && !rpc.result?.isError;\n} catch (e) {\n ok = false;\n}\n\nconst pending = $('Apply delivery').item.json;\nif (!ok) {\n return [{ json: { delivered: false, reorder_id: pending.reorder_id, why: 'the write did not confirm; retrying tomorrow' } }];\n}\n\nconst store = $getWorkflowStaticData('global');\nif (store.pendingDeliveries) delete store.pendingDeliveries[pending.reorder_id];\nreturn [{ json: { delivered: true, reorder_id: pending.reorder_id, productcode: pending.productcode, new_quantity: pending.new_quantity } }];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1540,
300
],
"id": "clear-delivery",
"name": "Confirm delivery"
}
],
"connections": {
"Procurement events": {
"main": [
[
{
"node": "Only requests",
"type": "main",
"index": 0
}
]
]
},
"Only requests": {
"main": [
[
{
"node": "Decide the request",
"type": "main",
"index": 0
}
]
]
},
"Decide the request": {
"main": [
[
{
"node": "Build the answer",
"type": "main",
"index": 0
},
{
"node": "Schedule the delivery",
"type": "main",
"index": 0
}
]
]
},
"Build the answer": {
"main": [
[
{
"node": "Publish the answer",
"type": "main",
"index": 0
}
]
]
},
"Every morning (delivery)": {
"main": [
[
{
"node": "Machine token",
"type": "main",
"index": 0
}
]
]
},
"Machine token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "Due deliveries",
"type": "main",
"index": 0
}
]
]
},
"Due deliveries": {
"main": [
[
{
"node": "Read current stock",
"type": "main",
"index": 0
}
]
]
},
"Read current stock": {
"main": [
[
{
"node": "Apply delivery",
"type": "main",
"index": 0
}
]
]
},
"Apply delivery": {
"main": [
[
{
"node": "Update stock",
"type": "main",
"index": 0
}
]
]
},
"Update stock": {
"main": [
[
{
"node": "Confirm delivery",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}