This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.187 修正 `/daily_sales`、`/growth_analysis` 圖表空白:Chart JSON 改從 `<template>.content.textContent` 讀取,補空資料診斷狀態;成長分析改用 realtime 明細新鮮度覆蓋過期月結摘要,並為 growth cache 加入資料指紋。
|
||||
- V10.151 接續前端 V3 全站 UI/UX:廠商缺貨 `/vendor-stockout/vendor-management`、`/vendor-stockout/send-email`、`/vendor-stockout/history` 改走新版 `ewoooc_base.html` shell 與 `page-vendor-tools.css`,移除舊紫藍 navbar/live route。
|
||||
- V10.151 補 `/abc_analysis/detail` 新版 ABC 詳情頁與安全 loading state,移除 raw HTML fallback,資料表維持正式快取資料來源與匯出連結。
|
||||
- V10.151 補 `/login` 新版登入頁:暖紙背景、點陣視覺、EwoooC 品牌、手機版 390px 無水平 overflow。
|
||||
|
||||
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.186"
|
||||
SYSTEM_VERSION = "V10.187"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -426,6 +426,67 @@ def _build_growth_kpi(kpi_row):
|
||||
}
|
||||
|
||||
|
||||
def _growth_pg_number_expr(column_name):
|
||||
return f'COALESCE(NULLIF(regexp_replace("{column_name}"::text, \'[^0-9.-]\', \'\', \'g\'), \'\')::numeric, 0)'
|
||||
|
||||
|
||||
def _fetch_growth_latest_detail_month(engine, table_name='realtime_sales_monthly'):
|
||||
"""回傳業績明細表最新月份;摘要表落後時需改走明細聚合。"""
|
||||
try:
|
||||
if not inspect(engine).has_table(table_name):
|
||||
return None
|
||||
table_name = validate_table_name(table_name)
|
||||
table_ref = f'"{table_name}"'
|
||||
if engine.dialect.name == 'postgresql':
|
||||
latest_sql = text(f"""
|
||||
SELECT date_trunc('month', MAX("日期"::date))::date AS latest_month
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
""")
|
||||
else:
|
||||
latest_sql = text(f"""
|
||||
SELECT date(MAX("日期"), 'start of month') AS latest_month
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
""")
|
||||
with engine.connect() as conn:
|
||||
latest = conn.execute(latest_sql).scalar()
|
||||
latest_dt = pd.to_datetime(latest, errors='coerce')
|
||||
if pd.isna(latest_dt):
|
||||
return None
|
||||
return latest_dt.date().replace(day=1)
|
||||
except Exception as exc:
|
||||
sys_log.warning(f"[GrowthAnalysis] 無法取得明細最新月份,摘要新鮮度檢查略過: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _get_growth_source_fingerprint(engine, table_name='realtime_sales_monthly'):
|
||||
"""成長分析快取指紋;匯入新資料後同一 worker 下一次 request 會重算。"""
|
||||
try:
|
||||
if not inspect(engine).has_table(table_name):
|
||||
return None
|
||||
table_name = validate_table_name(table_name)
|
||||
table_ref = f'"{table_name}"'
|
||||
if engine.dialect.name == 'postgresql':
|
||||
fingerprint_sql = text(f"""
|
||||
SELECT MAX("日期"::date)::text, COUNT(*)
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
""")
|
||||
else:
|
||||
fingerprint_sql = text(f"""
|
||||
SELECT CAST(MAX("日期") AS TEXT), COUNT(*)
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
""")
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(fingerprint_sql).fetchone()
|
||||
return (row[0], row[1]) if row else None
|
||||
except Exception as exc:
|
||||
sys_log.warning(f"[GrowthAnalysis] 快取指紋查詢失敗,保守沿用 TTL: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_growth_payload_summary(engine):
|
||||
"""優先使用月結摘要表,避免成長頁冷 worker 掃 70+ 萬列原始明細。"""
|
||||
table_name = 'monthly_summary_analysis'
|
||||
@@ -468,6 +529,16 @@ def _fetch_growth_payload_summary(engine):
|
||||
return None
|
||||
|
||||
latest_label = chart_data['labels'][-1]
|
||||
summary_latest_month = pd.to_datetime(f"{latest_label}-01", errors='coerce')
|
||||
detail_latest_month = _fetch_growth_latest_detail_month(engine)
|
||||
if detail_latest_month and not pd.isna(summary_latest_month):
|
||||
if summary_latest_month.date().replace(day=1) < detail_latest_month:
|
||||
sys_log.info(
|
||||
"[GrowthAnalysis] 月結摘要落後明細資料,改走 realtime_sales_monthly 聚合 | "
|
||||
f"summary={latest_label}, detail={detail_latest_month.strftime('%Y-%m')}"
|
||||
)
|
||||
return None
|
||||
|
||||
current_year = int(latest_label[:4])
|
||||
latest_month = int(latest_label[5:7])
|
||||
last_year = current_year - 1
|
||||
@@ -502,14 +573,15 @@ def _fetch_growth_payload_sql(engine, table_name):
|
||||
|
||||
if engine.dialect.name == 'postgresql':
|
||||
date_expr = '"日期"::date'
|
||||
amount_expr = 'COALESCE(NULLIF(regexp_replace("總業績"::text, \'[^0-9.-]\', \'\', \'g\'), \'\')::numeric, 0)'
|
||||
cost_expr = 'COALESCE(NULLIF(regexp_replace("總成本"::text, \'[^0-9.-]\', \'\', \'g\'), \'\')::numeric, 0)'
|
||||
amount_expr = _growth_pg_number_expr('總業績')
|
||||
cost_expr = _growth_pg_number_expr('總成本')
|
||||
volume_expr = _growth_pg_number_expr('數量')
|
||||
monthly_sql = text(f"""
|
||||
SELECT
|
||||
date_trunc('month', {date_expr})::date AS month_start,
|
||||
SUM({amount_expr}) AS amount,
|
||||
SUM({amount_expr} - {cost_expr}) AS profit,
|
||||
COUNT(DISTINCT "訂單編號") AS orders
|
||||
SUM({volume_expr}) AS orders
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY 1
|
||||
@@ -536,11 +608,11 @@ def _fetch_growth_payload_sql(engine, table_name):
|
||||
WHEN {date_expr} >= (b.max_date - interval '30 days')::date
|
||||
AND {date_expr} <= b.max_date
|
||||
THEN {amount_expr} ELSE 0 END), 0) AS recent_revenue,
|
||||
COUNT(DISTINCT CASE
|
||||
COALESCE(SUM(CASE
|
||||
WHEN {date_expr} >= (b.max_date - interval '30 days')::date
|
||||
AND {date_expr} <= b.max_date
|
||||
THEN "訂單編號" END) AS recent_orders,
|
||||
COUNT(DISTINCT "訂單編號") AS total_orders
|
||||
THEN {volume_expr} ELSE 0 END), 0) AS recent_orders,
|
||||
COALESCE(SUM({volume_expr}), 0) AS total_orders
|
||||
FROM {table_ref}
|
||||
CROSS JOIN bounds b
|
||||
WHERE "日期" IS NOT NULL
|
||||
@@ -552,7 +624,7 @@ def _fetch_growth_payload_sql(engine, table_name):
|
||||
date("日期", 'start of month') AS month_start,
|
||||
SUM(COALESCE(CAST("總業績" AS REAL), 0)) AS amount,
|
||||
SUM(COALESCE(CAST("總業績" AS REAL), 0) - COALESCE(CAST("總成本" AS REAL), 0)) AS profit,
|
||||
COUNT(DISTINCT "訂單編號") AS orders
|
||||
SUM(COALESCE(CAST("數量" AS REAL), 0)) AS orders
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY 1
|
||||
@@ -579,11 +651,11 @@ def _fetch_growth_payload_sql(engine, table_name):
|
||||
WHEN date("日期") >= date(b.max_date, '-30 days')
|
||||
AND date("日期") <= b.max_date
|
||||
THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS recent_revenue,
|
||||
COUNT(DISTINCT CASE
|
||||
COALESCE(SUM(CASE
|
||||
WHEN date("日期") >= date(b.max_date, '-30 days')
|
||||
AND date("日期") <= b.max_date
|
||||
THEN "訂單編號" END) AS recent_orders,
|
||||
COUNT(DISTINCT "訂單編號") AS total_orders
|
||||
THEN COALESCE(CAST("數量" AS REAL), 0) ELSE 0 END), 0) AS recent_orders,
|
||||
COALESCE(SUM(COALESCE(CAST("數量" AS REAL), 0)), 0) AS total_orders
|
||||
FROM {table_ref}
|
||||
CROSS JOIN bounds b
|
||||
WHERE "日期" IS NOT NULL
|
||||
@@ -1852,8 +1924,16 @@ def growth_analysis():
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
db = DatabaseManager()
|
||||
table_name = 'realtime_sales_monthly'
|
||||
inspector = inspect(db.engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return _render_growth_empty(f"尚未匯入業績資料 ({table_name})")
|
||||
|
||||
source_fingerprint = _get_growth_source_fingerprint(db.engine, table_name)
|
||||
|
||||
# 檢查快取
|
||||
if is_growth_cache_valid():
|
||||
if is_growth_cache_valid(source_fingerprint):
|
||||
cache = get_growth_cache()
|
||||
cache_age = int((datetime.now(TAIPEI_TZ) - cache['timestamp']).total_seconds())
|
||||
sys_log.debug(f"[GrowthAnalysis] [Cache] 使用快取 | 快取年齡: {cache_age}秒")
|
||||
@@ -1870,13 +1950,6 @@ def growth_analysis():
|
||||
# 快取失效,重新計算
|
||||
sys_log.debug("[GrowthAnalysis] [Cache] 快取失效,重新計算數據...")
|
||||
|
||||
db = DatabaseManager()
|
||||
table_name = 'realtime_sales_monthly'
|
||||
|
||||
inspector = inspect(db.engine)
|
||||
if not inspector.has_table(table_name):
|
||||
return _render_growth_empty(f"尚未匯入業績資料 ({table_name})")
|
||||
|
||||
try:
|
||||
payload = _fetch_growth_payload_summary(db.engine)
|
||||
except Exception as summary_error:
|
||||
@@ -1895,7 +1968,7 @@ def growth_analysis():
|
||||
chart_data, kpi = payload
|
||||
|
||||
# 儲存快取
|
||||
set_growth_cache(chart_data, kpi)
|
||||
set_growth_cache(chart_data, kpi, source_fingerprint)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
sys_log.debug(f"[GrowthAnalysis] [Cache] 數據計算完成 | 耗時: {elapsed:.3f}秒")
|
||||
|
||||
@@ -33,7 +33,8 @@ _SALES_RESULT_TTL = _SALES_CACHE_TTL
|
||||
_GROWTH_ANALYSIS_CACHE = {
|
||||
'chart_data': None,
|
||||
'kpi': None,
|
||||
'timestamp': None
|
||||
'timestamp': None,
|
||||
'source_fingerprint': None,
|
||||
}
|
||||
_GROWTH_CACHE_TTL = 1800 # 成長分析快取: 30 分鐘
|
||||
|
||||
@@ -80,7 +81,8 @@ def clear_growth_cache():
|
||||
_GROWTH_ANALYSIS_CACHE = {
|
||||
'chart_data': None,
|
||||
'kpi': None,
|
||||
'timestamp': None
|
||||
'timestamp': None,
|
||||
'source_fingerprint': None,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,16 +118,21 @@ def get_growth_cache():
|
||||
return _GROWTH_ANALYSIS_CACHE
|
||||
|
||||
|
||||
def set_growth_cache(chart_data, kpi):
|
||||
def set_growth_cache(chart_data, kpi, source_fingerprint=None):
|
||||
"""設定成長分析快取"""
|
||||
global _GROWTH_ANALYSIS_CACHE
|
||||
_GROWTH_ANALYSIS_CACHE = {
|
||||
'chart_data': chart_data,
|
||||
'kpi': kpi,
|
||||
'timestamp': datetime.now(TAIPEI_TZ)
|
||||
'timestamp': datetime.now(TAIPEI_TZ),
|
||||
'source_fingerprint': source_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
def is_growth_cache_valid():
|
||||
def is_growth_cache_valid(source_fingerprint=None):
|
||||
"""檢查成長分析快取是否有效"""
|
||||
return is_cache_valid(_GROWTH_ANALYSIS_CACHE.get('timestamp'), _GROWTH_CACHE_TTL)
|
||||
if not is_cache_valid(_GROWTH_ANALYSIS_CACHE.get('timestamp'), _GROWTH_CACHE_TTL):
|
||||
return False
|
||||
if source_fingerprint is not None:
|
||||
return _GROWTH_ANALYSIS_CACHE.get('source_fingerprint') == source_fingerprint
|
||||
return True
|
||||
|
||||
@@ -10,7 +10,10 @@ def test_daily_sales_canvas_is_primary_and_fallback_is_opt_in():
|
||||
|
||||
assert ".chart-container.has-html-chart canvas" not in css
|
||||
assert ".chart-container.chart-fallback-active canvas" in css
|
||||
assert ".chart-container.chart-empty-active canvas" in css
|
||||
assert ".chart-container:not(.chart-fallback-active) .chart-fallback-list" in css
|
||||
assert "node.content && node.content.textContent" in script
|
||||
assert "chart-empty-active" in script
|
||||
render_body = script.split("function renderAllCharts()", 1)[1].split("function bootCharts()", 1)[0]
|
||||
assert "renderHtmlChartFallbacks();" not in render_body
|
||||
assert "catch(error =>" in script
|
||||
@@ -23,7 +26,10 @@ def test_growth_analysis_canvas_is_primary_and_fallback_is_opt_in():
|
||||
|
||||
assert ".ga-chart-card__body.has-html-chart canvas" not in css
|
||||
assert ".ga-chart-card__body.chart-fallback-active canvas" in css
|
||||
assert ".ga-chart-card__body.chart-empty-active canvas" in css
|
||||
assert ".ga-chart-card__body:not(.chart-fallback-active) .ga-chart-snapshot" in css
|
||||
assert "node.content && node.content.textContent" in script
|
||||
assert "chart-empty-active" in script
|
||||
render_body = script.split("function renderCharts()", 1)[1].split("function bootCharts()", 1)[0]
|
||||
assert "renderHtmlChartFallbacks();" not in render_body
|
||||
assert "catch(error =>" in script
|
||||
|
||||
87
tests/test_growth_analysis_data_source.py
Normal file
87
tests/test_growth_analysis_data_source.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from routes.sales_routes import (
|
||||
_fetch_growth_latest_detail_month,
|
||||
_fetch_growth_payload_sql,
|
||||
_fetch_growth_payload_summary,
|
||||
_get_growth_source_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def _seed_growth_tables(engine):
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE monthly_summary_analysis (
|
||||
"year" INTEGER,
|
||||
"month" INTEGER,
|
||||
sales_amt_curr REAL,
|
||||
profit_amt_curr REAL,
|
||||
sales_vol_curr REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE realtime_sales_monthly (
|
||||
"日期" TEXT,
|
||||
"總業績" TEXT,
|
||||
"總成本" TEXT,
|
||||
"數量" TEXT,
|
||||
"訂單編號" TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO monthly_summary_analysis
|
||||
("year", "month", sales_amt_curr, profit_amt_curr, sales_vol_curr)
|
||||
VALUES (2025, 12, 1000, 250, 10)
|
||||
"""
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO realtime_sales_monthly
|
||||
("日期", "總業績", "總成本", "數量", "訂單編號")
|
||||
VALUES
|
||||
('2026-05-01', '1000', '700', '10', 'A001'),
|
||||
('2026-05-02', '2000', '1200', '20', 'A002')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_growth_summary_is_skipped_when_realtime_detail_is_newer():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
_seed_growth_tables(engine)
|
||||
|
||||
assert _fetch_growth_latest_detail_month(engine) == date(2026, 5, 1)
|
||||
assert _fetch_growth_payload_summary(engine) is None
|
||||
|
||||
chart_data, kpi = _fetch_growth_payload_sql(engine, "realtime_sales_monthly")
|
||||
assert chart_data["labels"] == ["2026-05"]
|
||||
assert chart_data["revenue"] == [3000.0]
|
||||
assert chart_data["orders"] == [30]
|
||||
assert chart_data["aov"] == [100.0]
|
||||
assert kpi["current_year"] == 2026
|
||||
assert kpi["ytd_revenue"] == 3000.0
|
||||
assert kpi["total_orders"] == 30
|
||||
|
||||
|
||||
def test_growth_source_fingerprint_changes_after_import_rows_change():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
_seed_growth_tables(engine)
|
||||
|
||||
before = _get_growth_source_fingerprint(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
INSERT INTO realtime_sales_monthly
|
||||
("日期", "總業績", "總成本", "數量", "訂單編號")
|
||||
VALUES ('2026-05-03', '500', '300', '5', 'A003')
|
||||
"""
|
||||
)
|
||||
after = _get_growth_source_fingerprint(engine)
|
||||
|
||||
assert before != after
|
||||
@@ -885,6 +885,39 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-container.chart-empty-active canvas {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-empty-state {
|
||||
position: absolute;
|
||||
inset: var(--momo-space-4, 16px);
|
||||
border: 1px dashed var(--momo-page-accent-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: color-mix(in srgb, var(--momo-bg-paper) 86%, white);
|
||||
color: var(--momo-text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
text-align: center;
|
||||
padding: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.chart-empty-state strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: var(--momo-text-body, 16px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chart-empty-state span {
|
||||
max-width: 34rem;
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.chart-fallback-bars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1006,6 +1039,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-container:not(.chart-empty-active) .chart-empty-state {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.chart-container.chart-fallback-active .chart-fallback-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,35 @@
|
||||
.growth-analysis-page .ga-chart-card__body.chart-fallback-active canvas {
|
||||
display: none !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body.chart-empty-active canvas {
|
||||
display: none !important;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-empty {
|
||||
position: absolute;
|
||||
inset: var(--momo-space-4, 16px);
|
||||
border: 1px dashed var(--momo-page-accent-line);
|
||||
border-radius: var(--momo-radius-md, 6px);
|
||||
background: color-mix(in srgb, var(--momo-bg-paper) 86%, white);
|
||||
color: var(--momo-text-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
text-align: center;
|
||||
padding: var(--momo-space-4, 16px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-empty strong {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: var(--momo-text-body, 16px);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-empty span {
|
||||
max-width: 34rem;
|
||||
font-size: var(--momo-text-caption, 12px);
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-fallback {
|
||||
position: absolute;
|
||||
inset: var(--momo-space-4, 16px);
|
||||
@@ -312,6 +341,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body:not(.chart-empty-active) .ga-chart-empty {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body.chart-fallback-active .ga-chart-fallback {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,17 @@
|
||||
const node = document.getElementById('daily-sales-data');
|
||||
if (!node && window.__DAILY_SALES__) return window.__DAILY_SALES__;
|
||||
if (!node) return null;
|
||||
const rawPayload = [
|
||||
node.content && node.content.textContent,
|
||||
node.textContent,
|
||||
node.innerHTML
|
||||
].find(value => value && value.trim());
|
||||
if (!rawPayload) {
|
||||
console.error('[daily_sales] chart data is empty');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(node.textContent || '{}');
|
||||
return JSON.parse(rawPayload);
|
||||
} catch (error) {
|
||||
console.error('[daily_sales] chart data parse failed:', error);
|
||||
return null;
|
||||
@@ -199,6 +208,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function hasSeriesData(labels, ...seriesList) {
|
||||
return Array.isArray(labels) &&
|
||||
labels.length > 0 &&
|
||||
seriesList.some(series => Array.isArray(series) && series.length > 0);
|
||||
}
|
||||
|
||||
function renderChartEmpty(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.chart-container') : null;
|
||||
if (!wrap || wrap.querySelector('.chart-empty-state')) return;
|
||||
wrap.classList.add('chart-empty-active');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'chart-empty-state';
|
||||
empty.innerHTML = `<strong>尚無可繪製資料</strong><span>${message}</span>`;
|
||||
wrap.appendChild(empty);
|
||||
}
|
||||
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
function makeLineDataset(label, data, color, yAxisID) {
|
||||
return {
|
||||
@@ -229,7 +256,11 @@
|
||||
// -- Chart 1: trend (multi-line) --------------------------------------
|
||||
function renderTrend() {
|
||||
const el = document.getElementById('trendChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.labels, safe.revenue, safe.profit, safe.avg_price, safe.qty)) {
|
||||
renderChartEmpty('trendChart', '目前圖表 payload 沒有日別業績序列,請確認匯入資料與快取狀態。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
@@ -276,7 +307,11 @@
|
||||
// -- Chart 2: DoD (multi-line %) --------------------------------------
|
||||
function renderDod() {
|
||||
const el = document.getElementById('dodChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.labels, safe.dod_revenue, safe.dod_profit, safe.dod_avg_price, safe.dod_qty)) {
|
||||
renderChartEmpty('dodChart', '目前沒有 Day-over-Day 成長率序列。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
@@ -312,7 +347,11 @@
|
||||
// -- Chart 3: WoW (multi-line %, 前 7 天淡灰) -------------------------
|
||||
function renderWow() {
|
||||
const el = document.getElementById('wowChart');
|
||||
if (!el || !safe.labels.length) return;
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.labels, safe.wow_revenue, safe.wow_profit, safe.wow_avg_price, safe.wow_qty)) {
|
||||
renderChartEmpty('wowChart', '目前沒有 Week-over-Week 成長率序列。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'line',
|
||||
@@ -355,7 +394,11 @@
|
||||
// -- Chart 4: Top 10 (橫向 bar) ---------------------------------------
|
||||
function renderTop10() {
|
||||
const el = document.getElementById('top10Chart');
|
||||
if (!el || !safe.top10_labels.length) return;
|
||||
if (!el) return;
|
||||
if (!hasSeriesData(safe.top10_labels, safe.top10_values)) {
|
||||
renderChartEmpty('top10Chart', '所選日期沒有可排序的商品業績資料。');
|
||||
return;
|
||||
}
|
||||
|
||||
rememberChart(new Chart(el, {
|
||||
type: 'bar',
|
||||
@@ -390,6 +433,10 @@
|
||||
function renderMarketingBar(elId, marketing, color) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el || !marketing) return;
|
||||
if (!hasSeriesData(marketing.labels, marketing.values)) {
|
||||
renderChartEmpty(elId, '所選期間沒有對應的行銷活動業績資料。');
|
||||
return;
|
||||
}
|
||||
const shades = Array.from({ length: marketing.labels.length }, (_, i) => {
|
||||
const a = 0.8 - i * 0.05;
|
||||
return rgba(color, Math.max(a, 0.25));
|
||||
|
||||
@@ -13,8 +13,17 @@
|
||||
function readGrowthData() {
|
||||
const node = document.getElementById('chart-data');
|
||||
if (!node) return null;
|
||||
const rawPayload = [
|
||||
node.content && node.content.textContent,
|
||||
node.textContent,
|
||||
node.innerHTML
|
||||
].find(value => value && value.trim());
|
||||
if (!rawPayload) {
|
||||
console.error('[growth_analysis] chart data is empty');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(node.textContent || '{}');
|
||||
return JSON.parse(rawPayload);
|
||||
} catch (error) {
|
||||
console.error('[growth_analysis] chart data parse failed:', error);
|
||||
return null;
|
||||
@@ -122,6 +131,24 @@
|
||||
renderHtmlBars('marginChart', data.labels, data.margin_rate, { mode: 'pct' });
|
||||
}
|
||||
|
||||
function hasSeriesData(labels, ...seriesList) {
|
||||
return Array.isArray(labels) &&
|
||||
labels.length > 0 &&
|
||||
seriesList.some(series => Array.isArray(series) && series.length > 0);
|
||||
}
|
||||
|
||||
function renderChartEmpty(canvasId, message) {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
const wrap = canvas ? canvas.closest('.ga-chart-card__body') : null;
|
||||
if (!wrap || wrap.querySelector('.ga-chart-empty')) return;
|
||||
wrap.classList.add('chart-empty-active');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'ga-chart-empty';
|
||||
empty.innerHTML = `<strong>尚無可繪製資料</strong><span>${message}</span>`;
|
||||
wrap.appendChild(empty);
|
||||
}
|
||||
|
||||
function loadChartJs() {
|
||||
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
|
||||
return window.EwoooCChartTheme.loadChartJs();
|
||||
@@ -144,6 +171,9 @@
|
||||
const marginEl = document.getElementById('marginChart');
|
||||
if (!revenueEl || !momEl || !aovEl || !marginEl) return;
|
||||
|
||||
if (!hasSeriesData(data.labels, data.revenue, data.yoy)) {
|
||||
renderChartEmpty('revenueChart', '目前圖表 payload 沒有月營收或年增率序列。');
|
||||
} else {
|
||||
rememberChart(new Chart(revenueEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -199,7 +229,11 @@
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (!hasSeriesData(data.labels, data.mom)) {
|
||||
renderChartEmpty('momChart', '目前圖表 payload 沒有月增率序列。');
|
||||
} else {
|
||||
rememberChart(new Chart(momEl, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
@@ -226,7 +260,11 @@
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (!hasSeriesData(data.labels, data.aov)) {
|
||||
renderChartEmpty('aovChart', '目前圖表 payload 沒有平均單價序列。');
|
||||
} else {
|
||||
rememberChart(new Chart(aovEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -257,7 +295,11 @@
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (!hasSeriesData(data.labels, data.margin_rate)) {
|
||||
renderChartEmpty('marginChart', '目前圖表 payload 沒有毛利率序列。');
|
||||
} else {
|
||||
rememberChart(new Chart(marginEl, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -288,6 +330,7 @@
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
stabilizeCharts();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user