diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 532f546..7739bf6 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.52 (Vendor stockout API query service extraction) +> **當前版本**: V10.53 (Dashboard competitor decision overview) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 47c44ca..0ff5f8b 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.52: Vendor stockout API query service extraction -SYSTEM_VERSION = "V10.52" +# 🚩 2026-05-01 V10.53: Dashboard competitor decision overview +SYSTEM_VERSION = "V10.53" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index f4a5593..af8b8e3 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.52" +SYSTEM_VERSION = "V10.53" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 0b5595c..6db4a27 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -37,6 +37,7 @@ SQL漏斗(~300筆) - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index f2413ba..2afc5eb 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -38,6 +38,12 @@ def _build_pchome_product_url(product_id): return f"https://24h.pchome.com.tw/prod/{str(product_id).strip()}" +def _build_momo_product_url(i_code): + if not i_code: + return None + return f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={str(i_code).strip()}" + + def _to_float(value): if value is None: return None @@ -134,6 +140,245 @@ def _load_pchome_competitor_map(session, skus): return result +def _format_dashboard_dt(value): + if not value: + return None + if hasattr(value, "strftime"): + return value.strftime("%Y-%m-%d %H:%M") + return str(value) + + +def _dashboard_decision_row(row, tone): + sku = str(row.get('sku') or '') + pchome_id = row.get('competitor_product_id') + return { + 'sku': sku, + 'name': row.get('name') or '', + 'category': row.get('category') or '', + 'momo_price': _to_float(row.get('momo_price')) or 0, + 'pchome_price': _to_float(row.get('pchome_price')) or 0, + 'gap_pct': _to_float(row.get('gap_pct')) or 0, + 'gap_amount': _to_float(row.get('gap_amount')) or 0, + 'confidence': _to_float(row.get('confidence')), + 'reason': row.get('reason') or '', + 'tone': tone, + 'momo_url': row.get('momo_url') or _build_momo_product_url(sku), + 'pchome_id': pchome_id, + 'pchome_name': row.get('competitor_product_name') or '', + 'pchome_url': _build_pchome_product_url(pchome_id), + 'crawled_at': _format_dashboard_dt(row.get('crawled_at') or row.get('created_at')), + } + + +def _load_competitor_decision_overview(session): + """讀取商品看板第一屏使用的 PChome 比價決策摘要。全部來自正式 DB。""" + default = { + 'total_active': 0, + 'matched_count': 0, + 'match_rate': 0, + 'pchome_advantage_count': 0, + 'momo_threat_count': 0, + 'near_count': 0, + 'pending_match_count': 0, + 'ai_pick_count': 0, + 'avg_advantage_gap': 0, + 'last_pchome_crawled': None, + 'top_picks': [], + 'top_pchome_advantages': [], + 'top_momo_threats': [], + 'pending_priority': [], + } + + latest_compared_cte = """ + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + p.name, + p.url AS momo_url, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + latest_products AS ( + SELECT * FROM latest_momo WHERE rn = 1 + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku, + cp.price AS pchome_price, + cp.competitor_product_id, + cp.competitor_product_name, + cp.match_score, + cp.crawled_at + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= 0.42 + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ), + compared AS ( + SELECT + lp.*, + vc.pchome_price, + vc.competitor_product_id, + vc.competitor_product_name, + vc.match_score, + vc.crawled_at, + (lp.momo_price - vc.pchome_price) AS gap_amount, + ((lp.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS gap_pct + FROM latest_products lp + JOIN valid_competitor vc ON vc.sku = lp.sku + ) + """ + + stats_sql = text(latest_compared_cte + """ + SELECT + (SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_active, + (SELECT COUNT(*) FROM compared) AS matched_count, + (SELECT COUNT(*) FROM compared WHERE gap_pct >= 5) AS pchome_advantage_count, + (SELECT COUNT(*) FROM compared WHERE gap_pct <= -5) AS momo_threat_count, + (SELECT COUNT(*) FROM compared WHERE gap_pct > -5 AND gap_pct < 5) AS near_count, + (SELECT COALESCE(ROUND(AVG(gap_pct)::numeric, 1), 0) FROM compared WHERE gap_pct >= 5) AS avg_advantage_gap, + (SELECT COUNT(*) FROM ai_price_recommendations WHERE strategy = 'product_pick' AND status = 'pending') AS ai_pick_count, + (SELECT MAX(crawled_at) FROM competitor_prices WHERE source = 'pchome') AS last_pchome_crawled + """) + + advantage_sql = text(latest_compared_cte + """ + SELECT * + FROM compared + WHERE gap_pct >= 5 + ORDER BY gap_pct DESC NULLS LAST, crawled_at DESC NULLS LAST + LIMIT 3 + """) + + threat_sql = text(latest_compared_cte + """ + SELECT * + FROM compared + WHERE gap_pct <= -5 + ORDER BY gap_pct ASC NULLS LAST, crawled_at DESC NULLS LAST + LIMIT 3 + """) + + pending_sql = text(""" + WITH latest_momo AS ( + SELECT + p.i_code AS sku, + p.name, + p.url AS momo_url, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ) + SELECT lm.* + FROM latest_momo lm + LEFT JOIN competitor_prices cp + ON cp.sku = lm.sku + AND cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= 0.42 + WHERE lm.rn = 1 + AND cp.sku IS NULL + ORDER BY lm.momo_price DESC NULLS LAST + LIMIT 3 + """) + + picks_sql = text(""" + WITH valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku, + cp.competitor_product_id, + cp.competitor_product_name, + cp.crawled_at + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= 0.42 + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ) + SELECT + ar.sku, + ar.name, + ar.momo_price, + ar.pchome_price, + ar.gap_pct, + ar.confidence, + ar.reason, + ar.created_at, + vc.competitor_product_id, + vc.competitor_product_name, + vc.crawled_at + FROM ai_price_recommendations ar + LEFT JOIN valid_competitor vc ON vc.sku = ar.sku + WHERE ar.strategy = 'product_pick' + AND ar.status = 'pending' + ORDER BY ar.confidence DESC NULLS LAST, ar.gap_pct DESC NULLS LAST, ar.created_at DESC + LIMIT 3 + """) + + try: + stats = session.execute(stats_sql).mappings().first() + overview = dict(default) + if stats: + total_active = int(stats.get('total_active') or 0) + matched_count = int(stats.get('matched_count') or 0) + overview.update({ + 'total_active': total_active, + 'matched_count': matched_count, + 'match_rate': round(matched_count / max(total_active, 1) * 100, 1), + 'pchome_advantage_count': int(stats.get('pchome_advantage_count') or 0), + 'momo_threat_count': int(stats.get('momo_threat_count') or 0), + 'near_count': int(stats.get('near_count') or 0), + 'pending_match_count': max(total_active - matched_count, 0), + 'ai_pick_count': int(stats.get('ai_pick_count') or 0), + 'avg_advantage_gap': _to_float(stats.get('avg_advantage_gap')) or 0, + 'last_pchome_crawled': _format_dashboard_dt(stats.get('last_pchome_crawled')), + }) + + overview['top_pchome_advantages'] = [ + _dashboard_decision_row(row, 'win') + for row in session.execute(advantage_sql).mappings().all() + ] + overview['top_momo_threats'] = [ + _dashboard_decision_row(row, 'risk') + for row in session.execute(threat_sql).mappings().all() + ] + overview['top_picks'] = [ + _dashboard_decision_row(row, 'pick') + for row in session.execute(picks_sql).mappings().all() + ] + overview['pending_priority'] = [ + { + 'sku': str(row.get('sku') or ''), + 'name': row.get('name') or '', + 'category': row.get('category') or '', + 'momo_price': _to_float(row.get('momo_price')) or 0, + 'momo_url': row.get('momo_url') or _build_momo_product_url(row.get('sku')), + } + for row in session.execute(pending_sql).mappings().all() + ] + return overview + except Exception as exc: + sys_log.warning(f"[Dashboard] PChome 比價決策摘要讀取略過: {exc}") + try: + session.rollback() + except Exception: + pass + return default + + # ========================================== # 快取與監控變數 # ========================================== @@ -734,6 +979,7 @@ def index(): competitor.get('price') if competitor else None ) + competitor_overview = _load_competitor_decision_overview(session) template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html' return render_template(template_name, @@ -769,6 +1015,7 @@ def index(): stable_count=stable_count, most_active_category=most_active_category, most_active_count=most_active_count, + competitor_overview=competitor_overview, active_page='dashboard') except Exception as e: sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}") diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 888c4b6..400b531 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -38,7 +38,7 @@ .dashboard-kpi-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); overflow: hidden; background: var(--momo-bg-surface); border: 1px solid var(--momo-border-light); @@ -77,12 +77,18 @@ .dashboard-kpi-value { margin-bottom: 8px; color: var(--momo-text-primary); - font-size: 44px; + font-size: 34px; font-weight: 800; letter-spacing: -0.04em; line-height: 1; } + .dashboard-kpi-value.is-small { + font-size: 20px; + letter-spacing: 0; + line-height: 1.15; + } + .dashboard-kpi-value.is-danger { color: var(--momo-danger); } @@ -91,6 +97,10 @@ color: var(--momo-success); } + .dashboard-kpi-value.is-warning { + color: var(--momo-warning-text); + } + .dashboard-kpi.is-accent .dashboard-kpi-value { color: var(--momo-text-inverse); } @@ -150,6 +160,78 @@ font-size: 11px; } + .dashboard-focus-list { + display: grid; + gap: 10px; + } + + .dashboard-focus-row { + display: grid; + gap: 5px; + padding: 10px 0; + border-top: 1px solid var(--momo-border-light); + } + + .dashboard-focus-row:first-child { + padding-top: 0; + border-top: 0; + } + + .dashboard-focus-row-title { + display: -webkit-box; + overflow: hidden; + color: var(--momo-text-primary); + font-size: 13px; + font-weight: 800; + line-height: 1.35; + text-decoration: none; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .dashboard-focus-row-title:hover { + color: var(--momo-accent-strong); + } + + .dashboard-focus-row-meta, + .dashboard-focus-row-links { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-focus-chip { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: var(--momo-radius-pill); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + white-space: nowrap; + } + + .dashboard-focus-chip.is-win { + color: var(--momo-success); + background: rgba(55, 136, 88, 0.10); + border: 1px solid rgba(55, 136, 88, 0.18); + } + + .dashboard-focus-chip.is-risk { + color: var(--momo-danger); + background: rgba(191, 72, 61, 0.10); + border: 1px solid rgba(191, 72, 61, 0.18); + } + + .dashboard-focus-chip.is-neutral { + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + } + .dashboard-filter-card { padding: 12px 16px; } @@ -597,12 +679,16 @@ } @media (max-width: 980px) { - .dashboard-kpi-grid, + .dashboard-kpi-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .dashboard-focus-grid { grid-template-columns: 1fr 1fr; } - .dashboard-kpi:nth-child(2) { + .dashboard-kpi:nth-child(3), + .dashboard-kpi:nth-child(6) { border-right: 0; } } @@ -637,32 +723,43 @@ {% block ewooo_content %}