{
  "id": "resinCostWatch01",
  "name": "Resin Cost Watch: daily material cost signal",
  "active": false,
  "nodes": [
    {
      "id": "manual",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -320,
        460
      ],
      "parameters": {}
    },
    {
      "id": "trigger",
      "name": "Every morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -320,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      }
    },
    {
      "id": "sources",
      "name": "Build source list",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -100,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// n8n Code node: Build source list\n//\n// Out: one item per cost driver, carrying the URL to fetch. The HTTP Request node that\n// follows runs once per item.\n//\n// The list lives here rather than in the cost_driver table on purpose. The table holds WHAT\n// is tracked and how much it weighs \u2014 a business definition. This file holds WHERE the number\n// comes from \u2014 a fetching detail. Keeping them apart means swapping a data source never\n// touches the database.\n\nconst YF = 'https://query1.finance.yahoo.com/v8/finance/chart';\n\nreturn [\n  {\n    json: {\n      code: 'USDIDR',\n      // Bank Indonesia JISDOR. PRIMARY source: this is the official reference rate that\n      // Indonesian companies book against. The page is HTML, not JSON, so it has to be\n      // parsed out of a table.\n      url: 'https://www.bi.go.id/id/statistik/informasi-kurs/jisdor/Default.aspx',\n      parser: 'jisdor',\n      tier: 'primary',\n    },\n  },\n  {\n    json: {\n      code: 'BRENT',\n      // Brent crude. SECONDARY source: Yahoo aggregates from exchanges and its interface is\n      // undocumented, so it can change without notice. Used because a free daily official\n      // price is not available, and its second-hand status is recorded openly.\n      url: `${YF}/BZ=F?range=6mo&interval=1d`,\n      parser: 'yahoo',\n      tier: 'secondary',\n    },\n  },\n  {\n    json: {\n      code: 'NATGAS',\n      url: `${YF}/NG=F?range=6mo&interval=1d`,\n      parser: 'yahoo',\n      tier: 'secondary',\n    },\n  },\n  {\n    json: {\n      code: 'TPIA',\n      // Chandra Asri Pacific, the largest resin producer in Indonesia. Used as a read on\n      // domestic supplier conditions, NOT as a resin price. A share price is not a material\n      // price, and that distinction is spelled out in the README.\n      url: `${YF}/TPIA.JK?range=6mo&interval=1d`,\n      parser: 'yahoo',\n      tier: 'secondary',\n    },\n  },\n];\n"
      }
    },
    {
      "id": "fetch",
      "name": "Fetch source",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        120,
        300
      ],
      "alwaysOutputData": true,
      "onError": "continueRegularOutput",
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "responseFormat": "text"
            }
          },
          "timeout": 30000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
            },
            {
              "name": "Accept",
              "value": "application/json, text/html"
            }
          ]
        }
      }
    },
    {
      "id": "parse",
      "name": "Parse readings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// n8n Code node: Parse readings\n//\n// In:  the HTTP Request results, paired by index with the items from \"Build source list\".\n// Out: one item per successful reading, plus an ok:false item for each source that failed.\n//\n// ONE FAILING SOURCE DOES NOT KILL THE RUN. If Yahoo is down but Bank Indonesia is up, the\n// index is still computed from what is available and the driver count is recorded. A pipeline\n// that stops completely because one upstream site is having a bad morning is no use in daily\n// operations.\n\nconst sources = $('Build source list').all().map((i) => i.json);\nconst responses = $input.all();\nconst out = [];\n\n// Indonesian month names, for parsing JISDOR dates like \"3 Agustus 2026\".\nconst BULAN = {\n  januari: 1, februari: 2, maret: 3, april: 4, mei: 5, juni: 6,\n  juli: 7, agustus: 8, september: 9, oktober: 10, november: 11, desember: 12,\n};\n\nfunction isoDate(y, m, d) {\n  return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;\n}\n\nfunction parseJisdor(html) {\n  const table = html.match(/<table[^>]*>([\\s\\S]*?)<\\/table>/);\n  if (!table) throw new Error('JISDOR table not found on the page');\n\n  const rows = table[1].match(/<tr[^>]*>[\\s\\S]*?<\\/tr>/g) || [];\n  const readings = [];\n\n  for (const row of rows) {\n    const cells = (row.match(/<t[dh][^>]*>([\\s\\S]*?)<\\/t[dh]>/g) || [])\n      .map((c) => c.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim())\n      .filter(Boolean);\n    if (cells.length < 2) continue;\n\n    // \"3 Agustus 2026\"\n    const dm = cells[0].match(/^(\\d{1,2})\\s+([A-Za-z]+)\\s+(\\d{4})$/);\n    if (!dm) continue;\n    const bulan = BULAN[dm[2].toLowerCase()];\n    if (!bulan) continue;\n\n    // \"Rp17.991,00\": dot separates thousands, comma separates decimals.\n    const nm = cells[1].replace(/[^\\d.,]/g, '').replace(/\\./g, '').replace(',', '.');\n    const value = Number(nm);\n    if (!Number.isFinite(value) || value <= 0) continue;\n\n    readings.push({ observed_on: isoDate(dm[3], bulan, dm[1]), value });\n  }\n\n  if (readings.length === 0) throw new Error('JISDOR table parsed but no valid rows found');\n  return readings;\n}\n\nfunction parseYahoo(raw) {\n  const d = typeof raw === 'string' ? JSON.parse(raw) : raw;\n  const r = d?.chart?.result?.[0];\n  if (!r) throw new Error('Yahoo response shape changed, chart.result is empty');\n\n  const stamps = r.timestamp || [];\n  const closes = r.indicators?.quote?.[0]?.close || [];\n  const readings = [];\n\n  for (let i = 0; i < stamps.length; i++) {\n    const v = closes[i];\n    // Market holidays return null. Not an error, just no trading that day.\n    if (v === null || v === undefined || !Number.isFinite(v)) continue;\n    readings.push({\n      observed_on: new Date(stamps[i] * 1000).toISOString().slice(0, 10),\n      value: Math.round(v * 10000) / 10000,\n    });\n  }\n\n  if (readings.length === 0) throw new Error('no valid closing prices in this range');\n  return readings;\n}\n\nfor (let i = 0; i < sources.length; i++) {\n  const src = sources[i];\n  const res = responses[i]?.json;\n\n  try {\n    if (!res) throw new Error('no response from the HTTP Request node');\n    const body = res.data ?? res.body ?? res;\n    const readings = src.parser === 'jisdor' ? parseJisdor(String(body)) : parseYahoo(body);\n\n    // Every reading is emitted, not just the latest. A single run then backfills the whole\n    // range the source offers, which is why one run produces six months of history.\n    for (const r of readings) {\n      out.push({ json: { ok: true, code: src.code, tier: src.tier, ...r } });\n    }\n  } catch (e) {\n    out.push({ json: { ok: false, code: src.code, tier: src.tier, reason: e.message } });\n  }\n}\n\nreturn out;\n"
      }
    },
    {
      "id": "keepOk",
      "name": "Reading valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        560,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "ok",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.ok }}",
              "rightValue": ""
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "saveReading",
      "name": "Save reading",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        800,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO driver_reading (driver_id, observed_on, value)\nSELECT d.id, $2::date, $3::numeric\nFROM cost_driver d\nWHERE d.code = $1 AND d.is_active\nON CONFLICT (driver_id, observed_on) DO UPDATE SET\n  value      = EXCLUDED.value,\n  fetched_at = now();",
        "options": {
          "queryReplacement": "={{ [$json.code, $json.observed_on, $json.value] }}"
        }
      }
    },
    {
      "id": "logSkip",
      "name": "Log unreadable source",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        800,
        420
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO run_log (stage, status, detail) VALUES ('fetch', 'failed', $1);",
        "options": {
          "queryReplacement": "={{ [$json.code + ': ' + $json.reason] }}"
        }
      }
    },
    {
      "id": "loadDrivers",
      "name": "Load drivers",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1040,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT code, name, weight, is_active FROM cost_driver WHERE is_active ORDER BY weight DESC;",
        "options": {}
      },
      "executeOnce": true
    },
    {
      "id": "loadHistory",
      "name": "Load history",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1260,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT d.code, r.observed_on, r.value\nFROM driver_reading r\nJOIN cost_driver d ON d.id = r.driver_id\nWHERE d.is_active AND r.observed_on >= (CURRENT_DATE - INTERVAL '400 days')\nORDER BY d.code, r.observed_on;",
        "options": {}
      },
      "executeOnce": true
    },
    {
      "id": "compute",
      "name": "Compute index",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1480,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// n8n Code node: Compute cost index\n//\n// In:  the full reading history from driver_reading, and the driver definitions with their\n//      weights from cost_driver.\n// Out: one item holding today's index, its changes, an alert level, and a summary a\n//      non-technical reader can act on.\n//\n// HOW THE INDEX WORKS\n// Deliberately simple. Each driver's value is divided by its value on a base date, multiplied\n// by its weight, summed, and scaled by 100. The base date is the earliest date on which EVERY\n// active driver has a reading, so the comparison is like for like. An index of 108 means the\n// cost drivers combined sit 8 percent above that base.\n//\n// THREE ASSUMPTIONS, STATED OPENLY rather than buried:\n//\n//   1. DIRECTION. Every driver is treated as moving cost the same way: up means cost pressure\n//      rises. For oil, gas and the exchange rate that is direct. For Chandra Asri it is not:\n//      its share price tends to rise when resin margins widen, which happens when resin\n//      outruns its own feedstock. Long chain, so it carries the smallest weight of the four.\n//\n//   2. WEIGHTS. Brent 0.35, exchange rate 0.35, Chandra Asri 0.20, gas 0.10. Reasoned starting\n//      points, NOT calibrated against resin purchase invoices. How to fix it is written in the\n//      README: compare index movement against actual purchase prices, then reset the weights.\n//\n//   3. ALERT THRESHOLDS. 2 percent for watch and 5 percent for act over 30 days. Starting\n//      points as well, not derived from real cost data.\n//\n// Saying all three out loud is more useful than presenting the numbers as settled. An index\n// that admits its assumptions can be improved; one that hides them cannot.\n\nconst drivers = $('Load drivers').all().map((i) => i.json);\nconst rows = $('Load history').all().map((i) => i.json);\n\n// --- group history by driver ---\nconst byCode = {};\nfor (const r of rows) {\n  const code = r.code;\n  if (!byCode[code]) byCode[code] = [];\n  byCode[code].push({ on: String(r.observed_on).slice(0, 10), value: Number(r.value) });\n}\nfor (const code of Object.keys(byCode)) {\n  byCode[code].sort((a, b) => a.on.localeCompare(b.on));\n}\n\nconst active = drivers.filter((d) => d.is_active !== false && byCode[d.code]?.length);\nconst skipped = drivers.filter((d) => !byCode[d.code]?.length).map((d) => d.code);\n\nif (active.length === 0) {\n  return [{ json: { ok: false, reason: 'no driver has any readings' } }];\n}\n\n// --- base date: the earliest date EVERY active driver has ---\nconst earliestPerDriver = active.map((d) => byCode[d.code][0].on);\nconst baseDate = earliestPerDriver.sort().reverse()[0];\n\n/** Value on a given date, or the last reading before it. */\nfunction valueOn(code, dateStr) {\n  const series = byCode[code];\n  let found = null;\n  for (const p of series) {\n    if (p.on <= dateStr) found = p;\n    else break;\n  }\n  return found ? found.value : null;\n}\n\nfunction latest(code) {\n  const s = byCode[code];\n  return s[s.length - 1];\n}\n\nfunction daysAgo(n) {\n  const d = new Date(latest(active[0].code).on + 'T00:00:00Z');\n  d.setUTCDate(d.getUTCDate() - n);\n  return d.toISOString().slice(0, 10);\n}\n\n/**\n * Weighted index on a given date, relative to the base date.\n * Returns the value ALONG WITH which drivers were actually used, because two index values are\n * only comparable when built from the same set of drivers.\n */\nfunction indexOn(dateStr) {\n  let total = 0;\n  let weightUsed = 0;\n  const used = [];\n  for (const d of active) {\n    const now = valueOn(d.code, dateStr);\n    const base = valueOn(d.code, baseDate);\n    if (now === null || base === null || base === 0) continue;\n    total += Number(d.weight) * (now / base);\n    weightUsed += Number(d.weight);\n    used.push(d.code);\n  }\n  // Weights are renormalised so a driver with missing data does not drag the index down as if\n  // its value were zero.\n  if (weightUsed === 0) return null;\n  return { value: (total / weightUsed) * 100, used: used.sort().join(',') };\n}\n\nconst today = latest(active[0].code).on;\nconst nowIdx = indexOn(today);\nconst indexNow = nowIdx ? nowIdx.value : null;\n\n/**\n * Index change over n days.\n *\n * Returns null when the driver composition DIFFERS from today. The first version did not check\n * this, and ended up comparing a four-driver index today against a three-driver index ninety\n * days ago, producing minus 40.7 percent. That is not cost movement, that is composition. This\n * is the easiest kind of mistake to miss, because the result is still a number that looks\n * perfectly reasonable.\n */\nfunction changeOver(days) {\n  const past = indexOn(daysAgo(days));\n  if (!past || !nowIdx || past.value === 0) return null;\n  if (past.used !== nowIdx.used) return null;\n  return Math.round(((nowIdx.value - past.value) / past.value) * 1000) / 10;\n}\n\nconst change7 = changeOver(7);\nconst change30 = changeOver(30);\nconst change90 = changeOver(90);\n\n// --- alert level ---\n// The 30-day window is the main reference. When history is not long enough yet, this must NOT\n// treat missing data as zero. The first version did exactly that (`change30 ?? 0`) and reported\n// \"bergerak 0%\" when the honest answer was \"not known yet\". A fabricated zero is more dangerous\n// than admitting insufficient data, because whoever reads it concludes costs are calm.\nlet window = null;\nlet driverChange = null;\nif (change30 !== null) { window = 30; driverChange = change30; }\nelse if (change7 !== null) { window = 7; driverChange = change7; }\n\nlet level;\nif (driverChange === null) level = 'insufficient-history';\nelse if (driverChange >= 5) level = 'act';\nelse if (driverChange >= 2) level = 'watch';\nelse level = 'stable';\n\n// --- human-readable summary ---\nconst parts = [];\nfor (const d of active) {\n  const l = latest(d.code);\n  const past = window === null ? null : valueOn(d.code, daysAgo(window));\n  const pct = past && past !== 0 ? Math.round(((l.value - past) / past) * 1000) / 10 : null;\n  const arah = pct === null\n    ? 'riwayat belum cukup untuk membandingkan'\n    : pct >= 0 ? `naik ${pct}% dalam ${window} hari` : `turun ${Math.abs(pct)}% dalam ${window} hari`;\n  parts.push(`${d.name}: ${l.value} (${arah})`);\n}\n\nconst headline =\n  level === 'insufficient-history'\n    ? 'Riwayat belum cukup panjang untuk mengukur pergerakan. Angka akan bermakna setelah alur berjalan beberapa hari.'\n    : level === 'act'\n      ? `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Tinjau penawaran yang berlaku lebih dari 60 hari.`\n      : level === 'watch'\n        ? `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Belum mendesak, tetapi perlu diawasi.`\n        : `Biaya bahan bergerak ${driverChange}% dalam ${window} hari. Tidak ada tindakan yang diperlukan.`;\n\nconst notes = [];\nif (skipped.length) notes.push(`Tidak terbaca hari ini: ${skipped.join(', ')}.`);\nnotes.push(`Tanggal acuan indeks: ${baseDate}.`);\nif (window !== null && window < 30) {\n  notes.push(`Perbandingan memakai jendela ${window} hari, bukan 30, karena riwayatnya belum sepanjang itu.`);\n}\n\nreturn [{\n  json: {\n    ok: true,\n    computed_on: today,\n    index_value: Math.round(indexNow * 100) / 100,\n    change_7d: change7,\n    change_30d: change30,\n    change_90d: change90,\n    level,\n    drivers_used: active.length,\n    summary: [headline, ...parts, ...notes].join('\\n'),\n  },\n}];\n"
      }
    },
    {
      "id": "saveSignal",
      "name": "Save signal",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1700,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO cost_signal\n  (computed_on, index_value, change_7d, change_30d, change_90d,\n   level, drivers_used, summary)\nVALUES ($1::date, $2, $3, $4, $5, $6, $7, $8)\nON CONFLICT (computed_on) DO UPDATE SET\n  index_value  = EXCLUDED.index_value,\n  change_7d    = EXCLUDED.change_7d,\n  change_30d   = EXCLUDED.change_30d,\n  change_90d   = EXCLUDED.change_90d,\n  level        = EXCLUDED.level,\n  drivers_used = EXCLUDED.drivers_used,\n  summary      = EXCLUDED.summary,\n  computed_at  = now()\nRETURNING computed_on, index_value, level;",
        "options": {
          "queryReplacement": "={{ [$json.computed_on, $json.index_value, $json.change_7d, $json.change_30d, $json.change_90d, $json.level, $json.drivers_used, $json.summary] }}"
        }
      }
    },
    {
      "id": "logOk",
      "name": "Log run",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1920,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO run_log (stage, status, detail) VALUES ('compute', 'ok', $1);",
        "options": {
          "queryReplacement": "={{ [$('Compute index').first().json.level + ' | index ' + $('Compute index').first().json.index_value] }}"
        }
      }
    }
  ],
  "connections": {
    "Every morning": {
      "main": [
        [
          {
            "node": "Build source list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run manually": {
      "main": [
        [
          {
            "node": "Build source list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build source list": {
      "main": [
        [
          {
            "node": "Fetch source",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch source": {
      "main": [
        [
          {
            "node": "Parse readings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse readings": {
      "main": [
        [
          {
            "node": "Reading valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reading valid?": {
      "main": [
        [
          {
            "node": "Save reading",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log unreadable source",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save reading": {
      "main": [
        [
          {
            "node": "Load drivers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load drivers": {
      "main": [
        [
          {
            "node": "Load history",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load history": {
      "main": [
        [
          {
            "node": "Compute index",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute index": {
      "main": [
        [
          {
            "node": "Save signal",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save signal": {
      "main": [
        [
          {
            "node": "Log run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true
  },
  "tags": []
}