修正業績圖表資料載入
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-18 15:03:41 +08:00
parent 160173a270
commit 634d6ba457
10 changed files with 366 additions and 32 deletions

View File

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

View File

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

View File

@@ -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}")

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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