feat: add pchome growth command center
All checks were successful
CD Pipeline / deploy (push) Successful in 1m10s

This commit is contained in:
OoO
2026-06-18 15:14:38 +08:00
parent 8145c227c7
commit 89407b054f
9 changed files with 997 additions and 32 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.631"
SYSTEM_VERSION = "V10.632"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
AI 推薦路由模組
提供時事熱點商品推薦與文案生成功能

View File

@@ -1292,6 +1292,197 @@ def _load_competitor_decision_overview(session, latest_items=None):
return default
def _load_pchome_growth_command_center(session):
"""Build the first-screen PChome revenue command center from real sales and comparison data."""
cache_key = 'pchome_growth_command_center'
cache_ts_key = 'pchome_growth_command_center_timestamp'
cached = _DASHBOARD_DATA_CACHE.get(cache_key)
cached_ts = _DASHBOARD_DATA_CACHE.get(cache_ts_key)
if cached and cached_ts and time.time() - cached_ts < 120:
return cached
default = {
'success': False,
'latest_sales_date': None,
'sales_7d': 0,
'sales_prev_7d': 0,
'sales_delta_pct': None,
'sales_delta_label': '待匯入',
'sales_delta_tone': 'neutral',
'sales_current_width': 0,
'sales_prev_width': 0,
'qty_7d': 0,
'active_product_count': 0,
'declining_product_count': 0,
'top_category': '',
'top_category_sales_7d': 0,
'candidate_count': 0,
'mapped_count': 0,
'mapping_rate': 0,
'mapping_rate_width': 0,
'needs_mapping_count': 0,
'opportunity_sales_7d': 0,
'action_code_counts': {},
'action_counts': {},
'priority_tasks': [],
'strategy_lanes': [],
'top_opportunities': [],
'message': 'PChome 業績作戰資料整理中',
}
try:
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
engine = session.get_bind()
payload = build_pchome_growth_opportunities(engine, limit=16)
stats = payload.get('stats') or {}
opportunities = payload.get('opportunities') or []
sales_7d = _to_float(stats.get('overall_sales_7d')) or 0
sales_prev_7d = _to_float(stats.get('overall_sales_prev_7d')) or 0
sales_delta_pct = stats.get('overall_sales_delta_pct')
sales_delta_value = _to_float(sales_delta_pct)
max_sales = max(sales_7d, sales_prev_7d, 1)
mapping_rate = _to_float(stats.get('mapping_rate')) or 0
action_code_counts = stats.get('action_code_counts') or {}
if sales_delta_value is None:
sales_delta_label = '前期不足'
sales_delta_tone = 'neutral'
elif sales_delta_value < -10:
sales_delta_label = f'較前 7 天 {sales_delta_value:.1f}%'
sales_delta_tone = 'danger'
elif sales_delta_value < 0:
sales_delta_label = f'較前 7 天 {sales_delta_value:.1f}%'
sales_delta_tone = 'warning'
else:
sales_delta_label = f'較前 7 天 +{sales_delta_value:.1f}%'
sales_delta_tone = 'success'
priority_tasks = []
needs_mapping = int(stats.get('needs_mapping_count') or 0)
if needs_mapping:
priority_tasks.append({
'rank': 1,
'tone': 'danger' if mapping_rate < 25 else 'warning',
'title': f'先補 {needs_mapping} 個高業績商品對應',
'metric': f'比價覆蓋 {mapping_rate:.1f}%',
'action': 'backfill',
'button': '啟動補抓',
})
review_price_count = int(action_code_counts.get('review_price_or_promo') or 0)
if review_price_count:
priority_tasks.append({
'rank': len(priority_tasks) + 1,
'tone': 'danger',
'title': f'檢查 {review_price_count} 個 MOMO 低價壓力商品',
'metric': '調價 / 券 / 組合',
'action': 'price_review',
'button': '看價格壓力',
})
amplify_count = int(action_code_counts.get('amplify_price_advantage') or 0)
if amplify_count:
priority_tasks.append({
'rank': len(priority_tasks) + 1,
'tone': 'success',
'title': f'放大 {amplify_count} 個 PChome 價格優勢',
'metric': '曝光 / 文案 / 主推',
'action': 'ai_picks',
'button': '看主推清單',
})
recover_count = int(action_code_counts.get('recover_sales_momentum') or 0)
if recover_count:
priority_tasks.append({
'rank': len(priority_tasks) + 1,
'tone': 'warning',
'title': f'找回 {recover_count} 個下滑商品動能',
'metric': '庫存 / 頁面 / 活動',
'action': 'daily_sales',
'button': '看業績',
})
if not priority_tasks:
priority_tasks.append({
'rank': 1,
'tone': 'neutral',
'title': '先看今日高業績商品',
'metric': '業績穩定,持續監控',
'action': 'daily_sales',
'button': '看業績',
})
strategy_lanes = [
{
'key': 'price_pressure',
'label': 'MOMO 更便宜',
'value': review_price_count,
'action': '檢查售價 / 券 / 組合',
'tone': 'danger',
},
{
'key': 'price_advantage',
'label': 'PChome 有優勢',
'value': amplify_count,
'action': '拉曝光 / 強化文案',
'tone': 'success',
},
{
'key': 'bundle',
'label': '單品 / 組合待判斷',
'value': sum(1 for item in opportunities if (item.get('external_price') or {}).get('price_basis') == 'unit_price'),
'action': '看單位價,決定組合包',
'tone': 'warning',
},
{
'key': 'mapping',
'label': '找不到同款',
'value': needs_mapping,
'action': '補抓 MOMO 候選',
'tone': 'neutral',
},
]
command_center = dict(default)
command_center.update({
'success': bool(payload.get('success', True)),
'latest_sales_date': stats.get('overall_latest_sales_date') or stats.get('latest_sales_date'),
'sales_7d': sales_7d,
'sales_prev_7d': sales_prev_7d,
'sales_delta_pct': sales_delta_value,
'sales_delta_label': sales_delta_label,
'sales_delta_tone': sales_delta_tone,
'sales_current_width': round(sales_7d / max_sales * 100, 1),
'sales_prev_width': round(sales_prev_7d / max_sales * 100, 1),
'qty_7d': _to_float(stats.get('overall_qty_7d')) or 0,
'active_product_count': int(stats.get('active_product_count') or 0),
'declining_product_count': int(stats.get('declining_product_count') or 0),
'top_category': stats.get('top_category') or '',
'top_category_sales_7d': _to_float(stats.get('top_category_sales_7d')) or 0,
'candidate_count': int(stats.get('candidate_count') or 0),
'mapped_count': int(stats.get('mapped_count') or 0),
'mapping_rate': round(mapping_rate, 1),
'mapping_rate_width': round(max(0, min(100, mapping_rate)), 1),
'needs_mapping_count': needs_mapping,
'opportunity_sales_7d': _to_float(stats.get('opportunity_sales_7d') or stats.get('total_sales_7d')) or 0,
'action_code_counts': action_code_counts,
'action_counts': stats.get('action_counts') or {},
'priority_tasks': priority_tasks[:4],
'strategy_lanes': strategy_lanes,
'top_opportunities': opportunities[:6],
'message': payload.get('message') or default['message'],
})
_DASHBOARD_DATA_CACHE[cache_key] = command_center
_DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
return command_center
except Exception as exc:
sys_log.warning(f"[Dashboard] PChome 業績作戰台讀取略過: {exc}")
try:
session.rollback()
except Exception:
pass
_DASHBOARD_DATA_CACHE[cache_key] = default
_DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
return default
def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
"""讀取商品看板 AI 挑品清單排序,供列表篩選使用。"""
sql = text("""
@@ -1913,6 +2104,7 @@ def _render_pchome_review_dashboard(
review_status_options = _build_review_status_options(competitor_overview)
total_pages = math.ceil(review_queue_total / per_page) if review_queue_total else 0
total_products_history = int(competitor_overview.get('total_active') or 0)
pchome_growth_command_center = _load_pchome_growth_command_center(session)
return render_template(
'dashboard_v2.html',
@@ -1953,6 +2145,7 @@ def _render_pchome_review_dashboard(
most_active_category=None,
most_active_count=0,
competitor_overview=competitor_overview,
pchome_growth_command_center=pchome_growth_command_center,
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
build_momo_product_url=_build_momo_product_url,
active_page='dashboard',
@@ -2689,6 +2882,7 @@ def index():
_DASHBOARD_DATA_CACHE['full_data'] = data
_write_shared_full_dashboard_cache(data)
review_status_options = _build_review_status_options(competitor_overview)
pchome_growth_command_center = _load_pchome_growth_command_center(session)
template_name = 'dashboard_v2.html'
return render_template(template_name,
@@ -2728,6 +2922,7 @@ def index():
most_active_category=most_active_category,
most_active_count=most_active_count,
competitor_overview=competitor_overview,
pchome_growth_command_center=pchome_growth_command_center,
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
build_momo_product_url=_build_momo_product_url,
active_page='dashboard')

View File

@@ -10,6 +10,8 @@ API 參考:
- 商品 API: https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=XXX
"""
from __future__ import annotations
import re
import json
import time

View File

@@ -183,6 +183,114 @@ def _fetch_sales_rows(conn, limit: int) -> tuple[list[dict[str, Any]], str | Non
return mapped_rows, latest_date
def _fetch_sales_summary(conn) -> dict[str, Any]:
cols = _daily_sales_columns(conn)
missing = [key for key in ["sku", "date", "revenue"] if not cols.get(key)]
if missing:
return {}
dialect = conn.dialect.name
sku_col = _quote_identifier(cols["sku"])
date_col = _quote_identifier(cols["date"])
revenue_expr = _numeric_expr(cols["revenue"], dialect)
qty_expr = _numeric_expr(cols["qty"], dialect) if cols.get("qty") else "0"
category_text = _as_text_expr(cols["category"], dialect) if cols.get("category") else "NULL"
sku_text = _as_text_expr(cols["sku"], dialect)
if dialect == "postgresql":
sale_date_expr = f"NULLIF({date_col}::text, '')::date"
curr_window = "lw.latest_date - INTERVAL '6 days'"
prev_window_start = "lw.latest_date - INTERVAL '13 days'"
prev_window_end = "lw.latest_date - INTERVAL '6 days'"
else:
sale_date_expr = f"date({date_col})"
curr_window = "date(lw.latest_date, '-6 days')"
prev_window_start = "date(lw.latest_date, '-13 days')"
prev_window_end = "date(lw.latest_date, '-6 days')"
row = conn.execute(text(f"""
WITH sales_rows AS (
SELECT
NULLIF(TRIM({sku_text}), '') AS pchome_product_id,
NULLIF(TRIM({_as_text_expr(category_text, dialect, raw=True)}), '') AS category,
{sale_date_expr} AS sale_date,
{revenue_expr} AS revenue,
{qty_expr} AS qty
FROM daily_sales_snapshot
WHERE {sku_col} IS NOT NULL
),
latest_window AS (
SELECT MAX(sale_date) AS latest_date
FROM sales_rows
WHERE sale_date IS NOT NULL
),
per_product AS (
SELECT
sr.pchome_product_id,
MAX(sr.category) AS category,
SUM(CASE WHEN sr.sale_date >= {curr_window}
THEN sr.revenue ELSE 0 END) AS sales_7d,
SUM(CASE WHEN sr.sale_date >= {prev_window_start}
AND sr.sale_date < {prev_window_end}
THEN sr.revenue ELSE 0 END) AS sales_prev_7d,
SUM(CASE WHEN sr.sale_date >= {curr_window}
THEN sr.qty ELSE 0 END) AS qty_7d,
MAX(lw.latest_date) AS latest_sales_date
FROM sales_rows sr
CROSS JOIN latest_window lw
WHERE sr.pchome_product_id IS NOT NULL
GROUP BY sr.pchome_product_id
),
category_sales AS (
SELECT
COALESCE(NULLIF(category, ''), '未分類') AS category,
SUM(sales_7d) AS sales_7d
FROM per_product
GROUP BY COALESCE(NULLIF(category, ''), '未分類')
)
SELECT
MAX(latest_sales_date) AS latest_sales_date,
COALESCE(SUM(sales_7d), 0) AS overall_sales_7d,
COALESCE(SUM(sales_prev_7d), 0) AS overall_sales_prev_7d,
COALESCE(SUM(qty_7d), 0) AS overall_qty_7d,
SUM(CASE WHEN sales_7d > 0 THEN 1 ELSE 0 END) AS active_product_count,
SUM(CASE WHEN sales_prev_7d > 0 AND sales_7d < sales_prev_7d THEN 1 ELSE 0 END) AS declining_product_count,
(
SELECT category
FROM category_sales
WHERE sales_7d > 0
ORDER BY sales_7d DESC
LIMIT 1
) AS top_category,
(
SELECT COALESCE(sales_7d, 0)
FROM category_sales
WHERE sales_7d > 0
ORDER BY sales_7d DESC
LIMIT 1
) AS top_category_sales_7d
FROM per_product
""")).mappings().first()
if not row:
return {}
current = _to_float(row.get("overall_sales_7d"))
previous = _to_float(row.get("overall_sales_prev_7d"))
delta_pct = ((current - previous) / previous * 100) if previous else None
latest_date = row.get("latest_sales_date")
return {
"overall_latest_sales_date": latest_date.isoformat() if hasattr(latest_date, "isoformat") else str(latest_date or ""),
"overall_sales_7d": round(current, 2),
"overall_sales_prev_7d": round(previous, 2),
"overall_sales_delta_pct": round(delta_pct, 1) if delta_pct is not None else None,
"overall_qty_7d": round(_to_float(row.get("overall_qty_7d")), 2),
"active_product_count": int(row.get("active_product_count") or 0),
"declining_product_count": int(row.get("declining_product_count") or 0),
"top_category": row.get("top_category") or "",
"top_category_sales_7d": round(_to_float(row.get("top_category_sales_7d")), 2),
}
def _json_dict(value: Any) -> dict[str, Any]:
if not value:
return {}
@@ -610,7 +718,31 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
}
with engine.connect() as conn:
sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
sales_summary = _fetch_sales_summary(conn)
try:
sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit)
except RuntimeError as exc:
return {
"success": True,
"system_name": SYSTEM_DISPLAY_NAME,
"generated_at": generated_at,
"source_scope": source_scope,
"stats": {
"latest_sales_date": sales_summary.get("overall_latest_sales_date"),
"candidate_count": 0,
"mapped_count": 0,
"mapping_rate": 0,
"needs_mapping_count": 0,
"total_sales_7d": 0,
"opportunity_sales_7d": 0,
"action_counts": {},
"action_code_counts": {},
"external_data_source_counts": {},
**sales_summary,
},
"opportunities": [],
"message": str(exc),
}
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
@@ -625,10 +757,13 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
mapped_count = len(opportunities) - needs_mapping_count
mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1)
action_counts: dict[str, int] = {}
action_code_counts: dict[str, int] = {}
external_data_source_counts: dict[str, int] = {}
for item in opportunities:
label = item["recommended_action"]["label"]
code = item["recommended_action"]["code"]
action_counts[label] = action_counts.get(label, 0) + 1
action_code_counts[code] = action_code_counts.get(code, 0) + 1
external_price = item.get("external_price") or {}
data_source_label = external_price.get("data_source_label")
if data_source_label:
@@ -642,14 +777,17 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
"generated_at": generated_at,
"source_scope": source_scope,
"stats": {
"latest_sales_date": latest_sales_date,
"latest_sales_date": latest_sales_date or sales_summary.get("overall_latest_sales_date"),
"candidate_count": len(opportunities),
"mapped_count": mapped_count,
"mapping_rate": mapping_rate,
"needs_mapping_count": needs_mapping_count,
"total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
"opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2),
"action_counts": action_counts,
"action_code_counts": action_code_counts,
"external_data_source_counts": external_data_source_counts,
**sales_summary,
},
"opportunities": opportunities,
"message": "已整理今日 PChome 業績成長作戰清單。",

View File

@@ -9,6 +9,171 @@
{% block ewooo_content %}
<div class="dashboard-v2-stack">
{% set overview = competitor_overview | default({}) %}
{% set growth = pchome_growth_command_center | default({}) %}
<section class="growth-command-center" aria-label="PChome 業績成長作戰台">
<div class="growth-command-head">
<div>
<div class="dashboard-section-label">
<span class="num momo-mono">00</span>
<span class="title">PChome 業績成長作戰台</span>
<span class="meta momo-mono">業績日 {{ growth.latest_sales_date or '待匯入' }}</span>
</div>
<h1 class="growth-command-title">先看業績,再決定調價、曝光與組合</h1>
</div>
<div class="growth-command-status is-{{ growth.sales_delta_tone | default('neutral') }}">
<span>近 7 天</span>
<strong>{{ growth.sales_delta_label | default('待匯入') }}</strong>
</div>
</div>
<div class="growth-command-kpis">
<div class="growth-command-kpi is-sales">
<span class="growth-kpi-label">PChome 近 7 天業績</span>
<strong class="growth-kpi-value momo-mono">NT$ {{ growth.sales_7d | default(0) | int | number_format }}</strong>
<div class="growth-bar-stack" aria-label="近 7 天與前 7 天業績比較">
<div class="growth-bar-row">
<span>近 7 天</span>
<i><b style="width: {{ growth.sales_current_width | default(0) }}%"></b></i>
</div>
<div class="growth-bar-row is-muted">
<span>前 7 天</span>
<i><b style="width: {{ growth.sales_prev_width | default(0) }}%"></b></i>
</div>
</div>
</div>
<div class="growth-command-kpi">
<span class="growth-kpi-label">下滑商品</span>
<strong class="growth-kpi-value momo-mono">{{ growth.declining_product_count | default(0) | number_format }}</strong>
<em>{{ growth.active_product_count | default(0) | number_format }} 個有銷售商品</em>
</div>
<div class="growth-command-kpi">
<span class="growth-kpi-label">比價可用率</span>
<strong class="growth-kpi-value momo-mono">{{ growth.mapping_rate | default(0) }}%</strong>
<div class="growth-progress"><span style="width: {{ growth.mapping_rate_width | default(0) }}%"></span></div>
<em>{{ growth.mapped_count | default(0) }} / {{ growth.candidate_count | default(0) }} 個高業績商品已對應</em>
</div>
<div class="growth-command-kpi">
<span class="growth-kpi-label">最大業績分類</span>
<strong class="growth-kpi-value is-text">{{ growth.top_category or '待分類' }}</strong>
<em>NT$ {{ growth.top_category_sales_7d | default(0) | int | number_format }}</em>
</div>
</div>
<div class="growth-command-grid">
<div class="growth-priority-panel">
<div class="growth-panel-head">
<strong>今天先做</strong>
<span>照順序處理</span>
</div>
<div class="growth-task-list">
{% for task in growth.priority_tasks | default([]) %}
{% if task.action == 'backfill' %}
<button class="growth-task is-{{ task.tone | default('neutral') }}" type="button" data-pchome-backfill-trigger data-limit="80">
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
<strong>{{ task.title }}</strong>
<em>{{ task.metric }}</em>
<b>{{ task.button }}</b>
</button>
{% elif task.action == 'price_review' %}
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
<strong>{{ task.title }}</strong>
<em>{{ task.metric }}</em>
<b>{{ task.button }}</b>
</a>
{% elif task.action == 'ai_picks' %}
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
<strong>{{ task.title }}</strong>
<em>{{ task.metric }}</em>
<b>{{ task.button }}</b>
</a>
{% else %}
<a class="growth-task is-{{ task.tone | default('neutral') }}" href="{{ url_for('daily_sales.daily_sales') }}">
<span class="momo-mono">{{ '%02d'|format(task.rank) }}</span>
<strong>{{ task.title }}</strong>
<em>{{ task.metric }}</em>
<b>{{ task.button }}</b>
</a>
{% endif %}
{% endfor %}
</div>
</div>
<div class="growth-strategy-panel">
<div class="growth-panel-head">
<strong>MOMO 比價後怎麼做</strong>
<span>直接轉成銷售策略</span>
</div>
<div class="growth-strategy-grid">
{% for lane in growth.strategy_lanes | default([]) %}
<div class="growth-strategy-lane is-{{ lane.tone | default('neutral') }}">
<span>{{ lane.label }}</span>
<strong class="momo-mono">{{ lane.value | default(0) | number_format }}</strong>
<em>{{ lane.action }}</em>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="growth-opportunity-panel">
<div class="growth-panel-head">
<strong>高業績商品作戰清單</strong>
<span>業績 × MOMO 價格 × 下一步</span>
</div>
<div class="growth-opportunity-table-wrap">
<table class="growth-opportunity-table">
<thead>
<tr>
<th>商品</th>
<th class="text-end">近 7 天業績</th>
<th>趨勢</th>
<th>MOMO 比價</th>
<th>下一步</th>
</tr>
</thead>
<tbody>
{% for item in growth.top_opportunities | default([]) %}
{% set external = item.external_price or {} %}
<tr>
<td>
<a class="growth-product-name" href="https://24h.pchome.com.tw/prod/{{ item.pchome_product_id }}" target="_blank" rel="noopener noreferrer">{{ item.product_name }}</a>
<span class="growth-product-id momo-mono">{{ item.pchome_product_id }}</span>
</td>
<td class="text-end momo-mono">NT$ {{ item.sales_7d | default(0) | int | number_format }}</td>
<td>
{% if item.sales_delta_pct is not none %}
<span class="growth-delta {% if item.sales_delta_pct < 0 %}is-down{% else %}is-up{% endif %}">
{{ '%+.1f'|format(item.sales_delta_pct) }}%
</span>
{% else %}
<span class="growth-delta is-flat">新基準</span>
{% endif %}
</td>
<td>
{% if external %}
<span class="growth-compare-pill {% if external.gap_pct is not none and external.gap_pct < -5 %}is-risk{% elif external.gap_pct is not none and external.gap_pct > 5 %}is-win{% else %}is-even{% endif %}">
{{ external.price_basis_label or '商品總價' }}
{% if external.gap_pct is not none %} {{ '%+.1f'|format(external.gap_pct) }}%{% endif %}
</span>
{% else %}
<span class="growth-compare-pill is-missing">未配對</span>
{% endif %}
</td>
<td><span class="growth-action-chip">{{ item.recommended_action.label }}</span></td>
</tr>
{% else %}
<tr>
<td colspan="5" class="growth-empty">尚未產生成長作戰清單,請先完成 PChome 業績匯入。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">01</span>

View File

@@ -244,6 +244,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "warm_full_dashboard_cache" in route_source
assert "force_rebuild=False" in route_source
assert "def _load_competitor_decision_overview(session, latest_items=None)" in route_source
assert "def _load_pchome_growth_command_center(session)" in route_source
assert "build_pchome_growth_opportunities(engine, limit=16)" in route_source
assert "pchome_growth_command_center=pchome_growth_command_center" in route_source
assert "fetch_competitor_review_queue" in route_source
assert "fetch_competitor_review_queue_page" in route_source
assert "_load_competitor_review_page(" in route_source
@@ -295,6 +298,14 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "多款任選待確認" in route_source
assert "MockRecord" not in route_source
assert "{% for item in items %}" in dashboard
assert "PChome 業績成長作戰台" in dashboard
assert "先看業績,再決定調價、曝光與組合" in dashboard
assert "growth-command-kpis" in dashboard
assert "MOMO 比價後怎麼做" in dashboard
assert "高業績商品作戰清單" in dashboard
assert "業績 × MOMO 價格 × 下一步" in dashboard
assert "growth.mapping_rate" in dashboard
assert "growth.top_opportunities" in dashboard
assert "比價監控總覽" in dashboard
assert "決策支援覆蓋率" in dashboard
assert "overview.decision_support_rate" in dashboard
@@ -313,6 +324,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "review_status='catalog_identity_review'" in dashboard
assert "身份採用待核" in dashboard
assert "grid-template-columns: repeat(5, minmax(0, 1fr))" in dashboard_css
assert ".growth-command-center" in dashboard_css
assert ".growth-strategy-grid" in dashboard_css
assert ".growth-opportunity-table" in dashboard_css
assert "{% if review_total_is_estimated %}約 {% endif %}" in dashboard
assert "filter='ai_picks'" in dashboard
assert "filter='pchome_review'" in dashboard

View File

@@ -6,6 +6,400 @@
max-width: 100%;
}
.growth-command-center {
display: grid;
gap: 14px;
min-width: 0;
}
.growth-command-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 18px 20px;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.growth-command-title {
margin: 6px 0 0;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: clamp(1.35rem, 2vw, 2rem);
font-weight: 900;
letter-spacing: 0;
line-height: 1.16;
}
.growth-command-status {
display: grid;
gap: 4px;
min-width: 168px;
padding: 12px 14px;
background: color-mix(in srgb, var(--momo-bg-paper) 88%, transparent);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
text-align: right;
}
.growth-command-status span,
.growth-kpi-label,
.growth-panel-head span,
.growth-product-id {
color: var(--momo-text-tertiary);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.06em;
}
.growth-command-status strong {
color: var(--momo-text-primary);
font-family: var(--momo-font-mono);
font-size: 1rem;
font-weight: 900;
}
.growth-command-status.is-danger strong {
color: var(--momo-danger);
}
.growth-command-status.is-warning strong {
color: var(--momo-warning-text);
}
.growth-command-status.is-success strong {
color: var(--momo-success);
}
.growth-command-kpis {
display: grid;
grid-template-columns: 1.6fr repeat(3, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
.growth-command-kpi,
.growth-priority-panel,
.growth-strategy-panel,
.growth-opportunity-panel {
min-width: 0;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
box-shadow: var(--momo-shadow-soft);
}
.growth-command-kpi {
display: grid;
align-content: start;
gap: 8px;
min-height: 132px;
padding: 15px;
}
.growth-kpi-value {
overflow-wrap: anywhere;
color: var(--momo-text-primary);
font-size: 1.82rem;
font-weight: 900;
letter-spacing: 0;
line-height: 1.05;
}
.growth-kpi-value.is-text {
font-family: var(--momo-font-display);
font-size: 1.1rem;
line-height: 1.25;
}
.growth-command-kpi em,
.growth-task em,
.growth-strategy-lane em {
color: var(--momo-text-secondary);
font-size: 11px;
font-style: normal;
font-weight: 800;
line-height: 1.35;
}
.growth-bar-stack {
display: grid;
gap: 7px;
margin-top: 4px;
}
.growth-bar-row {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
gap: 8px;
align-items: center;
color: var(--momo-text-secondary);
font-size: 11px;
font-weight: 800;
}
.growth-bar-row i,
.growth-progress {
display: block;
overflow: hidden;
height: 9px;
background: color-mix(in srgb, var(--momo-border-light) 70%, transparent);
border-radius: 999px;
}
.growth-bar-row b,
.growth-progress span {
display: block;
height: 100%;
min-width: 2px;
background: var(--momo-warm-caramel);
border-radius: inherit;
}
.growth-bar-row.is-muted b {
background: color-mix(in srgb, var(--momo-ink) 42%, var(--momo-border-light));
}
.growth-command-grid {
display: grid;
grid-template-columns: minmax(320px, 0.92fr) minmax(0, 1.35fr);
gap: 12px;
min-width: 0;
}
.growth-priority-panel,
.growth-strategy-panel,
.growth-opportunity-panel {
padding: 14px;
}
.growth-panel-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.growth-panel-head strong {
color: var(--momo-text-primary);
font-size: 14px;
font-weight: 900;
}
.growth-task-list {
display: grid;
gap: 8px;
}
.growth-task {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 8px 10px;
align-items: center;
min-height: 66px;
width: 100%;
padding: 10px 12px;
color: var(--momo-text-primary);
background: color-mix(in srgb, var(--momo-bg-paper) 78%, transparent);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
text-align: left;
text-decoration: none;
}
button.growth-task {
cursor: pointer;
}
.growth-task:hover,
.growth-task:focus {
color: var(--momo-text-primary);
border-color: color-mix(in srgb, var(--momo-warm-rust) 34%, var(--momo-border-light));
background: var(--momo-bg-surface);
}
.growth-task span {
grid-row: span 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--momo-warm-rust);
background: rgba(172, 92, 58, 0.12);
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
.growth-task strong {
min-width: 0;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 900;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.growth-task b {
grid-row: span 2;
align-self: center;
padding: 7px 10px;
color: var(--momo-text-primary);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 7px;
font-size: 11px;
font-weight: 900;
white-space: nowrap;
}
.growth-task.is-danger {
box-shadow: inset 3px 0 0 var(--momo-danger);
}
.growth-task.is-warning {
box-shadow: inset 3px 0 0 var(--momo-warning);
}
.growth-task.is-success {
box-shadow: inset 3px 0 0 var(--momo-success);
}
.growth-strategy-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.growth-strategy-lane {
display: grid;
gap: 7px;
min-height: 118px;
padding: 12px;
background: color-mix(in srgb, var(--momo-bg-paper) 76%, transparent);
border: 1px solid var(--momo-border-light);
border-radius: 8px;
}
.growth-strategy-lane span {
color: var(--momo-text-secondary);
font-size: 12px;
font-weight: 900;
}
.growth-strategy-lane strong {
color: var(--momo-text-primary);
font-size: 1.72rem;
font-weight: 900;
line-height: 1;
}
.growth-strategy-lane.is-danger {
border-color: rgba(188, 75, 49, 0.32);
background: rgba(255, 244, 239, 0.72);
}
.growth-strategy-lane.is-success {
border-color: rgba(48, 133, 94, 0.24);
background: rgba(235, 248, 241, 0.72);
}
.growth-strategy-lane.is-warning {
border-color: rgba(210, 158, 58, 0.34);
background: rgba(255, 248, 231, 0.72);
}
.growth-opportunity-table-wrap {
overflow-x: auto;
}
.growth-opportunity-table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
}
.growth-opportunity-table th,
.growth-opportunity-table td {
padding: 11px 10px;
border-bottom: 1px solid var(--momo-border-light);
vertical-align: middle;
}
.growth-opportunity-table th {
color: var(--momo-text-tertiary);
font-size: 11px;
font-weight: 900;
text-align: left;
}
.growth-product-name {
display: block;
max-width: 420px;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 13px;
font-weight: 900;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.growth-product-id {
display: block;
margin-top: 3px;
}
.growth-delta,
.growth-compare-pill,
.growth-action-chip {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 5px 8px;
border: 1px solid var(--momo-border-light);
border-radius: 999px;
background: var(--momo-bg-paper);
color: var(--momo-text-primary);
font-size: 11px;
font-weight: 900;
white-space: nowrap;
}
.growth-delta.is-down,
.growth-compare-pill.is-risk {
border-color: rgba(188, 75, 49, 0.28);
background: rgba(255, 244, 239, 0.86);
color: var(--momo-danger);
}
.growth-delta.is-up,
.growth-compare-pill.is-win {
border-color: rgba(48, 133, 94, 0.24);
background: rgba(235, 248, 241, 0.86);
color: var(--momo-success);
}
.growth-compare-pill.is-missing {
border-color: rgba(210, 158, 58, 0.32);
background: rgba(255, 248, 231, 0.9);
color: var(--momo-warning-text);
}
.growth-empty {
color: var(--momo-text-secondary);
font-size: 13px;
font-weight: 800;
text-align: center;
}
.dashboard-v2-stack > section,
.dashboard-filter-card,
.dashboard-table-card,
@@ -1505,6 +1899,28 @@
}
@media (max-width: 980px) {
.growth-command-head {
align-items: stretch;
flex-direction: column;
}
.growth-command-status {
min-width: 0;
text-align: left;
}
.growth-command-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.growth-command-grid {
grid-template-columns: 1fr;
}
.growth-strategy-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -1536,9 +1952,36 @@
}
@media (max-width: 640px) {
.growth-command-kpis,
.growth-strategy-grid,
.dashboard-kpi-grid,
.dashboard-ai-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
}
.growth-command-head,
.growth-priority-panel,
.growth-strategy-panel,
.growth-opportunity-panel {
padding: 12px;
}
.growth-command-title {
font-size: 1.28rem;
}
.growth-task {
grid-template-columns: 32px minmax(0, 1fr);
}
.growth-task b {
grid-column: 2;
grid-row: auto;
width: fit-content;
}
.growth-task strong {
white-space: normal;
}
.dashboard-focus-grid {

View File

@@ -281,13 +281,19 @@ let priceChartInstance = null;
});
let pchomeBackfillPollTimer = null;
const DEFAULT_PCHOME_BACKFILL_LABEL = '補強 60 筆';
const DEFAULT_PCHOME_REFRESH_STALE_LABEL = '刷新過期 120 筆';
function getPchomeBackfillElements() {
const card = document.querySelector('[data-pchome-backfill-card]');
const triggers = Array.from(document.querySelectorAll('[data-pchome-backfill-trigger]'));
const refreshStaleTriggers = Array.from(document.querySelectorAll('[data-pchome-refresh-stale-trigger]'));
return {
card,
trigger: document.querySelector('[data-pchome-backfill-trigger]'),
refreshStaleTrigger: document.querySelector('[data-pchome-refresh-stale-trigger]'),
trigger: triggers[0],
triggers,
refreshStaleTrigger: refreshStaleTriggers[0],
refreshStaleTriggers,
status: document.querySelector('[data-pchome-backfill-status]'),
result: document.querySelector('[data-pchome-backfill-result]'),
progress: document.querySelector('[data-pchome-backfill-progress]'),
@@ -407,20 +413,22 @@ let priceChartInstance = null;
: (coverageSummary || '尚無最近結果');
}
}
if (elements.trigger) {
elements.trigger.disabled = running;
elements.trigger.classList.toggle('is-loading', running);
elements.trigger.innerHTML = running
elements.triggers.forEach(trigger => {
const limit = Number(trigger.dataset.limit || 60);
trigger.disabled = running;
trigger.classList.toggle('is-loading', running);
trigger.innerHTML = running
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
: '<i class="fas fa-search"></i> 補強 60 筆';
}
if (elements.refreshStaleTrigger) {
elements.refreshStaleTrigger.disabled = running;
elements.refreshStaleTrigger.classList.toggle('is-loading', running);
elements.refreshStaleTrigger.innerHTML = running
: `<i class="fas fa-search"></i> 補強 ${limit}`;
});
elements.refreshStaleTriggers.forEach(trigger => {
const limit = Number(trigger.dataset.limit || 120);
trigger.disabled = running;
trigger.classList.toggle('is-loading', running);
trigger.innerHTML = running
? '<i class="fas fa-spinner fa-spin"></i> 執行中'
: '<i class="fas fa-rotate"></i> 刷新過期 120 筆';
}
: `<i class="fas fa-rotate"></i> 刷新過期 ${limit}`;
});
if (running) {
schedulePchomeBackfillPoll();
@@ -446,13 +454,14 @@ let priceChartInstance = null;
});
}
function backfillPchomeMatches() {
function backfillPchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.trigger) return;
const limit = Number(elements.trigger.dataset.limit || 60);
const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.trigger;
const limit = Number(trigger.dataset.limit || 60);
if (!confirm(`啟動 PChome 比價補強 ${limit} 筆?會先刷新舊 identity再重評近門檻與補抓未配對商品。`)) return;
elements.trigger.disabled = true;
trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出比價補強任務';
}
@@ -476,19 +485,18 @@ let priceChartInstance = null;
if (elements.status) {
elements.status.textContent = error.message || 'PChome 比價補強啟動失敗';
}
if (elements.trigger) {
elements.trigger.disabled = false;
}
trigger.disabled = false;
});
}
function refreshStalePchomeMatches() {
function refreshStalePchomeMatches(activeTrigger) {
const elements = getPchomeBackfillElements();
if (!elements.card || !elements.refreshStaleTrigger) return;
const limit = Number(elements.refreshStaleTrigger.dataset.limit || 120);
const trigger = activeTrigger && activeTrigger.dataset ? activeTrigger : elements.refreshStaleTrigger;
const limit = Number(trigger.dataset.limit || 120);
if (!confirm(`啟動 PChome 過期價格刷新 ${limit} 筆?`)) return;
elements.refreshStaleTrigger.disabled = true;
trigger.disabled = true;
if (elements.status) {
elements.status.textContent = '正在送出過期價格刷新任務';
}
@@ -512,19 +520,17 @@ let priceChartInstance = null;
if (elements.status) {
elements.status.textContent = error.message || 'PChome 過期價格刷新啟動失敗';
}
if (elements.refreshStaleTrigger) {
elements.refreshStaleTrigger.disabled = false;
}
trigger.disabled = false;
});
}
window.backfillPchomeMatches = backfillPchomeMatches;
window.refreshStalePchomeMatches = refreshStalePchomeMatches;
document.querySelectorAll('[data-pchome-backfill-trigger]').forEach(button => {
button.addEventListener('click', backfillPchomeMatches);
button.addEventListener('click', () => backfillPchomeMatches(button));
});
document.querySelectorAll('[data-pchome-refresh-stale-trigger]').forEach(button => {
button.addEventListener('click', refreshStalePchomeMatches);
button.addEventListener('click', () => refreshStalePchomeMatches(button));
});
loadPchomeBackfillStatus();