From 89407b054f0d813e64a99c7702c51ada0cc20d44 Mon Sep 17 00:00:00 2001 From: OoO Date: Thu, 18 Jun 2026 15:14:38 +0800 Subject: [PATCH] feat: add pchome growth command center --- config.py | 2 +- routes/ai_routes.py | 2 + routes/dashboard_routes.py | 195 ++++++++++ services/momo_crawler.py | 2 + services/pchome_revenue_growth_service.py | 142 ++++++- templates/dashboard_v2.html | 165 ++++++++ tests/test_frontend_v2_assets.py | 14 + web/static/css/page-dashboard-v2.css | 445 +++++++++++++++++++++- web/static/js/page-dashboard-v2.js | 62 +-- 9 files changed, 997 insertions(+), 32 deletions(-) diff --git a/config.py b/config.py index 2891aed..4c3657b 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 07ec296..e3cb3db 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from __future__ import annotations + """ AI 推薦路由模組 提供時事熱點商品推薦與文案生成功能 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 6081a02..176730a 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -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') diff --git a/services/momo_crawler.py b/services/momo_crawler.py index 3baa54a..d919add 100644 --- a/services/momo_crawler.py +++ b/services/momo_crawler.py @@ -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 diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index bfb23f0..3949c72 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -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 業績成長作戰清單。", diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index cf2d626..e4ef66d 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -9,6 +9,171 @@ {% block ewooo_content %}
{% set overview = competitor_overview | default({}) %} + {% set growth = pchome_growth_command_center | default({}) %} +
+
+
+ +

先看業績,再決定調價、曝光與組合

+
+
+ 近 7 天 + {{ growth.sales_delta_label | default('待匯入') }} +
+
+ +
+
+ PChome 近 7 天業績 + NT$ {{ growth.sales_7d | default(0) | int | number_format }} +
+
+ 近 7 天 + +
+
+ 前 7 天 + +
+
+
+
+ 下滑商品 + {{ growth.declining_product_count | default(0) | number_format }} + {{ growth.active_product_count | default(0) | number_format }} 個有銷售商品 +
+
+ 比價可用率 + {{ growth.mapping_rate | default(0) }}% +
+ {{ growth.mapped_count | default(0) }} / {{ growth.candidate_count | default(0) }} 個高業績商品已對應 +
+
+ 最大業績分類 + {{ growth.top_category or '待分類' }} + NT$ {{ growth.top_category_sales_7d | default(0) | int | number_format }} +
+
+ +
+
+
+ 今天先做 + 照順序處理 +
+
+ {% for task in growth.priority_tasks | default([]) %} + {% if task.action == 'backfill' %} + + {% elif task.action == 'price_review' %} + + {{ '%02d'|format(task.rank) }} + {{ task.title }} + {{ task.metric }} + {{ task.button }} + + {% elif task.action == 'ai_picks' %} + + {{ '%02d'|format(task.rank) }} + {{ task.title }} + {{ task.metric }} + {{ task.button }} + + {% else %} + + {{ '%02d'|format(task.rank) }} + {{ task.title }} + {{ task.metric }} + {{ task.button }} + + {% endif %} + {% endfor %} +
+
+ +
+
+ MOMO 比價後怎麼做 + 直接轉成銷售策略 +
+
+ {% for lane in growth.strategy_lanes | default([]) %} +
+ {{ lane.label }} + {{ lane.value | default(0) | number_format }} + {{ lane.action }} +
+ {% endfor %} +
+
+
+ +
+
+ 高業績商品作戰清單 + 業績 × MOMO 價格 × 下一步 +
+
+ + + + + + + + + + + + {% for item in growth.top_opportunities | default([]) %} + {% set external = item.external_price or {} %} + + + + + + + + {% else %} + + + + {% endfor %} + +
商品近 7 天業績趨勢MOMO 比價下一步
+ {{ item.product_name }} + {{ item.pchome_product_id }} + NT$ {{ item.sales_7d | default(0) | int | number_format }} + {% if item.sales_delta_pct is not none %} + + {{ '%+.1f'|format(item.sales_delta_pct) }}% + + {% else %} + 新基準 + {% endif %} + + {% if external %} + + {{ external.price_basis_label or '商品總價' }} + {% if external.gap_pct is not none %} {{ '%+.1f'|format(external.gap_pct) }}%{% endif %} + + {% else %} + 未配對 + {% endif %} + {{ item.recommended_action.label }}
尚未產生成長作戰清單,請先完成 PChome 業績匯入。
+
+
+
+