Files
classic-models-agent-flows/workflows/supply-periodic-reorder.json
T
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

239 lines
8.7 KiB
JSON

{
"name": "Supply \u2014 periodic reorder proposal",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "days",
"triggerAtHour": 6
}
]
}
},
"id": "every-morning",
"name": "Every morning",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
0
]
},
{
"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": {}
},
"id": "machine-token",
"name": "Machine token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
220,
0
]
},
{
"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-periodic\", \"version\": \"0.1.0\"}}}",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"id": "mcp-initialize",
"name": "MCP initialize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
440,
0
]
},
{
"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": "={{ $json.headers['mcp-session-id'] }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"jsonrpc\": \"2.0\", \"id\": 2, \"method\": \"tools/call\", \"params\": {\"name\": \"list-products\", \"arguments\": {\"query_page\": 1, \"query_page_size\": 200}}}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "list-products",
"name": "List products",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
0
],
"alwaysOutputData": true
},
{
"parameters": {
"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",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
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": {
"Every morning": {
"main": [
[
{
"node": "Machine token",
"type": "main",
"index": 0
}
]
]
},
"Machine token": {
"main": [
[
{
"node": "MCP initialize",
"type": "main",
"index": 0
}
]
]
},
"MCP initialize": {
"main": [
[
{
"node": "List products",
"type": "main",
"index": 0
}
]
]
},
"List products": {
"main": [
[
{
"node": "Propose reorders",
"type": "main",
"index": 0
}
]
]
},
"Propose reorders": {
"main": [
[
{
"node": "Emit reorder requests",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}