This commit is contained in:
@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.103"
|
||||
SYSTEM_VERSION = "V10.105"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -52,6 +52,356 @@ from utils.df_helpers import find_col # noqa: E402, F401
|
||||
from utils.security import validate_table_name, safe_read_sql # noqa: E402, F401
|
||||
|
||||
|
||||
def _growth_empty_payload(now_taipei=None):
|
||||
now_taipei = now_taipei or datetime.now(TAIPEI_TZ)
|
||||
return (
|
||||
{
|
||||
'labels': [],
|
||||
'revenue': [],
|
||||
'profit': [],
|
||||
'orders': [],
|
||||
'aov': [],
|
||||
'mom': [],
|
||||
'yoy': [],
|
||||
'margin_rate': []
|
||||
},
|
||||
{
|
||||
'ytd_revenue': 0,
|
||||
'ytd_growth': 0,
|
||||
'current_year': now_taipei.year,
|
||||
'recent_aov': 0,
|
||||
'total_orders': 0
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _growth_number(value):
|
||||
try:
|
||||
number = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return number if math.isfinite(number) else 0.0
|
||||
|
||||
|
||||
def _growth_month_iter(start_month, end_month):
|
||||
year = start_month.year
|
||||
month = start_month.month
|
||||
while (year, month) <= (end_month.year, end_month.month):
|
||||
yield datetime(year, month, 1).date()
|
||||
month += 1
|
||||
if month > 12:
|
||||
year += 1
|
||||
month = 1
|
||||
|
||||
|
||||
def _build_growth_chart_data(monthly_rows):
|
||||
monthly_values = {}
|
||||
for row in monthly_rows:
|
||||
month_raw = row.get('month_start')
|
||||
month_dt = pd.to_datetime(month_raw, errors='coerce')
|
||||
if pd.isna(month_dt):
|
||||
continue
|
||||
month_key = month_dt.date().replace(day=1)
|
||||
monthly_values[month_key] = {
|
||||
'amount': _growth_number(row.get('amount')),
|
||||
'profit': _growth_number(row.get('profit')),
|
||||
'orders': int(_growth_number(row.get('orders') or row.get('volume'))),
|
||||
}
|
||||
|
||||
if not monthly_values:
|
||||
return None
|
||||
|
||||
months = list(_growth_month_iter(min(monthly_values), max(monthly_values)))
|
||||
revenue = []
|
||||
profit = []
|
||||
orders = []
|
||||
aov = []
|
||||
margin_rate = []
|
||||
mom = []
|
||||
yoy = []
|
||||
|
||||
for index, month_key in enumerate(months):
|
||||
item = monthly_values.get(month_key, {'amount': 0, 'profit': 0, 'orders': 0})
|
||||
amount = item['amount']
|
||||
profit_value = item['profit']
|
||||
order_count = item['orders']
|
||||
|
||||
revenue.append(amount)
|
||||
profit.append(profit_value)
|
||||
orders.append(order_count)
|
||||
aov.append(round(amount / order_count, 0) if order_count > 0 else 0)
|
||||
margin_rate.append(round((profit_value / amount) * 100, 1) if amount > 0 else 0)
|
||||
|
||||
prev_amount = revenue[index - 1] if index >= 1 else 0
|
||||
last_year_amount = revenue[index - 12] if index >= 12 else 0
|
||||
mom.append(round(((amount - prev_amount) / prev_amount) * 100, 2) if prev_amount > 0 else 0)
|
||||
yoy.append(round(((amount - last_year_amount) / last_year_amount) * 100, 2) if last_year_amount > 0 else 0)
|
||||
|
||||
return {
|
||||
'labels': [month.strftime('%Y-%m') for month in months],
|
||||
'revenue': revenue,
|
||||
'profit': profit,
|
||||
'orders': orders,
|
||||
'aov': aov,
|
||||
'mom': mom,
|
||||
'yoy': yoy,
|
||||
'margin_rate': margin_rate
|
||||
}
|
||||
|
||||
|
||||
def _build_growth_kpi(kpi_row):
|
||||
if not kpi_row:
|
||||
return None
|
||||
|
||||
max_dt = pd.to_datetime(kpi_row.get('max_date'), errors='coerce')
|
||||
current_year = int(kpi_row.get('current_year') or (max_dt.year if not pd.isna(max_dt) else datetime.now(TAIPEI_TZ).year))
|
||||
ytd_revenue = _growth_number(kpi_row.get('ytd_revenue'))
|
||||
last_ytd_revenue = _growth_number(kpi_row.get('last_ytd_revenue'))
|
||||
recent_revenue = _growth_number(kpi_row.get('recent_revenue'))
|
||||
recent_orders = int(_growth_number(kpi_row.get('recent_orders')))
|
||||
|
||||
return {
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 if last_ytd_revenue > 0 else 0,
|
||||
'current_year': current_year,
|
||||
'recent_aov': recent_revenue / recent_orders if recent_orders > 0 else 0,
|
||||
'total_orders': int(_growth_number(kpi_row.get('total_orders')))
|
||||
}
|
||||
|
||||
|
||||
def _fetch_growth_payload_summary(engine):
|
||||
"""優先使用月結摘要表,避免成長頁冷 worker 掃 70+ 萬列原始明細。"""
|
||||
table_name = 'monthly_summary_analysis'
|
||||
if not inspect(engine).has_table(table_name):
|
||||
return None
|
||||
|
||||
table_ref = f'"{table_name}"'
|
||||
if engine.dialect.name == 'postgresql':
|
||||
summary_sql = text(f"""
|
||||
SELECT
|
||||
make_date("year"::int, "month"::int, 1) AS month_start,
|
||||
SUM(COALESCE(sales_amt_curr, 0)) AS amount,
|
||||
SUM(COALESCE(profit_amt_curr, 0)) AS profit,
|
||||
SUM(COALESCE(sales_vol_curr, 0)) AS volume
|
||||
FROM {table_ref}
|
||||
WHERE "year" IS NOT NULL
|
||||
AND "month" IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""")
|
||||
else:
|
||||
summary_sql = text(f"""
|
||||
SELECT
|
||||
printf('%04d-%02d-01', "year", "month") AS month_start,
|
||||
SUM(COALESCE(sales_amt_curr, 0)) AS amount,
|
||||
SUM(COALESCE(profit_amt_curr, 0)) AS profit,
|
||||
SUM(COALESCE(sales_vol_curr, 0)) AS volume
|
||||
FROM {table_ref}
|
||||
WHERE "year" IS NOT NULL
|
||||
AND "month" IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""")
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(summary_sql).mappings().all()
|
||||
|
||||
chart_data = _build_growth_chart_data(rows)
|
||||
if not chart_data or not chart_data['labels']:
|
||||
return None
|
||||
|
||||
latest_label = chart_data['labels'][-1]
|
||||
current_year = int(latest_label[:4])
|
||||
latest_month = int(latest_label[5:7])
|
||||
last_year = current_year - 1
|
||||
|
||||
ytd_revenue = 0.0
|
||||
last_ytd_revenue = 0.0
|
||||
for label, revenue in zip(chart_data['labels'], chart_data['revenue']):
|
||||
year = int(label[:4])
|
||||
month = int(label[5:7])
|
||||
if year == current_year and month <= latest_month:
|
||||
ytd_revenue += _growth_number(revenue)
|
||||
elif year == last_year and month <= latest_month:
|
||||
last_ytd_revenue += _growth_number(revenue)
|
||||
|
||||
latest_revenue = _growth_number(chart_data['revenue'][-1])
|
||||
latest_volume = int(_growth_number(chart_data['orders'][-1]))
|
||||
kpi = {
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 if last_ytd_revenue > 0 else 0,
|
||||
'current_year': current_year,
|
||||
'recent_aov': latest_revenue / latest_volume if latest_volume > 0 else 0,
|
||||
'total_orders': int(sum(_growth_number(value) for value in chart_data['orders']))
|
||||
}
|
||||
|
||||
return chart_data, kpi
|
||||
|
||||
|
||||
def _fetch_growth_payload_sql(engine, table_name):
|
||||
"""用 DB 聚合取代全表 pandas 載入,降低成長分析冷啟動 TTFB。"""
|
||||
table_name = validate_table_name(table_name)
|
||||
table_ref = f'"{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)'
|
||||
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
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""")
|
||||
kpi_sql = text(f"""
|
||||
WITH bounds AS (
|
||||
SELECT MAX({date_expr}) AS max_date
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM b.max_date)::int AS current_year,
|
||||
b.max_date::text AS max_date,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN {date_expr} >= date_trunc('year', b.max_date)::date
|
||||
AND {date_expr} <= b.max_date
|
||||
THEN {amount_expr} ELSE 0 END), 0) AS ytd_revenue,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN {date_expr} >= (date_trunc('year', b.max_date) - interval '1 year')::date
|
||||
AND {date_expr} <= (b.max_date - interval '1 year')::date
|
||||
THEN {amount_expr} ELSE 0 END), 0) AS last_ytd_revenue,
|
||||
COALESCE(SUM(CASE
|
||||
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
|
||||
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
|
||||
FROM {table_ref}
|
||||
CROSS JOIN bounds b
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY b.max_date
|
||||
""")
|
||||
else:
|
||||
monthly_sql = text(f"""
|
||||
SELECT
|
||||
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
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""")
|
||||
kpi_sql = text(f"""
|
||||
WITH bounds AS (
|
||||
SELECT date(MAX("日期")) AS max_date
|
||||
FROM {table_ref}
|
||||
WHERE "日期" IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
CAST(strftime('%Y', b.max_date) AS INTEGER) AS current_year,
|
||||
b.max_date AS max_date,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN date("日期") >= date(b.max_date, 'start of year')
|
||||
AND date("日期") <= b.max_date
|
||||
THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS ytd_revenue,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN date("日期") >= date(b.max_date, 'start of year', '-1 year')
|
||||
AND date("日期") <= date(b.max_date, '-1 year')
|
||||
THEN COALESCE(CAST("總業績" AS REAL), 0) ELSE 0 END), 0) AS last_ytd_revenue,
|
||||
COALESCE(SUM(CASE
|
||||
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
|
||||
WHEN date("日期") >= date(b.max_date, '-30 days')
|
||||
AND date("日期") <= b.max_date
|
||||
THEN "訂單編號" END) AS recent_orders,
|
||||
COUNT(DISTINCT "訂單編號") AS total_orders
|
||||
FROM {table_ref}
|
||||
CROSS JOIN bounds b
|
||||
WHERE "日期" IS NOT NULL
|
||||
GROUP BY b.max_date
|
||||
""")
|
||||
|
||||
with engine.connect() as conn:
|
||||
monthly_rows = conn.execute(monthly_sql).mappings().all()
|
||||
kpi_row = conn.execute(kpi_sql).mappings().first()
|
||||
|
||||
chart_data = _build_growth_chart_data(monthly_rows)
|
||||
kpi = _build_growth_kpi(kpi_row)
|
||||
if not chart_data or not kpi:
|
||||
return None
|
||||
return chart_data, kpi
|
||||
|
||||
|
||||
def _fetch_growth_payload_pandas(engine, table_name):
|
||||
req_cols = ['日期', '總業績', '訂單編號', '總成本', '數量']
|
||||
df = safe_read_sql(table_name, columns=req_cols, engine=engine)
|
||||
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
|
||||
df = df.dropna(subset=['dt'])
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
|
||||
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
|
||||
df['volume'] = pd.to_numeric(df['數量'], errors='coerce').fillna(0)
|
||||
df['profit'] = df['amount'] - df['cost']
|
||||
|
||||
monthly_stats = df.set_index('dt').resample('MS').agg({
|
||||
'amount': 'sum',
|
||||
'profit': 'sum',
|
||||
'volume': 'sum'
|
||||
}).rename(columns={'volume': 'orders'})
|
||||
|
||||
monthly_rows = [
|
||||
{
|
||||
'month_start': month_start,
|
||||
'amount': row['amount'],
|
||||
'profit': row['profit'],
|
||||
'orders': row['orders'],
|
||||
}
|
||||
for month_start, row in monthly_stats.iterrows()
|
||||
]
|
||||
chart_data = _build_growth_chart_data(monthly_rows)
|
||||
|
||||
current_year = df['dt'].max().year
|
||||
last_year = current_year - 1
|
||||
ytd_mask = df['dt'].dt.year == current_year
|
||||
last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
|
||||
last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
|
||||
|
||||
ytd_revenue = float(df.loc[ytd_mask, 'amount'].sum())
|
||||
last_ytd_revenue = float(df.loc[last_ytd_mask, 'amount'].sum())
|
||||
recent_revenue = float(df.loc[last_month_mask, 'amount'].sum())
|
||||
recent_orders = int(df.loc[last_month_mask, 'volume'].sum())
|
||||
|
||||
kpi = {
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 if last_ytd_revenue > 0 else 0,
|
||||
'current_year': current_year,
|
||||
'recent_aov': recent_revenue / recent_orders if recent_orders > 0 else 0,
|
||||
'total_orders': int(monthly_stats['orders'].sum())
|
||||
}
|
||||
|
||||
if not chart_data:
|
||||
return None
|
||||
return chart_data, kpi
|
||||
|
||||
|
||||
def _get_filtered_sales_data(cache_key):
|
||||
"""
|
||||
🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選
|
||||
@@ -1279,27 +1629,10 @@ def growth_analysis():
|
||||
from services.cache_service import (
|
||||
get_growth_cache, set_growth_cache, is_growth_cache_valid
|
||||
)
|
||||
import time
|
||||
|
||||
def _render_growth_empty(message=None):
|
||||
now_taipei = datetime.now(TAIPEI_TZ)
|
||||
empty_chart_data = {
|
||||
'labels': [],
|
||||
'revenue': [],
|
||||
'profit': [],
|
||||
'orders': [],
|
||||
'aov': [],
|
||||
'mom': [],
|
||||
'yoy': [],
|
||||
'margin_rate': []
|
||||
}
|
||||
empty_kpi = {
|
||||
'ytd_revenue': 0,
|
||||
'ytd_growth': 0,
|
||||
'current_year': now_taipei.year,
|
||||
'recent_aov': 0,
|
||||
'total_orders': 0
|
||||
}
|
||||
empty_chart_data, empty_kpi = _growth_empty_payload(now_taipei)
|
||||
return render_template('growth_analysis.html',
|
||||
chart_data=empty_chart_data,
|
||||
kpi=empty_kpi,
|
||||
@@ -1335,71 +1668,25 @@ def growth_analysis():
|
||||
table_name = 'realtime_sales_monthly'
|
||||
|
||||
inspector = inspect(db.engine)
|
||||
if table_name not in inspector.get_table_names():
|
||||
if not inspector.has_table(table_name):
|
||||
return _render_growth_empty(f"尚未匯入業績資料 ({table_name})")
|
||||
|
||||
req_cols = ['日期', '總業績', '訂單編號', '總成本']
|
||||
df = safe_read_sql(table_name, columns=req_cols, engine=db.engine)
|
||||
try:
|
||||
payload = _fetch_growth_payload_summary(db.engine)
|
||||
except Exception as summary_error:
|
||||
sys_log.warning(f"[GrowthAnalysis] 月結摘要聚合失敗,改走明細聚合: {summary_error}")
|
||||
payload = None
|
||||
|
||||
if df.empty:
|
||||
if not payload:
|
||||
try:
|
||||
payload = _fetch_growth_payload_sql(db.engine, table_name)
|
||||
except Exception as sql_error:
|
||||
sys_log.warning(f"[GrowthAnalysis] SQL 聚合失敗,回退 pandas 路徑: {sql_error}")
|
||||
payload = _fetch_growth_payload_pandas(db.engine, table_name)
|
||||
|
||||
if not payload:
|
||||
return _render_growth_empty(f"資料表 {table_name} 為空")
|
||||
|
||||
df['dt'] = pd.to_datetime(df['日期'], errors='coerce')
|
||||
df = df.dropna(subset=['dt'])
|
||||
df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0)
|
||||
df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0)
|
||||
df['profit'] = df['amount'] - df['cost']
|
||||
|
||||
monthly_stats = df.set_index('dt').resample('MS').agg({
|
||||
'amount': 'sum',
|
||||
'profit': 'sum',
|
||||
'訂單編號': 'nunique'
|
||||
}).rename(columns={'訂單編號': 'orders'})
|
||||
|
||||
monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders']
|
||||
monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100
|
||||
monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100
|
||||
monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100
|
||||
monthly_stats = monthly_stats.fillna(0)
|
||||
|
||||
labels = monthly_stats.index.strftime('%Y-%m').tolist()
|
||||
|
||||
chart_data = {
|
||||
'labels': labels,
|
||||
'revenue': monthly_stats['amount'].tolist(),
|
||||
'profit': monthly_stats['profit'].tolist(),
|
||||
'orders': monthly_stats['orders'].tolist(),
|
||||
'aov': monthly_stats['aov'].round(0).tolist(),
|
||||
'mom': monthly_stats['mom'].round(2).tolist(),
|
||||
'yoy': monthly_stats['yoy'].round(2).tolist(),
|
||||
'margin_rate': monthly_stats['margin_rate'].round(1).tolist()
|
||||
}
|
||||
|
||||
current_year = df['dt'].max().year
|
||||
last_year = current_year - 1
|
||||
|
||||
ytd_mask = df['dt'].dt.year == current_year
|
||||
last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear)
|
||||
|
||||
ytd_revenue = df.loc[ytd_mask, 'amount'].sum()
|
||||
last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum()
|
||||
|
||||
ytd_growth = 0
|
||||
if last_ytd_revenue > 0:
|
||||
ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100
|
||||
|
||||
last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30))
|
||||
recent_revenue = df.loc[last_month_mask, 'amount'].sum()
|
||||
recent_orders = df.loc[last_month_mask, '訂單編號'].nunique()
|
||||
recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0
|
||||
|
||||
kpi = {
|
||||
'ytd_revenue': ytd_revenue,
|
||||
'ytd_growth': ytd_growth,
|
||||
'current_year': current_year,
|
||||
'recent_aov': recent_aov,
|
||||
'total_orders': monthly_stats['orders'].sum()
|
||||
}
|
||||
chart_data, kpi = payload
|
||||
|
||||
# 儲存快取
|
||||
set_growth_cache(chart_data, kpi)
|
||||
|
||||
@@ -47,16 +47,16 @@
|
||||
</article>
|
||||
|
||||
<article class="ga-kpi ga-kpi--aov">
|
||||
<div class="ga-kpi__label">近 30 天平均客單價 (AOV)</div>
|
||||
<div class="ga-kpi__label">最新月平均單價</div>
|
||||
<div class="ga-kpi__value">${{ "{:,.0f}".format(kpi.recent_aov) }}</div>
|
||||
<div class="ga-kpi__hint">真實訂單基礎 (Unique Order ID)</div>
|
||||
<div class="ga-kpi__hint">月結銷售額 ÷ 銷量</div>
|
||||
<i class="fas fa-shopping-cart ga-kpi__icon-bg"></i>
|
||||
</article>
|
||||
|
||||
<article class="ga-kpi ga-kpi--orders">
|
||||
<div class="ga-kpi__label">總訂單數 (Total Orders)</div>
|
||||
<div class="ga-kpi__label">總銷量</div>
|
||||
<div class="ga-kpi__value">{{ "{:,.0f}".format(kpi.total_orders) }}</div>
|
||||
<div class="ga-kpi__hint">全時期累計</div>
|
||||
<div class="ga-kpi__hint">全時期累計件數</div>
|
||||
<i class="fas fa-receipt ga-kpi__icon-bg"></i>
|
||||
</article>
|
||||
</section>
|
||||
@@ -86,7 +86,7 @@
|
||||
<section class="ga-chart-row ga-chart-row--6-6">
|
||||
<article class="ga-chart-card">
|
||||
<header class="ga-chart-card__head">
|
||||
<span class="ga-chart-card__title"><i class="fas fa-wallet"></i> 客單價趨勢 (AOV Trend)</span>
|
||||
<span class="ga-chart-card__title"><i class="fas fa-wallet"></i> 平均單價趨勢</span>
|
||||
</header>
|
||||
<div class="ga-chart-card__body" style="--ga-chart-h: 300px;">
|
||||
<canvas id="aovChart"></canvas>
|
||||
|
||||
@@ -9,72 +9,82 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--momo-space-3, 16px);
|
||||
padding: var(--momo-space-3, 16px) var(--momo-space-4, 20px);
|
||||
margin-top: var(--momo-space-3, 16px);
|
||||
background: var(--momo-surface-1);
|
||||
gap: var(--momo-space-4, 16px);
|
||||
padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px);
|
||||
margin-top: var(--momo-space-3, 12px);
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-subtle);
|
||||
border-radius: var(--momo-radius-md, 8px);
|
||||
box-shadow: var(--momo-shadow-soft);
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
box-shadow: var(--momo-shadow-md);
|
||||
}
|
||||
.growth-analysis-page .ga-page-head__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-2, 12px);
|
||||
gap: var(--momo-space-2, 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-page-head__icon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--momo-warm-olive, #6f7a4a);
|
||||
color: var(--momo-page-accent);
|
||||
font-size: var(--momo-text-title);
|
||||
}
|
||||
.growth-analysis-page .ga-page-head__h1 {
|
||||
font-size: var(--momo-text-h4, 1.25rem);
|
||||
font-weight: 800;
|
||||
color: var(--momo-text-strong);
|
||||
margin: 0;
|
||||
color: var(--momo-page-ink, var(--momo-text-primary));
|
||||
font-family: var(--momo-font-display);
|
||||
font-size: var(--momo-text-headline);
|
||||
font-weight: var(--momo-font-weight-black);
|
||||
line-height: var(--momo-line-height-tight);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-page-head__meta {
|
||||
font-size: var(--momo-text-sm, 0.85rem);
|
||||
color: var(--momo-text-tertiary);
|
||||
flex: 0 0 auto;
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-meta);
|
||||
line-height: var(--momo-line-height-base);
|
||||
text-align: right;
|
||||
}
|
||||
.growth-analysis-page .ga-page-head__meta strong {
|
||||
color: var(--momo-text-strong);
|
||||
font-weight: 700;
|
||||
color: var(--momo-text-primary);
|
||||
font-family: var(--momo-font-mono);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-empty-state {
|
||||
display: grid;
|
||||
gap: var(--momo-space-2, 8px);
|
||||
padding: var(--momo-space-8, 40px) var(--momo-space-5, 24px);
|
||||
margin-bottom: var(--momo-space-4, 20px);
|
||||
color: var(--momo-ink-secondary);
|
||||
background: var(--momo-surface-0);
|
||||
padding: var(--momo-space-7, 48px) var(--momo-space-5, 24px);
|
||||
margin-bottom: var(--momo-space-4, 16px);
|
||||
color: var(--momo-text-tertiary);
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: var(--momo-radius-md, 8px);
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-empty-state i {
|
||||
color: var(--momo-warm-honey-deep);
|
||||
font-size: var(--momo-text-2xl, 1.5rem);
|
||||
color: var(--momo-page-accent-dark);
|
||||
font-size: var(--momo-text-headline);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-empty-state h2 {
|
||||
margin: 0;
|
||||
color: var(--momo-ink-primary);
|
||||
font-size: var(--momo-text-lg, 1.125rem);
|
||||
font-weight: var(--momo-font-bold, 700);
|
||||
color: var(--momo-text-primary);
|
||||
font-size: var(--momo-text-title);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
line-height: var(--momo-line-height-tight);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-empty-state p {
|
||||
margin: 0;
|
||||
color: var(--momo-ink-tertiary);
|
||||
color: var(--momo-text-secondary);
|
||||
}
|
||||
|
||||
/* ── KPI Row ────────────────────────────────────────── */
|
||||
.growth-analysis-page .ga-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--momo-space-3, 16px);
|
||||
gap: var(--momo-space-3, 12px);
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.growth-analysis-page .ga-kpi-row { grid-template-columns: 1fr; }
|
||||
@@ -83,45 +93,46 @@
|
||||
.growth-analysis-page .ga-kpi {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: var(--momo-space-4, 24px);
|
||||
border-radius: var(--momo-radius-md, 8px);
|
||||
color: var(--momo-text-strong);
|
||||
background: var(--momo-surface-raised);
|
||||
min-height: 132px;
|
||||
padding: var(--momo-space-5, 24px);
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-bg-elevated);
|
||||
border: 1px solid var(--momo-border-strong);
|
||||
border-left: 4px solid var(--ga-kpi-accent, var(--momo-page-accent));
|
||||
box-shadow: var(--momo-shadow-soft);
|
||||
min-height: 132px;
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
box-shadow: var(--momo-shadow-md);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi--revenue {
|
||||
--ga-kpi-accent: var(--momo-page-accent);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi--aov {
|
||||
--ga-kpi-accent: var(--momo-tag-honey);
|
||||
--ga-kpi-accent: var(--momo-warning-text);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi--orders {
|
||||
--ga-kpi-accent: var(--momo-tag-olive);
|
||||
--ga-kpi-accent: var(--momo-success-text);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-kpi__label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: var(--momo-text-sm, 0.85rem);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--momo-text-muted);
|
||||
margin-bottom: var(--momo-space-2, 8px);
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-body-sm);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
letter-spacing: 0;
|
||||
line-height: var(--momo-line-height-tight);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__value {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--momo-text-strong);
|
||||
font-family: var(--momo-font-mono, ui-monospace, monospace);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: var(--momo-space-1, 4px);
|
||||
line-height: 1.08;
|
||||
color: var(--momo-text-primary);
|
||||
font-family: var(--momo-font-mono);
|
||||
font-feature-settings: "tnum";
|
||||
font-size: var(--momo-text-display);
|
||||
font-weight: var(--momo-font-weight-black);
|
||||
letter-spacing: 0;
|
||||
line-height: var(--momo-line-height-tight);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__delta {
|
||||
position: relative;
|
||||
@@ -129,39 +140,43 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-1, 6px);
|
||||
gap: var(--momo-space-1, 4px);
|
||||
margin-top: var(--momo-space-2, 8px);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
background: color-mix(in srgb, var(--ga-kpi-accent) 12%, var(--momo-surface));
|
||||
color: var(--ga-kpi-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--ga-kpi-accent) 28%, var(--momo-border-subtle));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
padding: var(--momo-space-1, 4px) var(--momo-space-2, 8px);
|
||||
color: var(--momo-tag-honey-text);
|
||||
background: var(--momo-tag-honey-bg);
|
||||
border: 1px solid var(--momo-tag-honey-border);
|
||||
border-radius: var(--momo-radius-sm, 2px);
|
||||
font-size: var(--momo-text-label);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__yoy {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--momo-font-mono);
|
||||
font-feature-settings: "tnum";
|
||||
font-size: var(--momo-text-body-sm);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__yoy.is-up { color: var(--momo-tag-rust); }
|
||||
.growth-analysis-page .ga-kpi__yoy.is-down { color: var(--momo-tag-olive); }
|
||||
.growth-analysis-page .ga-kpi__yoy.is-up { color: var(--momo-danger-text); }
|
||||
.growth-analysis-page .ga-kpi__yoy.is-down { color: var(--momo-success-text); }
|
||||
.growth-analysis-page .ga-kpi__hint {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: var(--momo-text-xs, 0.78rem);
|
||||
color: var(--momo-text-muted);
|
||||
margin-top: 2px;
|
||||
margin-top: var(--momo-space-1, 4px);
|
||||
color: var(--momo-text-secondary);
|
||||
font-size: var(--momo-text-meta);
|
||||
}
|
||||
.growth-analysis-page .ga-kpi__icon-bg {
|
||||
position: absolute;
|
||||
right: -15px;
|
||||
bottom: -15px;
|
||||
font-size: 6rem;
|
||||
right: calc(var(--momo-space-4, 16px) * -1);
|
||||
bottom: calc(var(--momo-space-4, 16px) * -1);
|
||||
color: var(--ga-kpi-accent);
|
||||
font-size: 88px;
|
||||
opacity: 0.1;
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
@@ -170,7 +185,7 @@
|
||||
/* ── Chart Row 變體 ─────────────────────────────────── */
|
||||
.growth-analysis-page .ga-chart-row {
|
||||
display: grid;
|
||||
gap: var(--momo-space-3, 16px);
|
||||
gap: var(--momo-space-3, 12px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-row--8-4 { grid-template-columns: 2fr 1fr; }
|
||||
.growth-analysis-page .ga-chart-row--6-6 { grid-template-columns: 1fr 1fr; }
|
||||
@@ -183,29 +198,32 @@
|
||||
.growth-analysis-page .ga-chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--momo-surface-1);
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-strong);
|
||||
border-radius: var(--momo-radius-md, 8px);
|
||||
box-shadow: var(--momo-shadow-soft);
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
box-shadow: var(--momo-shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__head {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--momo-surface-2);
|
||||
padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px);
|
||||
background: var(--momo-bg-elevated);
|
||||
border-bottom: 1px solid var(--momo-border-subtle);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--momo-space-1, 8px);
|
||||
font-weight: 800;
|
||||
color: var(--momo-text-strong);
|
||||
gap: var(--momo-space-2, 8px);
|
||||
color: var(--momo-text-primary);
|
||||
font-size: var(--momo-text-title);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
line-height: var(--momo-line-height-tight);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__title i {
|
||||
color: var(--momo-warm-olive, #6f7a4a);
|
||||
color: var(--momo-page-accent);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body {
|
||||
padding: var(--momo-space-3, 16px);
|
||||
padding: var(--momo-space-4, 16px);
|
||||
height: var(--ga-chart-h, 320px);
|
||||
}
|
||||
.growth-analysis-page .ga-chart-card__body canvas {
|
||||
@@ -217,19 +235,29 @@
|
||||
.growth-analysis-page .ga-page-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 14px 16px;
|
||||
padding: var(--momo-space-3, 12px) var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-page-head__h1 {
|
||||
font-size: var(--momo-text-title);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-page-head__meta {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-kpi {
|
||||
min-height: 112px;
|
||||
padding: 16px;
|
||||
padding: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-kpi__value {
|
||||
font-size: 1.45rem;
|
||||
font-size: var(--momo-text-headline);
|
||||
}
|
||||
|
||||
.growth-analysis-page .ga-chart-card__body {
|
||||
height: 260px !important;
|
||||
padding: var(--momo-space-3, 12px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,24 @@
|
||||
.growth-analysis-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--momo-space-3, 18px);
|
||||
gap: var(--momo-space-4, 16px);
|
||||
}
|
||||
|
||||
.growth-analysis-page .card {
|
||||
border: 1px solid var(--momo-border-strong);
|
||||
border-radius: var(--momo-radius-md, 8px);
|
||||
box-shadow: var(--momo-shadow-soft);
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--momo-surface-1, rgba(255, 253, 248, 0.94));
|
||||
border-radius: var(--momo-radius-md, 4px);
|
||||
box-shadow: var(--momo-shadow-md);
|
||||
margin-bottom: var(--momo-space-5, 24px);
|
||||
background: var(--momo-bg-surface);
|
||||
}
|
||||
|
||||
.growth-analysis-page .card-header {
|
||||
background: var(--momo-surface-2, rgba(250, 247, 240, 0.9));
|
||||
background: var(--momo-bg-elevated);
|
||||
border-bottom: 1px solid var(--momo-border-subtle);
|
||||
font-weight: 800;
|
||||
color: var(--momo-text-strong);
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: var(--momo-text-title);
|
||||
font-weight: var(--momo-font-weight-bold);
|
||||
color: var(--momo-text-primary);
|
||||
padding: var(--momo-space-4, 16px) var(--momo-space-5, 24px);
|
||||
}
|
||||
|
||||
/* ── KPI 卡片 ────────────────────────────────────────── */
|
||||
@@ -34,25 +35,26 @@
|
||||
}
|
||||
.growth-analysis-page .kpi-card .icon-bg {
|
||||
position: absolute;
|
||||
right: -15px;
|
||||
bottom: -15px;
|
||||
font-size: 6rem;
|
||||
right: calc(var(--momo-space-4, 16px) * -1);
|
||||
bottom: calc(var(--momo-space-4, 16px) * -1);
|
||||
font-size: 88px;
|
||||
opacity: 0.15;
|
||||
transform: rotate(-15deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
.growth-analysis-page .kpi-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
font-family: var(--momo-font-mono);
|
||||
font-feature-settings: "tnum";
|
||||
font-size: var(--momo-text-display);
|
||||
font-weight: var(--momo-font-weight-black);
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-bottom: var(--momo-space-1, 4px);
|
||||
}
|
||||
.growth-analysis-page .kpi-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.9;
|
||||
font-size: var(--momo-text-body-sm);
|
||||
font-weight: var(--momo-font-weight-semibold);
|
||||
letter-spacing: 0;
|
||||
color: var(--momo-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Bootstrap 覆寫:用 page palette 漸層取代原色 ─── */
|
||||
@@ -69,8 +71,8 @@
|
||||
/* ── 趨勢色 ─────────────────────────────────────────── */
|
||||
.growth-analysis-page .text-success,
|
||||
.growth-analysis-page .trend-up {
|
||||
color: var(--momo-warm-honey, #c89043) !important;
|
||||
color: var(--momo-warning-text) !important;
|
||||
}
|
||||
.growth-analysis-page .trend-down {
|
||||
color: var(--momo-warm-rust, #b5342f) !important;
|
||||
color: var(--momo-danger-text) !important;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
'use strict';
|
||||
|
||||
const data = JSON.parse(document.getElementById('chart-data').textContent);
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const token = (name, fallback) => rootStyle.getPropertyValue(name).trim() || fallback;
|
||||
|
||||
// 與 design system page-group=analytics 對齊的暖色 palette
|
||||
const chartPalette = {
|
||||
caramel: 'rgba(201, 100, 66, 1)',
|
||||
caramelSoft: 'rgba(201, 100, 66, 0.58)',
|
||||
honey: 'rgba(184, 132, 22, 1)',
|
||||
honeySoft: 'rgba(184, 132, 22, 0.58)',
|
||||
rust: 'rgba(181, 52, 47, 1)',
|
||||
rustSoft: 'rgba(181, 52, 47, 0.48)',
|
||||
mahogany: 'rgba(143, 69, 48, 1)',
|
||||
mahoganySoft: 'rgba(143, 69, 48, 0.12)'
|
||||
caramel: token('--momo-page-chart-2', '#c96442'),
|
||||
caramelSoft: token('--momo-warm-caramel-soft', 'rgba(201, 100, 66, 0.58)'),
|
||||
honey: token('--momo-page-accent', '#c89043'),
|
||||
honeySoft: token('--momo-page-accent-soft', 'rgba(200, 144, 67, 0.14)'),
|
||||
rust: token('--momo-danger-text', '#7a3210'),
|
||||
rustSoft: token('--momo-danger-bg', '#efd3c4')
|
||||
};
|
||||
|
||||
Chart.defaults.color = '#6f665a';
|
||||
Chart.defaults.borderColor = 'rgba(126, 111, 92, 0.18)';
|
||||
Chart.defaults.font.family = "'Noto Sans TC', 'Inter', system-ui, sans-serif";
|
||||
Chart.defaults.color = token('--momo-text-secondary', '#6b6155');
|
||||
Chart.defaults.borderColor = token('--momo-border-light', 'rgba(42, 37, 32, 0.10)');
|
||||
Chart.defaults.font.family = token('--momo-font-family', "'Inter', system-ui, sans-serif");
|
||||
|
||||
// 1) Revenue + YoY
|
||||
new Chart(document.getElementById('revenueChart'), {
|
||||
@@ -70,10 +70,10 @@
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: '平均客單價 ($)',
|
||||
label: '平均單價 ($)',
|
||||
data: data.aov,
|
||||
borderColor: chartPalette.caramel,
|
||||
backgroundColor: 'rgba(201, 100, 66, 0.12)',
|
||||
backgroundColor: chartPalette.caramelSoft,
|
||||
fill: true, tension: 0.4
|
||||
}]
|
||||
},
|
||||
@@ -92,7 +92,7 @@
|
||||
label: '毛利率 (%)',
|
||||
data: data.margin_rate,
|
||||
borderColor: chartPalette.honey,
|
||||
backgroundColor: 'rgba(184, 132, 22, 0.12)',
|
||||
backgroundColor: chartPalette.honeySoft,
|
||||
fill: true, tension: 0.4
|
||||
}]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user