diff --git a/workflows/README.md b/workflows/README.md index 212f83a..025a5a9 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -21,6 +21,81 @@ POST /rest/workflows//activate {"versionId": ""} `PATCH {"active": true}` returns 200 and leaves the workflow inactive, silently: activation has its own endpoint. +## supply-procurement + +The service's own supplier, simulated. Reacts to `reorder.requested` (emitted +by the periodic run below, once it finds a reference under threshold), +decides — a fixed lead time, or a 15% chance the supplier cannot fulfil — +answers with `reorder.confirmed`/`reorder.rejected`, and on the confirmed +delivery date writes the stock itself. + +``` +reorder.requested {productcode, quantity, requested_at} + → reorder.confirmed {deliver_on} — 85% of the time + → reorder.rejected {reason: "supplier stockout"} — 15% +``` + +No purchase-order entity, no supplier entity, no negotiation — the whole model +is one probability and one lead time, on purpose: this simulates *that* +fulfilment can fail, not *why* a given supplier would. + +### One service, two capacities — not two identities + +This is still the supply agent, not a separate "procurement" component. It +both requests (the periodic run) and answers its own request (this workflow), +under the same Kafka account and the same Keycloak client — the 2026-08-31 +decision that réapprovisionnement is one service, not a split into a demand +side and a supply side. + +### The write goes through the front door + +On the delivery date, this workflow reads the product's current stock and +PATCHes it — through Kong, through the same `patch-product` MCP tool a human +product manager would use, under a **new, narrow role**: `restocker` may PATCH +a product and nothing else, not create, not replace, not delete. It sends +`X-Stock-Change-Reason: delivery:`, which `classic-models-api` +only honours in that exact shape; anything else falls back to +`manual-adjustment`. The resulting `product.stock_changed` event is the *only* +stock-update event — there is deliberately no second, n8n-emitted fact +claiming a delivery happened, because nothing would certify it against the +one that actually changed the database. See `classic-models-api`'s commit +"Accept a delivery reason on a stock PATCH" for the backend side. + +### Two triggers, one shared state + +`$getWorkflowStaticData('global').pendingDeliveries`, keyed by reorder id — +the same pattern the stock-watch workflow already uses for its watchlist, now +shared across **two** trigger chains in this one workflow: the Kafka-triggered +decision writes an entry when the supplier confirms, and a daily schedule +(06:15, deliberately after the periodic run's 06:00) reads what is due and +applies it. A pending entry is deleted only once the PATCH is confirmed by the +API's own response — a failed write leaves it in place so tomorrow's run +retries it, rather than losing a delivery nobody applied. + +### A bug this shipped with, and how it was found + +`Apply delivery` sits between `Read current stock` and `Update stock`, and its +first version wrote `{ ...$json, new_quantity }` — but `$json` at that point is +`Read current stock`'s own output, not the due-delivery record two nodes +earlier. `reorder_id`/`productcode`/`quantity` were silently dropped, and +`Update stock` failed two nodes later with `"path_productcode" is a required +property` — a symptom far from its cause. Fixed by naming the source +explicitly: `$('Due deliveries').item.json` for what the delivery *is*, `$json` +for what the catalog *says now*. The same rule `check_availability` already +follows for `Recent movement` — an implicit `$json` is only ever the +*immediately preceding* node, never "whichever upstream node has the field I +want." + +### Manual test runs do not persist static data + +Verified the hard way: triggering this workflow via `/rest/workflows/{id}/run` +executes for real (the write happens, the event is genuine) but the +`pendingDeliveries` mutation is **not** saved back to the workflow — the entry +reappears on the next check. Only a genuine trigger firing (a real Kafka +message, the real schedule) persists it. A manual run is fine for proving the +logic; it is not fine for proving the state machine, and the two were verified +separately here. + ## supply-check-availability Working. `POST /webhook/check-availability` with the end user's token in diff --git a/workflows/supply-periodic-reorder.json b/workflows/supply-periodic-reorder.json index 3cba58d..4884994 100644 --- a/workflows/supply-periodic-reorder.json +++ b/workflows/supply-periodic-reorder.json @@ -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": { diff --git a/workflows/supply-procurement.json b/workflows/supply-procurement.json new file mode 100644 index 0000000..e5168ef --- /dev/null +++ b/workflows/supply-procurement.json @@ -0,0 +1,459 @@ +{ + "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" + } +}