diff --git a/workflows/README.md b/workflows/README.md index 57f3f32..738be2e 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -89,6 +89,22 @@ and through the A2A facade, `escalate: true` becomes a task in state **failed** rather than a completed one — the honest shape for "a human has to look at this". +### Deducting open orders would double-count + +The answer used to end with *"quantities committed on open orders are not +deducted"*. That caveat is now **wrong**, and acting on it would be worse than +leaving it: placing an order line decrements stock in the same transaction, so +an open order has already taken what it needs. Subtracting it again counts it +twice. + +The API gained the filters that would have made the deduction possible — +`list-order-details` takes `productcode` and `ordernumber__status` — and the +right conclusion was that the deduction is no longer the thing to build. + +What remains true, and is said once: the order lines that came with the seed +data predate the stock movement and never decremented anything. They are +history, not commitments. + ### It now knows what the reactive half saw `check-availability` reads the watchlist before answering, so a crossing that diff --git a/workflows/approvisionnement-check-availability.json b/workflows/approvisionnement-check-availability.json index a479f3a..7f3e9cc 100644 --- a/workflows/approvisionnement-check-availability.json +++ b/workflows/approvisionnement-check-availability.json @@ -157,7 +157,7 @@ }, { "parameters": { - "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $('Read stock').first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// Only while the reference is still low. An entry stays on the watchlist until\n// a human clears it, so a reference that has since been restocked still has\n// one \u2014 and \"300 in stock \u2026 it crossed the threshold, from 68\" reads as a\n// contradiction even though both halves are true.\nlet context = '';\nif (movement && !available) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\n// Stock on hand only. Quantities already committed on unshipped orders are\n// NOT deducted, and the answer says so rather than implying a certainty it does\n// not have: the sales API cannot be filtered by product code, so working it out\n// would mean paging the whole orderdetails table on every question \u2014 sixty\n// pages, for one answer. See the backlog note in CLAUDE.md.\nconst basis = 'stock on hand; quantities committed on open orders are not deducted';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.${context}`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.${context}`,\n escalate: false,\n}}];\n" + "jsCode": "// Turn the MCP reply into a decision.\n//\n// An A2A call returns an answer, not data: the caller learns whether the stock\n// covers what it asked about, and never receives the stock table. That is what\n// keeps this agent from becoming a read proxy onto the catalog domain.\nconst THRESHOLD = 50; // below this, a reorder is due before promising anything\n\nconst decided = $('Decide the product').first().json;\nif (!decided.productcode) {\n return [{ json: {\n answer: 'No product reference in the question. Name one as S10_1949 and ask again.',\n escalate: true,\n }}];\n}\n\nconst item = $('Read stock').first();\nif (!item) {\n return [{ json: {\n answer: `The catalog returned nothing for ${decided.productcode}. It may not exist.`,\n escalate: true,\n }}];\n}\nconst raw = item.json ?? {};\nconst body = typeof raw === 'string' ? raw : (raw.data ?? JSON.stringify(raw));\n\n// The endpoint answers as an SSE event, so the JSON sits on a `data:` line.\nconst line = String(body).split('\\n').find((l) => l.startsWith('data:'));\nif (!line) {\n return [{ json: {\n answer: `Could not read the catalog's reply for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\n\nlet rpc;\ntry {\n rpc = JSON.parse(line.slice(5).trim());\n} catch (e) {\n return [{ json: {\n answer: `The catalog's reply for ${decided.productcode} could not be read.`,\n escalate: true,\n }}];\n}\n\n// A JSON-RPC `error` is a protocol failure \u2014 a malformed call, a session gone.\nif (rpc.error) {\n return [{ json: {\n answer: `The catalog refused the read for ${decided.productcode}: ${rpc.error.message}.`,\n escalate: true,\n }}];\n}\n\n// A *tool* failure is not that. MCP reports it as a perfectly successful result\n// carrying `isError: true`, with the reason as text \u2014 so a client that only\n// checks `error` reads a failure as data and tries to parse \"HTTP call failed\n// with status 404\" as a product. Which is exactly what this did.\nconst text = rpc.result?.content?.[0]?.text ?? '';\nif (rpc.result?.isError) {\n const missing = text.includes('404');\n return [{ json: {\n answer: missing\n ? `There is no product ${decided.productcode} in the catalog.`\n : `The catalog could not answer for ${decided.productcode}: ${text}`,\n escalate: true,\n }}];\n}\n\nlet product;\ntry {\n product = JSON.parse(text);\n} catch (e) {\n return [{ json: {\n answer: `The catalog returned something unreadable for ${decided.productcode}.`,\n escalate: true,\n }}];\n}\nconst stock = Number(product.quantityinstock);\nconst available = stock >= THRESHOLD;\n\n// What the reactive half noticed, if anything. `product.stock_changed` is\n// consumed as it happens, so a crossing that occurred twenty minutes ago is\n// known here \u2014 and \"it fell below this afternoon, under demand\" is a different\n// decision from \"it has been sitting below for a month\".\n//\n// Read from the watchlist, and absent if the watchlist could not be read: an\n// availability answer must not depend on it.\nlet movement = null;\ntry {\n const watch = $('Recent movement').first()?.json ?? {};\n movement = (watch.entries ?? []).find((e) => e.productcode === product.productcode) ?? null;\n} catch (e) {\n movement = null;\n}\n\n// Only while the reference is still low. An entry stays on the watchlist until\n// a human clears it, so a reference that has since been restocked still has\n// one \u2014 and \"300 in stock \u2026 it crossed the threshold, from 68\" reads as a\n// contradiction even though both halves are true.\nlet context = '';\nif (movement && !available) {\n const when = movement.last_movement_at ?? movement.observed_at;\n const driver = movement.driver === 'demand'\n ? 'driven by orders'\n : 'a stock correction, not demand';\n context = movement.stock_out\n ? ` It ran out at ${when} (${driver}).`\n : ` It crossed the threshold at ${when} (${driver}), from ${movement.was}.`;\n}\n\n// This caveat used to read \"quantities committed on open orders are not\n// deducted\". It is no longer true, and worse, deducting them now would be\n// **wrong**: placing an order line decrements stock in the same transaction, so\n// an open order has already taken what it needs. Subtracting it again would\n// count it twice.\n//\n// What remains, and is worth saying once: the order lines that came with the\n// seed data predate that behaviour and never decremented anything. They are\n// history, not commitments.\nconst basis = 'stock on hand, already net of order lines placed through the API';\n\nreturn [{ json: {\n answer: available\n ? `Yes \u2014 ${product.productname} (${product.productcode}) can be promised: `\n + `${stock} in stock, above the reorder threshold of ${THRESHOLD}. `\n + `Basis: ${basis}.${context}`\n : `No \u2014 ${product.productname} (${product.productcode}) is down to ${stock}, `\n + `below the reorder threshold of ${THRESHOLD}. A reorder is due before it can be promised. `\n + `Basis: ${basis}.${context}`,\n escalate: false,\n}}];\n" }, "id": "answer", "name": "Answer",