Compare commits
21 Commits
dev
...
codex/momo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e137d7a5d0 | ||
|
|
e8f9dfeba4 | ||
|
|
e3b082a299 | ||
|
|
5c26071a3e | ||
|
|
ef12c1d356 | ||
|
|
3057c73e0f | ||
|
|
84035906ab | ||
|
|
8240b59b84 | ||
|
|
5adeacd65c | ||
|
|
65aa23800c | ||
|
|
fa71897158 | ||
|
|
9610b4da18 | ||
|
|
2aa1ae04ed | ||
|
|
b87931c911 | ||
|
|
7cfca93754 | ||
|
|
7180c0f817 | ||
|
|
2d9acfdc5c | ||
|
|
776a7dd4ea | ||
|
|
873e0ce902 | ||
|
|
e6deaa4711 | ||
|
|
06418878e0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,避免正式頁面出現 502;fallback 只負責安全降級與標示 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`。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 "已排除候選,不會再進入待確認。",
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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:
|
||||
# 真正的匯入錯誤:返回失敗
|
||||
|
||||
@@ -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
0
templates/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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',
|
||||
|
||||
192
tests/test_auto_import_failure_boundaries.py
Normal file
192
tests/test_auto_import_failure_boundaries.py
Normal 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"] == "沒有找到待匯入的檔案"
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
11
tests/test_webcrumbs_asset_proxy.py
Normal file
11
tests/test_webcrumbs_asset_proxy.py
Normal 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
|
||||
1
web/static/uploads/.gitkeep
Normal file
1
web/static/uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
tracked placeholder for the upload directory
|
||||
Reference in New Issue
Block a user