diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 2937c5b..d8a76ec 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.58 (Dashboard AI pick list adds decision summary and reason column) +> **當前版本**: V10.59 (Dashboard AI pick list can export 50-item action workbook) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 872a30a..d0b2198 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.58: Dashboard AI pick list adds decision summary and reason column -SYSTEM_VERSION = "V10.58" +# 🚩 2026-05-01 V10.59: Dashboard AI pick list can export 50-item action workbook +SYSTEM_VERSION = "V10.59" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index 1124444..9314aba 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.58" +SYSTEM_VERSION = "V10.59" 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 fcec923..688ba28 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -37,7 +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 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表,並在列表上方顯示平均信心、平均價差、最大價差與估算總價差空間,列表列內顯示 AI 排名與建議理由。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表,並在列表上方顯示平均信心、平均價差、最大價差與估算總價差空間,列表列內顯示 AI 排名與建議理由,且可透過 `/api/export/excel/ai-picks` 匯出 50 品 Excel 操作清單。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/export_routes.py b/routes/export_routes.py index 7e7ef0d..b0b2cf8 100644 --- a/routes/export_routes.py +++ b/routes/export_routes.py @@ -10,7 +10,7 @@ import io from datetime import datetime, timezone, timedelta from flask import Blueprint, request, send_file, redirect, url_for, flash from auth import login_required -from sqlalchemy import func, desc +from sqlalchemy import func, desc, text import pandas as pd import numpy as np @@ -115,6 +115,119 @@ def export_excel_changes(): return f"匯出失敗: {e}", 500 +@export_bp.route('/api/export/excel/ai-picks') +@login_required +def export_excel_ai_picks(): + """匯出 AI 挑品清單 Excel,資料來源為正式 ai_price_recommendations。""" + db = DatabaseManager() + session = db.get_session() + try: + rows = session.execute(text(""" + WITH valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku, + 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 + ) + SELECT + ROW_NUMBER() OVER ( + ORDER BY ar.confidence DESC NULLS LAST, + ar.gap_pct DESC NULLS LAST, + ar.created_at DESC + ) AS rank, + ar.sku, + ar.name, + p.category, + ar.momo_price, + ar.pchome_price, + ar.gap_pct, + ar.confidence, + ar.sales_7d_delta, + ar.reason, + ar.created_at, + p.url AS momo_url, + vc.competitor_product_id, + vc.competitor_product_name, + vc.match_score, + vc.crawled_at + FROM ai_price_recommendations ar + LEFT JOIN products p ON p.i_code = ar.sku + 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 50 + """)).mappings().all() + + if not rows: + return "目前沒有 AI 挑品資料可匯出", 404 + + export_rows = [] + for row in rows: + sku = str(row.get('sku') or '') + pchome_id = row.get('competitor_product_id') or '' + momo_url = row.get('momo_url') or f"https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code={sku}" + pchome_url = f"https://24h.pchome.com.tw/prod/{str(pchome_id).strip()}" if pchome_id else '' + export_rows.append({ + 'AI排名': int(row.get('rank') or 0), + 'MOMO商品ID': sku, + 'MOMO商品名稱': row.get('name') or '', + '分類': row.get('category') or '', + 'MOMO價格': float(row.get('momo_price') or 0), + 'PChome價格': float(row.get('pchome_price') or 0), + '價差百分比': float(row.get('gap_pct') or 0), + 'AI信心百分比': round(float(row.get('confidence') or 0) * 100, 1), + '近7日銷售變化': float(row.get('sales_7d_delta') or 0), + 'PChome商品ID': pchome_id, + 'PChome商品名稱': row.get('competitor_product_name') or '', + 'PChome比對分數': round(float(row.get('match_score') or 0) * 100, 1), + 'AI建議理由': row.get('reason') or '', + 'MOMO商品URL': momo_url, + 'PChome商品URL': pchome_url, + 'AI產生時間': row.get('created_at').strftime('%Y-%m-%d %H:%M:%S') if row.get('created_at') else '', + 'PChome抓取時間': row.get('crawled_at').strftime('%Y-%m-%d %H:%M:%S') if row.get('crawled_at') else '', + }) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df = pd.DataFrame(export_rows) + df.to_excel(writer, index=False, sheet_name='AI挑品清單') + worksheet = writer.sheets['AI挑品清單'] + for column_cells in worksheet.columns: + header = str(column_cells[0].value or '') + width = min(max(len(header) + 4, 12), 42) + if header in {'MOMO商品名稱', 'PChome商品名稱', 'AI建議理由', 'MOMO商品URL', 'PChome商品URL'}: + width = 48 + worksheet.column_dimensions[column_cells[0].column_letter].width = width + worksheet.freeze_panes = 'A2' + + output.seek(0) + filename = f"AI挑品清單_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" + sys_log.info(f"[Web] [Export] AI 挑品清單匯出成功 | rows={len(export_rows)}") + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + except Exception as e: + sys_log.error(f"[Web] [Export] AI 挑品清單匯出失敗 | Error: {e}") + return f"匯出失敗: {e}", 500 + finally: + session.close() + + @export_bp.route('/api/export/excel/delisted') @login_required def export_excel_delisted(): diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 04a4878..a39503f 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -1027,6 +1027,11 @@ 匯出漲跌 + {% if current_filter == 'ai_picks' %} + + 匯出 AI 挑品 + + {% endif %} {% if current_filter == 'ai_picks' and ai_pick_summary %} diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index dc6bfd5..0ebaf1a 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -62,6 +62,8 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "AI 挑品清單" in dashboard assert "dashboard-ai-summary-grid" in dashboard assert "AI 建議" in dashboard + assert "/api/export/excel/ai-picks" in dashboard + assert "匯出 AI 挑品" in dashboard assert "item.ai_pick.reason" in dashboard assert "_summarize_ai_pick_selection(ai_pick_map)" in route_source assert "{{ ai_pick_list_limit }} 品" in dashboard @@ -73,6 +75,21 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "假商品" not in dashboard +def test_ai_pick_export_uses_real_recommendation_data(): + export_source = (ROOT / "routes/export_routes.py").read_text(encoding="utf-8") + + assert "@export_bp.route('/api/export/excel/ai-picks')" in export_source + assert "ai_price_recommendations ar" in export_source + assert "competitor_prices cp" in export_source + assert "LEFT JOIN products p ON p.i_code = ar.sku" in export_source + assert "ROW_NUMBER() OVER" in export_source + assert "LIMIT 50" in export_source + assert "MOMO商品ID" in export_source + assert "PChome商品ID" in export_source + assert "AI建議理由" in export_source + assert "pd.ExcelWriter" in export_source + + def test_dashboard_v2_restores_real_price_history_chart(): route_source = (ROOT / "routes/api_routes.py").read_text(encoding="utf-8") dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")