diff --git a/app.py b/app.py index 5baa4f3..0db805a 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.68: Sync latest MOMO Pro prototype styling to production frontend -SYSTEM_VERSION = "V10.68" +# 🚩 2026-05-01 V10.69: Speed up dashboard competitor overview with real cached product data +SYSTEM_VERSION = "V10.69" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index cdeda4c..ba1c8e9 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.68" +SYSTEM_VERSION = "V10.69" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 8bb9f11..5107f2c 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -209,7 +209,7 @@ def _dashboard_decision_row(row, tone): } -def _load_competitor_decision_overview(session): +def _load_competitor_decision_overview(session, latest_items=None): """讀取商品看板第一屏使用的 PChome 比價決策摘要。全部來自正式 DB。""" cache_key = 'competitor_decision_overview' cache_ts_key = 'competitor_decision_overview_timestamp' @@ -237,6 +237,133 @@ def _load_competitor_decision_overview(session): 'pending_priority': [], } + if latest_items: + try: + item_map = {} + for item in latest_items: + record = item.get('record') + product = getattr(record, 'product', None) + sku = str(getattr(product, 'i_code', '') or '') + if not sku: + continue + item_map[sku] = { + 'sku': sku, + 'name': getattr(product, 'name', '') or '', + 'category': getattr(product, 'category', '') or '', + 'momo_url': getattr(product, 'url', None) or _build_momo_product_url(sku), + 'momo_price': _to_float(getattr(record, 'price', None)) or 0, + } + + competitor_map = _load_pchome_competitor_map(session, item_map.keys()) + compared = [] + for sku, item in item_map.items(): + competitor = competitor_map.get(sku) + pchome_price = _to_float(competitor.get('price')) if competitor else None + if not pchome_price: + continue + gap_amount = item['momo_price'] - pchome_price + gap_pct = gap_amount / pchome_price * 100 if pchome_price else 0 + compared.append({ + **item, + 'pchome_price': pchome_price, + 'competitor_product_id': competitor.get('product_id'), + 'competitor_product_name': competitor.get('product_name'), + 'match_score': competitor.get('match_score'), + 'crawled_at': competitor.get('crawled_at'), + 'gap_amount': gap_amount, + 'gap_pct': gap_pct, + }) + + picks_rows = session.execute(text(""" + SELECT + sku, + name, + momo_price, + pchome_price, + gap_pct, + confidence, + reason, + model_footprint, + created_at + FROM ai_price_recommendations + WHERE strategy = 'product_pick' + AND status = 'pending' + ORDER BY confidence DESC NULLS LAST, gap_pct DESC NULLS LAST, created_at DESC + LIMIT 3 + """)).mappings().all() + ai_pick_count = session.execute(text(""" + SELECT COUNT(*) + FROM ai_price_recommendations + WHERE strategy = 'product_pick' + AND status = 'pending' + """)).scalar() or 0 + + total_active = len(item_map) + matched_count = len(compared) + pchome_advantages = [row for row in compared if row['gap_pct'] >= 5] + momo_threats = [row for row in compared if row['gap_pct'] <= -5] + near_items = [row for row in compared if -5 < row['gap_pct'] < 5] + pending_items = [ + row for sku, row in item_map.items() + if sku not in competitor_map + ] + last_crawled = max( + (row.get('crawled_at') for row in compared if row.get('crawled_at')), + default=None, + ) + + overview = dict(default) + overview.update({ + 'total_active': total_active, + 'matched_count': matched_count, + 'match_rate': round(matched_count / max(total_active, 1) * 100, 1), + 'pchome_advantage_count': len(pchome_advantages), + 'momo_threat_count': len(momo_threats), + 'near_count': len(near_items), + 'pending_match_count': max(total_active - matched_count, 0), + 'ai_pick_count': int(ai_pick_count), + 'avg_advantage_gap': round( + sum(row['gap_pct'] for row in pchome_advantages) / len(pchome_advantages), + 1, + ) if pchome_advantages else 0, + 'last_pchome_crawled': _format_dashboard_dt(last_crawled), + }) + overview['top_pchome_advantages'] = [ + _dashboard_decision_row(row, 'win') + for row in sorted(pchome_advantages, key=lambda row: row['gap_pct'], reverse=True)[:3] + ] + overview['top_momo_threats'] = [ + _dashboard_decision_row(row, 'risk') + for row in sorted(momo_threats, key=lambda row: row['gap_pct'])[:3] + ] + overview['top_picks'] = [] + for row in picks_rows: + pick = dict(row) + competitor = competitor_map.get(str(pick.get('sku') or '')) or {} + pick['competitor_product_id'] = competitor.get('product_id') + pick['competitor_product_name'] = competitor.get('product_name') + pick['crawled_at'] = competitor.get('crawled_at') + overview['top_picks'].append(_dashboard_decision_row(pick, 'pick')) + overview['pending_priority'] = [ + { + 'sku': row['sku'], + 'name': row['name'], + 'category': row['category'], + 'momo_price': row['momo_price'], + 'momo_url': row['momo_url'], + } + for row in sorted(pending_items, key=lambda row: row['momo_price'], reverse=True)[:3] + ] + _DASHBOARD_DATA_CACHE[cache_key] = overview + _DASHBOARD_DATA_CACHE[cache_ts_key] = time.time() + return overview + except Exception as exc: + sys_log.warning(f"[Dashboard] PChome 比價快取摘要讀取略過,改用 SQL 後備: {exc}") + try: + session.rollback() + except Exception: + pass + latest_compared_cte = """ WITH latest_momo AS ( SELECT @@ -1276,7 +1403,7 @@ def index(): competitor.get('price') if competitor else None ) - competitor_overview = _load_competitor_decision_overview(session) + competitor_overview = _load_competitor_decision_overview(session, unique_items) template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html' return render_template(template_name, diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index ec812ef..e72148b 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -87,7 +87,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "_write_shared_full_dashboard_cache(full_data)" in route_source assert "warm_full_dashboard_cache" in route_source assert "force_rebuild=False" in route_source - assert "_load_competitor_decision_overview(session)" in route_source + assert "def _load_competitor_decision_overview(session, latest_items=None)" in route_source + assert "_load_competitor_decision_overview(session, unique_items)" in route_source + assert "item_map = {}" in route_source + assert "competitor_map = _load_pchome_competitor_map(session, item_map.keys())" in route_source assert "ai_price_recommendations" in route_source assert "pending_match_count" in route_source assert "MockRecord" not in route_source