{
  "name": "ERP Order Automation [DEBUG - local claude CLI]",
  "nodes": [
    {
      "parameters": {
        "event": "messageReceived",
        "output": "raw",
        "filters": {
          "foldersToInclude": [
            "inbox"
          ]
        },
        "options": {
          "downloadAttachments": false
        },
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        }
      },
      "id": "aaaa0001-0001-0001-0001-000000000001",
      "name": "Outlook Trigger - Incoming Order",
      "type": "n8n-nodes-base.microsoftOutlookTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "credentials": {
        "microsoftOutlookOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "POLLS EVERY HOUR and returns EVERY new message since the last poll - not just the newest one.\n\nThis only runs when the workflow is ACTIVE. 'Test workflow' waits for the next incoming mail and will look dead if you send the test email first - that is why your test did not fire.\n\nWhile testing, set pollTimes to 'everyMinute' for fast feedback, then put it back to everyHour.\n\nIn production point folderId at a dedicated 'Orders' folder fed by an Outlook rule, instead of 'inbox'."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c3",
              "name": "notifyEmail",
              "value": "mc.mack786@gmail.com",
              "type": "string"
            },
            {
              "id": "c4",
              "name": "reviewQueueEmail",
              "value": "mc.mack786@gmail.com",
              "type": "string"
            },
            {
              "id": "c8",
              "name": "customerDomains",
              "value": "",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "aaaa0002-0002-0002-0002-000000000002",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        280,
        0
      ],
      "notes": "Values read by the pipeline: notifyEmail / reviewQueueEmail (where the two notification emails go), customerDomains (sender allowlist for the inbox gate - comma-separated, blank allows any sender)."
    },
    {
      "parameters": {
        "jsCode": "// Gate the inbox. An attachment ALONE is not an order - a bank statement, an invoice\n// and a newsletter all have attachments. Require an actual order signal, and honour a\n// sender allowlist when one is configured.\nconst cfg = $('Config').first().json;\n\nconst domains = String(cfg.customerDomains || '')\n  .split(',').map(d => d.trim().toLowerCase()).filter(Boolean);\n\n// Words that appear in a purchase order and not in a bank statement.\nconst ORDER_WORDS = /\\b(purchase\\s*order|sales\\s*order|\\bP\\.?O\\.?\\s*(no|number|#)|\\bLPO\\b|order\\s*(no|number|#|date|confirmation)|requisition|please\\s+(supply|deliver|arrange)|kindly\\s+(supply|deliver)|item\\s*code|unit\\s*price|qty\\b|quantity)/i;\n\n// Words that mean \"this is finance/marketing mail\", not a customer order.\n// Bare 'receipt' is a trap: a real PO says \"confirm receipt of this order\".\n// Every entry here must be a phrase a customer PO would never contain.\nconst NOT_ORDER = /\\b(statement of account|bank statement|credit card|e-?statement|payment advice|remittance advice|salary|payslip|newsletter|unsubscribe|invoice\\s*(no|number|#)|tax invoice|(payment|cash|delivery)\\s+receipt|otp|verification code)\\b/i;\n\nconst kept = [];\nconst dropped = [];\n\nfor (const item of $input.all()) {\n  const m = item.json;\n  const sender  = (m.from?.emailAddress?.address || '').toLowerCase();\n  const subject = m.subject || '';\n  const body    = m.bodyPreview || m.body?.content || '';\n  const text    = `${subject}\\n${body}`;\n\n  // 0. OUR OWN mail. The notifications this workflow sends land in the very inbox it watches,\n  //    and they are full of order words, so the gate below would happily feed them back in and\n  //    fail on them forever. Nothing we send is ever an order.\n  if (/^(Purchase order intake FAILED|Purchase order extracted)/i.test(subject)) {\n    dropped.push(`${sender}: this workflow's own notification - not an order`);\n    continue;\n  }\n\n  // 1. Allowlist, when configured. A bank cannot forge your customer's domain.\n  if (domains.length && !domains.some(d => sender.endsWith('@' + d) || sender.endsWith('.' + d))) {\n    dropped.push(`${sender}: not a customer domain`);\n    continue;\n  }\n\n  // 2. Explicit non-order mail is out - UNLESS the subject line itself names an\n  //    order. A PO whose body happens to say \"confirm receipt\" or \"credit card\"\n  //    must not lose to a noise word; the subject is the stronger signal.\n  const subjectIsOrder = ORDER_WORDS.test(subject);\n  if (NOT_ORDER.test(text) && !subjectIsOrder) {\n    dropped.push(`${sender}: looks like statement/invoice/marketing, not an order`);\n    continue;\n  }\n\n  // 3. Must actually look like an order. An attachment on its own proves nothing.\n  if (!ORDER_WORDS.test(text)) {\n    dropped.push(`${sender}: no order wording in subject or body`);\n    continue;\n  }\n\n  kept.push(item);\n}\n\nif (dropped.length) console.log('Dropped ' + dropped.length + ' message(s):\\n' + dropped.join('\\n'));\n\nreturn kept;"
      },
      "id": "aaaa0003-0003-0003-0003-000000000003",
      "name": "Looks Like An Order?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        0
      ],
      "notes": "THE INBOX GATE. An attachment alone is NOT an order - a bank statement, an invoice and a newsletter all have attachments. Requires real order wording, explicitly rejects statements/invoices/marketing, and honours the customerDomains allowlist in Config. Nothing past this point costs an LLM call.\n\nBetter still: create an 'Orders' folder in Outlook, add a rule that files customer mail into it, and point the trigger's folderId at that folder instead of 'inbox'. Then this gate is a backstop, not the front line.",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "messageAttachment",
        "operation": "getAll",
        "messageId": "={{ $json.id }}",
        "returnAll": true,
        "options": {}
      },
      "id": "aaaa0004-0004-0004-0004-000000000004",
      "name": "List Attachments",
      "type": "n8n-nodes-base.microsoftOutlook",
      "typeVersion": 2,
      "position": [
        1400,
        -95
      ],
      "credentials": {
        "microsoftOutlookOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// One email per loop iteration - read it from the loop, NOT from the gate\n// (the gate still holds ALL emails; .first() on it would return email #1 every time).\n// An order mail carries the order document plus logos, signature images, terms sheets.\n// Pick the real one. Customers send PDF, Excel, Word, or a photo/scan.\nconst atts = $input.all().map(i => i.json);\nconst mail = $('Loop Over Emails').first().json;\n\nconst kindOf = a => {\n  const t = (a.contentType || '').toLowerCase();\n  const n = (a.name || '').toLowerCase();\n  if (t.includes('pdf') || n.endsWith('.pdf')) return 'pdf';\n  if (t.includes('spreadsheet') || t.includes('excel') || /\\.xlsx?$/.test(n)) return 'xlsx';\n  if (t.includes('wordprocessing') || t.includes('msword') || /\\.docx?$/.test(n)) return 'docx';\n  if (t.includes('image')) return 'image';\n  return null;\n};\n\n// Real documents outrank pictures. A big image is probably a scan/photo of an order;\n// a small one is a logo in the signature block.\nconst rank = a => {\n  const k = kindOf(a);\n  if (k === 'pdf' || k === 'xlsx' || k === 'docx') return 3;\n  if (k === 'image' && (a.size || 0) > 40000) return 1;\n  return 0;\n};\n\nconst best = atts.filter(a => !a.isInline && rank(a) > 0)\n  .sort((a, b) => rank(b) - rank(a) || (b.size || 0) - (a.size || 0))[0];\n\nconst base = {\n  sourceEmailId: mail.id,\n  customerEmail: mail.from?.emailAddress?.address || null,\n  customerNameHint: mail.from?.emailAddress?.name || null,\n  subject: mail.subject || '',\n  receivedAt: mail.receivedDateTime || null\n};\n\n// Attachments exist but none is a readable order document (e.g. only a logo).\n// Fall back to the email body rather than giving up - the order may be typed there.\nif (!best) {\n  return [{ json: { ...base,\n  _step: '1 PICK  FAILED - no usable attachment (logos and signature images do not count)',\n  docKind: 'none', error: 'NO_USABLE_ATTACHMENT', isValid: false,\n    missingFields: ['no readable order document attached (PDF, Excel, Word or a scan)'] } }];\n}\n\nreturn [{ json: { ...base,\n  _step: `1 PICK  ${best.name}  (${kindOf(best)}, ${Math.round((best.size || 0) / 1024)} KB)`,\n  attachmentId: best.id, attachmentName: best.name, docKind: kindOf(best) } }];"
      },
      "id": "aaaa0005-0005-0005-0005-000000000005",
      "name": "Pick Order Document",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        0
      ],
      "notes": "Handles every format customers actually send: PDF (text or scanned), Excel/LPO form, Word, and photos/scans. Inline logos and signature images are the usual false positive - filtered by isInline plus a size floor.",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "found",
              "leftValue": "={{ $json.error }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "aaaa0006-0006-0006-0006-000000000006",
      "name": "Document Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1960,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "messageAttachment",
        "operation": "download",
        "messageId": "={{ $json.sourceEmailId }}",
        "attachmentId": "={{ $json.attachmentId }}",
        "binaryPropertyName": "order",
        "options": {}
      },
      "id": "aaaa0007-0007-0007-0007-000000000007",
      "name": "Download Order Document",
      "type": "n8n-nodes-base.microsoftOutlook",
      "typeVersion": 2,
      "position": [
        2240,
        0
      ],
      "credentials": {
        "microsoftOutlookOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict",
                  "version": 2
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "r-xlsx",
                    "leftValue": "={{ $json.docKind }}",
                    "rightValue": "xlsx",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "xlsx"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict",
                  "version": 2
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "r-pdf",
                    "leftValue": "={{ $json.docKind }}",
                    "rightValue": "pdf",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "pdf"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict",
                  "version": 2
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "r-docx",
                    "leftValue": "={{ $json.docKind }}",
                    "rightValue": "docx",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "docx"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "image"
        }
      },
      "id": "aaaa0008-0008-0008-0008-000000000008",
      "name": "Route by Document Kind",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2520,
        0
      ],
      "notes": "0=xlsx (LPO form), 1=pdf (text or scan), 2=docx (Word), fallback 3=image (photo/scan).",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "xlsx",
        "binaryPropertyName": "order",
        "options": {
          "headerRow": false
        }
      },
      "id": "aaaa0009-0009-0009-0009-000000000009",
      "name": "Extract Spreadsheet",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        2800,
        -285
      ],
      "onError": "continueRegularOutput",
      "notes": "headerRow=false on purpose. The LPO template is a FORM, not a table - customer name sits at C5, the item grid starts at row 14. Treating row 1 as headers would mangle it. We hand Claude the raw grid instead."
    },
    {
      "parameters": {
        "jsCode": "// Flatten the spreadsheet grid to text for Claude. The LPO is a form, not a clean\n// table - the customer block, the item grid and the totals all live at fixed cells.\n// Giving Claude the whole grid as rows lets it read it the way a human would.\nconst rows = $input.all().map(i => i.json);\nconst src = $('Pick Order Document').first().json;\n\nconst asText = rows.map((r, n) => {\n  const cells = Object.values(r)\n    .map(v => (v === null || v === undefined) ? '' : String(v).trim())\n    .filter(v => v !== '');\n  return cells.length ? `row${n + 1}: ${cells.join(' | ')}` : null;\n}).filter(Boolean).join('\\n');\n\nreturn [{ json: { ...src,\n  _step: `2 EXTRACT  xlsx -> ${asText.length} chars of text`,\n  text: asText, needsVision: false } }];"
      },
      "id": "aaaa0010-0010-0010-0010-000000000010",
      "name": "Spreadsheet to Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        -95
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "order",
        "options": {}
      },
      "id": "aaaa0011-0011-0011-0011-000000000011",
      "name": "Attempt PDF Text Extraction",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        2800,
        -95
      ],
      "onError": "continueRegularOutput",
      "notes": "onError=continue so an unparseable PDF falls through to the vision path instead of killing the run."
    },
    {
      "parameters": {
        "jsCode": "// A text PDF yields real characters (the St Regis PO gives ~3700).\n// A scan yields almost nothing (the sample SO format gives 1 char) -> send it to vision.\nconst src = $('Pick Order Document').first().json;\nreturn $input.all().map(item => {\n  const text = item.json.text || '';\n  return {\n    json: { ...src,\n      _step: text.trim().length <= 40\n        ? `2 EXTRACT  pdf has no text layer (${text.trim().length} chars) -> sending the page to vision`\n        : `2 EXTRACT  pdf text layer -> ${text.trim().length} chars`,\n      text, needsVision: text.trim().length <= 40 },\n    binary: item.binary\n  };\n});"
      },
      "id": "aaaa0012-0012-0012-0012-000000000012",
      "name": "Text or Scan?",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        95
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Image attachment - straight to vision, no text to try.\nconst src = $('Pick Order Document').first().json;\nreturn $input.all().map(item => ({\n  json: { ...src, text: '', needsVision: true },\n  binary: item.binary\n}));"
      },
      "id": "aaaa0013-0013-0013-0013-000000000013",
      "name": "Image to Vision",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2800,
        95
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict",
                  "version": 2
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "r-vision",
                    "leftValue": "={{ $json.needsVision }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "vision"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "text"
        }
      },
      "id": "aaaa0014-0014-0014-0014-000000000014",
      "name": "Vision or Text?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        3360,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://host.docker.internal:8787/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: 'claude-haiku-4-5',\n  max_tokens: 3000,\n  system: \"You extract data from a CUSTOMER'S PURCHASE ORDER so we, the SUPPLIER, can raise a Sales Order against it. The document was sent TO us BY the customer who wants to buy. Return ONLY a JSON object - no prose, no markdown fences.\\n\\nSchema: {\\\"customer_name\\\":string|null,\\\"customer_po_number\\\":string|null,\\\"order_date\\\":\\\"YYYY-MM-DD\\\"|null,\\\"delivery_date\\\":\\\"YYYY-MM-DD\\\"|null,\\\"currency\\\":string|null,\\\"delivery_address\\\":string|null,\\\"ship_from_location\\\":string|null,\\\"subsidiary\\\":string|null,\\\"line_items\\\":[{\\\"item_code\\\":string|null,\\\"item_description\\\":string,\\\"quantity\\\":number,\\\"uom\\\":string|null,\\\"unit_price\\\":number,\\\"line_total\\\":number|null}],\\\"subtotal\\\":number|null,\\\"tax_total\\\":number|null,\\\"grand_total\\\":number|null,\\\"confidence\\\":\\\"high\\\"|\\\"medium\\\"|\\\"low\\\",\\\"extraction_notes\\\":string}\\n\\nRules:\\n- customer_name is the BUYER (the hotel/company placing the order), never the supplier. If the document names both a supplier and a customer, take the customer.\\n- customer_po_number is the buyer's own PO/LPO reference.\\n- Strip currency symbols and thousands separators from numbers: 'AED55.0000' -> 55. Quantities like '5.00' -> 5.\\n- Capture EVERY line item. Do not summarise or truncate the list.\\n- ship_from_location and subsidiary: SOME purchase orders name the supplier's warehouse/location and legal entity to ship from. Copy them VERBATIM if present. Never infer or invent them - null if the document does not say.\\n- Use null for anything not present. Never invent a value.\\n- Set confidence=low if prices or quantities are unclear, if the line items are unreadable, or if this does not look like a purchase order.\",\n  messages: [{ role: 'user', content: 'Sender: ' + ($json.customerEmail || 'unknown') + ' (' + ($json.customerNameHint || '') + ')\\nSubject: ' + ($json.subject || '') + '\\n\\nDocument:\\n' + ($json.text || '') }]\n}) }}",
        "options": {}
      },
      "id": "aaaa0015-0015-0015-0015-000000000015",
      "name": "Claude - Text Extraction",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        -95
      ],
      "notes": "[DEBUG] local claude_shim.js \u2014 no API tokens burned. Anthropic key is hardcoded in the x-api-key header. Rotate it at console.anthropic.com and update it here. Body via JSON.stringify so quotes/newlines in the order text can't break the payload.",
      "onError": "continueErrorOutput",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://host.docker.internal:8787/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: 'claude-haiku-4-5',\n  max_tokens: 3000,\n  system: \"You extract data from a CUSTOMER'S PURCHASE ORDER so we, the SUPPLIER, can raise a Sales Order against it. The document was sent TO us BY the customer who wants to buy. Return ONLY a JSON object - no prose, no markdown fences.\\n\\nSchema: {\\\"customer_name\\\":string|null,\\\"customer_po_number\\\":string|null,\\\"order_date\\\":\\\"YYYY-MM-DD\\\"|null,\\\"delivery_date\\\":\\\"YYYY-MM-DD\\\"|null,\\\"currency\\\":string|null,\\\"delivery_address\\\":string|null,\\\"ship_from_location\\\":string|null,\\\"subsidiary\\\":string|null,\\\"line_items\\\":[{\\\"item_code\\\":string|null,\\\"item_description\\\":string,\\\"quantity\\\":number,\\\"uom\\\":string|null,\\\"unit_price\\\":number,\\\"line_total\\\":number|null}],\\\"subtotal\\\":number|null,\\\"tax_total\\\":number|null,\\\"grand_total\\\":number|null,\\\"confidence\\\":\\\"high\\\"|\\\"medium\\\"|\\\"low\\\",\\\"extraction_notes\\\":string}\\n\\nRules:\\n- customer_name is the BUYER (the hotel/company placing the order), never the supplier.\\n- Strip currency symbols and separators from numbers: 'AED55.0000' -> 55.\\n- Capture EVERY line item, including ones that continue onto a second page.\\n- ship_from_location and subsidiary: SOME purchase orders name the supplier's warehouse/location and legal entity to ship from. Copy them VERBATIM if present. Never infer or invent them - null if the document does not say.\\n- Use null for anything not present. Never invent a value.\\n- Set confidence=low if the scan is unclear or any price/quantity is a guess.\",\n  messages: [{\n    role: 'user',\n    content: [\n      { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: $binary.order.data } },\n      { type: 'text', text: 'Sender: ' + ($json.customerEmail || 'unknown') + '. Extract the purchase order per the schema.' }\n    ]\n  }]\n}) }}",
        "options": {}
      },
      "id": "aaaa0016-0016-0016-0016-000000000016",
      "name": "Claude - Vision Extraction",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3640,
        95
      ],
      "notes": "[DEBUG] local claude_shim.js \u2014 no API tokens burned. Anthropic key is hardcoded in the x-api-key header. Rotate it at console.anthropic.com and update it here. Body via JSON.stringify so quotes/newlines in the order text can't break the payload.",
      "onError": "continueErrorOutput",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "mode": "append",
        "numberInputs": 2,
        "options": {}
      },
      "id": "aaaa0017-0017-0017-0017-000000000017",
      "name": "Merge Extraction Branches",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        3920,
        0
      ],
      "notes": "append, NOT chooseBranch - chooseBranch discards whichever branch didn't run."
    },
    {
      "parameters": {
        "jsCode": "// Strip fences, parse, clean the numbers. Claude is told to strip currency symbols,\n// but the samples are full of 'AED55.0000' and '5.00' so never trust that - coerce here.\n// An order reaches us one of two ways: as an attachment (Pick Order Document ->\n// pdf/xlsx/docx/image) or typed into the email body (Email Body as Order). Only ONE\n// of those nodes runs, and $() on a node that did not run throws. This node sits\n// after the merge, on the path BOTH branches share, so it must ask for whichever\n// one actually executed rather than assume the attachment branch.\nconst upstream = name => {\n  try { return $(name).first().json; } catch { return null; }\n};\nconst src = upstream('Pick Order Document') ?? upstream('Email Body as Order') ?? {};\n\nconst num = v => {\n  if (typeof v === 'number') return Number.isFinite(v) ? v : null;\n  if (v === null || v === undefined) return null;\n  const cleaned = String(v).replace(/[^0-9.\\-]/g, '');   // 'AED55.0000' -> '55.0000'\n  const n = parseFloat(cleaned);\n  return Number.isFinite(n) ? n : null;\n};\n\nreturn $input.all().map(item => {\n  let raw = item.json.content?.[0]?.text ?? '';\n  raw = String(raw).replace(/```json|```/g, '').trim();\n\n  let e;\n  try {\n    e = JSON.parse(raw);\n  } catch (err) {\n    e = { confidence: 'low', extraction_notes: 'PARSE_FAILURE', line_items: [], raw };\n  }\n\n  e.line_items = (Array.isArray(e.line_items) ? e.line_items : []).map(li => ({\n    ...li,\n    quantity: num(li.quantity),\n    unit_price: num(li.unit_price),\n    line_total: num(li.line_total)\n  }));\n  e.subtotal = num(e.subtotal);\n  e.tax_total = num(e.tax_total);\n  e.grand_total = num(e.grand_total);\n\n  // The sender's display name is a strong customer hint - it is who actually emailed us.\n  if (!e.customer_name && src.customerNameHint) e.customer_name = src.customerNameHint;\n\n  const lines = (e.line_items || []).length;\n  const step = e.extraction_notes === 'PARSE_FAILURE'\n    ? '3 READ DOC  FAILED - Claude did not return valid JSON (see extracted.raw)'\n    : `3 READ DOC  ${e.customer_name || 'NO CUSTOMER NAME'} | PO ${e.customer_po_number || 'none'} | `\n      + `${lines} line${lines === 1 ? '' : 's'} | total ${e.currency || ''} ${e.grand_total ?? '?'} | confidence ${e.confidence}`;\n  console.log(step);\n  console.log('   raw JSON from Claude:', JSON.stringify(e));\n  return { json: { _step: step, extracted: e, ...src } };\n});"
      },
      "id": "aaaa0018-0018-0018-0018-000000000018",
      "name": "Parse Extraction JSON",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4200,
        0
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-att",
              "leftValue": "={{ $json.hasAttachments }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "options": {}
      },
      "id": "aaaa0029-0029-0029-0029-000000000029",
      "name": "Attachment or Body?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1120,
        0
      ],
      "notes": "true -> the order is an attached file. false -> the order is typed in the email body itself.",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// One email per loop iteration - read it from the loop, NOT from the gate.\n// The order is typed straight into the email body - no attachment.\n// Strip HTML and quoted history, then treat it exactly like extracted document text.\nconst m = $('Loop Over Emails').first().json;\n\nconst stripHtml = h => String(h || '')\n  .replace(/<br\\s*\\/?>/gi, '\\n')\n  .replace(/<\\/(p|div|tr|li)>/gi, '\\n')\n  .replace(/<\\/td>/gi, ' | ')\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&nbsp;/gi, ' ')\n  .replace(/&amp;/gi, '&')\n  .replace(/[ \\t]+/g, ' ');\n\nconst raw = m.body?.content || m.bodyPreview || '';\nconst isHtml = (m.body?.contentType || '').toLowerCase() === 'html';\nlet text = isHtml ? stripHtml(raw) : String(raw);\n\n// Cut quoted history - an old order in the chain must not be re-read as a new one.\nfor (const re of [/-----\\s*Original Message\\s*-----/i, /_{10,}/, /On .{0,100}\\bwrote:/i]) {\n  const hit = text.match(re);\n  if (hit) text = text.slice(0, hit.index);\n}\n\nreturn [{ json: {\n  _step: `2 EXTRACT  no attachment - read the email body itself -> ${text.trim().length} chars`,\n  sourceEmailId: m.id,\n  customerEmail: m.from?.emailAddress?.address || null,\n  customerNameHint: m.from?.emailAddress?.name || null,\n  subject: m.subject || '',\n  attachmentName: '(email body)',\n  docKind: 'body',\n  text: text.trim(),\n  needsVision: false\n} }];"
      },
      "id": "aaaa0030-0030-0030-0030-000000000030",
      "name": "Email Body as Order",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1400,
        95
      ],
      "notes": "Plain-text orders in the mail body. Quoted history is cut so an older order further down the chain is not re-read as a new one.",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// .docx is a ZIP containing word/document.xml. Node's builtin zlib can inflate it,\n// so no new dependency - but n8n must run with NODE_FUNCTION_ALLOW_BUILTIN=crypto,zlib.\n//\n// .doc (old binary Word) is NOT a zip and cannot be read this way - it routes to\n// manual review with a clear reason rather than producing garbage.\nconst zlib = require('zlib');\n\nconst src = $('Pick Order Document').first().json;\nconst bin = $input.first().binary?.order;\n\nconst fail = reason => [{ json: { ...src, error: 'DOCX_UNREADABLE', isValid: false,\n  missingFields: [reason], text: '', needsVision: false } }];\n\nif (!bin?.data) return fail('Word attachment could not be downloaded');\n\nconst buf = Buffer.from(bin.data, 'base64');\n\n// A ZIP always starts 'PK'. A legacy .doc starts with an OLE2 signature.\nif (buf[0] !== 0x50 || buf[1] !== 0x4b) {\n  return fail('Legacy .doc format (not .docx) - cannot be read automatically. Ask the customer to send PDF or .docx.');\n}\n\n// Walk the ZIP central directory to find word/document.xml.\nconst EOCD = 0x06054b50;\nlet eocd = -1;\nfor (let i = buf.length - 22; i >= 0 && i > buf.length - 65558; i--) {\n  if (buf.readUInt32LE(i) === EOCD) { eocd = i; break; }\n}\nif (eocd < 0) return fail('Word file is not a readable .docx (no ZIP directory)');\n\nlet p = buf.readUInt32LE(eocd + 16);          // start of central directory\nconst count = buf.readUInt16LE(eocd + 10);\nlet entry = null;\n\nfor (let i = 0; i < count; i++) {\n  if (buf.readUInt32LE(p) !== 0x02014b50) break;\n  const method    = buf.readUInt16LE(p + 10);\n  const compSize  = buf.readUInt32LE(p + 20);\n  const nameLen   = buf.readUInt16LE(p + 28);\n  const extraLen  = buf.readUInt16LE(p + 30);\n  const cmtLen    = buf.readUInt16LE(p + 32);\n  const localOff  = buf.readUInt32LE(p + 42);\n  const name      = buf.toString('utf8', p + 46, p + 46 + nameLen);\n  if (name === 'word/document.xml') { entry = { method, compSize, localOff }; break; }\n  p += 46 + nameLen + extraLen + cmtLen;\n}\nif (!entry) return fail('No document body found inside the Word file');\n\n// Local header: skip its variable-length name + extra to reach the compressed bytes.\nconst lo = entry.localOff;\nconst dataStart = lo + 30 + buf.readUInt16LE(lo + 26) + buf.readUInt16LE(lo + 28);\nconst raw = buf.subarray(dataStart, dataStart + entry.compSize);\n\nlet xml;\ntry {\n  xml = (entry.method === 8 ? zlib.inflateRawSync(raw) : raw).toString('utf8');\n} catch (e) {\n  return fail('Word file could not be decompressed: ' + e.message);\n}\n\n// Word XML -> text. Keep paragraph and table-cell structure: a PO in a Word table\n// is unreadable as one run-on line.\nconst text = xml\n  .replace(/<w:tab[^>]*\\/>/g, '\\t')\n  .replace(/<\\/w:tc>/g, ' | ')\n  .replace(/<\\/w:p>/g, '\\n')\n  .replace(/<w:br[^>]*\\/>/g, '\\n')\n  .replace(/<[^>]+>/g, '')\n  .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n  .replace(/[ \\t]+/g, ' ')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .trim();\n\nif (text.length < 20) return fail('Word file has no readable text');\n\nreturn [{ json: { ...src, text, needsVision: false } }];"
      },
      "id": "aaaa0031-0031-0031-0031-000000000031",
      "name": "Extract Word Document",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2800,
        285
      ],
      "notes": "docx = ZIP of XML, inflated with builtin zlib (needs NODE_FUNCTION_ALLOW_BUILTIN=crypto,zlib). Legacy .doc is detected and sent to manual review instead of producing garbage.",
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "aaaa0032-0032-0032-0032-000000000032",
      "name": "Loop Over Emails",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        840,
        0
      ],
      "notes": "Feeds ONE email at a time through the whole pipeline, then loops back for the next.\n\nWithout this, a poll that returns 5 orders creates 1 sales order and throws 4 away - every downstream node uses .first(), which only ever sees item 0. Each email now gets its own extraction, its own lookups and its own SO, and one bad email cannot take the others down with it."
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "toRecipients": "={{ $('Config').first().json.reviewQueueEmail }}",
        "subject": "={{ $json.subject }}",
        "bodyContent": "={{ $json.body }}",
        "additionalFields": {
          "bodyContentType": "html"
        }
      },
      "type": "n8n-nodes-base.microsoftOutlook",
      "typeVersion": 2,
      "position": [
        6660,
        220
      ],
      "id": "notify-manual-review-0001",
      "name": "Notify - Manual Review",
      "credentials": {
        "microsoftOutlookOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// EVERY failure in this workflow lands here: an unreadable document, a Claude call that died,\n// a document Claude could not extract a usable order from. They fail at DIFFERENT depths, so\n// most of the nodes upstream may never have run, and reading an unexecuted node THROWS in n8n.\n// So every lookup is guarded and the whole email is built here rather than in expressions on\n// the Outlook node - an expression that throws would take down the very notification that is\n// supposed to report the failure.\n// first() resolves the item PAIRED to this failure, which is what we want when several\n// emails are in one poll. But it THROWS on a node whose items cannot be paired back (the\n// inbox gate, which fans many mails into one output), and a throw here would have read as\n// \"that step never ran\" - which is how the email came to name the wrong step. Fall back to\n// the raw output before giving up on the node.\nconst safe = (name) => {\n  try { return $(name).first().json; } catch {}\n  try { const all = $(name).all(); return all.length ? all[0].json : null; } catch { return null; }\n};\n\nconst mail = safe('Outlook Trigger - Incoming Order') || {};\nconst src  = safe('Parse Extraction JSON') || {};\nconst res  = $input.first().json || {};\n\n// WHERE it died. n8n does not hand the error branch the name of the node that threw, so we\n// infer it: walk the pipeline in order, and the last step that actually produced data is the\n// last one that worked. The failure is at the step after it. Branch nodes that were never on\n// this document's path simply never executed, which is exactly what we want them to report.\nconst STEPS = [\n  ['Outlook Trigger - Incoming Order',            'reading the email'],\n  ['Looks Like An Order?',                        'deciding whether the email is an order'],\n  ['List Attachments',                            'listing the email attachments'],\n  ['Pick Order Document',                         'choosing which attachment is the order'],\n  ['Download Order Document',                     'downloading the order document'],\n  ['Email Body as Order',                         'reading the order out of the email body'],\n  ['Extract Spreadsheet',                         'reading the spreadsheet'],\n  ['Attempt PDF Text Extraction',                 'reading the text layer of the PDF'],\n  ['Extract Word Document',                       'reading the Word document'],\n  ['Image to Vision',                             'preparing the scanned image'],\n  ['Claude - Text Extraction',                    'asking Claude to read the order'],\n  ['Claude - Vision Extraction',                  'asking Claude to read the scanned order'],\n  ['Parse Extraction JSON',                       'parsing what Claude returned'],\n  ['Format Extracted Order',                      'formatting the extracted order'],\n];\n// Branches leave GAPS - a PDF never runs the spreadsheet reader - so the first unexecuted step\n// is not the failure. The failure is the step after the LAST one that ran.\nlet lastIdx = -1;\nSTEPS.forEach(([name], i) => { if (safe(name)) lastIdx = i; });\nconst lastOk = lastIdx >= 0 ? STEPS[lastIdx] : null;\nconst failedAt = STEPS[lastIdx + 1] || null;\n// Even that is a guess. Prefer the node n8n names, when it names one.\nconst named = res.error?.node?.name || res.error?.nodeName || null;\nconst step = named ? [named, (STEPS.find(s => s[0] === named) || [])[1] || 'running'] : failedAt;\n\n// The raw error, wherever this version of n8n hid it.\nconst rawErr = res.error?.message || res.error?.description ||\n  (typeof res.error === 'string' ? res.error : null) || res.message || null;\n\n// This stage only reads a document and asks Claude to extract it - there is no business\n// decision to be blocked on here, only \"it read cleanly\" or \"something broke\". A thrown\n// error is a technical fault; no document/no usable extraction is the closest thing to a\n// business reason this stage can give, and it is reported as \"unreadable\", not \"blocked\".\nconst kind = rawErr ? 'error' : 'unreadable';\nconst reasons = kind === 'error' ? [rawErr]\n  : [res.message || 'the order document could not be read or Claude could not extract a usable order from it'];\n\nconst at = step;\n\nconst customer = src.extracted?.customer_name || 'not identified';\nconst poNumber = src.extracted?.customer_po_number || 'not identified';\n\n// The rendering half of the \"Build Review Email\" node. patch_review_html.js splices it onto\n// the node's logic half (nodes/build_review_email.logic.js) inside the workflow JSON, so this\n// file is the thing you edit - never the JSON.\n//\n// Inline styles only: Outlook and Gmail both strip a <style> block, so a stylesheet arrives as\n// an unstyled wall of text. Tables, not flexbox, for the same reason.\nconst esc = s => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\nconst RED = '#b42318', SLATE = '#475467', LINE = '#e4e7ec', BG = '#f9fafb';\nconst accent = RED;\n\nconst mono = s => `<span style=\"font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px\">${esc(s)}</span>`;\n\nconst row = (k, v) => (v == null || v === '') ? '' : `<tr>\n  <td style=\"padding:6px 16px 6px 0;color:${SLATE};font-size:13px;white-space:nowrap;vertical-align:top\">${esc(k)}</td>\n  <td style=\"padding:6px 0;color:#101828;font-size:13px;vertical-align:top\">${v}</td>\n</tr>`;\n\nconst section = (title, inner) => `<tr><td style=\"padding:22px 28px 0\">\n  <div style=\"font-size:11px;font-weight:700;letter-spacing:.9px;color:${SLATE};text-transform:uppercase\">${esc(title)}</div>\n  <div style=\"height:1px;background:${LINE};margin:8px 0 12px\"></div>\n  ${inner}\n</td></tr>`;\n\n// WHY. One thrown error, verbatim - trimming the text of an exception into prose helps\n// nobody debug it.\nconst why = reasons.map(r => `<div style=\"margin:0 0 6px;padding:10px 12px;background:#fef3f2;border-left:3px solid ${accent};border-radius:0 4px 4px 0\">\n  <span style=\"font-size:13px;color:#101828\">${esc(r)}</span>\n</div>`).join('');\n\n// The lines as the document printed them - this stage only reads and extracts, it does not\n// resolve them against anything, so there is nothing to show them matched to.\nconst lines = src.extracted?.line_items || [];\n\nconst TH = (h, right) => `<th style=\"text-align:${right ? 'right' : 'left'};padding:0 10px 6px 0;font-size:11px;color:${SLATE};font-weight:600;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid ${LINE}\">${h}</th>`;\nconst TD = (v, right) => `<td style=\"padding:8px 10px 8px 0;font-size:13px;color:#101828;text-align:${right ? 'right' : 'left'};border-bottom:1px solid ${BG}\">${v}</td>`;\n\nconst lineTable = !lines.length ? '' : `<table cellspacing=\"0\" cellpadding=\"0\" style=\"width:100%;border-collapse:collapse\">\n  <tr>${TH('#')}${TH('Item code')}${TH('Description')}${TH('Qty', true)}${TH('Unit price', true)}</tr>\n  ${lines.map((li, i) => `<tr>\n    ${TD(String(i))}${TD(mono(li.item_code || '-'))}${TD(esc(li.item_description || ''))}\n    ${TD(esc(li.quantity ?? '?'), true)}${TD(esc(li.unit_price ?? '?'), true)}\n  </tr>`).join('')}\n</table>`;\n\nconst locationOnPo = src.extracted?.ship_from_location;\n\nconst body = `<div style=\"margin:0;padding:24px;background:${BG};font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif\">\n<table cellspacing=\"0\" cellpadding=\"0\" style=\"width:100%;max-width:680px;margin:0 auto;background:#ffffff;border:1px solid ${LINE};border-radius:8px\">\n\n  <tr><td style=\"padding:24px 28px;border-top:3px solid ${accent};border-bottom:1px solid ${LINE}\">\n    <div style=\"font-size:11px;font-weight:700;letter-spacing:.9px;color:${accent};text-transform:uppercase\">Intake failed</div>\n    <div style=\"font-size:20px;font-weight:600;color:#101828;margin-top:6px\">${esc(customer)}</div>\n    <div style=\"font-size:13px;color:${SLATE};margin-top:3px\">Their purchase order ${mono(poNumber)} &middot; could not be extracted</div>\n  </td></tr>\n\n  ${section('What went wrong', why)}\n\n  ${section('Where it failed', `<table cellspacing=\"0\" cellpadding=\"0\">\n    ${row('Step', at ? mono(at[0]) : 'unknown')}\n    ${row('Doing', at ? at[1] : '')}\n    ${row('Note', 'The purchase order may be perfectly fine - something in the extraction pipeline broke.')}\n  </table>`)}\n\n  ${section('The order, as far as we got', `<table cellspacing=\"0\" cellpadding=\"0\">\n    ${row('Customer', esc(customer))}\n    ${row('Their PO number', mono(poNumber))}\n    ${row('Ship-from location', locationOnPo ? esc(locationOnPo) : `<span style=\"color:${SLATE}\">the document does not name one</span>`)}\n    ${row('Document total', src.extracted?.grand_total != null ? `${esc(src.extracted?.currency || '')} ${esc(src.extracted.grand_total)}` : null)}\n    ${row('Extraction confidence', src.extracted?.confidence || null)}\n  </table>`)}\n\n  ${lineTable ? section('The lines, as the document printed them', lineTable) : ''}\n\n  ${section('The email it came from', `<table cellspacing=\"0\" cellpadding=\"0\">\n    ${row('Subject', esc(mail.subject || src.subject || 'unknown'))}\n    ${row('From', esc(mail.from?.emailAddress?.address || src.customerEmail || 'unknown sender'))}\n    ${row('Received', esc(mail.receivedDateTime || 'unknown'))}\n    ${row('Document', esc(src.attachmentName || '(email body)'))}\n  </table>`)}\n\n  ${section('Next step', `<div style=\"font-size:13px;color:#101828;line-height:1.55\">Check the step named above; once the cause is fixed, re-sending the email reprocesses it.</div>`)}\n\n  <tr><td style=\"padding:20px 28px 24px\">\n    <div style=\"height:1px;background:${LINE};margin-bottom:12px\"></div>\n    <div style=\"font-size:11px;color:#98a2b3\">Automated purchase-order intake &middot; sent only when a document could not be read or extracted.</div>\n  </td></tr>\n</table>\n</div>`;\n\nconst subject = 'Purchase order intake FAILED (' + (at ? at[0] : 'unknown step') + ') - ' + customer + ' (PO ' + poNumber + ')';\n\nconsole.log('X ' + kind.toUpperCase() + ' at ' + (at ? at[0] : '?') + ' - ' + reasons.join(' | '));\n\nreturn [{ json: { subject, body, reasons, kind, failedStep: at ? at[0] : null } }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        6300,
        220
      ],
      "id": "build-review-email-0001",
      "name": "Build Review Email"
    },
    {
      "id": "aaaa0028-0028-0028-0028-000000000028",
      "name": "Format Extracted Order",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "onError": "continueErrorOutput",
      "position": [
        5880,
        0
      ],
      "parameters": {
        "jsCode": "// Terminal node of the public pipeline: extraction only. No ERP is resolved or written to\n// here - this node just turns what Claude read off the document into a clean, formatted\n// summary email. Wiring the resolved data into a live ERP (matching the customer/items/\n// location, creating the record) is the part done per engagement, not shipped in this repo.\nconst src = $('Parse Extraction JSON').first().json;\nconst e = src.extracted || {};\n\n// Nothing downstream can do anything useful with an order Claude could not actually read -\n// route it to the review queue same as any other extraction failure, rather than mailing\n// out a \"summary\" of an empty order.\nif (e.extraction_notes === 'PARSE_FAILURE') throw new Error('Claude did not return valid JSON for this document - see extracted.raw');\nif (!(e.line_items || []).length) throw new Error('no line items were extracted from this document');\n\nconst esc = s => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\nconst GREEN = '#067647', SLATE = '#475467', LINE = '#e4e7ec', BG = '#f9fafb';\nconst mono = s => `<span style=\"font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px\">${esc(s)}</span>`;\n\nconst row = (k, v) => (v == null || v === '') ? '' : `<tr>\n  <td style=\"padding:6px 16px 6px 0;color:${SLATE};font-size:13px;white-space:nowrap;vertical-align:top\">${esc(k)}</td>\n  <td style=\"padding:6px 0;color:#101828;font-size:13px;vertical-align:top\">${v}</td>\n</tr>`;\n\nconst section = (title, inner) => `<tr><td style=\"padding:22px 28px 0\">\n  <div style=\"font-size:11px;font-weight:700;letter-spacing:.9px;color:${SLATE};text-transform:uppercase\">${esc(title)}</div>\n  <div style=\"height:1px;background:${LINE};margin:8px 0 12px\"></div>\n  ${inner}\n</td></tr>`;\n\nconst TH = (h, right) => `<th style=\"text-align:${right ? 'right' : 'left'};padding:0 10px 6px 0;font-size:11px;color:${SLATE};font-weight:600;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid ${LINE}\">${h}</th>`;\nconst TD = (v, right) => `<td style=\"padding:8px 10px 8px 0;font-size:13px;color:#101828;text-align:${right ? 'right' : 'left'};border-bottom:1px solid ${BG}\">${v}</td>`;\n\nconst lines = e.line_items || [];\nconst lineTable = `<table cellspacing=\"0\" cellpadding=\"0\" style=\"width:100%;border-collapse:collapse\">\n  <tr>${TH('#')}${TH('Item code')}${TH('Description')}${TH('Qty', true)}${TH('Unit price', true)}${TH('Line total', true)}</tr>\n  ${lines.map((li, i) => `<tr>\n    ${TD(String(i))}${TD(mono(li.item_code || '-'))}${TD(esc(li.item_description || ''))}\n    ${TD(esc(li.quantity ?? '?'), true)}${TD(esc(li.unit_price ?? '?'), true)}${TD(esc(li.line_total ?? '?'), true)}\n  </tr>`).join('')}\n</table>`;\n\nconst body = `<div style=\"margin:0;padding:24px;background:${BG};font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif\">\n<table cellspacing=\"0\" cellpadding=\"0\" style=\"width:100%;max-width:680px;margin:0 auto;background:#ffffff;border:1px solid ${LINE};border-radius:8px\">\n\n  <tr><td style=\"padding:24px 28px;border-top:3px solid ${GREEN};border-bottom:1px solid ${LINE}\">\n    <div style=\"font-size:11px;font-weight:700;letter-spacing:.9px;color:${GREEN};text-transform:uppercase\">Purchase order extracted</div>\n    <div style=\"font-size:20px;font-weight:600;color:#101828;margin-top:6px\">${esc(e.customer_name || 'Unknown customer')}</div>\n    <div style=\"font-size:13px;color:${SLATE};margin-top:3px\">Their purchase order ${mono(e.customer_po_number || 'unknown')}</div>\n  </td></tr>\n\n  ${section('Order summary', `<table cellspacing=\"0\" cellpadding=\"0\">\n    ${row('Customer', esc(e.customer_name))}\n    ${row('Their PO number', mono(e.customer_po_number))}\n    ${row('Ship-from location', e.ship_from_location ? esc(e.ship_from_location) : `<span style=\"color:${SLATE}\">not stated on the document</span>`)}\n    ${row('Document total', e.grand_total != null ? `${esc(e.currency || '')} ${esc(e.grand_total)}` : null)}\n    ${row('Extraction confidence', e.confidence || null)}\n  </table>`)}\n\n  ${section('Line items, as extracted', lineTable)}\n\n  ${section('The email it came from', `<table cellspacing=\"0\" cellpadding=\"0\">\n    ${row('Subject', esc(src.subject || 'unknown'))}\n    ${row('From', esc(src.customerEmail || 'unknown sender'))}\n    ${row('Document', esc(src.attachmentName || '(email body)'))}\n  </table>`)}\n\n  ${section('Next step', `<div style=\"font-size:13px;color:#101828;line-height:1.55\">Matching this against your ERP and creating the order there is built per engagement - reach out and I'll show you a demo.</div>`)}\n\n  <tr><td style=\"padding:20px 28px 24px\">\n    <div style=\"height:1px;background:${LINE};margin-bottom:12px\"></div>\n    <div style=\"font-size:11px;color:#98a2b3\">Automated purchase-order intake &middot; extraction only.</div>\n  </td></tr>\n</table>\n</div>`;\n\nconst subject = `Purchase order extracted - ${e.customer_name || 'unknown customer'} (PO ${e.customer_po_number || 'unknown'})`;\n\nconsole.log(`4 EXTRACTED  ${e.customer_name || 'NO CUSTOMER NAME'} | PO ${e.customer_po_number || 'none'} | ${lines.length} line(s)`);\n\nreturn [{ json: { subject, body, extracted: e, sourceEmailId: src.sourceEmailId } }];"
      }
    },
    {
      "id": "aaaa0027-0027-0027-0027-000000000027",
      "name": "Notify - Order Extracted",
      "type": "n8n-nodes-base.microsoftOutlook",
      "typeVersion": 2,
      "position": [
        6160,
        -160
      ],
      "parameters": {
        "resource": "message",
        "operation": "send",
        "toRecipients": "={{ $('Config').first().json.notifyEmail }}",
        "subject": "={{ $json.subject }}",
        "bodyContent": "={{ $json.body }}",
        "additionalFields": {
          "bodyContentType": "html"
        }
      },
      "credentials": {
        "microsoftOutlookOAuth2Api": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "Outlook Trigger - Incoming Order": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Looks Like An Order?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List Attachments": {
      "main": [
        [
          {
            "node": "Pick Order Document",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Order Document": {
      "main": [
        [
          {
            "node": "Document Found?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Document Found?": {
      "main": [
        [
          {
            "node": "Download Order Document",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Order Document": {
      "main": [
        [
          {
            "node": "Route by Document Kind",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Document Kind": {
      "main": [
        [
          {
            "node": "Extract Spreadsheet",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Attempt PDF Text Extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract Word Document",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Image to Vision",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Spreadsheet": {
      "main": [
        [
          {
            "node": "Spreadsheet to Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Spreadsheet to Text": {
      "main": [
        [
          {
            "node": "Vision or Text?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attempt PDF Text Extraction": {
      "main": [
        [
          {
            "node": "Text or Scan?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text or Scan?": {
      "main": [
        [
          {
            "node": "Vision or Text?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Image to Vision": {
      "main": [
        [
          {
            "node": "Vision or Text?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vision or Text?": {
      "main": [
        [
          {
            "node": "Claude - Vision Extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Claude - Text Extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude - Vision Extraction": {
      "main": [
        [
          {
            "node": "Merge Extraction Branches",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude - Text Extraction": {
      "main": [
        [
          {
            "node": "Merge Extraction Branches",
            "type": "main",
            "index": 1
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Extraction Branches": {
      "main": [
        [
          {
            "node": "Parse Extraction JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Extraction JSON": {
      "main": [
        [
          {
            "node": "Format Extracted Order",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Looks Like An Order?": {
      "main": [
        [
          {
            "node": "Loop Over Emails",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attachment or Body?": {
      "main": [
        [
          {
            "node": "List Attachments",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email Body as Order",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email Body as Order": {
      "main": [
        [
          {
            "node": "Vision or Text?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Word Document": {
      "main": [
        [
          {
            "node": "Vision or Text?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Emails": {
      "main": [
        [],
        [
          {
            "node": "Attachment or Body?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify - Manual Review": {
      "main": [
        [
          {
            "node": "Loop Over Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Review Email": {
      "main": [
        [
          {
            "node": "Notify - Manual Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Extracted Order": {
      "main": [
        [
          {
            "node": "Notify - Order Extracted",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Review Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "note": "The Anthropic key lives in a credential (httpHeaderAuth), not inline here - safe to commit. The Outlook credential still has to be picked from the node dropdowns on import (n8n stores credentials by id, not in the JSON)."
  }
}