From ef9c2272b9ce669902b8c5a631d4bc88a1309558 Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 17:15:21 +0800 Subject: [PATCH] V10.437 harden sales chart rendering --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 2 +- docs/memory/history_logs.md | 1 + scripts/check_sales_charts_runtime.js | 53 ++++++++++++++++++++------- tests/test_chart_fallback_contract.py | 19 ++++++++++ web/static/js/analysis-chart-theme.js | 53 ++++++++++++++++++++++++--- 6 files changed, 109 insertions(+), 21 deletions(-) diff --git a/config.py b/config.py index 2da1938..6415ce9 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.436" +SYSTEM_VERSION = "V10.437" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index dee316d..a57babb 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.436 +> **適用版本**: V10.437 --- diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index c2d2c5f..7691646 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.437 業績圖表載入韌性與 QA 升級**: `analysis-chart-theme.js` 的 Chart.js loader 加入 4.5 秒 timeout 與 jsDelivr → unpkg → cdnjs 三來源 fallback;若外部 CDN 卡住或失敗,`/daily_sales`、`/growth_analysis` 會切到既有 HTML snapshot / fallback 圖表,不再留下空白圖表框。`check_sales_charts_runtime.js` 也從「canvas 有墨點」升級為檢查非零 dataset、可見元素、彩色資料筆跡與 canvas ink,避免只有座標軸的假通過。 - **V10.436 daily_sales snapshot_date 型別修復**: `/daily_sales` 日期窗口查詢改為依 DB dialect 明確 cast:PostgreSQL 使用 `"snapshot_date"::date` 並把參數 `CAST(:start_date AS date)` / `CAST(:end_date AS date)`,SQLite 使用 `date("snapshot_date")`;metadata / fingerprint 查詢同步引用 cast 後日期,避免正式庫 `snapshot_date` 為 text 時出現 `text = date` / `text >= date` 類型錯誤。後台 `chart_generator_service.monthly_overview_chart()` 的月業績 SQL 也改為 `snapshot_date::date`,防止報表圖表因 text 欄位而空白。 - **V10.435 商品列 PChome 狀態診斷翻譯**: Dashboard 商品列的 `_build_pchome_match_status()` 補上 `makeup_finish_conflict`、`nail_tool_function_conflict`、`schick_razor_line_conflict`、`variant_selection_review` 等具體狀態文案;`_load_pchome_match_attempt_map()` 同步解析 `match_diagnostic_json` 產生 `diagnostic_reasons` / `diagnostic_reason_text`,讓 overview、覆核隊列、商品列表與 Excel 的診斷語意一致。 - **V10.434 PChome 人工覆核閉環補搜尋**: 商品看板 PChome review queue 新增「補搜尋」人工決策按鈕,對應 `needs_research` → `manual_needs_research`;`manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 納入全部覆核隊列與「人工閉環」篩選,避免操作員按完否決/單位價/補搜尋後項目從列表消失、後續無法追蹤。 diff --git a/scripts/check_sales_charts_runtime.js b/scripts/check_sales_charts_runtime.js index 8f7cbeb..062c068 100644 --- a/scripts/check_sales_charts_runtime.js +++ b/scripts/check_sales_charts_runtime.js @@ -122,30 +122,47 @@ function safeName(input) { async function collectChartMetrics(page, contract) { return page.evaluate(({ expectedCanvases, readyDataset }) => { - function countInkPixels(canvas) { + function countCanvasPixels(canvas) { const context = canvas.getContext('2d', { willReadFrequently: true }); - if (!context || !canvas.width || !canvas.height) return 0; + if (!context || !canvas.width || !canvas.height) return { inkPixels: 0, colorPixels: 0 }; const sampleWidth = Math.min(canvas.width, 900); const sampleHeight = Math.min(canvas.height, 520); const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data; let ink = 0; + let color = 0; for (let i = 0; i < pixels.length; i += 4) { const alpha = pixels[i + 3]; if (alpha > 10) { ink += 1; - if (ink > 250) break; + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const channelSpread = Math.max(r, g, b) - Math.min(r, g, b); + if (alpha > 24 && channelSpread > 18) color += 1; } } - return ink; + return { inkPixels: ink, colorPixels: color }; } - function datasetPointCount(chart) { - if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) return 0; - return chart.data.datasets.reduce((total, dataset) => { + function datasetStats(chart) { + if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) { + return { dataPoints: 0, nonZeroPoints: 0, min: null, max: null }; + } + const values = []; + chart.data.datasets.forEach((dataset) => { const data = Array.isArray(dataset.data) ? dataset.data : []; - return total + data.filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value))).length; - }, 0); + data.forEach((value) => { + const number = Number(value); + if (value !== null && value !== undefined && Number.isFinite(number)) values.push(number); + }); + }); + return { + dataPoints: values.length, + nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length, + min: values.length ? Math.min(...values) : null, + max: values.length ? Math.max(...values) : null, + }; } function visibleElementCount(chart) { @@ -172,6 +189,8 @@ async function collectChartMetrics(page, contract) { const rect = canvas.getBoundingClientRect(); const chart = chartGetter(canvas); + const pixels = countCanvasPixels(canvas); + const stats = datasetStats(chart); return { id, exists: true, @@ -179,10 +198,14 @@ async function collectChartMetrics(page, contract) { cssHeight: Math.round(rect.height), width: canvas.width, height: canvas.height, - inkPixels: countInkPixels(canvas), + inkPixels: pixels.inkPixels, + colorPixels: pixels.colorPixels, hasChart: Boolean(chart), datasetCount: chart && chart.data && Array.isArray(chart.data.datasets) ? chart.data.datasets.length : 0, - dataPoints: datasetPointCount(chart), + dataPoints: stats.dataPoints, + nonZeroPoints: stats.nonZeroPoints, + min: stats.min, + max: stats.max, visibleElements: visibleElementCount(chart), ariaHidden: canvas.getAttribute('aria-hidden') || '', }; @@ -217,8 +240,12 @@ function evaluateMetrics(route, status, metrics, consoleErrors) { if (item.datasetCount <= 0 || item.dataPoints <= 0) { failures.push(`${item.id} has no drawable dataset`); } + if (item.nonZeroPoints <= 0) { + failures.push(`${item.id} has no non-zero dataset values`); + } if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`); - if (item.inkPixels <= 250) failures.push(`${item.id} canvas appears blank`); + if (item.inkPixels <= 900) failures.push(`${item.id} canvas appears blank`); + if (item.colorPixels <= 40) failures.push(`${item.id} has no colored chart marks`); } for (const err of consoleErrors) { failures.push(`console ${err}`); @@ -301,7 +328,7 @@ async function main() { } else { for (const result of results) { const canvasSummary = result.metrics.canvases - .map((item) => `${item.id}:chart=${item.hasChart ? 'yes' : 'no'},points=${item.dataPoints || 0},ink=${item.inkPixels || 0}`) + .map((item) => `${item.id}:chart=${item.hasChart ? 'yes' : 'no'},points=${item.dataPoints || 0},nonzero=${item.nonZeroPoints || 0},ink=${item.inkPixels || 0},color=${item.colorPixels || 0}`) .join(' '); console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} status=${result.status} ${canvasSummary}`); for (const failure of result.failures) { diff --git a/tests/test_chart_fallback_contract.py b/tests/test_chart_fallback_contract.py index a346c1a..97f97d3 100644 --- a/tests/test_chart_fallback_contract.py +++ b/tests/test_chart_fallback_contract.py @@ -34,3 +34,22 @@ def test_growth_analysis_canvas_is_primary_and_fallback_is_opt_in(): assert "renderHtmlChartFallbacks();" not in render_body assert "catch(error =>" in script assert "renderHtmlChartFallbacks();" in script.split("catch(error =>", 1)[1] + + +def test_chart_theme_has_cdn_timeout_and_fallback_sources(): + script = (ROOT / "web/static/js" / "analysis-chart-theme.js").read_text(encoding="utf-8") + + assert "CHART_LOAD_TIMEOUT_MS" in script + assert "Chart.js 載入逾時" in script + assert "cdn.jsdelivr.net/npm/chart.js@4.4.6" in script + assert "unpkg.com/chart.js@4.4.6" in script + assert "cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.6" in script + + +def test_sales_chart_runtime_guard_rejects_axis_only_canvases(): + script = (ROOT / "scripts" / "check_sales_charts_runtime.js").read_text(encoding="utf-8") + + assert "nonZeroPoints" in script + assert "colorPixels" in script + assert "has no colored chart marks" in script + assert "has no non-zero dataset values" in script diff --git a/web/static/js/analysis-chart-theme.js b/web/static/js/analysis-chart-theme.js index 7e74eaa..630e625 100644 --- a/web/static/js/analysis-chart-theme.js +++ b/web/static/js/analysis-chart-theme.js @@ -17,6 +17,12 @@ */ (function () { let chartJsLoader = null; + const CHART_LOAD_TIMEOUT_MS = 4500; + const CHART_CDN_URLS = [ + 'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js', + 'https://unpkg.com/chart.js@4.4.6/dist/chart.umd.js', + 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.6/chart.umd.min.js' + ]; const root = document.documentElement; const css = (name, fallback) => getComputedStyle(root).getPropertyValue(name).trim() || fallback; @@ -250,17 +256,52 @@ } if (chartJsLoader) return chartJsLoader; - chartJsLoader = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js'; - script.async = true; - script.onload = () => { + const loadFromCdn = index => new Promise((resolve, reject) => { + const url = CHART_CDN_URLS[index]; + if (!url) { + reject(new Error('Chart.js 載入失敗')); + return; + } + + if (window.Chart) { installChartJsTheme(); resolve(window.Chart); + return; + } + + let settled = false; + const finish = callback => value => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + callback(value); }; - script.onerror = () => reject(new Error('Chart.js 載入失敗')); + const fail = finish(error => { + script.remove(); + if (index + 1 < CHART_CDN_URLS.length) { + loadFromCdn(index + 1).then(resolve).catch(reject); + } else { + reject(error); + } + }); + const succeed = finish(() => { + installChartJsTheme(); + resolve(window.Chart); + }); + const timer = window.setTimeout( + () => fail(new Error(`Chart.js 載入逾時: ${url}`)), + CHART_LOAD_TIMEOUT_MS + ); + + const script = document.createElement('script'); + script.src = url; + script.async = true; + script.dataset.ewooocChartjs = String(index + 1); + script.onload = succeed; + script.onerror = () => fail(new Error(`Chart.js 載入失敗: ${url}`)); document.head.appendChild(script); }); + chartJsLoader = loadFromCdn(0); return chartJsLoader; }