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:
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user