{
  "name": "Pipeline Forecast Engine",
  "nodes": [
    {
      "id": "86ad424a-c917-476d-8a74-094d495878d9",
      "name": "Weekly Forecast Run",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        112
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "10 8 * * 1"
            }
          ]
        }
      }
    },
    {
      "id": "036ee4c1-d950-4d46-b682-23e5eb33b595",
      "name": "Get Open Opportunities",
      "type": "n8n-nodes-base.salesforce",
      "typeVersion": 1.1,
      "position": [
        224,
        112
      ],
      "parameters": {
        "resource": "search",
        "operation": "query",
        "query": "SELECT Id, Name, StageName, Amount, CloseDate, CreatedDate, LastStageChangeDate, Probability, ForecastCategoryName, Account.Name FROM Opportunity WHERE IsClosed = false"
      },
      "executeOnce": true,
      "credentials": {
        "salesforceOAuth2Api": {
          "id": "553Tae1kPe5Wpthq",
          "name": "Salesforce account"
        }
      }
    },
    {
      "id": "86d1f40b-f5a0-4dea-8947-eee773eab7db",
      "name": "Get Stage History",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        448,
        112
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "FDaiEFabviIBiRoM",
          "cachedResultName": "Seeded Stage History"
        },
        "returnAll": true
      },
      "executeOnce": true
    },
    {
      "id": "b72f23a4-5d55-486c-ba61-da3a32e0bf2d",
      "name": "Get Previous Snapshots",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        672,
        112
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "7XE1BKeyUKiElqgm",
          "cachedResultName": "Forecast Snapshots"
        },
        "returnAll": true
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "eea375b0-fc15-4b27-8118-9d45334eaa49",
      "name": "Compute Forecast",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        896,
        112
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Pipeline forecast, computed entirely in code. Claude only writes the narrative afterwards.\nconst runDate = $today.toFormat('yyyy-MM-dd');\nconst DAY = 86400000, t0 = Date.parse(runDate + 'T00:00:00Z');\nconst days = d => Math.round((t0 - Date.parse(String(d).slice(0, 10) + 'T00:00:00Z')) / DAY);\nconst open = $('Get Open Opportunities').all().map(i => i.json).filter(r => r.Id);\nconst hist = $('Get Stage History').all().map(i => i.json).filter(r => r.stage);\nconst prevSnaps = $('Get Previous Snapshots').all().map(i => i.json).filter(r => r.runDate)\n  .sort((a, b) => String(b.loggedAt).localeCompare(String(a.loggedAt)));\n\n// Forecast quarter: the current calendar quarter, or the next one if fewer than 7 days remain in it.\nconst d0 = new Date(t0);\nlet qy = d0.getUTCFullYear(), qi = Math.floor(d0.getUTCMonth() / 3);\nlet qEnd = new Date(Date.UTC(qy, qi * 3 + 3, 0));\nif ((qEnd - d0) / DAY < 7) { qi++; if (qi > 3) { qi = 0; qy++; } qEnd = new Date(Date.UTC(qy, qi * 3 + 3, 0)); }\nconst quarter = 'Q' + (qi + 1) + ' ' + qy, quarterEnd = qEnd.toISOString().slice(0, 10);\n\n// Salesforce stages -> the four forecast stages the history is kept in.\nconst BUCKET = { 'Prospecting': 'Qualification', 'Qualification': 'Qualification',\n  'Needs Analysis': 'Discovery', 'Value Proposition': 'Discovery', 'Id. Decision Makers': 'Discovery', 'Perception Analysis': 'Discovery',\n  'Proposal/Price Quote': 'Proposal', 'Proposal/Negotiation': 'Proposal', 'Negotiation/Review': 'Negotiation' };\nconst STAGES = ['Qualification', 'Discovery', 'Proposal', 'Negotiation'];\n\n// Per stage, from history: P(close won | reached this stage), median days in stage, and P(won | sat > 2x median).\nconst median = a => { const s = [...a].sort((x, y) => x - y), m = s.length >> 1; return s.length ? (s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2) : null; };\nconst K = 5; // shrink small stalled samples toward the stage rate\nconst stats = {};\nfor (const s of STAGES) {\n  const r = hist.filter(h => h.stage === s), won = r.filter(h => h.outcome === 'won').length;\n  const med = median(r.map(h => Number(h.daysInStage))), thr = med * 2;\n  const st = r.filter(h => Number(h.daysInStage) > thr), stWon = st.filter(h => h.outcome === 'won').length;\n  const p = r.length ? won / r.length : null;\n  stats[s] = { reached: r.length, won, p, medianDays: med, stallAfterDays: thr, stalledN: st.length, stalledWon: stWon,\n    pStalled: r.length ? (stWon + K * p) / (st.length + K) : null };\n}\n\nconst deals = [], excluded = [], later = [];\nfor (const o of open) {\n  const pastDueDays = o.CloseDate ? days(o.CloseDate) : null;\n  if (pastDueDays != null && pastDueDays > 180) { excluded.push(o.Name); continue; } // abandoned: >180 days past its close date\n  if (o.CloseDate && o.CloseDate > quarterEnd) { later.push(o.Name); continue; }\n  const bucket = BUCKET[o.StageName] || null, st = bucket ? stats[bucket] : null;\n  const enteredStage = (o.LastStageChangeDate || o.CreatedDate || '').slice(0, 10);\n  const inStage = enteredStage ? days(enteredStage) : null;\n  const amount = Number(o.Amount) || 0;\n  const stalled = !!(st && inStage != null && inStage > st.stallAfterDays);\n  const pStage = st ? st.p : 0, pUsed = st ? (stalled ? st.pStalled : st.p) : 0;\n  const flags = [];\n  if (!amount) flags.push('no amount');\n  if (pastDueDays != null && pastDueDays > 0) flags.push('close date passed ' + pastDueDays + 'd ago');\n  if (stalled) flags.push(inStage + 'd in ' + bucket + ' (stall line ' + Math.round(st.stallAfterDays) + 'd)');\n  if (Number(o.Probability) === 0 || o.ForecastCategoryName === 'Omitted') flags.push('Salesforce stage \"' + o.StageName + '\" carries 0% / Omitted, so the CRM forecast drops it');\n  if (!bucket) flags.push('stage not mapped');\n  deals.push({ id: o.Id, name: o.Name, account: (o.Account || {}).Name || '', sfStage: o.StageName, stage: bucket, amount, closeDate: o.CloseDate,\n    daysInStage: inStage, stalled, pStage: +pStage.toFixed(3), pUsed: +pUsed.toFixed(3), crmProbability: Number(o.Probability) || 0,\n    weighted: Math.round(amount * pUsed), drag: Math.round(amount * (pStage - pUsed)), crmWeighted: Math.round(amount * (Number(o.Probability) || 0) / 100), flags });\n}\nconst sum = (a, f) => a.reduce((t, x) => t + f(x), 0);\nconst naive = sum(deals, d => d.amount), crmWeighted = sum(deals, d => d.crmWeighted);\nconst stageWeighted = Math.round(sum(deals, d => d.amount * d.pStage)), stallAdjusted = sum(deals, d => d.weighted), drag = sum(deals, d => d.drag);\nconst sd = Math.round(Math.sqrt(sum(deals, d => d.amount * d.amount * d.pUsed * (1 - d.pUsed)))); // 1-sd range if each deal closes independently\nconst prev = prevSnaps[0];\nreturn [{ json: {\n  runDate, quarter, quarterEnd, dealsInForecast: deals.length, excludedStale: excluded.length, excludedNames: excluded, laterQuarters: later.length,\n  naivePipeline: naive, crmWeighted, stageWeighted, stallAdjusted, range: [stallAdjusted - sd, stallAdjusted + sd], stallDrag: drag,\n  stalledDeals: deals.filter(d => d.stalled).length, pastDueDeals: deals.filter(d => d.flags.some(f => f.startsWith('close date passed'))).length,\n  previous: prev ? { runDate: prev.runDate, stallAdjusted: prev.stallAdjusted, quarter: prev.quarter } : null,\n  historyDeals: new Set(hist.map(h => h.opportunityId)).size, historySeeded: hist.some(h => String(h.seedRule || '').startsWith('seeded')),\n  stats, deals: deals.sort((a, b) => b.drag - a.drag || b.amount - a.amount)\n} }];\n"
      },
      "executeOnce": true
    },
    {
      "id": "ad62c001-7c4c-477c-9d98-f2fcb43059b2",
      "name": "Forecast Narrator",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 3.1,
      "position": [
        1120,
        112
      ],
      "parameters": {
        "promptType": "define",
        "text": "={{ JSON.stringify({ quarter: $json.quarter, runDate: $json.runDate, naivePipeline: $json.naivePipeline, crmWeighted: $json.crmWeighted, stageWeighted: $json.stageWeighted, stallAdjusted: $json.stallAdjusted, range: $json.range, stallDrag: $json.stallDrag, excludedStale: $json.excludedStale, previous: $json.previous, historySeeded: $json.historySeeded, historyDeals: $json.historyDeals, stageStats: $json.stats, deals: $json.deals.map(d => ({ name: d.name, stage: d.stage, salesforceStage: d.sfStage, amount: d.amount, closeDate: d.closeDate, daysInStage: d.daysInStage, pStage: d.pStage, pUsed: d.pUsed, drag: d.drag, crmProbability: d.crmProbability, flags: d.flags })) }, null, 1) }}",
        "hasOutputParser": true,
        "options": {
          "systemMessage": "You write the weekly pipeline forecast note for Fortavault, Inc. (a fictional data protection company) revenue leadership. Every number has ALREADY been computed in code from Salesforce; you never compute, round differently or change any figure. Quote dollar amounts in $K with no decimals (e.g. $321K).\n\nWrite:\n- narrative: 3 to 4 sentences, under 110 words in total. Lead with the stall-adjusted forecast for the quarter and its range, contrasted with the naive pipeline total and Salesforce's own probability-weighted number. Then name the specific deals dragging the number (the ones with drag > 0) and roughly how much each drags. Then mention any deals whose close date has passed and any deal Salesforce silently drops because its stage carries 0% / Omitted.\n- confidence: \"low\", \"medium\" or \"high\".\n- confidenceReason: one sentence in plain English.\n\nRules:\n- Every deal you are given is still OPEN. A passed close date means the deal is overdue and its close date needs updating; never say a deal \"closed\", was won or was lost.\n- If historySeeded is true, confidence cannot be higher than \"low\", and the reason must say that the win/loss outcomes are real but the stage-by-stage history behind the per-stage odds is seeded. Never quote field or variable names such as historySeeded, pStage or drag.\n- Use only the facts given. Never invent owners, reasons or next steps that are not in the data. Do not call a deal \"at risk\" unless it has a flag."
        }
      },
      "executeOnce": true
    },
    {
      "id": "67c3bfde-2e67-4b3f-be8d-68ad13b1a0dd",
      "name": "Claude Sonnet",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        1136,
        336
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-4-6",
          "cachedResultName": "Claude Sonnet 4.6"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "credentials": {
        "anthropicApi": {
          "id": "3d3ZiTi7BwnkFwqU",
          "name": "Anthropic account"
        }
      }
    },
    {
      "id": "260d2f55-b872-4f8a-9173-e3682b5c64f5",
      "name": "Narrative Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        1264,
        336
      ],
      "parameters": {
        "schemaType": "fromJson",
        "jsonSchemaExample": "{ \"narrative\": \"x\", \"confidence\": \"low\", \"confidenceReason\": \"x\" }"
      }
    },
    {
      "id": "b0180dac-f301-4ad4-871d-57f40dedf551",
      "name": "Build Forecast Post",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1472,
        112
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const f = $('Compute Forecast').first().json;\nconst n = $('Forecast Narrator').first().json.output || {};\n// Slack treats text between two ~ as strikethrough, so Claude's \"~$15K\" style approximations are made Slack-safe.\nconst safe = t => String(t || '').replace(/~/g, '≈');\nconst K = v => '$' + Math.round(v / 1000) + 'K';\nconst pct = p => Math.round(p * 100) + '%';\nconst risky = f.deals.filter(d => d.flags.length);\nconst lines = risky.map(d => (d.stalled ? ':hourglass_flowing_sand:' : d.flags.some(x => x.includes('0% / Omitted')) ? ':ghost:' : ':warning:') +\n  ' *' + d.name + '* — ' + (d.amount ? K(d.amount) : 'no amount') + ', ' + d.sfStage + (d.drag ? ', win odds ' + pct(d.pStage) + ' → ' + pct(d.pUsed) + ' (drags ' + K(d.drag) + ')' : '') +\n  '\\n      _' + d.flags.join('; ') + '_');\nconst trend = f.previous ? ('vs ' + K(f.previous.stallAdjusted) + ' on ' + f.previous.runDate + ' (' + (f.stallAdjusted >= f.previous.stallAdjusted ? '+' : '−') + K(Math.abs(f.stallAdjusted - f.previous.stallAdjusted)) + ')') : 'first snapshot logged';\nconst text = ':crystal_ball: *Pipeline Forecast — Fortavault ' + f.quarter + '* (run ' + f.runDate + ')\\n' +\n  '*' + K(f.stallAdjusted) + '* stall-adjusted forecast (range ' + K(f.range[0]) + '–' + K(f.range[1]) + ') · ' + trend + '\\n' +\n  'Naive pipeline ' + K(f.naivePipeline) + ' · Salesforce stage % ' + K(f.crmWeighted) + ' · historical stage odds ' + K(f.stageWeighted) + ' · stall drag −' + K(f.stallDrag) + '\\n\\n' +\n  safe(n.narrative) + '\\n\\n' +\n  '*Confidence: ' + (n.confidence || 'n/a') + '* — ' + safe(n.confidenceReason) + '\\n\\n' +\n  (lines.length ? '*Deals needing attention (' + lines.length + '):*\\n' + lines.join('\\n') : '_No flagged deals._') +\n  '\\n\\n_' + f.dealsInForecast + ' open deals close by ' + f.quarterEnd + '; ' + f.excludedStale + ' excluded as abandoned (close date 180+ days past). Stage odds from ' + f.historyDeals + ' closed deals' + (f.historySeeded ? ' — stage history is seeded, see the Seeded Stage History table' : '') + '._';\nreturn [{ json: { slackText: text.slice(0, 3000) } }];\n"
      }
    },
    {
      "id": "7e05b579-1512-4897-b83c-352ee9dbf417",
      "name": "Post Forecast to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1696,
        16
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "C0BQ3G96HL6",
          "cachedResultName": "general"
        },
        "text": "={{ $json.slackText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "webhookId": "dae8083b-2960-4088-b274-7b202b9086fd",
      "credentials": {
        "slackApi": {
          "id": "mv7TIi8kucFPqPg6",
          "name": "Slack account"
        }
      }
    },
    {
      "id": "b3c71200-cf17-4394-8b9f-970ded8b2b84",
      "name": "Build Snapshot Row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1696,
        208
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const f = $('Compute Forecast').first().json;\nconst n = $('Forecast Narrator').first().json.output || {};\nconst st = Object.entries(f.stats).map(([s, v]) => s + ': p=' + v.p.toFixed(2) + ' (n=' + v.reached + '), median ' + v.medianDays + 'd, stalled p=' + v.pStalled.toFixed(2) + ' (n=' + v.stalledN + ')').join(' | ');\nreturn [{ json: { runDate: f.runDate, quarter: f.quarter, dealsInForecast: f.dealsInForecast, excludedStale: f.excludedStale, naivePipeline: f.naivePipeline,\n  crmWeighted: f.crmWeighted, stageWeighted: f.stageWeighted, stallAdjusted: f.stallAdjusted, stalledDeals: f.stalledDeals, stallDrag: f.stallDrag,\n  pastDueDeals: f.pastDueDeals, stageTable: st, dealDetail: JSON.stringify(f.deals.map(d => [d.name, d.stage, d.amount, d.pUsed, d.drag, d.flags])).slice(0, 4000),\n  narrative: (n.narrative || '') + ' Confidence: ' + (n.confidence || '') + ' (' + (n.confidenceReason || '') + ')', loggedAt: $now.toISO() } }];\n"
      }
    },
    {
      "id": "7f67129d-408a-449f-9843-7458c85b32f3",
      "name": "Log Forecast Snapshot",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1920,
        208
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "7XE1BKeyUKiElqgm",
          "cachedResultName": "Forecast Snapshots"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {}
        }
      }
    }
  ],
  "connections": {
    "Weekly Forecast Run": {
      "main": [
        [
          {
            "node": "Get Open Opportunities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Open Opportunities": {
      "main": [
        [
          {
            "node": "Get Stage History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Stage History": {
      "main": [
        [
          {
            "node": "Get Previous Snapshots",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Previous Snapshots": {
      "main": [
        [
          {
            "node": "Compute Forecast",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Forecast": {
      "main": [
        [
          {
            "node": "Forecast Narrator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Forecast Narrator": {
      "main": [
        [
          {
            "node": "Build Forecast Post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Sonnet": {
      "ai_languageModel": [
        [
          {
            "node": "Forecast Narrator",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Narrative Parser": {
      "ai_outputParser": [
        [
          {
            "node": "Forecast Narrator",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Build Forecast Post": {
      "main": [
        [
          {
            "node": "Post Forecast to Slack",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Snapshot Row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Snapshot Row": {
      "main": [
        [
          {
            "node": "Log Forecast Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}