V10.437 harden sales chart rendering
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **最後更新**: 2026-05-24 (台北時間)
|
||||
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
|
||||
> **適用版本**: V10.436
|
||||
> **適用版本**: V10.437
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` 納入全部覆核隊列與「人工閉環」篩選,避免操作員按完否決/單位價/補搜尋後項目從列表消失、後續無法追蹤。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user