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