V10.437 harden sales chart rendering
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-24 17:15:21 +08:00
parent 1eef91ec7a
commit ef9c2272b9
6 changed files with 109 additions and 21 deletions

View File

@@ -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 # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-24 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 備援預設關閉
> **適用版本**: V10.436
> **適用版本**: V10.437
---

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24PChome 近門檻身份回收第二輪
- **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 明確 castPostgreSQL 使用 `"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` 納入全部覆核隊列與「人工閉環」篩選,避免操作員按完否決/單位價/補搜尋後項目從列表消失、後續無法追蹤。

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;
}