feat(dashboard): 匯出 AI 挑品操作清單
All checks were successful
CD Pipeline / deploy (push) Successful in 2m39s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m39s
This commit is contained in:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
4
app.py
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 操作清單。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1027,6 +1027,11 @@
|
||||
<a class="dashboard-action-link" href="/api/export/excel/changes">
|
||||
<i class="fas fa-arrow-trend-up"></i> 匯出漲跌
|
||||
</a>
|
||||
{% if current_filter == 'ai_picks' %}
|
||||
<a class="dashboard-action-link" href="/api/export/excel/ai-picks">
|
||||
<i class="fas fa-file-excel"></i> 匯出 AI 挑品
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if current_filter == 'ai_picks' and ai_pick_summary %}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user