{
  "name": "QSR Inventory Reorder Alert (POS Export to Manager Approval)",
  "nodes": [
    {
      "parameters": {},
      "id": "trigger-manual",
      "name": "Run Once (manual)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        120,
        220
      ],
      "notes": "Demo entry point. In production, swap for the Schedule Trigger below."
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 24,
              "triggerAtHour": 9,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "trigger-schedule",
      "name": "Daily 09:00 Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        120,
        380
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "fileSelector": "/var/qsr/inbox/{{ $now.format('yyyy_MM_dd') }}_inventory.csv",
        "options": {}
      },
      "id": "read-file",
      "name": "Read Daily POS Export",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        360,
        300
      ],
      "notes": "Manager drops the POS export here. We chose file-drop over POS API write-access so we cannot break the live POS. For the demo, point this at the sample_pos_export.csv on your machine."
    },
    {
      "parameters": {
        "operation": "csv",
        "options": {
          "headerRow": true
        }
      },
      "id": "extract-csv",
      "name": "Parse CSV Rows",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        580,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Aggregate all parsed rows into a single payload for the LLM.\nconst rows = $input.all().map(i => i.json);\nreturn [{ json: { rows } }];"
      },
      "id": "code-aggregate",
      "name": "Aggregate rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        800,
        300
      ]
    },
    {
      "parameters": {
        "resource": "chat",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "value": "gpt-4o-mini",
          "mode": "list"
        },
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "You normalize messy QSR POS inventory rows. Input is JSON: { rows: [...] } where each row has sku_raw, item_name_raw, uom, on_hand, par_level. Output a JSON array {canonical_sku, canonical_name, on_hand_units, par_units, deficit, action_required}. Rules: (1) Treat 'Napkins 500pk', 'Napkin 500-count', 'NPK-500', 'NAP500' as the same canonical SKU; collapse duplicates by summing on_hand. (2) Convert uom to a single canonical unit per item. (3) If on_hand or par_level is missing on ANY of the duplicate rows, set action_required='manager_input_needed'. (4) deficit = max(0, par_units - on_hand_units). (5) action_required='reorder' if deficit > 0 AND no missing input, else 'ok'. Return ONLY valid JSON. No prose, no code fences."
            },
            {
              "role": "user",
              "content": "={{ JSON.stringify($json.rows) }}"
            }
          ]
        },
        "options": {
          "temperature": 0
        }
      },
      "id": "openai-normalize",
      "name": "AI Normalize + Deficit Calc",
      "type": "n8n-nodes-base.openAi",
      "typeVersion": 1.8,
      "position": [
        1020,
        300
      ],
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const raw = ($input.first().json.message?.content) || $input.first().json.text || $input.first().json.output || '[]';\nlet rows;\ntry { rows = JSON.parse(raw); } catch(e) {\n  const m = raw.match(/\\[[\\s\\S]*\\]/);\n  rows = m ? JSON.parse(m[0]) : [];\n}\nconst reorder = rows.filter(r => r.action_required === 'reorder');\nconst needs_input = rows.filter(r => r.action_required === 'manager_input_needed');\nreturn [{ json: { reorder, needs_input, count_reorder: reorder.length, count_needs_input: needs_input.length } }];"
      },
      "id": "code-split",
      "name": "Split: Reorder vs Needs Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1240,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "has-reorder",
              "leftValue": "={{ $json.count_reorder }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "id": "if-reorder",
      "name": "Anything to reorder?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1460,
        300
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "#qsr-reorder",
          "mode": "name"
        },
        "text": "=:warning: *Low Stock Alert* \u2014 {{ $json.count_reorder }} items below par.\n\n```\n{{ $json.reorder.map(r => `${r.canonical_name}: on_hand=${r.on_hand_units}, par=${r.par_units}, deficit=${r.deficit}`).join('\\n') }}\n```\n\nApprove to add to today's vendor draft: <APPROVE_LINK> \u00b7 Decline: <DECLINE_LINK>\n\n_This message is a draft only. No order will be placed until a manager clicks Approve._",
        "otherOptions": {}
      },
      "id": "slack-alert",
      "name": "Slack Alert (Manager Approval)",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1680,
        200
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "fromEmail": "ops@example-qsr.com",
        "toEmail": "manager@example-qsr.com",
        "subject": "=QSR reorder draft \u2014 {{ $json.count_reorder }} items need approval",
        "emailFormat": "text",
        "text": "={{ $json.reorder.map(r => `- ${r.canonical_name}: on_hand=${r.on_hand_units}, par=${r.par_units}, reorder ${r.deficit}`).join('\\n') }}\n\nReply APPROVE to send to US Foods draft. Reply DECLINE to ignore.\nNo order is placed automatically.",
        "options": {}
      },
      "id": "email-alert",
      "name": "Email Alert (fallback)",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1680,
        360
      ],
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// HUMAN-IN-THE-LOOP CHECKPOINT.\n// This workflow intentionally STOPS here. It never writes to the POS, never confirms\n// a vendor order, never moves money. A manager must approve via the Slack button\n// or email reply. Approval triggers a separate workflow (out of scope for this demo)\n// that drafts the vendor order.\nreturn $input.all();"
      },
      "id": "code-stop",
      "name": "Human Approval Required (Stop)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1900,
        280
      ]
    }
  ],
  "connections": {
    "Run Once (manual)": {
      "main": [
        [
          {
            "node": "Read Daily POS Export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily 09:00 Trigger": {
      "main": [
        [
          {
            "node": "Read Daily POS Export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Daily POS Export": {
      "main": [
        [
          {
            "node": "Parse CSV Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CSV Rows": {
      "main": [
        [
          {
            "node": "Aggregate rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate rows": {
      "main": [
        [
          {
            "node": "AI Normalize + Deficit Calc",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Normalize + Deficit Calc": {
      "main": [
        [
          {
            "node": "Split: Reorder vs Needs Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split: Reorder vs Needs Input": {
      "main": [
        [
          {
            "node": "Anything to reorder?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything to reorder?": {
      "main": [
        [
          {
            "node": "Slack Alert (Manager Approval)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email Alert (fallback)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Human Approval Required (Stop)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Alert (Manager Approval)": {
      "main": [
        [
          {
            "node": "Human Approval Required (Stop)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email Alert (fallback)": {
      "main": [
        [
          {
            "node": "Human Approval Required (Stop)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [
    "qsr",
    "inventory",
    "human-in-the-loop",
    "openclaw"
  ],
  "meta": {
    "n8n_min_version": "1.50.0",
    "version_notes": "Demo workflow. Manual or daily-schedule trigger -> read CSV from disk -> AI SKU normalization -> deficit math -> manager-approval alert (Slack + email). No POS writes, no auto-orders. Replace REPLACE_WITH_YOUR_*_CRED_ID with your own n8n credential IDs."
  }
}