{
  "name": "Lumeor | Inventory reorder alerts",
  "nodes": [
    {
      "parameters": {
        "content": "## Inventory reorder alerts (Shopify + Google Sheets)\n\nEvery morning: reads on-hand, committed and incoming stock per SKU and yesterday's units sold from Shopify, saves a dated snapshot, and calculates velocity (excluding out-of-stock days), reorder point and a suggested quantity rounded to case pack and minimum order. SKUs at or below their reorder point go into one Slack digest, grouped by supplier. Claude only writes short notes for unusual lines; it never changes a number.\n\n**Setup**\n1. Credentials: Shopify Access Token (read_products, read_inventory, read_orders), Google Sheets OAuth2, Anthropic API, Slack.\n2. Create the `Suppliers` and `Snapshots` tabs (headers on the landing page).\n3. Replace REPLACE_WITH_SHOP, REPLACE_WITH_SHEET_ID and the Slack channel IDs.\n4. Let snapshots build for a week or two before trusting velocity.\n\nThis starter reads 100 variants and 80 orders per day. Paginate the GraphQL calls or use Shopify bulk operations for larger stores.\n\n`claude-haiku-5-5` is a cheaper option for high-volume, simple classification.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-inventory-reorder-alerts/",
        "height": 620,
        "width": 460
      },
      "id": "0dea2698-38cc-4dfc-8f45-bec54de11dab",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -520,
        40
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "id": "63d84b13-bd13-4a14-8b53-32a7abde8a31",
      "name": "Daily at 6am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_SHOP.myshopify.com/admin/api/2026-10/graphql.json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "shopifyAccessTokenApi",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: 'query Stock { productVariants(first: 100) { pageInfo { hasNextPage } nodes { sku displayName inventoryItem { tracked inventoryLevels(first: 3) { nodes { quantities(names: [\\\"on_hand\\\", \\\"committed\\\", \\\"incoming\\\"]) { name quantity } } } } } } }' }) }}",
        "options": {}
      },
      "id": "86a41030-59d7-446e-9485-5b8a19cb54b6",
      "name": "Shopify stock levels",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_SHOP.myshopify.com/admin/api/2026-10/graphql.json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "shopifyAccessTokenApi",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: 'query Sold($q: String!) { orders(first: 80, query: $q) { pageInfo { hasNextPage } nodes { lineItems(first: 10) { nodes { sku quantity } } } } }', variables: { q: 'created_at:>=' + $now.minus({ days: 1 }).toFormat('yyyy-MM-dd') + ' AND created_at:<' + $now.toFormat('yyyy-MM-dd') + ' AND -status:cancelled' } }) }}",
        "options": {}
      },
      "id": "067d6a57-8038-4b13-b336-73fcaa0b61ce",
      "name": "Shopify orders yesterday",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Suppliers",
          "mode": "name"
        },
        "options": {}
      },
      "id": "6c4160bb-a18d-4c1c-a2f0-271fcec86371",
      "name": "Read supplier table",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        660,
        300
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Snapshots",
          "mode": "name"
        },
        "options": {}
      },
      "id": "117e0994-ad9c-4a24-b222-a7126ce4f803",
      "name": "Read snapshot history",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        880,
        300
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const stock = $('Shopify stock levels').first().json;\nconst sold = $('Shopify orders yesterday').first().json;\nif (stock.errors || !stock.data) throw new Error('Shopify stock query failed: ' + JSON.stringify(stock.errors || stock).slice(0, 300));\nif (sold.errors || !sold.data) throw new Error('Shopify orders query failed: ' + JSON.stringify(sold.errors || sold).slice(0, 300));\n\nconst date = $now.minus({ days: 1 }).toFormat('yyyy-MM-dd');\nconst units = {};\nfor (const o of sold.data.orders.nodes) for (const l of o.lineItems.nodes) if (l.sku) units[l.sku] = (units[l.sku] || 0) + l.quantity;\n\nconst rows = [];\nfor (const v of stock.data.productVariants.nodes) {\n  if (!v.sku || !v.inventoryItem || !v.inventoryItem.tracked) continue;\n  const q = { on_hand: 0, committed: 0, incoming: 0 };\n  for (const lvl of v.inventoryItem.inventoryLevels.nodes) for (const x of lvl.quantities) q[x.name] += x.quantity;\n  rows.push({ json: { date, sku: v.sku, name: v.displayName, on_hand: q.on_hand, committed: q.committed, incoming: q.incoming, units_sold: units[v.sku] || 0,\n    truncated: stock.data.productVariants.pageInfo.hasNextPage || sold.data.orders.pageInfo.hasNextPage } });\n}\nreturn rows;\n"
      },
      "id": "6d6ded25-682e-4232-87ac-bc67d3aacf8f",
      "name": "Build daily snapshot",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        300
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Snapshots",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {
          "handlingExtraData": "ignoreIt",
          "cellFormat": "RAW"
        }
      },
      "id": "bee28407-e0c5-4a45-a7ec-68b28ca2ad77",
      "name": "Save snapshot",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1320,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const WINDOW_DAYS = 28;\nconst REVIEW_DAYS = 14;      // order enough to cover lead time + safety + this many days\nconst MIN_HISTORY_DAYS = 7;\n\nconst today = $('Build daily snapshot').all().map((i) => i.json);\nconst history = $('Read snapshot history').all().map((i) => i.json).filter((r) => r.sku);\nconst suppliers = Object.fromEntries($('Read supplier table').all().map((i) => [String(i.json.sku), i.json]));\nconst cutoff = $now.minus({ days: WINDOW_DAYS }).toFormat('yyyy-MM-dd');\n\nconst lines = [];\nfor (const s of today) {\n  const sup = suppliers[s.sku];\n  if (!sup) continue; // not managed in the supplier table\n  const days = history.filter((h) => String(h.sku) === s.sku && h.date >= cutoff && h.date < s.date).concat([s]);\n  const inStock = days.filter((d) => Number(d.on_hand) > 0 || Number(d.units_sold) > 0); // stockout days do not drag velocity down\n  const velocity = inStock.length ? inStock.reduce((t, d) => t + Number(d.units_sold || 0), 0) / inStock.length : 0;\n  const lead = Number(sup.lead_time_days) || 0;\n  const safetyDays = Number(sup.safety_days) || 0;\n  const reorderPoint = velocity * (lead + safetyDays);\n  const position = s.on_hand + s.incoming + Number(sup.on_order_qty || 0) - s.committed;\n  if (velocity === 0 || position > reorderPoint) continue;\n\n  const pack = Math.max(1, Number(sup.case_pack) || 1);\n  const need = Math.max(velocity * (lead + safetyDays + REVIEW_DAYS) - position, Number(sup.min_order_qty) || 0);\n  const qty = Math.ceil(need / pack) * pack;\n  // Flags the model may explain. It never changes quantities.\n  const recent = days.slice(-7), earlier = days.slice(0, -7);\n  const avg = (a) => (a.length ? a.reduce((t, d) => t + Number(d.units_sold || 0), 0) / a.length : 0);\n  const flags = [];\n  if (inStock.length < MIN_HISTORY_DAYS) flags.push('only ' + inStock.length + ' days of history');\n  if (earlier.length >= 7 && avg(recent) >= 2 * avg(earlier) && avg(recent) > 0) flags.push('velocity doubled in the last 7 days');\n  if (position <= 0) flags.push('already out of stock');\n  lines.push({ sku: s.sku, name: s.name, supplier: sup.supplier || 'Unassigned', on_hand: s.on_hand, position, velocity: Math.round(velocity * 100) / 100,\n    reorder_point: Math.round(reorderPoint), days_cover: velocity ? Math.round((position / velocity) * 10) / 10 : null, qty, case_pack: pack, lead_time_days: lead, flags });\n}\nif (!lines.length) return [];\n\nconst flagged = lines.filter((l) => l.flags.length);\nreturn [{\n  json: {\n    lines,\n    truncated: today.some((t) => t.truncated),\n    claude: {\n      system: 'You write one-line notes for a purchasing buyer. You get computed reorder lines with flags. For each flagged line, write a short, factual note (under 25 words) on what the flag means for this order and what to check. ' +\n        'Do not suggest different quantities and do not invent causes. Return notes only for SKUs in the input.',\n      messages: [{ role: 'user', content: JSON.stringify(flagged.length ? flagged : lines.slice(0, 1)) }],\n      schema: {\n        type: 'object',\n        additionalProperties: false,\n        required: ['notes'],\n        properties: { notes: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['sku', 'note'], properties: { sku: { type: 'string' }, note: { type: 'string' } } } } },\n      },\n    },\n  },\n}];\n"
      },
      "id": "0d627375-5747-4ba9-b8d5-b44667eea90a",
      "name": "Reorder math",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 2000, system: $json.claude.system, messages: $json.claude.messages, output_config: { effort: 'low', format: { type: 'json_schema', schema: $json.claude.schema } } }) }}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "69bc9bfc-f2fb-41e5-9099-278030694a54",
      "name": "Explain flags with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Reads an Anthropic Messages API response. Returns { ok, data } or { ok: false, reason }.\n// Opus 5.5 can return a thinking block before the text block, so find the text block by type.\nfunction readClaude(res) {\n  if (!res || res.error) {\n    const msg = res && res.error ? (res.error.message || JSON.stringify(res.error)) : 'empty response';\n    return { ok: false, reason: 'api_error: ' + String(msg).slice(0, 300) };\n  }\n  if (res.stop_reason === 'refusal') return { ok: false, reason: 'refusal' };\n  if (res.stop_reason === 'max_tokens') return { ok: false, reason: 'max_tokens (raise max_tokens)' };\n  const block = (res.content || []).find((c) => c.type === 'text');\n  if (!block) return { ok: false, reason: 'no text block in response' };\n  try {\n    return { ok: true, data: JSON.parse(block.text) };\n  } catch (e) {\n    return { ok: false, reason: 'response was not valid JSON' };\n  }\n}\n\nconst src = $('Reorder math').first().json;\nconst r = readClaude($input.first().json);\nconst notes = {};\nif (r.ok) for (const n of r.data.notes || []) notes[n.sku] = n.note;\n\nconst bySupplier = {};\nfor (const l of src.lines) (bySupplier[l.supplier] = bySupplier[l.supplier] || []).push(l);\nconst sections = Object.entries(bySupplier).map(([sup, list]) => {\n  list.sort((a, b) => (a.days_cover ?? 0) - (b.days_cover ?? 0));\n  return '*' + sup + '*\\n' + list.map((l) =>\n    '- ' + l.sku + ' ' + l.name + ': order *' + l.qty + '* (case ' + l.case_pack + '). ' + l.days_cover + ' days cover. ' +\n    'Position ' + l.position + ' vs reorder point ' + l.reorder_point + ' (' + l.velocity + '/day x ' + l.lead_time_days + 'd lead + safety)' +\n    (notes[l.sku] ? '\\n    _' + notes[l.sku] + '_' : l.flags.length ? '\\n    _Flags: ' + l.flags.join('; ') + '_' : '')).join('\\n');\n});\nconst out = [{ json: { is_exception: false, slack_text: ':package: *Reorder suggestions for ' + $now.toFormat('ccc d LLL') + '* (' + src.lines.length + ' SKUs)\\n\\n' + sections.join('\\n\\n') +\n  (src.truncated ? '\\n\\n:warning: Shopify returned more than one page. Some SKUs or orders were not read.' : '') } }];\nif (!r.ok) out.push({ json: { is_exception: true, slack_text: ':warning: Reorder digest sent without AI notes: ' + r.reason } });\nreturn out;\n"
      },
      "id": "bef6a340-ca5f-4c31-ab8b-f3819d257afe",
      "name": "Build digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ca19db2c-4fea-402f-ba1f-693aaaf33d3e",
              "leftValue": "={{ $json.is_exception }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f685e312-30dc-4233-8cf1-71ea9b1f76ee",
      "name": "Exception?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2200,
        300
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_EXCEPTIONS_CHANNEL_ID",
          "mode": "id"
        },
        "text": "={{ $json.slack_text }}",
        "otherOptions": {}
      },
      "id": "37c29d46-4565-43b7-92d3-659190ced81b",
      "name": "Post inventory exception",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2420,
        200
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_PURCHASING_CHANNEL_ID",
          "mode": "id"
        },
        "text": "={{ $json.slack_text }}",
        "otherOptions": {}
      },
      "id": "213f48ee-4d79-4d36-884c-befa6e01eafe",
      "name": "Post reorder digest",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2420,
        400
      ]
    }
  ],
  "connections": {
    "Daily at 6am": {
      "main": [
        [
          {
            "node": "Shopify stock levels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shopify stock levels": {
      "main": [
        [
          {
            "node": "Shopify orders yesterday",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shopify orders yesterday": {
      "main": [
        [
          {
            "node": "Read supplier table",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read supplier table": {
      "main": [
        [
          {
            "node": "Read snapshot history",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read snapshot history": {
      "main": [
        [
          {
            "node": "Build daily snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build daily snapshot": {
      "main": [
        [
          {
            "node": "Save snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save snapshot": {
      "main": [
        [
          {
            "node": "Reorder math",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reorder math": {
      "main": [
        [
          {
            "node": "Explain flags with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Explain flags with Claude": {
      "main": [
        [
          {
            "node": "Build digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build digest": {
      "main": [
        [
          {
            "node": "Exception?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Exception?": {
      "main": [
        [
          {
            "node": "Post inventory exception",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post reorder digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
