1ec9e308ba
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>
239 lines
8.7 KiB
JSON
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"
|
|
}
|
|
}
|