給銷售的 AI 工具:LLM 產自助 HTML × ERP CRUD × 即時資訊圖

重點摘要(TL;DR)

  • 銷售/業務人員在 Telegram 一句話「幫我畫上週每個客戶訂單金額長條圖」 → LLM 30 秒產出 self-contained HTML 檔案 → 員工瀏覽器點開 → 看到即時圖表。
  • HTML 是 standalone 純靜態,從 CDN 載 Chart.js + Tailwind,內含 JS 透過公司 Gateway 對接 iDempiere REST(不直連 ERP)。
  • 四層架構:Generation(LLM 產)/ Execution(瀏覽器跑)/ Data(Gateway proxy)/ Rendering(Chart.js 渲染)
  • 混合模式:LLM 產 HTML 時 inline 一份預載資料(打開立即顯示),HTML 內按「重新整理」可主動 fetch 最新值。速度和即時性兼得
  • 安全設計:HTML 不含 secrets、Gateway 認 SSO、LLM 產的 HTML 用 textContent 防 XSS、Gateway 校驗 OData filter 防 injection、員工只看到 AD_Role 允許的資料。
  • 本文是腦子系統的第六篇,前五篇:Why / How / Scale / Tools / ERP

一、使用情境

銷售 Tom 在通勤路上滑手機,腦子裡想到「**等等開會要秀上週業績**」。

傳統流程(沒有這個工具)

  1. 到公司打開電腦
  2. 登入 ERP
  3. 找到訂單視窗
  4. 篩選日期
  5. 匯出 Excel
  6. 用 PowerPoint / Excel 畫圖
  7. 合計花 30-60 分鐘

新流程(本文設計)

  1. Telegram 對 bot 說:「上週每個客戶訂單金額長條圖」
  2. 30 秒後 bot 回 HTML 檔(或連結)
  3. 點開,圖表立刻渲染
  4. 開會時直接全螢幕秀
  5. 合計花 30 秒

關鍵:銷售不需要學任何工具、不需要安裝 app、不需要 IT 部署任何東西。產出的 HTML 還可以分享給同事、存證、離線重看。

二、四層架構

銷售 Tom (Telegram chat)
   ↓
[Generation 層] LLM 看公司腦 + 員工 prompt
   ↓ 產 self-contained HTML
HTML 檔案 (Chart.js + Tailwind 從 CDN 載)
   ↓ Tom 在瀏覽器打開
[Execution 層] 瀏覽器跑 HTML 內 JS
   ↓ JS 對 https://gateway.example.com/erp/query 發 fetch
[Data 層] 公司 Gateway (LiteLLM + Portkey + 自製 ERP proxy)
   ↓ Gateway 帶 Tom 的 SSO 身份
   ↓ 呼叫 iDempiere MCP server / REST API
   ↓ iDempiere AD_Role 自動過濾資料
   ↓ 回 JSON
HTML 內 JS 接到 JSON
   ↓
[Rendering 層] Chart.js 渲染圖表 / 動態表格 / 資訊圖

四層各自的職責清晰、可獨立替換:

  • Generation:換 LLM(Claude/GPT/本地 Qwen)不影響其他層
  • Execution:瀏覽器標準環境,任何裝置都能跑(Mac / Windows / iPhone Safari)
  • Data:Gateway 換成內部 service mesh、ERP 換成另一套都不影響 HTML
  • Rendering:換 Chart.js 為 ECharts / Plotly 只改前端,後端不動

三、混合模式:預載 + 即時刷新

LLM 產生 HTML 時面對一個取捨:

模式 優點 缺點
A 純 inline:LLM 把資料寫死進 HTML 簡單、離線可看、無 CORS 資料是快照,要新查就要重新產
B 純 fetch:HTML 啟動才查 每次最新 打開時白屏 1-2 秒、需連線
C 混合(推薦):預載 + 重新整理按鈕 立即顯示 + 隨時更新 + 離線可看快照 HTML 較大(包含初始資料)

實務上 C 混合模式最佳。實作:LLM 在產 HTML 時順便呼叫一次 Gateway 拿初始資料,把 JSON 寫進 HTML 的 const initialData = [...],同時保留 refresh() 函數讓員工按按鈕主動更新。

四、LLM 怎麼產 HTML — Prompt 設計

給 LLM 的 system prompt 要包含五件事:

  1. HTML 模板骨架:固定的 head / body 結構,用哪個 CDN 圖表庫
  2. Gateway URL 與 API schema:fetch 要打哪、payload 格式
  3. 可用 ERP table 與欄位:從公司腦讀(C_Order / C_BPartner / M_Product 等的可查欄位)
  4. OData filter 語法:eq/neq/gt/contains 等(注意 iDempiere 用 neq 不是 ne)
  5. 安全規範:用 textContent 不用 innerHTML、不要 hardcode token、不要 eval()

4.1 System Prompt 範例(精簡版)

You are a HTML dashboard generator for sales staff.

CONTEXT (from company brain):
- Available ERP tables: C_Order, C_BPartner, M_Product, M_InOut
- Common columns for C_Order: GrandTotal, DateOrdered, C_BPartner_ID, IsSOTrx
- Filter syntax: OData (use 'neq' not 'ne')
- Gateway endpoint: https://gateway.example.com/erp/query
- Gateway auth: SSO cookies (credentials: 'include')

OUTPUT REQUIREMENTS:
1. Generate ONE complete self-contained HTML file
2. Use Chart.js via CDN (https://cdn.jsdelivr.net/npm/chart.js)
3. Use Tailwind via CDN (https://cdn.tailwindcss.com)
4. Include initial data inline (call Gateway once and embed JSON)
5. Provide a refresh() function for live update
6. Use textContent (NEVER innerHTML) when displaying data
7. Add a loading spinner during fetch
8. Style: clean, presentation-ready (用得上開會秀客戶)

USER QUERY: {{user_message}}

五、產出 HTML 範例(完整可執行)

下面是 LLM 看到「上週每個客戶訂單金額長條圖」這個 query 後產出的範例 HTML。這是真實可執行的 self-contained 檔案:

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>上週訂單金額(by 客戶)</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-50 p-6 font-sans">

<div class="max-w-4xl mx-auto">
  <div class="flex items-center justify-between mb-4">
    <h1 class="text-2xl font-bold">上週訂單金額(by 客戶)</h1>
    <button id="refreshBtn"
            class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
      🔄 重新整理
    </button>
  </div>
  <p id="meta" class="text-sm text-slate-500 mb-4"></p>
  <canvas id="chart" height="120"></canvas>
  <table class="mt-6 w-full text-sm">
    <thead class="bg-slate-200">
      <tr><th class="text-left p-2">客戶</th><th class="text-right p-2">訂單數</th><th class="text-right p-2">金額</th></tr>
    </thead>
    <tbody id="tableBody"></tbody>
  </table>
</div>

<script>
// === 預載資料(LLM 產生時 inline 進來) ===
const initialData = [
  {customer: "客戶 A", orderCount: 5, amount: 1280000},
  {customer: "客戶 B", orderCount: 3, amount: 850000},
  {customer: "客戶 C", orderCount: 7, amount: 2100000},
  {customer: "客戶 D", orderCount: 2, amount: 420000}
];
const generatedAt = "2026-05-02 09:30";

// === Gateway 設定 ===
const GATEWAY_URL = "https://gateway.example.com/erp/query";
const QUERY = {
  table: "C_Order",
  filter: "DateOrdered ge '2026-04-25' and DateOrdered le '2026-05-01'",
  groupBy: "C_BPartner_ID",
  aggregate: ["count", "sum(GrandTotal)"]
};

// === 渲染函數 ===
let chart;
function render(data, ts) {
  // 注意:用 textContent 不用 innerHTML 防 XSS
  document.getElementById('meta').textContent = `資料時間:${ts}`;

  const tbody = document.getElementById('tableBody');
  tbody.textContent = '';
  data.forEach(row => {
    const tr = document.createElement('tr');
    tr.className = 'border-b';
    [row.customer, row.orderCount, row.amount.toLocaleString()].forEach((v, i) => {
      const td = document.createElement('td');
      td.className = i === 0 ? 'p-2' : 'p-2 text-right';
      td.textContent = v;
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });

  if (chart) chart.destroy();
  chart = new Chart(document.getElementById('chart'), {
    type: 'bar',
    data: {
      labels: data.map(d => d.customer),
      datasets: [{
        label: '訂單金額(NTD)',
        data: data.map(d => d.amount),
        backgroundColor: 'rgba(59, 130, 246, 0.6)'
      }]
    },
    options: {
      responsive: true,
      plugins: {legend: {display: false}}
    }
  });
}

// === 即時刷新 ===
async function refresh() {
  const btn = document.getElementById('refreshBtn');
  btn.disabled = true;
  btn.textContent = '⏳ 載入中...';
  try {
    const r = await fetch(GATEWAY_URL, {
      method: 'POST',
      credentials: 'include',  // 帶 SSO cookies
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(QUERY)
    });
    if (!r.ok) throw new Error(`Gateway error ${r.status}`);
    const data = await r.json();
    render(data.rows, new Date().toLocaleString('zh-TW'));
  } catch (e) {
    alert('刷新失敗:' + e.message);
  } finally {
    btn.disabled = false;
    btn.textContent = '🔄 重新整理';
  }
}

// === 初始化 ===
document.getElementById('refreshBtn').addEventListener('click', refresh);
render(initialData, generatedAt);
</script>

</body>
</html>

這個檔案存成 orders.html,雙擊即可在瀏覽器打開。打開時看到預載資料(已渲染圖表 + 表格);按「重新整理」就 fetch 最新資料。整個檔案約 80 行,包含全部 logic

六、安全設計(必看)

6.1 HTML 端

  • 絕不在 HTML 寫 token / API key:HTML 是員工拿到的檔案,寫 token 等於洩漏。所有認證在 Gateway server side
  • ✅ 用 fetch(..., {credentials: 'include'}) 帶員工 SSO cookies
  • ✅ 渲染用 textContent,不用 innerHTML(防 LLM 產的 XSS)
  • 不用 eval()、Function() 等動態 code 執行
  • ✅ Chart.js / Tailwind 從固定 CDN 載(版本鎖定),不從不可信來源載

6.2 Gateway 端

  • SSO 認證:員工已登入公司,cookies 自動帶,Gateway 認 user identity
  • OData filter 校驗:LLM 產生的 filter 要過 Gateway 校驗(白名單欄位、operator 限制),防 SQL injection / 越權查詢
  • Rate limit:單一員工每分鐘最多 X 個 query,防 LLM 產的迴圈失控
  • Audit log:每個 query 記錄(誰、何時、查什麼、回傳幾筆),進 SIEM
  • CORS 白名單:Gateway 只允許指定 origin(若 HTML 託管在內網檔案分享伺服器,設定該 origin)

6.3 ERP 端

  • iDempiere AD_Role 自動套:Gateway 帶員工 token 進 iDempiere,業務 Tom 看不到 CFO 才看的到的資料
  • 不直連 ERP:HTML 的 fetch 不直接打 iDempiere,一律走 Gateway proxy。理由:ERP 不該暴露在 internet,Gateway 才是受控邊界
  • Process call 限制:銷售工具預設 read-only,要寫資料(下單、修改)需要更高層審核或專用工具

七、CORS / 認證的具體做法

三條路徑分析:

路徑 CORS 認證 推薦
HTML → 直連 iDempiere REST 需開 iDempiere CORS 設定 JWT token 存 HTML(危險) ❌ 不要
HTML → 公司 Gateway → iDempiere Gateway 設 CORS 白名單 SSO cookies 自動帶 ✅ 推薦
HTML → MCP server → iDempiere MCP server 設 CORS MCP OAuth 2.1 ⚠️ 進階(複雜但可)