{
  "name": "02 · Reorder Alert — safe-stock reorder points + draft POs",
  "nodes": [
    {
      "parameters": {
        "content": "# 02 · Reorder Alert — with SAFE STOCK\n\n**The problem:** fast movers go out of stock because nobody checks levels until a customer asks. Every stockout is lost margin.\n\n**What this does:** daily it computes each SKU's real reorder point from **demand + lead time + safety stock**, then groups the shortages **by supplier** as a draft purchase order.\n\n**The formula:**\n`ROP = (avg daily demand × lead days) + safety stock`\n`safety stock = z × demand std-dev × √lead days`\n\n`z` = service level: 1.65 → 95% (default), 2.33 → 99%\n\nSo a fast mover with a 5-day lead gets a *higher* trigger than a slow mover with the same stock on hand — which is the whole point. A static `Min` cannot do that.\n\n**Flags a CRITICAL when** stock will run out *before* the delivery lands (on-hand < avg daily demand × lead days).\n\n**Setup:**\n• `Stock` — SKU, Item, Qty, Location\n• `Rules` — SKU, Item, Max, Supplier, Cost, **LeadDays**, ServiceLevel\n• `Sales` — SKU, QtySold, Date  *(drives demand + safety stock)*\n\n**No sales history?** It falls back to your manual `Min` and labels the line ⚠️ NO SALES HISTORY so you know it's an estimate, not silently wrong.\n\n**Tune:** `WINDOW_DAYS` (demand lookback, default 28) and `DEFAULT_Z` (service level) at the top of *Calc Reorder Points*.\n\nBuilt by **Maui Tech Hub** · mauitechhub.com/workflows",
        "height": 620,
        "width": 430,
        "color": 7
      },
      "id": "c4994ac9-9bea-45f0-9695-ab44d6daa3b9",
      "name": "NOTE",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -420,
        -60
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * *"
            }
          ]
        }
      },
      "id": "6e46f79c-b9cd-4875-9b03-0777fd827410",
      "name": "Daily 7am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        80
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "YOUR_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Stock",
          "mode": "name"
        },
        "options": {}
      },
      "id": "3e6f2e9f-24bf-4972-a9bf-24c8cf052e3a",
      "name": "Read Stock",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 1,
      "position": [
        240,
        40
      ],
      "notes": "SKU, Item, Qty, Location"
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "YOUR_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Rules",
          "mode": "name"
        },
        "options": {}
      },
      "id": "2e2263ca-5965-4e40-89f1-2fb253a2f61e",
      "name": "Read Rules",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 1,
      "position": [
        240,
        200
      ],
      "notes": "SKU, Item, Max, Supplier, Cost, LeadDays, ServiceLevel"
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "YOUR_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Sales",
          "mode": "name"
        },
        "options": {}
      },
      "id": "754995ff-47c4-42ec-8e1d-27dd80b05853",
      "name": "Read Sales History",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 1,
      "position": [
        240,
        360
      ],
      "notes": "SKU, QtySold, Date — NEEDED for demand + safety stock"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// ============================================================\n// SAFE-STOCK REORDER POINT\n//   ROP = (avg daily demand x lead time) + safety stock\n//   safety stock = z x demand std-dev x sqrt(lead time)\n//   z = service level (1.65 = 95%, 2.33 = 99%)\n// Fallback: if a SKU has no sales history we cannot compute demand\n// variability, so we fall back to its Min and flag it as UNVERIFIED.\n// ============================================================\nconst WINDOW_DAYS = 28;     // demand lookback. 28 = 4 clean weeks. Tune.\nconst DEFAULT_LEAD = 3;     // if LeadDays missing\nconst DEFAULT_Z = 1.65;     // 95% service level\n\nconst stock = $('Read Stock').all().map(i => i.json);\nconst rules = $('Read Rules').all().map(i => i.json);\nconst sales = $('Read Sales History').all().map(i => i.json);\n\n// ---- 1. demand per SKU: total, and the per-day series for std-dev\nconst now = new Date(); now.setHours(0,0,0,0);\nconst cut = new Date(now.getTime() - WINDOW_DAYS*86400000);\nconst dem = {};\nfor (const s of sales) {\n  const sku = String(s.SKU ?? s.sku ?? '').trim();\n  if (!sku) continue;\n  const d = new Date(s.Date ?? s.date ?? now);\n  if (!isNaN(d) && d < cut) continue;\n  const qty = Number(s.QtySold ?? s.qty ?? s.Quantity ?? 0);\n  const key = isNaN(d) ? 'nodate' : d.toISOString().slice(0,10);\n  const e = dem[sku] ||= { total: 0, days: {} };\n  e.total += qty;\n  e.days[key] = (e.days[key] || 0) + qty;\n}\n\n// ---- 2. index\nconst ruleBy = {};\nfor (const r of rules) {\n  const sku = String(r.SKU ?? r.sku ?? '').trim();\n  if (sku) ruleBy[sku] = r;\n}\nconst stockBy = {};\nfor (const r of stock) {\n  const sku = String(r.SKU ?? r.sku ?? '').trim();\n  if (sku) stockBy[sku] = r;\n}\n\nconst out = [];\nconst skus = new Set([...Object.keys(stockBy), ...Object.keys(ruleBy)]);\nfor (const sku of skus) {\n  const st = stockBy[sku] || {};\n  const rl = ruleBy[sku] || {};\n  const item = rl.Item ?? rl.item ?? st.Item ?? st.item ?? sku;\n  const qty = Number(st.Qty ?? st.qty ?? 0);\n  const lead = Number(rl.LeadDays ?? rl.lead_days ?? 0) || DEFAULT_LEAD;\n  const z = Number(rl.ServiceLevel ?? rl.service_level ?? 0) || DEFAULT_Z;\n  const fillTo = Number(rl.Max ?? rl.max ?? 0);\n  const cost = Number(rl.Cost ?? rl.cost ?? 0);\n  const supplier = rl.Supplier ?? rl.supplier ?? 'UNASSIGNED';\n\n  // ---- 3. demand stats\n  const e = dem[sku];\n  let avgDaily = 0, stdDaily = 0, verified = false;\n  if (e && e.total > 0) {\n    const dayVals = Object.values(e.days);\n    const observed = Math.max(1, dayVals.length);\n    avgDaily = e.total / WINDOW_DAYS;          // spread over the calendar window\n    const mean = e.total / observed;           // mean of selling days\n    const varsum = dayVals.reduce((s,v)=>s+Math.pow(v-mean,2),0) + Math.pow(0,2)*(WINDOW_DAYS-observed);\n    stdDaily = Math.sqrt(varsum / WINDOW_DAYS);\n    verified = true;\n  }\n\n  // ---- 4. safety stock + reorder point\n  let safety = 0, rop, basis, min = Number(rl.Min ?? rl.min ?? 0);\n  if (verified) {\n    safety = z * stdDaily * Math.sqrt(lead);\n    rop = (avgDaily * lead) + safety;\n    basis = 'safe-stock';\n  } else {\n    rop = min;                                  // last resort: the manual Min\n    basis = min ? 'MIN ONLY' : 'NO DATA';\n  }\n  const daysCover = avgDaily > 0 ? +(qty / avgDaily).toFixed(1) : null;\n\n  // stock out before the PO lands?\n  const stockoutBeforeDelivery = verified && qty < (avgDaily * lead);\n  if (qty > rop) continue;                      // healthy, leave it alone\n  const target = fillTo > 0 ? fillTo : Math.ceil(rop * 2);\n  const order = Math.max(0, Math.ceil(target - qty));\n  if (order <= 0) continue;\n\n  out.push({ json: {\n    sku, item, supplier, qty, cost,\n    lead_days: lead, service_level: z,\n    avg_daily: +avgDaily.toFixed(2),\n    std_daily: +stdDaily.toFixed(2),\n    safety_stock: +safety.toFixed(1),\n    reorder_point: +rop.toFixed(1),\n    basis,\n    days_of_cover: daysCover,\n    order_qty: order,\n    line_value: +(order * cost).toFixed(3),\n    urgency: qty <= 0 ? 'STOCKED OUT'\n           : stockoutBeforeDelivery ? 'CRITICAL — will run out before delivery'\n           : qty <= rop * 0.5 ? 'CRITICAL' : 'LOW',\n  }});\n}\nout.sort((a,b) => (a.json.qty - b.json.qty) || a.json.supplier.localeCompare(b.json.supplier));\nreturn out;"
      },
      "id": "8c17cf22-9005-489b-95b6-7a631683241e",
      "name": "Calc Reorder Points",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        180
      ],
      "notes": "The whole safe-stock formula lives here — tune WINDOW_DAYS and DEFAULT_Z"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// One draft PO per supplier — that is how you actually place orders.\nconst CURRENCY = 'KD';        // <- change to '$', '£', '€', 'AED' ...\nconst items = $input.all().map(i => i.json);\nconst by = {};\nfor (const i of items) (by[i.supplier] ||= []).push(i);\nconst out = [];\nfor (const [supplier, rows] of Object.entries(by)) {\n  const value = rows.reduce((s,r)=>s+r.line_value,0);\n  const maxLead = Math.max(...rows.map(r=>r.lead_days||0));\n  let m = '*DRAFT PO — '+supplier+'*\\n'+new Date().toISOString().slice(0,10)+'\\n\\n';\n  for (const r of rows) {\n    m += `• ${r.item} — order *${r.order_qty}*\\n`;\n    m += `   have ${r.qty} · reorder at ${r.reorder_point}`;\n    if (r.basis === 'safe-stock')\n      m += ` · demand ${r.avg_daily}/day · safety ${r.safety_stock}`\n         + (r.days_of_cover != null ? ` · ${r.days_of_cover}d cover` : '');\n    else\n      m += ` · ⚠️ NO SALES HISTORY (manual min fallback)`;\n    m += '\\n';\n  }\n  m += `\\n_Lines: ${rows.length} · Est. ${CURRENCY} ${value.toFixed(3)}_`;\n  if (maxLead) m += `\\n_Order ${maxLead}d before you need it_`;\n  const unverified = rows.filter(r=>r.basis!=='safe-stock').length;\n  if (unverified) m += `\\n_${unverified} line(s) lack sales history — reorder `\n    + `points are estimates until they have sold._`;\n  out.push({ json: { supplier, lines: rows.length,\n    value: +value.toFixed(3), message: m, items: rows,\n    unverified_lines: unverified } });\n}\nout.sort((a,b) => b.json.value - a.json.value);\nreturn out;"
      },
      "id": "e544efa3-bcbc-421b-84f0-17cf180560d6",
      "name": "Group by Supplier",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        180
      ]
    },
    {
      "parameters": {
        "chatId": "YOUR_OWNER_CHAT_ID",
        "text": "={{ $json.message }}",
        "additionalFields": {}
      },
      "id": "16382b12-3662-49a3-85c1-28f10d544420",
      "name": "Send to Owner",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1280,
        120
      ],
      "notes": "Telegram is free; swap to WhatsApp if you prefer"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "YOUR_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "PurchaseOrders",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {}
        },
        "options": {}
      },
      "id": "da88e11c-b273-416f-be2d-cc40bec47171",
      "name": "Log Draft POs",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 1,
      "position": [
        1280,
        340
      ]
    }
  ],
  "connections": {
    "Daily 7am": {
      "main": [
        [
          {
            "node": "Read Stock",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Rules",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Sales History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Rules": {
      "main": [
        [
          {
            "node": "Calc Reorder Points",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Sales History": {
      "main": [
        [
          {
            "node": "Calc Reorder Points",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Stock": {
      "main": [
        [
          {
            "node": "Calc Reorder Points",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calc Reorder Points": {
      "main": [
        [
          {
            "node": "Group by Supplier",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Group by Supplier": {
      "main": [
        [
          {
            "node": "Send to Owner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to Owner": {
      "main": [
        [
          {
            "node": "Log Draft POs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "instanceId": "mauitechhub-retail-ops"
  }
}