feat: add momo review candidate queue
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m11s
This commit is contained in:
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.639"
|
||||
SYSTEM_VERSION = "V10.640"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
- 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 成長作戰清單快取。
|
||||
|
||||
## 零之一、12 Agent 決策信封(2026-05-24)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "已排除候選,不會再進入待確認。",
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@
|
||||
|
||||
.growth-metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -845,6 +845,62 @@
|
||||
color: #94372d;
|
||||
}
|
||||
|
||||
.review-candidate-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.6fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.review-candidate-summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-candidate-result {
|
||||
border: 1px solid rgba(42, 37, 32, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
min-height: 212px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.review-candidate-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.review-candidate-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.review-candidate-title {
|
||||
margin: 0;
|
||||
color: var(--momo-text-strong);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.review-candidate-meta,
|
||||
.review-candidate-reason {
|
||||
margin: 4px 0 0;
|
||||
color: var(--momo-text-muted);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.review-candidate-actions {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
align-content: start;
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.ai-intel-hero {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1007,6 +1063,7 @@
|
||||
}
|
||||
|
||||
.growth-ops-grid,
|
||||
.review-candidate-panel,
|
||||
.growth-executive-strip,
|
||||
.offer-dryrun-grid,
|
||||
.growth-metric-row,
|
||||
@@ -1225,6 +1282,41 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── MOMO 待確認候選 ── -->
|
||||
<section class="card shadow-sm ai-panel" id="growthReviewPanel">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
|
||||
<span class="ai-panel-title">
|
||||
<i class="fas fa-list-check"></i>MOMO 待確認候選
|
||||
<small class="text-muted fw-normal ms-2">先確認同款,再進價格判斷</small>
|
||||
</span>
|
||||
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthReviewCandidates(true)">
|
||||
<i class="fas fa-redo me-1"></i>更新候選
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="review-candidate-panel">
|
||||
<div class="review-candidate-summary">
|
||||
<div class="growth-metric">
|
||||
<strong id="reviewCandidateTotal">—</strong>
|
||||
<span>待確認</span>
|
||||
</div>
|
||||
<div class="growth-metric">
|
||||
<strong id="reviewCandidateReadyHint">0</strong>
|
||||
<span>確認後可進作戰</span>
|
||||
</div>
|
||||
<div class="growth-action-hint">
|
||||
確認同款後才會進入 MOMO 價格參考;不確定色號、容量或組合時請先排除。
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-candidate-result" id="growthReviewCandidateList">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>整理待確認候選中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── KPI 卡片 ── -->
|
||||
<div class="row g-3 mb-4" id="kpiRow">
|
||||
<div class="col-6 col-md-3">
|
||||
@@ -1520,6 +1612,15 @@ function escapeHtml(value) {
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function safeHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''), window.location.origin);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
@@ -1740,11 +1841,13 @@ async function loadGrowthOps(forceRefresh = false) {
|
||||
|
||||
renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []);
|
||||
renderGrowthOps(data.opportunities || []);
|
||||
loadGrowthReviewCandidates();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
renderOpsCommandDashboard({}, {});
|
||||
renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 });
|
||||
renderGrowthDataSourceSummary({});
|
||||
renderGrowthReviewCandidates([]);
|
||||
list.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
今日處理清單暫時讀不到,請重新整理;若仍失敗,請檢查業績資料。
|
||||
@@ -1921,6 +2024,108 @@ function renderGrowthOps(rows) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadGrowthReviewCandidates(forceRefresh = false) {
|
||||
const box = document.getElementById('growthReviewCandidateList');
|
||||
if (!box) return;
|
||||
if (forceRefresh) {
|
||||
box.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2"></div>更新待確認候選中...
|
||||
</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/pchome-growth/review-candidates?limit=20');
|
||||
const data = await readJsonResponse(response);
|
||||
if (!data.success) throw new Error(data.error || data.message || '讀取失敗');
|
||||
renderGrowthReviewCandidates(data.rows || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
box.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-exclamation d-block mb-2"></i>
|
||||
待確認候選暫時讀不到,請稍後再試。
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrowthReviewCandidates(rows) {
|
||||
const box = document.getElementById('growthReviewCandidateList');
|
||||
const total = document.getElementById('reviewCandidateTotal');
|
||||
const readyHint = document.getElementById('reviewCandidateReadyHint');
|
||||
if (!box) return;
|
||||
|
||||
rows = Array.isArray(rows) ? rows : [];
|
||||
if (total) total.textContent = rows.length.toLocaleString();
|
||||
if (readyHint) readyHint.textContent = rows.length.toLocaleString();
|
||||
|
||||
if (!rows.length) {
|
||||
box.innerHTML = `<div class="text-center py-4 text-muted">
|
||||
<i class="fas fa-circle-check d-block mb-2"></i>
|
||||
目前沒有待確認候選。
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = rows.map((row) => {
|
||||
const reasons = (row.match_reasons || []).slice(0, 3).join('、') || '候選已找到,需確認同款、色號或組合';
|
||||
const gap = row.gap_pct === null || row.gap_pct === undefined ? '' : ` · 參考差距 ${Number(row.gap_pct).toFixed(1)}%`;
|
||||
const score = Number(row.quality_score || 0).toFixed(0);
|
||||
const momoPrice = row.momo_price ? formatMoney(row.momo_price) : '未取得 MOMO 價格';
|
||||
const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格';
|
||||
const safeUrl = safeHttpUrl(row.product_url);
|
||||
const url = safeUrl ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
|
||||
return `<article class="review-candidate-row">
|
||||
<div>
|
||||
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
|
||||
<p class="review-candidate-meta">
|
||||
PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}
|
||||
</p>
|
||||
<p class="review-candidate-meta">
|
||||
MOMO:${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url}
|
||||
</p>
|
||||
<p class="review-candidate-reason">
|
||||
可信度 ${score}% · ${escapeHtml(reasons)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="review-candidate-actions">
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'confirm', this)">確認同款</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'reject', this)">不是同款</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function updateGrowthReviewCandidate(id, action, button) {
|
||||
if (!id || !action) return;
|
||||
const actionText = action === 'confirm' ? '確認同款' : '排除候選';
|
||||
const originalText = button ? button.textContent : '';
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = '處理中';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ai/pchome-growth/review-candidates/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
if (!data.success) throw new Error(data.error || data.message || `${actionText}失敗`);
|
||||
showToast('success', data.message || `${actionText}完成`, 3500);
|
||||
await loadGrowthReviewCandidates(true);
|
||||
await loadGrowthOps(true);
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast('error', `${actionText}失敗:${error.message}`, 5000);
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillExternalOfferSample() {
|
||||
const sample = [
|
||||
'來源,平台商品編號,商品名稱,售價,資料時間,取得方式,PChome商品編號,是否同款,可信度',
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -421,7 +421,11 @@ 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 "今日重點總覽" in template
|
||||
assert "nextActionTitle" in template
|
||||
assert "商品處理進度" in template
|
||||
|
||||
Reference in New Issue
Block a user