21 Commits

Author SHA1 Message Date
ogt
e137d7a5d0 fix(import): fail auto import on drive auth failure
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
2026-06-25 09:32:29 +08:00
ogt
e8f9dfeba4 fix: refine growth dashboard summary data
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-25 09:25:52 +08:00
ogt
e3b082a299 feat: align growth homepage dashboard experience
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s
2026-06-25 09:23:37 +08:00
ogt
5c26071a3e fix: preserve production placeholder directories
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s
2026-06-25 00:06:48 +08:00
ogt
ef12c1d356 fix: render growth homepage without redirect
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s
2026-06-24 23:55:25 +08:00
ogt
3057c73e0f fix: make growth command center primary nav
All checks were successful
CD Pipeline / deploy (push) Successful in 1m8s
2026-06-24 23:42:43 +08:00
ogt
84035906ab fix(import): fail daily sales job on monthly sync failure
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 21:56:24 +08:00
ogt
8240b59b84 fix: add webcrumbs loader fallback
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s
2026-06-24 21:26:10 +08:00
ogt
5adeacd65c fix: route formal homepage to growth command center
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 21:18:09 +08:00
ogt
65aa23800c fix: keep growth detail row highlighted from action plan
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 21:02:19 +08:00
ogt
fa71897158 feat: add growth daily action plan
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s
2026-06-24 21:00:05 +08:00
ogt
9610b4da18 feat: add growth sales playbook board
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s
2026-06-24 20:51:11 +08:00
ogt
2aa1ae04ed feat: add growth category strategy board
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 20:46:15 +08:00
ogt
b87931c911 feat: add growth product decision panel
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 20:41:39 +08:00
ogt
7cfca93754 feat: add growth detail search and sort
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
2026-06-24 20:23:25 +08:00
ogt
7180c0f817 feat: add growth action summary
All checks were successful
CD Pipeline / deploy (push) Successful in 1m16s
2026-06-24 20:05:58 +08:00
ogt
2d9acfdc5c feat: show price evidence in growth details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m12s
2026-06-24 19:59:46 +08:00
ogt
776a7dd4ea feat: add growth strategy split cards
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
2026-06-24 15:53:50 +08:00
ogt
873e0ce902 feat: add growth dashboard drilldown details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m5s
2026-06-24 15:28:53 +08:00
ogt
e6deaa4711 feat: link growth dashboard metrics to details
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
2026-06-24 14:28:17 +08:00
ogt
06418878e0 feat: add momo review candidate queue
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
2026-06-24 13:09:56 +08:00
21 changed files with 3901 additions and 94 deletions

3
.gitignore vendored
View File

@@ -95,10 +95,11 @@ data/ai_automation_smoke_history.jsonl
web/static/uploads/
web/static/screenshots/
uploads/
!web/static/uploads/
!web/static/uploads/.gitkeep
screenshots/
MOMO Pro/uploads/
MOMO Pro/screenshots/
templates/__init__.py
# 本機前端設計稿 / 產生式 prototype sandbox未整合前不進版本庫
MOMO Pro/

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.639"
SYSTEM_VERSION = "V10.657"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -75,6 +75,22 @@
- V10.623 起 `/price_comparison``/ai_intelligence` 不得只靠大段文字說明流程:比價頁第一屏必須有主 KPI、目前卡點、四步流程與結果決策摘要作戰頁第一屏必須有今日任務、可立即處理、待補比價與最新業績日。所有狀態都要由實際 API/前端狀態驅動,讓使用者一眼知道下一步要按哪個動作。
- V10.638 起 PChome 導向 MOMO 補抓會把「找到但不能自動比價」的候選以 `match_status='needs_review'``data_quality_status='needs_review'` 保存到 `external_offers`;這些候選不得進價格壓力判斷,也不得發告警,但 `/api/ai/pchome-growth/opportunities` 可回傳待確認候選數,讓 UI 顯示「已有候選待確認」而不是只顯示無法比價。
- V10.639 起待確認候選排序必須容忍缺少單位數量;沒有 `momo_total_quantity` / `competitor_total_quantity` 時仍可保存為 `needs_review`,不得中斷 PChome 導向 MOMO 回填。
- V10.640 起 `/ai_intelligence` 必須提供 MOMO 待確認候選操作佇列;使用者可直接確認同款或排除候選。確認後 `external_offers` 會轉為 `verified/verified` 並進入作戰清單,排除後轉為 `rejected/rejected`,兩者都必須清掉 PChome 成長作戰清單快取。
- V10.641 起 `/ai_intelligence` 的摘要數字不可只是靜態文字;第一屏 KPI、商品處理進度、待確認數字都必須可點擊並導向對應明細。今日清單若已有 MOMO 待確認候選,下一步必須顯示「確認候選」並跳到候選面板,不得再只顯示「補齊比價」。
- V10.642 起 `/ai_intelligence` 的摘要卡與商品處理數字不可只跳到大區塊;點擊後必須開啟商品明細面板,列出商品名稱、分類、近 7 天業績、業績變化、MOMO 比價狀態與下一步按鈕。明細需至少支援全部、價格壓力、價格優勢、待確認、缺比價與有外部價切換;外部價格風險分佈也必須能一鍵篩選下方表格。
- V10.643 起 `/ai_intelligence` 的商品明細上方必須提供「商品策略分流」視覺摘要,至少包含價格壓力、價格優勢、待確認、缺比價四類;每一類需顯示件數、近 7 天業績與比例條,且可點擊切換明細。舊 KPI 卡也不得是靜態數字,需可導向全部商品、可處理商品、高風險比價或處理紀錄。
- V10.644 起 `/ai_intelligence` 的商品明細列不得只用句子描述比價;每列必須顯示 PChome 價格、MOMO 參考價、差距、可信度四格價格證據,並保留下一步按鈕。單位價候選需顯示單位價與單位,候選待確認或缺資料則以「待補 / 候選待確認」呈現,不得捏造價格。
- V10.645 起 `/ai_intelligence` 的商品明細分流切換後,必須顯示「這類商品怎麼處理」的行動摘要,包含件數、近 7 天業績、平均可信度、最大價差、代表商品與主按鈕;使用者不得只能看到商品列表而不知道下一步。
- V10.646 起 `/ai_intelligence` 的商品明細必須提供搜尋與排序;搜尋至少涵蓋商品、分類、商品編號與 MOMO 候選資訊,排序至少支援優先級、近 7 天業績、價差、下滑幅度與可信度。搜尋/排序後的行動摘要與明細列表必須使用同一批結果。
- V10.647 起 `/ai_intelligence` 的商品明細每一筆都必須能打開單品作戰詳情,詳情需顯示商品、建議動作、近 7 天業績、業績變化、PChome/MOMO 價格證據、價差、可信度、判斷原因與下一步操作;不得只讓使用者看一排文字後自行猜測。
- V10.648 起 `/ai_intelligence` 的商品明細上方必須提供分類策略看板,把商品依分類彙總成可點擊的數據條列;每列至少顯示分類、近 7 天業績、商品數、價格壓力、價格優勢、缺比價、待確認與建議下一步。點擊分類後必須切到該分類商品明細。
- V10.649 起 `/ai_intelligence` 必須提供銷售策略建議看板,把商品分成價格防守、主推曝光、組合/單位價、資料補齊等營運路徑;每張策略卡需顯示件數、近 7 天業績、代表商品與可點擊下一步,點擊後必須切到對應商品明細。
- V10.650 起 `/ai_intelligence` 必須提供「今日策略動作」清單,從作戰商品中挑出前 5 件具體行動;每列需顯示處理順序、動作、商品、近 7 天業績、原因與可點擊的詳情/處理入口,避免使用者只看到分類與策略後仍不知道下一步要做哪一件商品。
- V10.651 起從「今日策略動作」或其他非明細列入口打開單品作戰詳情時,商品明細列表中的對應商品仍必須標示為目前選取;使用者需能看出詳情與明細列的關聯。
- V10.652 起正式首頁 `/` 必須顯示「PChome 業績成長自動化作戰系統」,舊商品看板僅保留在 `/dashboard``/product-dashboard`;「今日策略動作」必須放在首屏任務摘要後方,不能只藏在商品明細區;每列必須直接顯示價格證據,至少包含 PChome、MOMO、差距與可信度四格。候選待確認或缺資料時需以待確認/待補呈現,不得要求使用者先打開詳情才知道判斷依據。
- V10.654 起全站側邊欄第一個主入口必須命名為「業績成長指揮台」;舊商品看板只能以「舊商品看板」保留在 `/dashboard`,比價工作台必須直連 `/dashboard?filter=pchome_review...`,不得再使用 `/` query 轉址,避免正式首頁與舊頁混淆。
- V10.655 起正式首頁 `/` 必須以 HTTP 200 原地渲染「業績成長指揮台」,不得 302 跳轉到 `/ai_intelligence`;側邊欄第一個主入口必須直連 `/``/ai_intelligence` 只作為相容入口保留,不得成為主導流路由。
- V10.656 起正式首頁首屏必須是「PChome 業績成長系統」專業儀表板,而非大段說明型頁首;第一屏需直接呈現近 7 天業績、比價可用率、下滑商品、待補比價、最大分類、下滑商品 TOP 5、PChome/MOMO 價格狀態圓環與處理狀態,且全部使用 `/api/ai/pchome-growth/opportunities` 真資料渲染。
## 零之一、12 Agent 決策信封2026-05-24

View File

@@ -68,5 +68,6 @@ style.css
- Host data metadata 需同時輸出 `matched_count/coverage_rate``fresh_match_count/fresh_match_rate/stale_match_count/pending_match_count`,讓共用 UI 分清「身份覆蓋」與「價格新鮮度」。
- `/webcrumbs` 未取得登入 session 或 `X-Internal-Key` 時只能注入 `auth_required` 空狀態,不得把真實 SKU、價格或價差寫進 inline seed data。
- `/webcrumbs-assets/loader/webcrumbs-compatible-loader.js` 回 200 且 content type 是 JavaScript。
- 若 Shared UI Hub 或 `WEBCRUMBS_ASSET_UPSTREAM_URL` 暫時不可用,`/webcrumbs-assets/loader/webcrumbs-compatible-loader.js` 必須回 200 的本地 fallback loader避免正式頁面出現 502fallback 只負責安全降級與標示 runtime 離線,不取代真實 plugin bundle。
- `ewoooc_base.html``WEBCRUMBS_ENABLED=true` 且 runtime URL 有效時輸出 `<script data-webcrumbs-runtime=...>`
- 任一試點頁嵌入 plugin 後,瀏覽器 console 不應有 `MISSING_URI``STYLE_LOAD_ERROR``SCRIPT_LOAD_ERROR`

View File

@@ -1724,6 +1724,64 @@ def api_pchome_growth_source_contract():
}), 500
@ai_bp.route('/api/ai/pchome-growth/review-candidates')
@login_required
def api_pchome_growth_review_candidates():
"""列出 MOMO 待確認候選,只讀、不呼叫 LLM。"""
try:
from config import DATABASE_PATH
from services.external_market_offer_service import list_momo_review_candidates
limit = request.args.get('limit', 20, type=int)
limit = max(1, min(limit, 50))
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
try:
payload = list_momo_review_candidates(engine, limit=limit)
finally:
engine.dispose()
status_code = 200 if payload.get("success") else 400
return jsonify(payload), status_code
except Exception as exc:
logger.error("[PChomeGrowth] MOMO 待確認候選讀取失敗: %s", exc, exc_info=True)
return jsonify({
"success": False,
"error": "MOMO 待確認候選暫時無法讀取,請稍後再試。",
}), 500
@ai_bp.route('/api/ai/pchome-growth/review-candidates/<int:offer_id>', methods=['POST'])
@login_required
def api_pchome_growth_update_review_candidate(offer_id):
"""確認或排除 MOMO 待確認候選,不呼叫 LLM。"""
payload = request.get_json(silent=True) or {}
action = str(payload.get("action") or "").strip().lower()
note = str(payload.get("note") or "").strip()
engine = None
try:
from config import DATABASE_PATH
from services.external_market_offer_service import update_momo_review_candidate
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
result = update_momo_review_candidate(engine, offer_id, action, note=note)
if result.get("success"):
_PCHOME_GROWTH_CACHE.update({
"expires_at": 0.0,
"epoch": 0.0,
"payload": None,
})
status_code = 200 if result.get("success") else 400
return jsonify(result), status_code
except Exception as exc:
logger.error("[PChomeGrowth] MOMO 待確認候選更新失敗: %s", exc, exc_info=True)
return jsonify({
"success": False,
"error": "MOMO 待確認候選暫時無法更新,請稍後再試。",
}), 500
finally:
if engine is not None:
engine.dispose()
def _decode_external_offer_csv_upload(raw_bytes):
for encoding in ("utf-8-sig", "utf-8", "big5", "cp950"):
try:

View File

@@ -2624,6 +2624,14 @@ def get_pchome_review_queue_api():
@dashboard_bp.route('/')
@login_required
def index():
"""正式首頁PChome 業績成長自動化作戰系統。"""
return render_template('ai_intelligence.html', active_page='ai_intelligence')
@dashboard_bp.route('/dashboard')
@dashboard_bp.route('/product-dashboard')
@login_required
def product_dashboard():
"""商品看板首頁"""
db = DatabaseManager()
session = db.get_session()

View File

@@ -44,6 +44,32 @@ LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = os.getenv('PUBLIC_URL', '服務啟動中...')
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
WEBCRUMBS_ASSET_ALLOWED_PREFIXES = ('loader/', 'plugins/', 'demo/')
WEBCRUMBS_COMPATIBLE_LOADER_PATH = 'loader/webcrumbs-compatible-loader.js'
WEBCRUMBS_FALLBACK_LOADER = """
(() => {
const state = { status: 'fallback', reason: 'upstream_unavailable' };
window.WebcrumbsRuntime = Object.assign(window.WebcrumbsRuntime || {}, state);
if (!customElements.get('stock-platform-plugin')) {
customElements.define('stock-platform-plugin', class extends HTMLElement {
connectedCallback() {
if (this.dataset.webcrumbsFallbackRendered === '1') return;
this.dataset.webcrumbsFallbackRendered = '1';
const uri = this.getAttribute('uri') || '';
const box = document.createElement('div');
box.setAttribute('role', 'status');
box.style.cssText = 'border:1px solid rgba(42,37,32,.14);border-radius:8px;padding:12px;background:#fffaf0;color:#3b332b;font:600 13px/1.5 system-ui,sans-serif;';
const title = document.createElement('strong');
title.textContent = 'Webcrumbs runtime temporarily offline';
const detail = document.createElement('div');
detail.textContent = uri ? `Plugin waiting for runtime: ${uri}` : 'Plugin waiting for runtime.';
box.append(title, detail);
this.replaceChildren(box);
}
});
}
window.dispatchEvent(new CustomEvent('webcrumbs:runtime-fallback', { detail: state }));
})();
""".strip()
def _has_sensitive_webcrumbs_access():
@@ -167,11 +193,24 @@ def _normalize_webcrumbs_asset_path(asset_path):
return normalized
def _webcrumbs_fallback_loader_response(reason):
response = Response(WEBCRUMBS_FALLBACK_LOADER, status=200, mimetype='application/javascript')
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['X-Webcrumbs-Fallback'] = reason
return response
@system_public_bp.route('/webcrumbs-assets/<path:asset_path>')
def webcrumbs_asset_proxy(asset_path):
"""Serve allowlisted shared-ui assets through momo-pro's own origin."""
normalized_path = _normalize_webcrumbs_asset_path(asset_path)
if not WEBCRUMBS_ENABLED or not WEBCRUMBS_ASSET_UPSTREAM_URL or not normalized_path:
if not normalized_path or not WEBCRUMBS_ENABLED:
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
if not WEBCRUMBS_ASSET_UPSTREAM_URL:
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
return _webcrumbs_fallback_loader_response('missing-upstream')
return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
upstream_url = f"{WEBCRUMBS_ASSET_UPSTREAM_URL}/{quote(normalized_path, safe='/@._-')}"
@@ -179,10 +218,14 @@ def webcrumbs_asset_proxy(asset_path):
upstream_response = requests.get(upstream_url, timeout=(2, 8))
except requests.RequestException as exc:
sys_log.warning(f"[Webcrumbs] Asset proxy failed: {normalized_path} -> {exc}")
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
return _webcrumbs_fallback_loader_response('upstream-unavailable')
return Response('Webcrumbs asset upstream unavailable', status=502, mimetype='text/plain')
if upstream_response.status_code >= 400:
sys_log.warning(f"[Webcrumbs] Asset proxy returned {upstream_response.status_code}: {normalized_path}")
if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
return _webcrumbs_fallback_loader_response(f'upstream-{upstream_response.status_code}')
return Response('Webcrumbs asset upstream returned error', status=upstream_response.status_code, mimetype='text/plain')
content_type = upstream_response.headers.get('Content-Type')

View File

@@ -1435,3 +1435,185 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]:
"connector_contract": build_connector_contracts(),
"plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。",
}
def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]:
"""列出待人工確認的 MOMO 候選,供前台直接處理。"""
limit = max(1, min(int(limit or 20), 50))
generated_at = datetime.now().isoformat(timespec="seconds")
required_tables = {"external_offers"}
with engine.connect() as conn:
missing_tables = sorted(table for table in required_tables if not _has_table(conn, table))
if missing_tables:
return {
"success": False,
"generated_at": generated_at,
"rows": [],
"count": 0,
"missing_tables": missing_tables,
"message": "待確認候選暫時無法讀取,缺少必要資料表。",
}
rows = conn.execute(text("""
SELECT
id,
source_product_id,
title,
product_url,
image_url,
price,
pchome_product_id,
momo_sku,
match_status,
quality_score,
data_quality_status,
quality_notes_json,
raw_payload_json,
observed_at,
updated_at
FROM external_offers
WHERE source_code = 'momo_reference'
AND ingestion_method = 'targeted_momo_review'
AND (
match_status = 'needs_review'
OR data_quality_status = 'needs_review'
)
ORDER BY observed_at DESC, id DESC
LIMIT :limit
"""), {"limit": limit * 4}).mappings().all()
seen: set[tuple[str, str]] = set()
items: list[dict[str, Any]] = []
for row in rows:
raw_payload = _load_json_dict(row.get("raw_payload_json"))
quality_notes = _load_json_list(row.get("quality_notes_json"))
key = (
str(row.get("pchome_product_id") or "").strip(),
str(row.get("source_product_id") or "").strip(),
)
if key in seen:
continue
seen.add(key)
reasons = [
str(reason)
for reason in (raw_payload.get("match_reasons") or [])
if str(reason or "").strip()
]
if not reasons:
reasons = [str(note) for note in quality_notes if str(note or "").strip()]
pchome_price = _to_float(raw_payload.get("pchome_public_price"))
momo_price = _to_float(row.get("price"))
gap_pct = _to_float(raw_payload.get("target_gap_pct"))
items.append({
"id": int(row.get("id")),
"pchome_product_id": row.get("pchome_product_id"),
"pchome_product_name": raw_payload.get("pchome_public_name") or "",
"pchome_price": pchome_price,
"momo_sku": row.get("momo_sku") or row.get("source_product_id"),
"momo_title": row.get("title"),
"momo_price": momo_price,
"product_url": row.get("product_url"),
"image_url": row.get("image_url"),
"quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2),
"alert_tier": raw_payload.get("alert_tier") or "identity_review",
"price_basis": raw_payload.get("price_basis") or "manual_review",
"gap_pct": gap_pct,
"match_reasons": reasons[:5],
"observed_at": str(row.get("observed_at") or ""),
"updated_at": str(row.get("updated_at") or ""),
"plain_status": "待確認同款或色號",
"suggested_next_action": "確認同款後才進入價格判斷;不是同款就排除。",
})
if len(items) >= limit:
break
return {
"success": True,
"generated_at": generated_at,
"rows": items,
"count": len(items),
"message": "已整理 MOMO 待確認候選。",
}
def update_momo_review_candidate(engine, offer_id: int, action: str, *, note: str = "") -> dict[str, Any]:
"""確認或排除一筆 MOMO 待確認候選。"""
try:
offer_id = int(offer_id)
except (TypeError, ValueError):
return {"success": False, "message": "缺少有效的候選編號。"}
action = str(action or "").strip().lower()
if action not in {"confirm", "reject"}:
return {"success": False, "message": "請選擇確認同款或排除候選。"}
generated_at = datetime.now().isoformat(timespec="seconds")
new_match_status = "verified" if action == "confirm" else "rejected"
new_quality_status = "verified" if action == "confirm" else "rejected"
label = "人工確認同款" if action == "confirm" else "人工排除候選"
review_note = str(note or "").strip()[:240]
with engine.begin() as conn:
if not _has_table(conn, "external_offers"):
return {
"success": False,
"generated_at": generated_at,
"message": "待確認候選暫時無法更新,缺少必要資料表。",
}
row = conn.execute(text("""
SELECT id, match_status, data_quality_status, quality_notes_json, raw_payload_json
FROM external_offers
WHERE id = :offer_id
AND source_code = 'momo_reference'
AND ingestion_method = 'targeted_momo_review'
LIMIT 1
"""), {"offer_id": offer_id}).mappings().first()
if not row:
return {
"success": False,
"generated_at": generated_at,
"message": "找不到這筆待確認候選。",
}
raw_payload = _load_json_dict(row.get("raw_payload_json"))
raw_payload["review_state"] = new_match_status
raw_payload["reviewed_at"] = generated_at
raw_payload["review_action"] = action
if review_note:
raw_payload["review_note"] = review_note
tags = raw_payload.get("tags") if isinstance(raw_payload.get("tags"), list) else []
tag_to_add = "manual_verified" if action == "confirm" else "manual_rejected"
raw_payload["tags"] = [*tags, tag_to_add] if tag_to_add not in tags else tags
notes = [str(item) for item in _load_json_list(row.get("quality_notes_json")) if str(item or "").strip()]
notes.append(label if not review_note else f"{label}{review_note}")
conn.execute(text("""
UPDATE external_offers
SET match_status = :match_status,
data_quality_status = :data_quality_status,
quality_notes_json = :quality_notes_json,
raw_payload_json = :raw_payload_json,
updated_at = CURRENT_TIMESTAMP
WHERE id = :offer_id
"""), {
"offer_id": offer_id,
"match_status": new_match_status,
"data_quality_status": new_quality_status,
"quality_notes_json": json.dumps(notes[-6:], ensure_ascii=False),
"raw_payload_json": json.dumps(raw_payload, ensure_ascii=False),
})
mark_pchome_growth_cache_stale()
return {
"success": True,
"generated_at": generated_at,
"id": offer_id,
"action": action,
"match_status": new_match_status,
"data_quality_status": new_quality_status,
"message": "已確認同款,會進入作戰清單。" if action == "confirm" else "已排除候選,不會再進入待確認。",
}

View File

@@ -38,6 +38,16 @@ class GoogleDriveService:
"""初始化 Google Drive 服務"""
self.service = None
self.credentials = None
self.last_error = None
self.last_error_kind = None
def _clear_error(self) -> None:
self.last_error = None
self.last_error_kind = None
def _set_error(self, kind: str, message: str) -> None:
self.last_error_kind = kind
self.last_error = str(message)[:500]
@staticmethod
def _escape_query_value(value: str) -> str:
@@ -51,6 +61,7 @@ class GoogleDriveService:
bool: 認證是否成功
"""
try:
self._clear_error()
# 舊版 pickle token 遷移提示(不自動刪除舊檔)
if os.path.exists(_LEGACY_PICKLE_FILE) and not os.path.exists(TOKEN_FILE):
logger.warning(
@@ -73,7 +84,9 @@ class GoogleDriveService:
else:
# 需要重新認證
if not os.path.exists(CREDENTIALS_FILE):
logger.error(f"找不到認證檔案: {CREDENTIALS_FILE}")
error_message = f"找不到認證檔案: {CREDENTIALS_FILE}"
self._set_error("authentication_failed", error_message)
logger.error(error_message)
return False
logger.info("進行 Google Drive 認證...")
@@ -90,10 +103,12 @@ class GoogleDriveService:
# 建立 Drive API 服務
self.service = build('drive', 'v3', credentials=self.credentials)
self._clear_error()
logger.info("Google Drive 服務已連接")
return True
except Exception as e:
self._set_error("authentication_failed", str(e))
logger.error(f"Google Drive 認證失敗: {str(e)}")
return False
@@ -111,6 +126,8 @@ class GoogleDriveService:
if not self.service:
if not self.authenticate():
return []
else:
self._clear_error()
try:
# 首先找到資料夾 ID
@@ -138,11 +155,13 @@ class GoogleDriveService:
).execute()
files = results.get('files', [])
self._clear_error()
logger.info(f"{folder_path} 找到 {len(files)} 個 Excel 檔案")
return files
except HttpError as error:
self._set_error("drive_api_failed", str(error))
logger.error(f"列出檔案時發生錯誤: {error}")
return []

View File

@@ -60,6 +60,20 @@ def _date_filter_expr(column_name: str) -> str:
return f"date({column_name})"
def _table_columns(conn, table_name: str) -> set[str]:
"""Return non-id table columns for PostgreSQL and SQLite-backed tests."""
if _db_dialect_name() == "postgresql":
rows = conn.execute(text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = :table_name AND column_name != 'id'
ORDER BY ordinal_position
"""), {"table_name": table_name}).fetchall()
return {row[0] for row in rows}
rows = conn.execute(text(f'PRAGMA table_info("{table_name}")')).fetchall()
return {row[1] for row in rows if row[1] != 'id'}
def _normalise_date_values_for_sql(values):
"""Keep PostgreSQL params as date objects; use ISO strings for SQLite/text comparisons."""
normalised = []
@@ -765,13 +779,7 @@ class ImportService:
# 2026-01-30 新增:驗證 DataFrame 欄位和目標表欄位是否一致
with engine.connect() as conn:
col_query = text(f"""
SELECT column_name FROM information_schema.columns
WHERE table_name = '{monthly_table}' AND column_name != 'id'
ORDER BY ordinal_position
""")
result = conn.execute(col_query)
target_columns = set([row[0] for row in result])
target_columns = _table_columns(conn, monthly_table)
df_columns = set(df_monthly.columns)
missing_in_table = df_columns - target_columns
@@ -863,26 +871,6 @@ class ImportService:
logger.error(f"任務 {job_id} 發送告警失敗: {notify_error}")
# 更新成功資訊
self.update_job_progress(
job_id,
processed_rows=total_rows,
success_rows=total_rows
)
# 2026-01-30 修正:根據同步狀態設置完成訊息
if sync_success:
completion_msg = '匯入完成(已同步至業績分析儀表板)'
else:
completion_msg = '匯入完成(警告:業績分析儀表板同步失敗,需手動處理)'
self.update_job_status(
job_id,
'completed',
100,
completion_msg
)
# 計算日期範圍
date_min = None
date_max = None
@@ -923,6 +911,43 @@ class ImportService:
finally:
session.close()
if not sync_success:
error_msg = sync_error_msg or '同步至業績分析儀表板失敗'
self.update_job_progress(
job_id,
processed_rows=total_rows,
success_rows=0,
error_rows=total_rows,
)
self.update_job_status(
job_id,
'failed',
95,
'業績分析儀表板同步失敗',
error_msg,
)
logger.error(
"任務 %s 匯入未完成daily_sales_snapshot 已寫入,但 %s 同步失敗;"
"保留來源檔案等待重試,不移動 Google Drive 檔案",
job_id,
monthly_table,
)
return False
# 更新成功資訊
self.update_job_progress(
job_id,
processed_rows=total_rows,
success_rows=total_rows
)
self.update_job_status(
job_id,
'completed',
100,
'匯入完成(已同步至業績分析儀表板)'
)
logger.info(f"任務 {job_id} 匯入成功: {total_rows}")
try:
from services.cache_service import clear_growth_cache
@@ -957,6 +982,24 @@ class ImportService:
# 列出檔案
files = drive_service.list_files_in_folder(folder_path, file_pattern)
drive_error_kind = getattr(drive_service, "last_error_kind", None)
drive_error = getattr(drive_service, "last_error", None)
if drive_error_kind:
message = (
"Google Drive 連線或認證失敗,未能確認來源資料夾是否有新檔案:"
f"{drive_error or drive_error_kind}"
)
logger.error(message)
return {
'success': False,
'message': message,
'file_count': 0,
'imported_count': 0,
'failed_count': 1,
'connection_error': True,
'error_kind': drive_error_kind,
}
if not files:
logger.info("沒有找到待匯入的檔案")
@@ -1176,14 +1219,15 @@ class ImportService:
is_connection_error = any(err.lower() in error_msg.lower() for err in connection_errors)
if is_connection_error:
# 連線錯誤:返回成功但無檔案(避免發送告警)
logger.warning(f"Google Drive 連線問題,跳過本次匯入檢查: {error_msg}")
# Drive 連線 / 認證錯誤不是「無檔案」,必須 fail-closed 才能觸發告警與人工補件。
logger.error(f"Google Drive 連線問題,無法確認待匯入檔案: {error_msg}")
return {
'success': True, # 標記為成功避免告警
'message': f'Google Drive 連線問題,跳過本次檢查',
'success': False,
'message': f'Google Drive 連線問題,無法確認待匯入檔案: {error_msg}',
'file_count': 0,
'imported_count': 0,
'connection_error': True # 標記為連線錯誤供日誌記錄
'failed_count': 1,
'connection_error': True
}
else:
# 真正的匯入錯誤:返回失敗

View File

@@ -545,7 +545,104 @@ def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str,
return {**legacy_map, **normalized_map}
def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] | None) -> dict[str, Any]:
def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]:
inspector = inspect(conn)
if not inspector.has_table("external_offers"):
return {}
ids = [str(item).strip() for item in pchome_product_ids if str(item or "").strip()]
if not ids:
return {}
if conn.dialect.name == "postgresql":
sql = """
WITH latest_review AS (
SELECT DISTINCT ON (eo.pchome_product_id)
eo.id,
eo.pchome_product_id,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.raw_payload_json,
eo.observed_at
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.ingestion_method = 'targeted_momo_review'
AND eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND (
eo.match_status = 'needs_review'
OR eo.data_quality_status = 'needs_review'
)
ORDER BY eo.pchome_product_id, eo.observed_at DESC NULLS LAST, eo.id DESC
)
SELECT * FROM latest_review
"""
else:
sql = """
WITH latest_review AS (
SELECT
eo.id,
eo.pchome_product_id,
eo.source_product_id AS momo_sku,
eo.title AS momo_name,
eo.product_url,
eo.price AS momo_price,
eo.quality_score,
eo.raw_payload_json,
eo.observed_at,
ROW_NUMBER() OVER (
PARTITION BY eo.pchome_product_id
ORDER BY eo.observed_at DESC, eo.id DESC
) AS rn
FROM external_offers eo
WHERE eo.source_code = 'momo_reference'
AND eo.ingestion_method = 'targeted_momo_review'
AND eo.pchome_product_id IS NOT NULL
AND eo.pchome_product_id IN :ids
AND (
eo.match_status = 'needs_review'
OR eo.data_quality_status = 'needs_review'
)
)
SELECT *
FROM latest_review
WHERE rn = 1
"""
stmt = text(sql).bindparams(bindparam("ids", expanding=True))
rows = conn.execute(stmt, {"ids": ids}).mappings().all()
result: dict[str, dict[str, Any]] = {}
for row in rows:
key = str(row.get("pchome_product_id") or "").strip()
if not key:
continue
raw_payload = _json_dict(row.get("raw_payload_json"))
reasons = raw_payload.get("match_reasons") if isinstance(raw_payload.get("match_reasons"), list) else []
result[key] = {
"id": row.get("id"),
"pchome_product_id": key,
"pchome_product_name": raw_payload.get("pchome_public_name"),
"pchome_price": raw_payload.get("pchome_public_price"),
"momo_sku": row.get("momo_sku"),
"momo_name": row.get("momo_name"),
"momo_price": row.get("momo_price"),
"product_url": row.get("product_url"),
"quality_score": round(_to_float(row.get("quality_score")), 2),
"match_score": _match_score_from_quality(row.get("quality_score")),
"match_reasons": [str(reason) for reason in reasons[:5]],
"gap_pct": raw_payload.get("target_gap_pct"),
"observed_at": str(row.get("observed_at") or ""),
}
return result
def _score_opportunity(
sales_row: dict[str, Any],
external_row: dict[str, Any] | None,
review_candidate: dict[str, Any] | None = None,
) -> dict[str, Any]:
sales_7d = _to_float(sales_row.get("sales_7d"))
sales_prev_7d = _to_float(sales_row.get("sales_prev_7d"))
qty_7d = _to_float(sales_row.get("qty_7d"))
@@ -558,6 +655,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
decline_score = min(24, abs(sales_delta_pct) / 45 * 24) if sales_delta_pct is not None and sales_delta_pct < 0 else 0
data_quality_score = 54
external_payload = None
review_candidate_payload = None
action_code = "map_external_product"
action_label = "先補商品對應"
action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。"
@@ -633,8 +731,26 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else:
reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if review_candidate:
review_candidate_payload = {
"id": review_candidate.get("id"),
"momo_sku": review_candidate.get("momo_sku"),
"momo_name": review_candidate.get("momo_name"),
"momo_price": review_candidate.get("momo_price"),
"pchome_price": review_candidate.get("pchome_price"),
"quality_score": review_candidate.get("quality_score"),
"gap_pct": review_candidate.get("gap_pct"),
"match_reasons": review_candidate.get("match_reasons") or [],
"product_url": review_candidate.get("product_url"),
}
data_quality_score = max(data_quality_score, 62)
action_code = "review_external_candidate"
action_label = "確認候選"
action_message = "已找到 MOMO 候選,但還要確認同款、色號或組合後才能進價格判斷。"
reason_lines.append("已找到 MOMO 候選,先確認同款、色號或組合。")
else:
data_quality_score -= 12
reason_lines.append("尚未找到可確認的 MOMO 對照商品。")
if sales_delta_pct is None:
reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。")
@@ -659,7 +775,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
issues = []
if not external_row:
issues.append("需要補商品對應")
issues.append("需要確認 MOMO 候選" if review_candidate_payload else "需要補商品對應")
if sales_delta_pct is None:
issues.append("前期業績不足")
@@ -677,6 +793,7 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
else None,
"last_sale_date": str(sales_row.get("last_sale_date") or ""),
"external_price": external_payload,
"review_candidate": review_candidate_payload,
"priority_score": round(priority_score, 1),
"recommended_action": {
"code": action_code,
@@ -690,6 +807,8 @@ def _score_opportunity(sales_row: dict[str, Any], external_row: dict[str, Any] |
if external_payload and external_payload.get("price_basis") == "unit_price"
else "資料可直接判斷"
if external_row
else "候選待確認"
if review_candidate_payload
else "需要補資料"
),
"score": round(max(0, min(100, data_quality_score)), 1),
@@ -760,11 +879,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any]
}
sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows]
external_map = _fetch_external_price_map(conn, sales_ids)
review_candidate_map = _fetch_review_candidate_map(conn, sales_ids)
opportunities = []
for row in sales_rows:
key = str(row.get("pchome_product_id") or "").strip()
opportunities.append(_score_opportunity(row, external_map.get(key)))
opportunities.append(_score_opportunity(row, external_map.get(key), review_candidate_map.get(key)))
opportunities.sort(key=lambda item: item["priority_score"], reverse=True)
opportunities = opportunities[:limit]

0
templates/__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
#}
{% set _active_page = active_page|default('') %}
{% set _is_growth_command = _active_page == 'ai_intelligence' %}
{% set _is_price_workbench = _active_page == 'dashboard' and request is defined and request.args.get('filter') == 'pchome_review' %}
{% set _next_run = next_run|default(None) %}
{% set _session_username = session.get('username') if session is defined else None %}
@@ -45,19 +46,24 @@
</a>
<nav class="momo-nav momo-scroll">
{# 群組 1: 監控caramel #}
{# 群組 1: 作戰caramel #}
<div class="momo-nav-group">
<div class="momo-nav-group-title">監控</div>
<a class="momo-nav-link {% if _active_page == 'dashboard' and not _is_price_workbench %}is-active{% endif %}" href="/">
<span class="momo-nav-icon"><i class="fas fa-border-all"></i></span>
<span class="momo-nav-label">商品看板</span>
<div class="momo-nav-group-title">作戰</div>
<a class="momo-nav-link {% if _is_growth_command %}is-active{% endif %}" href="/">
<span class="momo-nav-icon"><i class="fas fa-chart-line"></i></span>
<span class="momo-nav-label">業績成長指揮台</span>
<span class="momo-nav-code">01</span>
</a>
<a class="momo-nav-link {% if _is_price_workbench %}is-active{% endif %}" href="/?filter=pchome_review&review_status=all&sort_by=pchome_review&order=desc">
<a class="momo-nav-link {% if _is_price_workbench %}is-active{% endif %}" href="/dashboard?filter=pchome_review&review_status=all&sort_by=pchome_review&order=desc">
<span class="momo-nav-icon"><i class="fas fa-scale-balanced"></i></span>
<span class="momo-nav-label">比價工作台</span>
<span class="momo-nav-code">02</span>
</a>
<a class="momo-nav-link {% if _active_page == 'dashboard' and not _is_price_workbench %}is-active{% endif %}" href="/dashboard">
<span class="momo-nav-icon"><i class="fas fa-border-all"></i></span>
<span class="momo-nav-label">舊商品看板</span>
<span class="momo-nav-code"></span>
</a>
<a class="momo-nav-link {% if _active_page in ['edm', 'campaigns'] %}is-active{% endif %}" href="/edm">
<span class="momo-nav-icon"><i class="fas fa-bullhorn"></i></span>
<span class="momo-nav-label">活動看板</span>
@@ -119,7 +125,7 @@
{# 群組 4: AI 中樞saffron #}
<div class="momo-nav-group">
<div class="momo-nav-group-title">AI 中樞</div>
<a class="momo-nav-link {% if _active_page in ['ai_recommend', 'ai_history', 'ai_intelligence', 'pchome_crawler', 'price_comparison', 'trends'] %}is-active{% endif %}" href="/ai_recommend">
<a class="momo-nav-link {% if _active_page in ['ai_recommend', 'ai_history', 'pchome_crawler', 'price_comparison', 'trends'] %}is-active{% endif %}" href="/ai_recommend">
<span class="momo-nav-icon"><i class="fas fa-wand-magic-sparkles"></i></span>
<span class="momo-nav-label">AI 助手</span>
<span class="momo-nav-code">05</span>

View File

@@ -2,10 +2,10 @@
{% set legacy_bridge_title = '商品看板已升級' %}
{% set legacy_bridge_kicker = 'MIGRATED DASHBOARD' %}
{% set legacy_bridge_icon = 'fas fa-border-all' %}
{% set legacy_bridge_heading = '請使用新版商品看板' %}
{% set legacy_bridge_body = '舊版 dashboard.html 已停用正式入口現在由 dashboard_v2.html 與 EwoooC V3 shell 負責。' %}
{% set legacy_bridge_target = '/' %}
{% set legacy_bridge_cta = '開啟商品看板' %}
{% set legacy_bridge_heading = '請使用業績成長指揮台' %}
{% set legacy_bridge_body = '舊版 dashboard.html 已停用正式入口現在是 PChome 業績成長指揮台,商品看板僅保留為舊資料檢視入口。' %}
{% set legacy_bridge_target = '/ai_intelligence' %}
{% set legacy_bridge_cta = '開啟業績成長指揮台' %}
{% set legacy_bridge_secondary_target = '/observability/overview' %}
{% set legacy_bridge_secondary_cta = 'AI 觀測台' %}
{% set legacy_bridge_meta = 'Legacy guard / dashboard.html' %}

View File

@@ -49,10 +49,10 @@
{# 群組映射 — Jinja 計算 [data-page-group] #}
{% set _page = active_page|default('') %}
{% set _group_monitor = ['dashboard', 'edm', 'campaigns'] %}
{% set _group_monitor = ['ai_intelligence', 'dashboard', 'edm', 'campaigns'] %}
{% set _group_analytics = ['sales', 'daily_sales', 'monthly', 'growth', 'metabase', 'grist'] %}
{% set _group_ops = ['vendor_stockout', 'auto_import', 'market_intel'] %}
{% set _group_ai = ['ai_recommend', 'ai_history', 'ai_intelligence',
{% set _group_ai = ['ai_recommend', 'ai_history',
'pchome_crawler', 'price_comparison', 'trends',
'obs_overview', 'obs_agent_orchestration', 'obs_business_intel',
'obs_host_health', 'obs_ai_calls', 'obs_budget',

View File

@@ -0,0 +1,192 @@
import importlib
import json
import os
import sys
import types
import pandas as pd
from sqlalchemy import text
def _load_import_service(monkeypatch, database_url):
os.environ.setdefault("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "true")
import config
monkeypatch.setattr(config, "DATABASE_PATH", database_url)
import services.import_service as import_service
return importlib.reload(import_service)
def _prepare_daily_sales_tables(import_service):
import_service.Base.metadata.create_all(import_service.engine)
with import_service.engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS daily_sales_snapshot"))
conn.execute(text("DROP TABLE IF EXISTS realtime_sales_monthly"))
conn.execute(text("""
CREATE TABLE daily_sales_snapshot (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER,
snapshot_date TEXT
)
"""))
conn.execute(text("""
CREATE TABLE realtime_sales_monthly (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER
)
"""))
def test_daily_sales_import_fails_when_monthly_sync_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_daily_sales_tables(import_service)
class FakeNotificationManager:
sent_messages = []
def _send_telegram_messages(self, messages):
self.sent_messages.extend(messages)
monkeypatch.setitem(
sys.modules,
"services.notification_manager",
types.SimpleNamespace(NotificationManager=FakeNotificationManager),
)
source_df = pd.DataFrame([
{
"日期": "2026-06-24",
"商品ID": "A001",
"商品名稱": "測試商品",
"銷售金額": 1200,
}
])
monkeypatch.setattr(
import_service,
"_read_daily_sales_excel",
lambda _path: (
source_df.copy(),
{"date_col": "日期", "sheet_name": "即時業績明細", "header_row": 1},
),
)
original_to_sql = pd.DataFrame.to_sql
def fail_monthly_sync(self, name, *args, **kwargs):
if name == "realtime_sales_monthly":
raise RuntimeError("monthly sync boom")
return original_to_sql(self, name, *args, **kwargs)
monkeypatch.setattr(pd.DataFrame, "to_sql", fail_monthly_sync)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
assert service.process_daily_sales_import(job_id, str(tmp_path / "daily.xlsx")) is False
session = import_service.Session()
try:
job = session.query(import_service.ImportJob).filter_by(id=job_id).one()
assert job.status == "failed"
assert job.progress_percent == 95
assert job.current_step == "業績分析儀表板同步失敗"
assert "monthly sync boom" in job.error_message
summary = json.loads(job.import_summary)
assert summary["sync_success"] is False
assert summary["synced_to"] is None
assert "monthly sync boom" in summary["sync_error"]
finally:
session.close()
with import_service.engine.connect() as conn:
snapshot_rows = conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar()
monthly_rows = conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar()
assert snapshot_rows == 1
assert monthly_rows == 0
assert FakeNotificationManager.sent_messages
def test_auto_import_does_not_move_drive_file_when_import_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
moved_files = []
def list_files_in_folder(self, folder_path, file_pattern):
return [{"id": "drive-file-1", "name": "daily.xlsx", "size": 1024}]
def download_file(self, file_id, local_path):
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as handle:
handle.write(b"test")
return True
def move_file(self, file_id, folder, create_missing=False):
self.moved_files.append((file_id, folder, create_missing))
return True
fake_drive = FakeDriveService()
monkeypatch.setattr(import_service, "drive_service", fake_drive)
service = import_service.ImportService()
monkeypatch.setattr(service, "process_daily_sales_import", lambda job_id, path: False)
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["failed_count"] == 1
assert fake_drive.moved_files == []
def test_auto_import_fails_closed_when_drive_auth_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
last_error_kind = "authentication_failed"
last_error = "could not locate runnable browser"
def list_files_in_folder(self, folder_path, file_pattern):
return []
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["file_count"] == 0
assert result["imported_count"] == 0
assert result["failed_count"] == 1
assert result["connection_error"] is True
assert result["error_kind"] == "authentication_failed"
assert "Google Drive" in result["message"]
assert "could not locate runnable browser" in result["message"]
def test_auto_import_empty_drive_folder_remains_success(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
class FakeDriveService:
last_error_kind = None
last_error = None
def list_files_in_folder(self, folder_path, file_pattern):
return []
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
result = service.auto_import_from_drive()
assert result["success"] is True
assert result["file_count"] == 0
assert result["message"] == "沒有找到待匯入的檔案"

View File

@@ -429,6 +429,58 @@ def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(monkeypa
assert stale_marks == [True]
def test_momo_review_candidate_queue_can_confirm_candidate(monkeypatch):
from services import external_market_offer_service as service
stale_marks = []
monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True))
engine = create_engine("sqlite:///:memory:")
_seed_external_offer_sync_tables(engine)
service.sync_targeted_momo_review_candidates_to_external_offers(engine, [
{
"product_id": "14917079",
"name": "【cle de peau 肌膚之鑰】光采柔焦蜜粉 24g (國際航空版)",
"price": 2618,
"target_pchome_product_id": "PCH-CDP",
"target_pchome_name": "cle de peau 光采柔焦蜜粉 24g #1",
"target_pchome_price": 2790,
"target_match_score": 1.0,
"target_price_basis": "none",
"target_alert_tier": "identity_review",
"target_match_type": "exact",
"target_match_reasons": ["variant_selection_review"],
"target_gap_pct": -6.16,
},
])
queue = service.list_momo_review_candidates(engine)
assert queue["success"] is True
assert queue["count"] == 1
candidate = queue["rows"][0]
assert candidate["pchome_product_name"] == "cle de peau 光采柔焦蜜粉 24g #1"
assert candidate["momo_sku"] == "14917079"
assert candidate["plain_status"] == "待確認同款或色號"
updated = service.update_momo_review_candidate(engine, candidate["id"], "confirm", note="同款 #1")
assert updated["success"] is True
assert updated["match_status"] == "verified"
assert service.list_momo_review_candidates(engine)["count"] == 0
with engine.connect() as conn:
row = conn.execute(text("""
SELECT match_status, data_quality_status, raw_payload_json
FROM external_offers
WHERE id = :id
"""), {"id": candidate["id"]}).mappings().one()
raw_payload = __import__("json").loads(row["raw_payload_json"])
assert row["match_status"] == "verified"
assert row["data_quality_status"] == "verified"
assert raw_payload["review_action"] == "confirm"
assert stale_marks == [True, True]
def test_sync_targeted_momo_candidates_keeps_best_unit_quantity_match(monkeypatch):
from services import external_market_offer_service as service
@@ -547,6 +599,10 @@ def test_external_offer_csv_dry_run_route_is_registered_as_post_only():
assert "@ai_bp.route('/api/ai/pchome-growth/external-offers/csv-dry-run', methods=['POST'])" in route_source
assert "dry_run_external_offer_csv" in route_source
assert "@ai_bp.route('/api/ai/pchome-growth/review-candidates')" in route_source
assert "@ai_bp.route('/api/ai/pchome-growth/review-candidates/<int:offer_id>', methods=['POST'])" in route_source
assert "list_momo_review_candidates" in route_source
assert "update_momo_review_candidate" in route_source
def test_external_offer_sync_is_registered_in_scheduler():

View File

@@ -57,6 +57,7 @@ def _seed_growth_external_offers(engine):
source_product_id TEXT,
source_offer_key TEXT,
title TEXT,
product_url TEXT,
price REAL,
observed_at TEXT,
expires_at TEXT,
@@ -73,13 +74,13 @@ def _seed_growth_external_offers(engine):
conn.execute(text("""
INSERT INTO external_offers (
id, source_code, platform_code, source_product_id, source_offer_key,
title, price, observed_at, expires_at, ingestion_method,
title, product_url, price, observed_at, expires_at, ingestion_method,
pchome_product_id, momo_sku, match_status, quality_score,
data_quality_status, quality_notes_json, raw_payload_json
)
VALUES (
1, 'momo_reference', 'momo', 'MOMO-NEW', 'momo_reference:MOMO-NEW:PCH-1',
'MOMO 新資料層商品', 870, '2026-06-14 12:00:00', NULL, 'legacy_competitor_cache',
'MOMO 新資料層商品', 'https://www.momoshop.com.tw/goods/MOMO-NEW', 870, '2026-06-14 12:00:00', NULL, 'legacy_competitor_cache',
'PCH-1', 'MOMO-NEW', 'verified', 92,
'verified', '["自動同步"]',
'{"pchome_public_price": 1000, "pchome_public_name": "PChome 公開商品"}'
@@ -97,6 +98,7 @@ def _seed_growth_unit_price_external_offer(engine):
source_product_id TEXT,
source_offer_key TEXT,
title TEXT,
product_url TEXT,
price REAL,
observed_at TEXT,
expires_at TEXT,
@@ -113,13 +115,13 @@ def _seed_growth_unit_price_external_offer(engine):
conn.execute(text("""
INSERT INTO external_offers (
id, source_code, platform_code, source_product_id, source_offer_key,
title, price, observed_at, expires_at, ingestion_method,
title, product_url, price, observed_at, expires_at, ingestion_method,
pchome_product_id, momo_sku, match_status, quality_score,
data_quality_status, quality_notes_json, raw_payload_json
)
VALUES (
1, 'momo_reference', 'momo', 'MOMO-UNIT', 'momo_reference:MOMO-UNIT:PCH-1:unit_price',
'MOMO 單位價商品', 468, '2026-06-14 12:00:00', NULL, 'targeted_momo_search',
'MOMO 單位價商品', 'https://www.momoshop.com.tw/goods/MOMO-UNIT', 468, '2026-06-14 12:00:00', NULL, 'targeted_momo_search',
'PCH-1', 'MOMO-UNIT', 'verified', 82,
'verified', '["自動單位價比較"]',
'{"price_basis": "unit_price", "pchome_public_price": 920, "pchome_public_name": "PChome 公開商品", "tags": ["identity_v2", "price_basis_unit_price"], "unit_price_comparison": {"unit_label": "ml", "momo_unit_price": 11.7, "competitor_unit_price": 23.0, "momo_total_quantity": 40, "competitor_total_quantity": 40, "unit_gap_pct": -49.13}}'
@@ -421,7 +423,18 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "PChome 業績成長自動化作戰系統" in template
assert "/api/ai/pchome-growth/opportunities" in template
assert "/api/ai/pchome-growth/external-offers/csv-dry-run" in template
assert "/api/ai/pchome-growth/review-candidates" in template
assert "growthSourceReadiness" in template
assert "MOMO 待確認候選" in template
assert "確認同款" in template
assert "不是同款" in template
assert "review_external_candidate" in template
assert "focusReviewCandidate" in template
assert "handleDrilldownKey" in template
assert "drilldown-hint" in template
assert "候選待確認" in template
assert "看明細" in template
assert "data-pchome-id" in template
assert "今日重點總覽" in template
assert "nextActionTitle" in template
assert "商品處理進度" in template
@@ -430,6 +443,61 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "growthDataSourceSummary" in template
assert "external_data_source_counts" in template
assert "compSourceSummary" in template
assert "growthDrilldownPanel" in template
assert "showGrowthDetail" in template
assert "growthDetailRows" in template
assert "growth-detail-tab" in template
assert "今日商品明細" in template
assert "價格壓力" in template
assert "價格優勢" in template
assert "有外部價" in template
assert "showPriceRiskDetail" in template
assert "handlePriceRiskKey" in template
assert "opportunities?limit=50" in template
assert "growthStrategyGrid" in template
assert "renderGrowthStrategySummary" in template
assert "商品策略分流" in template
assert "growth-strategy-card" in template
assert "aiRecsPanel" in template
assert "growth-detail-price-grid" in template
assert "growth-price-chip" in template
assert "formatGrowthDetailPrice" in template
assert "formatGapDisplay" in template
assert "growthDecisionSummary" in template
assert "renderGrowthDecisionSummary" in template
assert "growth-decision-metric" in template
assert "最大價差" in template
assert "growthDetailSearch" in template
assert "growthDetailSort" in template
assert "setGrowthDetailSearch" in template
assert "setGrowthDetailSort" in template
assert "clearGrowthDetailFilters" in template
assert "價差大到小" in template
assert "growthProductDecisionPanel" in template
assert "showGrowthProductDetail" in template
assert "growth-product-evidence-grid" in template
assert "查看判斷" in template
assert "growthCategoryBoard" in template
assert "分類策略看板" in template
assert "showGrowthCategoryDetail" in template
assert "data-growth-action=\"show-category-detail\"" in template
assert "growthPlaybookBoard" in template
assert "銷售策略建議" in template
assert "showGrowthPlaybookDetail" in template
assert "組合 / 單位價" in template
assert "growthActionBoard" in template
assert "今日策略動作" in template
assert "renderGrowthActionBoard" in template
assert "PChome 業績成長系統" in template
assert "growth-command-pro" in template
assert "commandSales7d" in template
assert "commandTopDecliners" in template
assert "PChome 與 MOMO 價格狀態" in template
assert "renderGrowthCommandCenter" in template
assert "growthActionPlanForRow" in template
assert "growthActionEvidence" in template
assert "growth-action-evidence-chip" in template
assert "document.querySelectorAll('.growth-detail-row')" in template
assert "scrollToPanel('externalPricePanel')" in template
assert "備援資料檢查" in template
assert "外部報價預檢" not in template
@@ -437,3 +505,33 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
assert "鎖定商品" in template
assert "無法比價" in template
assert "補齊比價資料" in template
def test_formal_homepage_routes_to_growth_command_center():
from pathlib import Path
route_source = Path("routes/dashboard_routes.py").read_text(encoding="utf-8")
assert "@dashboard_bp.route('/')" in route_source
assert "render_template('ai_intelligence.html', active_page='ai_intelligence')" in route_source
assert "return redirect" not in route_source
assert "url_for('ai.ai_intelligence')" not in route_source
assert "@dashboard_bp.route('/dashboard')" in route_source
assert "@dashboard_bp.route('/product-dashboard')" in route_source
def test_sidebar_uses_growth_command_center_as_primary_entry():
from pathlib import Path
shell = Path("templates/components/_ewoooc_shell.html").read_text(encoding="utf-8")
base = Path("templates/ewoooc_base.html").read_text(encoding="utf-8")
assert 'href="/"' in shell
assert 'href="/ai_intelligence"' not in shell
assert "業績成長指揮台" in shell
assert "舊商品看板" in shell
assert 'href="/dashboard?filter=pchome_review' in shell
assert 'href="/?filter=pchome_review' not in shell
assert "{% set _group_monitor = ['ai_intelligence', 'dashboard', 'edm', 'campaigns'] %}" in base
ai_helper_line = next(line for line in shell.splitlines() if 'href="/ai_recommend"' in line)
assert "ai_intelligence" not in ai_helper_line

View File

@@ -0,0 +1,11 @@
from pathlib import Path
def test_webcrumbs_loader_has_safe_fallback_response():
source = Path("routes/system_public_routes.py").read_text(encoding="utf-8")
assert "WEBCRUMBS_COMPATIBLE_LOADER_PATH = 'loader/webcrumbs-compatible-loader.js'" in source
assert "WEBCRUMBS_FALLBACK_LOADER" in source
assert "status=200, mimetype='application/javascript'" in source
assert "X-Webcrumbs-Fallback" in source
assert "upstream-unavailable" in source

View File

@@ -0,0 +1 @@
tracked placeholder for the upload directory