Files
Stéphane Tailland 1ec9e308ba 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>
2026-08-31 16:41:46 +02:00

460 lines
17 KiB
JSON

{
"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"
}
}