969 lines
43 KiB
Python
969 lines
43 KiB
Python
from sqlalchemy import create_engine, text
|
||
|
||
|
||
def _seed_growth_tables(engine):
|
||
with engine.begin() as conn:
|
||
conn.execute(text(
|
||
'CREATE TABLE daily_sales_snapshot ('
|
||
'"商品ID" TEXT, "商品名稱" TEXT, snapshot_date TEXT, "總業績" REAL, "數量" REAL, "商品館" TEXT)'
|
||
))
|
||
conn.execute(text(
|
||
"CREATE TABLE competitor_prices ("
|
||
"sku TEXT, source TEXT, competitor_product_id TEXT, competitor_product_name TEXT, "
|
||
"price REAL, match_score REAL, tags TEXT, crawled_at TEXT, expires_at TEXT)"
|
||
))
|
||
conn.execute(text(
|
||
"CREATE TABLE products ("
|
||
"id INTEGER PRIMARY KEY, i_code TEXT, name TEXT, status TEXT)"
|
||
))
|
||
conn.execute(text(
|
||
"CREATE TABLE price_records ("
|
||
"id INTEGER PRIMARY KEY, product_id INTEGER, price REAL, timestamp TEXT)"
|
||
))
|
||
conn.execute(text("""
|
||
INSERT INTO daily_sales_snapshot
|
||
("商品ID", "商品名稱", snapshot_date, "總業績", "數量", "商品館")
|
||
VALUES
|
||
('PCH-1', '高業績商品', '2026-06-14', 120000, 36, '保養'),
|
||
('PCH-1', '高業績商品', '2026-06-08', 180000, 50, '保養'),
|
||
('PCH-2', '待補對應商品', '2026-06-14', 90000, 18, '彩妝'),
|
||
('PCH-2', '待補對應商品', '2026-06-08', 30000, 8, '彩妝')
|
||
"""))
|
||
conn.execute(text(
|
||
"INSERT INTO products (id, i_code, name, status) "
|
||
"VALUES (1, 'MOMO-1', 'MOMO 參考商品', 'ACTIVE')"
|
||
))
|
||
conn.execute(text(
|
||
"INSERT INTO price_records (product_id, price, timestamp) "
|
||
"VALUES (1, 900, '2026-06-14 10:00:00')"
|
||
))
|
||
conn.execute(text("""
|
||
INSERT INTO competitor_prices
|
||
(sku, source, competitor_product_id, competitor_product_name,
|
||
price, match_score, tags, crawled_at, expires_at)
|
||
VALUES
|
||
('MOMO-1', 'pchome', 'PCH-1', '高業績商品',
|
||
1000, 0.91, '["identity_v2","match_type_exact"]', '2026-06-14 11:00:00', NULL)
|
||
"""))
|
||
|
||
|
||
def _seed_growth_external_offers(engine):
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
CREATE TABLE external_offers (
|
||
id INTEGER PRIMARY KEY,
|
||
source_code TEXT,
|
||
platform_code TEXT,
|
||
source_product_id TEXT,
|
||
source_offer_key TEXT,
|
||
title TEXT,
|
||
product_url TEXT,
|
||
price REAL,
|
||
observed_at TEXT,
|
||
expires_at TEXT,
|
||
ingestion_method TEXT,
|
||
pchome_product_id TEXT,
|
||
momo_sku TEXT,
|
||
match_status TEXT,
|
||
quality_score REAL,
|
||
data_quality_status TEXT,
|
||
quality_notes_json TEXT,
|
||
raw_payload_json TEXT
|
||
)
|
||
"""))
|
||
conn.execute(text("""
|
||
INSERT INTO external_offers (
|
||
id, source_code, platform_code, source_product_id, source_offer_key,
|
||
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 新資料層商品', '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 公開商品"}'
|
||
)
|
||
"""))
|
||
|
||
|
||
def _seed_growth_unit_price_external_offer(engine):
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
CREATE TABLE external_offers (
|
||
id INTEGER PRIMARY KEY,
|
||
source_code TEXT,
|
||
platform_code TEXT,
|
||
source_product_id TEXT,
|
||
source_offer_key TEXT,
|
||
title TEXT,
|
||
product_url TEXT,
|
||
price REAL,
|
||
observed_at TEXT,
|
||
expires_at TEXT,
|
||
ingestion_method TEXT,
|
||
pchome_product_id TEXT,
|
||
momo_sku TEXT,
|
||
match_status TEXT,
|
||
quality_score REAL,
|
||
data_quality_status TEXT,
|
||
quality_notes_json TEXT,
|
||
raw_payload_json TEXT
|
||
)
|
||
"""))
|
||
conn.execute(text("""
|
||
INSERT INTO external_offers (
|
||
id, source_code, platform_code, source_product_id, source_offer_key,
|
||
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 單位價商品', '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}}'
|
||
)
|
||
"""))
|
||
|
||
|
||
def _seed_growth_review_candidate_offer(engine):
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
CREATE TABLE external_offers (
|
||
id INTEGER PRIMARY KEY,
|
||
source_code TEXT,
|
||
platform_code TEXT,
|
||
source_product_id TEXT,
|
||
source_offer_key TEXT,
|
||
title TEXT,
|
||
product_url TEXT,
|
||
image_url TEXT,
|
||
price REAL,
|
||
observed_at TEXT,
|
||
expires_at TEXT,
|
||
ingestion_method TEXT,
|
||
pchome_product_id TEXT,
|
||
momo_sku TEXT,
|
||
match_status TEXT,
|
||
quality_score REAL,
|
||
data_quality_status TEXT,
|
||
quality_notes_json TEXT,
|
||
raw_payload_json TEXT,
|
||
updated_at TEXT
|
||
)
|
||
"""))
|
||
conn.execute(text("""
|
||
INSERT INTO external_offers (
|
||
id, source_code, platform_code, source_product_id, source_offer_key,
|
||
title, product_url, image_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, updated_at
|
||
)
|
||
VALUES (
|
||
1, 'momo_reference', 'momo', 'MOMO-REVIEW', 'momo_reference:MOMO-REVIEW:PCH-REVIEW',
|
||
'MOMO 候選商品', 'https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=MOMO-REVIEW',
|
||
NULL, 2300, '2026-06-25 12:00:00', NULL, 'targeted_momo_review',
|
||
'PCH-REVIEW', 'MOMO-REVIEW', 'needs_review', 97,
|
||
'needs_review', '[]',
|
||
'{"pchome_public_price": 1430, "pchome_public_name": "PChome 待確認商品", "target_gap_pct": 60.8, "match_reasons": ["variant_selection_review", "focused_exact_identity_ysl_blush_catalog"]}',
|
||
'2026-06-25 12:10:00'
|
||
)
|
||
"""))
|
||
|
||
|
||
def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang():
|
||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||
|
||
engine = create_engine("sqlite:///:memory:")
|
||
_seed_growth_tables(engine)
|
||
|
||
payload = build_pchome_growth_opportunities(engine, limit=5)
|
||
|
||
assert payload["success"] is True
|
||
assert payload["system_name"] == "PChome 業績成長自動化作戰系統"
|
||
assert payload["source_scope"]["active_external_sources"] == ["MOMO 外部價格參考"]
|
||
assert payload["source_scope"]["paused_external_sources"] == ["蝦皮", "酷澎"]
|
||
readiness = payload["source_scope"]["source_readiness"]
|
||
sources = {source["code"]: source for source in readiness["sources"]}
|
||
assert sources["momo_reference"]["status_label"] == "正在使用"
|
||
assert sources["shopee"]["status_label"] == "先暫停"
|
||
assert sources["coupang"]["status_label"] == "先暫停"
|
||
assert payload["stats"]["candidate_count"] == 2
|
||
assert payload["stats"]["mapped_count"] == 1
|
||
assert payload["stats"]["needs_mapping_count"] == 1
|
||
|
||
actions = {item["pchome_product_id"]: item["recommended_action"]["label"] for item in payload["opportunities"]}
|
||
assert actions["PCH-1"] == "檢查售價與活動"
|
||
assert actions["PCH-2"] == "先補商品對應"
|
||
pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1")
|
||
assert pchome_1["pchome_price"] == 3488.37
|
||
assert pchome_1["external_price"]["data_source"] == "competitor_prices"
|
||
assert payload["stats"]["external_data_source_counts"] == {"舊比價快取": 1}
|
||
assert all("identity" not in " ".join(item["reason_lines"]).lower() for item in payload["opportunities"])
|
||
|
||
|
||
def test_momo_review_candidates_return_dual_store_links_and_plain_reasons():
|
||
from services.external_market_offer_service import list_momo_review_candidates
|
||
|
||
engine = create_engine("sqlite:///:memory:")
|
||
_seed_growth_review_candidate_offer(engine)
|
||
|
||
payload = list_momo_review_candidates(engine)
|
||
|
||
assert payload["success"] is True
|
||
assert payload["count"] == 1
|
||
row = payload["rows"][0]
|
||
assert row["pchome_url"] == "https://24h.pchome.com.tw/prod/PCH-REVIEW"
|
||
assert row["momo_url"] == "https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=MOMO-REVIEW"
|
||
assert "pchome_image_url" in row
|
||
assert "momo_image_url" in row
|
||
assert row["match_reason_labels"]
|
||
assert all("_" not in label for label in row["match_reason_labels"])
|
||
assert all("_" not in label for label in row["match_reasons"])
|
||
assert "色號" in row["reason_summary"] or "款式" in row["reason_summary"]
|
||
|
||
|
||
def test_pchome_growth_prefers_external_offers_over_legacy_competitor_cache():
|
||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||
|
||
engine = create_engine("sqlite:///:memory:")
|
||
_seed_growth_tables(engine)
|
||
_seed_growth_external_offers(engine)
|
||
|
||
payload = build_pchome_growth_opportunities(engine, limit=5)
|
||
|
||
assert payload["success"] is True
|
||
pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1")
|
||
external_price = pchome_1["external_price"]
|
||
assert external_price["data_source"] == "external_offers"
|
||
assert external_price["data_source_label"] == "自動同步資料層"
|
||
assert external_price["momo_sku"] == "MOMO-NEW"
|
||
assert external_price["momo_price"] == 870
|
||
assert external_price["pchome_price"] == 1000
|
||
assert external_price["gap_pct"] == -13.0
|
||
assert payload["stats"]["external_data_source_counts"] == {"自動同步資料層": 1}
|
||
|
||
|
||
def test_pchome_growth_understands_unit_price_external_offers():
|
||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||
|
||
engine = create_engine("sqlite:///:memory:")
|
||
_seed_growth_tables(engine)
|
||
_seed_growth_unit_price_external_offer(engine)
|
||
|
||
payload = build_pchome_growth_opportunities(engine, limit=5)
|
||
|
||
assert payload["success"] is True
|
||
pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1")
|
||
external_price = pchome_1["external_price"]
|
||
assert external_price["data_source"] == "external_offers"
|
||
assert external_price["price_basis"] == "unit_price"
|
||
assert external_price["price_basis_label"] == "單位價"
|
||
assert external_price["momo_price"] == 468
|
||
assert external_price["pchome_price"] == 920
|
||
assert external_price["momo_unit_price"] == 11.7
|
||
assert external_price["pchome_unit_price"] == 23.0
|
||
assert external_price["gap_pct"] == -49.1
|
||
assert pchome_1["recommended_action"]["label"] == "檢查售價與活動"
|
||
assert pchome_1["data_quality"]["label"] == "資料可用單位價判斷"
|
||
assert any("單位價" in line for line in pchome_1["reason_lines"])
|
||
|
||
|
||
def test_pchome_growth_route_cache_respects_shared_invalidation_epoch(monkeypatch):
|
||
from routes import ai_routes as routes
|
||
|
||
epoch = {"value": 1.0}
|
||
monkeypatch.setattr(routes, "get_pchome_growth_cache_epoch", lambda: epoch["value"])
|
||
routes._PCHOME_GROWTH_CACHE.update({
|
||
"expires_at": 0.0,
|
||
"epoch": 0.0,
|
||
"payload": None,
|
||
})
|
||
|
||
routes._set_pchome_growth_cache({"success": True, "stats": {"candidate_count": 1}})
|
||
|
||
cached = routes._get_cached_pchome_growth_payload()
|
||
assert cached["success"] is True
|
||
assert cached["stats"]["candidate_count"] == 1
|
||
|
||
epoch["value"] = 2.0
|
||
assert routes._get_cached_pchome_growth_payload() is None
|
||
|
||
|
||
def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items():
|
||
from services.pchome_growth_momo_backfill_service import run_pchome_growth_momo_backfill
|
||
|
||
captured = {}
|
||
|
||
class FakeEngine:
|
||
pass
|
||
|
||
before_payload = {
|
||
"success": True,
|
||
"stats": {"mapping_rate": 0, "candidate_count": 3, "mapped_count": 0},
|
||
"opportunities": [
|
||
{
|
||
"pchome_product_id": "PCH-NEEDS-1",
|
||
"product_name": "需要補對應商品一",
|
||
"pchome_price": 920,
|
||
"sales_7d": 120000,
|
||
"priority_score": 91,
|
||
"external_price": None,
|
||
"recommended_action": {"code": "map_external_product"},
|
||
},
|
||
{
|
||
"pchome_product_id": "PCH-MAPPED",
|
||
"product_name": "已有比價商品",
|
||
"pchome_price": 880,
|
||
"external_price": {"momo_sku": "MOMO-OLD"},
|
||
"recommended_action": {"code": "review_price_or_promo"},
|
||
},
|
||
{
|
||
"pchome_product_id": "PCH-NEEDS-2",
|
||
"product_name": "需要補對應商品二",
|
||
"pchome_price": 760,
|
||
"sales_7d": 90000,
|
||
"priority_score": 82,
|
||
"external_price": None,
|
||
"recommended_action": {"code": "map_external_product"},
|
||
},
|
||
],
|
||
}
|
||
after_payload = {
|
||
"success": True,
|
||
"stats": {"mapping_rate": 66.7, "candidate_count": 3, "mapped_count": 2},
|
||
"opportunities": [],
|
||
}
|
||
|
||
payload_calls = []
|
||
|
||
def fake_build_payload(engine, limit):
|
||
payload_calls.append(limit)
|
||
return before_payload if len(payload_calls) == 1 else after_payload
|
||
|
||
def fake_search(targets, limit):
|
||
captured["targets"] = targets
|
||
captured["search_limit"] = limit
|
||
return True, "找到候選", [
|
||
{
|
||
"product_id": "MOMO-AUTO",
|
||
"auto_compare_type": "total_price",
|
||
"can_auto_compare": True,
|
||
},
|
||
{
|
||
"product_id": "MOMO-UNIT",
|
||
"auto_compare_type": "unit_price",
|
||
"can_auto_compare": True,
|
||
},
|
||
{
|
||
"product_id": "MOMO-REVIEW",
|
||
"auto_compare_type": "manual_review",
|
||
"can_auto_compare": False,
|
||
},
|
||
]
|
||
|
||
def fake_sync(engine, candidates):
|
||
captured["sync_candidates"] = candidates
|
||
return {
|
||
"success": True,
|
||
"status": "synced",
|
||
"written_count": len(candidates),
|
||
"total_price_count": 1,
|
||
"unit_price_count": 1,
|
||
}
|
||
|
||
def fake_sync_review(engine, candidates):
|
||
captured["review_sync_candidates"] = candidates
|
||
return {
|
||
"success": True,
|
||
"status": "synced",
|
||
"written_count": len(candidates),
|
||
}
|
||
|
||
payload = run_pchome_growth_momo_backfill(
|
||
FakeEngine(),
|
||
limit=2,
|
||
build_payload_func=fake_build_payload,
|
||
search_func=fake_search,
|
||
sync_func=fake_sync,
|
||
sync_review_func=fake_sync_review,
|
||
)
|
||
|
||
assert payload["success"] is True
|
||
assert payload["data"]["scanned_products"] == 2
|
||
assert payload["data"]["candidate_count"] == 3
|
||
assert payload["data"]["auto_compare_count"] == 2
|
||
assert payload["data"]["review_count"] == 1
|
||
assert payload["data"]["external_offer_sync"]["written_count"] == 2
|
||
assert payload["data"]["review_candidate_sync"]["written_count"] == 1
|
||
assert payload["data"]["after_stats"]["mapping_rate"] == 66.7
|
||
assert [item["product_id"] for item in captured["targets"]] == ["PCH-NEEDS-1", "PCH-NEEDS-2"]
|
||
assert [item["price"] for item in captured["targets"]] == [920, 760]
|
||
assert [item["product_id"] for item in captured["sync_candidates"]] == ["MOMO-AUTO", "MOMO-UNIT"]
|
||
assert [item["product_id"] for item in captured["review_sync_candidates"]] == ["MOMO-REVIEW"]
|
||
assert captured["search_limit"] == 2
|
||
|
||
|
||
def test_pchome_growth_momo_backfill_default_search_uses_deeper_terms(monkeypatch):
|
||
from services import pchome_growth_momo_backfill_service as service
|
||
|
||
captured = {}
|
||
|
||
def fake_search(targets, **kwargs):
|
||
captured["targets"] = targets
|
||
captured["kwargs"] = kwargs
|
||
return True, "ok", []
|
||
|
||
monkeypatch.delenv("PCHOME_GROWTH_MOMO_BACKFILL_LIMIT_PER_TERM", raising=False)
|
||
monkeypatch.delenv("PCHOME_GROWTH_MOMO_BACKFILL_MAX_TERMS", raising=False)
|
||
monkeypatch.delenv("PCHOME_GROWTH_MOMO_BACKFILL_MIN_SCORE", raising=False)
|
||
monkeypatch.setattr("services.momo_crawler.search_momo_products_for_pchome_products", fake_search)
|
||
|
||
payload = service._default_search_candidates([{"product_id": "PCH-1", "name": "商品"}], limit=3)
|
||
|
||
assert payload == (True, "ok", [])
|
||
assert captured["kwargs"]["max_products"] == 3
|
||
assert captured["kwargs"]["limit_per_product"] == 8
|
||
assert captured["kwargs"]["max_terms_per_product"] == 8
|
||
assert captured["kwargs"]["min_score"] == 0.45
|
||
|
||
|
||
def test_pchome_growth_momo_backfill_route_calls_shared_service(monkeypatch):
|
||
from flask import Flask
|
||
from routes import ai_routes as routes
|
||
|
||
captured = {}
|
||
|
||
class FakeEngine:
|
||
def dispose(self):
|
||
captured["disposed"] = True
|
||
|
||
def fake_run(engine, limit):
|
||
captured["limit"] = limit
|
||
return {
|
||
"success": True,
|
||
"message": "已完成",
|
||
"data": {
|
||
"scanned_products": 2,
|
||
"candidate_count": 3,
|
||
"auto_compare_count": 2,
|
||
"review_count": 1,
|
||
"external_offer_sync": {"written_count": 2},
|
||
},
|
||
}
|
||
|
||
monkeypatch.setattr(routes, "_create_icaim_dashboard_engine", lambda database_path: FakeEngine())
|
||
monkeypatch.setattr(routes, "_run_pchome_growth_momo_backfill", fake_run)
|
||
|
||
app = Flask(__name__)
|
||
with app.test_request_context(
|
||
"/api/ai/pchome-growth/backfill-momo-candidates",
|
||
method="POST",
|
||
json={"limit": 2},
|
||
):
|
||
response = routes.api_pchome_growth_backfill_momo_candidates.__wrapped__()
|
||
|
||
payload = response.get_json()
|
||
assert payload["success"] is True
|
||
assert payload["data"]["external_offer_sync"]["written_count"] == 2
|
||
assert captured["limit"] == 2
|
||
assert captured["disposed"] is True
|
||
|
||
|
||
def test_ai_product_pick_sales_join_by_sku_disabled_by_default(monkeypatch):
|
||
from services.ai_product_pick_agent import _sales_join_by_momo_sku_enabled
|
||
|
||
monkeypatch.delenv("PCHOME_SALES_JOIN_BY_MOMO_SKU_ENABLED", raising=False)
|
||
|
||
assert _sales_join_by_momo_sku_enabled() is False
|
||
|
||
|
||
def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint():
|
||
from pathlib import Path
|
||
|
||
template = Path("templates/ai_intelligence.html").read_text(encoding="utf-8")
|
||
|
||
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 "sources.map((source)" in template
|
||
assert "MOMO 待確認候選" in template
|
||
assert "確認同款" in template
|
||
assert "不是同款" in template
|
||
assert "同時開兩家賣場" in template
|
||
assert "並排看兩家賣場" in template
|
||
assert "review-candidate-flow" in template
|
||
assert "review-candidate-store is-pchome" in template
|
||
assert "review-candidate-store is-momo" in template
|
||
assert "openReviewCandidateStores" in template
|
||
assert "data-pchome-url" in template
|
||
assert "data-momo-url" in template
|
||
assert "PChome 賣場" in template
|
||
assert "MOMO 賣場" in template
|
||
assert "商品ID ${escapeHtml(row.pchome_product_id" in template
|
||
assert "商品ID ${escapeHtml(row.momo_sku" in template
|
||
assert "renderProductThumb" in template
|
||
assert "待補圖片" in template
|
||
assert "row.match_reason_labels" in template
|
||
assert "row.match_reasons" not in template
|
||
assert "variant_selection_review" not in template
|
||
assert "focused_exact_identity" not in template
|
||
assert "momo_reference" not in template
|
||
assert "source_code" not in template
|
||
assert "PChome 價格壓力" not in template
|
||
assert "MOMO 價格優勢" not in template
|
||
assert "MOMO 更便宜" not in template
|
||
assert "PChome 有優勢" not in template
|
||
assert "review-candidate-compare" 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
|
||
|
||
|
||
def test_growth_analysis_uses_actionable_price_command_panel():
|
||
from pathlib import Path
|
||
|
||
template = Path("templates/growth_analysis.html").read_text(encoding="utf-8")
|
||
css = Path("web/static/css/page-growth-bem.css").read_text(encoding="utf-8")
|
||
|
||
assert "PChome 價格作戰可用度" in template
|
||
assert "ga-chart-card__body--command" in template
|
||
assert "ga-competitor-command" in template
|
||
assert "可直接決策" in template
|
||
assert "待補 / 待確認" in template
|
||
assert "下一步" in template
|
||
assert "前往今日作戰" in template
|
||
assert "ga-competitor-signal" in css
|
||
assert "ga-competitor-next" in css
|
||
assert ".growth-analysis-page .ga-chart-card__body--command" in css
|
||
assert "比價資料品質" not in template
|
||
assert "高信心門檻" not in template
|
||
assert "未知新鮮度" not in template
|
||
assert "人工否決" not in template
|
||
assert "可直接決策" in template
|
||
assert "chart_data.competitor_coverage" in template
|
||
assert "先補齊高業績商品的 MOMO 對應" in template
|
||
assert "先刷新過期價格" in template
|
||
assert "先處理待補與候選確認" in template
|
||
assert "可進入價格策略檢查" in template
|
||
assert "MOMO 低價壓力趨勢" in template
|
||
assert "PChome 價格優勢" in template
|
||
assert "competitorPressureChart" in template
|
||
assert "growth-command-pro" not in template
|
||
assert "growth-ops-table" not in template
|
||
assert "外部報價預檢" not 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
|
||
assert "momo-growth-rail" in base
|
||
assert "PChome 業績提升" in base
|
||
assert "今日重點:優先商品" in base
|
||
assert "今日重點:價格判斷" in base
|
||
assert "今日重點:業績建議" in base
|
||
assert "今日重點:執行清單" in base
|
||
assert "今日重點:資料品質" in base
|
||
assert "評估" in base
|
||
assert "分析" in base
|
||
assert "建議" in base
|
||
assert "解法" 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
|
||
|
||
|
||
def test_global_ui_guard_keeps_pages_professional_and_responsive():
|
||
from pathlib import Path
|
||
|
||
shell_css = Path("web/static/css/ewoooc-shell.css").read_text(encoding="utf-8")
|
||
guard_css = Path("web/static/css/ewoooc-v3-page-guard.css").read_text(encoding="utf-8")
|
||
|
||
assert "width: min(100%, var(--momo-content-max-width))" in shell_css
|
||
assert ".momo-content > *" in shell_css
|
||
assert "--momo-page-readable-line: 74ch" in guard_css
|
||
assert "--momo-page-action-strip-width: 1040px" in guard_css
|
||
assert "overflow-x: clip" in guard_css
|
||
assert "overflow-wrap: anywhere" in guard_css
|
||
assert "white-space: normal" in guard_css
|
||
assert "-webkit-line-clamp: 2" in guard_css
|
||
assert "momo-growth-rail" in shell_css
|
||
assert "momo-growth-step.is-active" in shell_css
|
||
assert ".momo-content .btn:not(.btn-close)" in guard_css
|
||
assert ":not(.btn-close)" in guard_css
|
||
|
||
|
||
def test_primary_pages_use_growth_outcome_copy_instead_of_feature_explaining():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/daily_sales.html": "找出下滑與價差壓力",
|
||
"templates/ai_recommend.html": "把價差、商品證據與趨勢轉成主推、調價、補比價動作",
|
||
"templates/auto_import_index.html": "保持 PChome 業績新鮮",
|
||
"templates/price_comparison.html": "確認同款、判斷價差、決定下一步",
|
||
"templates/vendor_stockout_index_v2.html": "避免主推商品斷貨",
|
||
"templates/monthly_summary_analysis.html": "判斷成長、毛利與品類結構",
|
||
"templates/dashboard_v2.html": "先看業績,再決定調價、曝光與組合",
|
||
"templates/edm_dashboard_v2.html": "用活動價格異動找主推、補貨與曝光機會",
|
||
"templates/sales_analysis.html": "用分類、品牌與毛利找出主推、守價與補資料順序",
|
||
"templates/growth_analysis.html": "用月趨勢評估成長缺口、價差壓力與毛利品質",
|
||
"templates/vendor_stockout_import_v2.html": "補齊缺貨資料,先保住主推商品供貨",
|
||
"templates/vendor_stockout_list_v2.html": "先看待發送與失敗,避免主推商品斷貨拖累業績",
|
||
"templates/vendor_stockout_send_email_v2.html": "先處理失敗通知,讓補貨協調不中斷",
|
||
"templates/vendor_stockout_vendor_management_v2.html": "維護供應商與收件人,讓缺貨補救能快速送達",
|
||
"templates/vendor_stockout_history_v2.html": "回看缺貨處理紀錄,找出需要補救的供貨風險",
|
||
}
|
||
|
||
for path, marker in expected.items():
|
||
assert marker in Path(path).read_text(encoding="utf-8")
|
||
|
||
|
||
def test_homepage_next_action_cta_keeps_visible_primary_contrast():
|
||
from pathlib import Path
|
||
|
||
template = Path("templates/ai_intelligence.html").read_text(encoding="utf-8")
|
||
|
||
assert "#commandTaskButton.growth-command-alert-action" in template
|
||
assert "background-color: #8f442b !important" in template
|
||
assert "color: #fff !important" in template
|
||
assert "width: min(100%, 980px)" in template
|
||
|
||
|
||
def test_secondary_and_governance_pages_keep_growth_decision_copy_concise():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/dashboard.html": "先回業績成長指揮台",
|
||
"templates/edm_dashboard.html": "決定主推、補貨與曝光位置",
|
||
"templates/crawler_management.html": "讓比價與業績判斷保持新鮮",
|
||
"templates/history.html": "補救會影響主推商品的供貨風險",
|
||
"templates/import.html": "讓分析、建議與解法有可靠來源",
|
||
"templates/send_email.html": "避免補貨協調中斷",
|
||
"templates/settings.html": "守住比價資料新鮮度",
|
||
"templates/system_settings.html": "先補齊業績與備份",
|
||
"templates/admin/agent_orchestration.html": "是否支撐業績決策",
|
||
"templates/admin/ai_calls_dashboard.html": "支援業績判斷",
|
||
"templates/admin/budget.html": "留給能推動業績的任務",
|
||
"templates/admin/host_health.html": "避免 AI 建議與比價流程中斷",
|
||
"templates/admin/rag_queries.html": "避免業績建議缺少根據",
|
||
"templates/admin/quality_trend.html": "AI 建議是否可靠",
|
||
"templates/admin/promotion_review.html": "避免錯誤知識污染業績建議",
|
||
"templates/admin/business_intel.html": "追蹤閉環結果與競品訊號",
|
||
}
|
||
|
||
forbidden = [
|
||
"舊版 dashboard.html 已停用",
|
||
"舊版 edm_dashboard.html 已停用",
|
||
"舊版 crawler_management.html 已停用",
|
||
"舊版 history.html 已停用",
|
||
"舊版 import.html 已停用",
|
||
"舊版 send_email.html 已停用",
|
||
"這頁回答",
|
||
"這頁是 AI 中樞",
|
||
"這頁是 RAG",
|
||
"這裡不是流水帳",
|
||
"這裡追蹤每次 RAG",
|
||
"這裡看 AI 的回答",
|
||
"資料列表",
|
||
]
|
||
|
||
for path, marker in expected.items():
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
assert marker in text
|
||
for bad in forbidden:
|
||
assert bad not in text
|
||
|
||
|
||
def test_help_empty_login_and_ppt_copy_are_action_oriented():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/ai_recommend.html": [
|
||
"銷售動作",
|
||
"把商品證據轉成可追蹤的銷售動作",
|
||
"回到業績成長指揮台追蹤是否帶動商品處理",
|
||
],
|
||
"templates/ai_intelligence.html": [
|
||
"先整理業績、比價與資料狀態,再決定下一步",
|
||
"先檢查備援資料品質",
|
||
],
|
||
"templates/daily_sales.html": ["才能判斷下滑與價差壓力"],
|
||
"templates/growth_analysis.html": ["先匯入月度業績資料,再評估成長、AOV 與毛利缺口"],
|
||
"templates/index.html": ["缺貨處理順序", "避免主推商品斷貨"],
|
||
"templates/login.html": ["登入後先看 PChome 業績、價差、缺貨與 AI 建議"],
|
||
"templates/system_settings.html": ["會更新對應年月份的業績資料"],
|
||
"templates/admin/ppt_audit_history.html": [
|
||
"先確認簡報可預覽、可審核,再把問題交給修復流程",
|
||
"先補齊視覺 QA 執行條件,再判斷簡報品質",
|
||
"先補排程或手動產出,再進行視覺 QA",
|
||
"先補視覺 QA 條件",
|
||
],
|
||
}
|
||
forbidden = [
|
||
"使用說明",
|
||
"這裡會顯示最適合的下一步",
|
||
"再用這裡檢查",
|
||
"這裡會顯示背景任務狀態",
|
||
"目前查無本月 DB 產出紀錄",
|
||
"為什麼這頁空",
|
||
"目前不是模型能力問題",
|
||
"大量資料可能需要較長時間",
|
||
]
|
||
|
||
for path, markers in expected.items():
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
for marker in markers:
|
||
assert marker in text
|
||
for bad in forbidden:
|
||
assert bad not in text
|
||
|
||
|
||
def test_edge_tool_pages_keep_growth_decision_copy_concise():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/ai_history.html": ["回收有效文案", "尚無可回收文案", "產生銷售動作"],
|
||
"templates/pchome_crawler.html": ["補齊 PChome 商品資料"],
|
||
"templates/trends.html": ["用外部趨勢挑選可主推商品"],
|
||
"templates/user_management.html": ["守住帳號權限"],
|
||
"templates/sales_analysis.html": ["直接看影響業績的圖表、分類與商品明細"],
|
||
"templates/settings.html": ["補齊比價來源"],
|
||
"templates/admin/ppt_audit_history.html": ["修復完成後回報處理結果", "可直接派工"],
|
||
"templates/external_tool_status.html": ["先回正式工作流", "避免決策資料分散", "可用正式入口"],
|
||
"templates/market_intel/disabled.html": ["市場情報尚未進入正式決策", "正式操作入口", "先處理候選同款"],
|
||
}
|
||
forbidden = [
|
||
"查看、管理和複用資料庫內已生成的文案與操作紀錄",
|
||
"尚無生成記錄",
|
||
"爬取 PChome 24h 商品資料",
|
||
"Google News、PTT、Dcard、YouTube 趨勢訊號",
|
||
"管理系統用戶帳號與權限",
|
||
"入口已由 momo-pro 接管",
|
||
"建議操作入口",
|
||
"請輸入完整的 MOMO 商城分類網址",
|
||
"選擇任一條件後,系統將自動載入並顯示詳細的圖表與數據分析",
|
||
]
|
||
|
||
for path, markers in expected.items():
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
for marker in markers:
|
||
assert marker in text
|
||
for bad in forbidden:
|
||
assert bad not in text
|
||
|
||
|
||
def test_governance_and_low_frequency_pages_avoid_engineering_status_copy():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/components/_legacy_bridge_panel.html": ["頁面狀態", "已整併", "業績流程可用"],
|
||
"templates/403.html": ["權限守門", "未授權操作影響營運資料", "權限控管"],
|
||
"templates/maintenance.html": ["服務維護", "確認業績、比價與匯入狀態", "台北時間"],
|
||
"templates/auto_import_index.html": [
|
||
"更新日報、成長分析與今日作戰清單",
|
||
"送出後更新日報、成長分析與今日作戰清單",
|
||
"作戰清單保持新鮮",
|
||
"共更新",
|
||
"buildImportActionHint",
|
||
"重新確認 Google Drive 授權",
|
||
"改用當日業績明細檔",
|
||
"重新匯入最新檔案",
|
||
],
|
||
"templates/settings.html": ["比價來源同步", "補齊 MOMO 參考來源"],
|
||
"templates/system_settings.html": [
|
||
"營運資料備份",
|
||
"可回復的業績與設定狀態",
|
||
"toImportActionMessage",
|
||
"匯入沒有完成",
|
||
"業績資料處理未完整完成",
|
||
],
|
||
"templates/ai_recommend.html": ["值得主推、調價或補比價", "回到 PChome 銷售動作"],
|
||
"templates/notification_templates.html": ["商品、風險與下一步"],
|
||
"templates/vendor_stockout_index_v2.html": ["處理缺貨", "主推商品供貨風險"],
|
||
"templates/vendor_stockout_vendor_management_v2.html": ["正確窗口"],
|
||
"templates/login_history.html": ["避免未授權操作影響業績流程", "裝置資訊"],
|
||
"templates/logs.html": ["錯誤", "注意", "資訊"],
|
||
"templates/ai_automation_smoke.html": ["AI 閉環守門", "支援業績流程"],
|
||
"templates/external_tool_status.html": ["共用預覽"],
|
||
"templates/market_intel/disabled.html": ["市場情報", "操作入口"],
|
||
"templates/brand_assets.html": ["EwoooC 品牌資產庫", "品牌資產混用"],
|
||
"templates/code_review.html": ["部署守門與程式碼審查", "業績流程上線前檢查", "上線證據"],
|
||
}
|
||
forbidden = [
|
||
"工作視窗",
|
||
"Codex",
|
||
"Claude",
|
||
"推到 Gitea",
|
||
"本輪已完成",
|
||
"剛剛修正",
|
||
"後續 session",
|
||
"MIGRATED PAGE",
|
||
"V3 READY",
|
||
"Growth workflow active",
|
||
"ACCESS CONTROL",
|
||
"403 / Forbidden",
|
||
"SYSTEM MAINTENANCE",
|
||
"Asia Taipei",
|
||
"VENDOR OPERATIONS",
|
||
"SYSTEM OPERATIONS",
|
||
"MIGRATED DASHBOARD",
|
||
"MIGRATED CAMPAIGN",
|
||
"Legacy guard",
|
||
"資料將會",
|
||
"無需重啟系統",
|
||
"完整備份",
|
||
"打包所有程式碼與資料庫",
|
||
"輸入商品名稱後點擊",
|
||
"點擊「預覽」按鈕查看效果",
|
||
"目前沒有匯入的缺貨資料",
|
||
"提示:可以從 Excel",
|
||
"系統登入記錄與異常嘗試追蹤",
|
||
"User Agent",
|
||
"四 Agent 控制面",
|
||
"不會影響 DB",
|
||
"Live Plugin",
|
||
"MARKET INTEL",
|
||
"OPERATIONS",
|
||
"WOOO TECH",
|
||
"Main Logo",
|
||
"Glass Version",
|
||
"Gradient Version",
|
||
"AI 程式碼審查",
|
||
"Google Drive →",
|
||
"匯入資料庫",
|
||
"realtime_sales_monthly",
|
||
"daily_sales_snapshot",
|
||
"資料落點",
|
||
"檢查日誌",
|
||
"發生系統錯誤",
|
||
"刪除雲端原檔",
|
||
"資料表:",
|
||
"資料表:",
|
||
"vendor_stockout / vendor_list",
|
||
"vendor_id {{",
|
||
"email_send_log",
|
||
"模板代碼",
|
||
]
|
||
|
||
legacy_paths = [
|
||
"templates/dashboard.html",
|
||
"templates/edm_dashboard.html",
|
||
"templates/send_email.html",
|
||
"templates/history.html",
|
||
"templates/import.html",
|
||
"templates/crawler_management.html",
|
||
"web/templates/vendor_stockout/send_email.html",
|
||
"web/templates/vendor_stockout/index.html",
|
||
"web/templates/vendor_stockout/list.html",
|
||
"web/templates/vendor_stockout/history.html",
|
||
"web/templates/vendor_stockout/import.html",
|
||
"web/templates/vendor_stockout/vendor_management.html",
|
||
]
|
||
|
||
for path, markers in expected.items():
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
for marker in markers:
|
||
assert marker in text
|
||
for bad in forbidden:
|
||
assert bad not in text
|
||
|
||
for path in legacy_paths:
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
assert "已整併" in text
|
||
for bad in forbidden:
|
||
assert bad not in text
|
||
|
||
|
||
def test_visible_operations_pages_hide_internal_runtime_terms():
|
||
from pathlib import Path
|
||
|
||
expected = {
|
||
"templates/ai_recommend.html": ["銷售動作生成", "建議目的", "處理順序"],
|
||
"templates/vendor_stockout_index_v2.html": ["先匯入缺貨批次", "供貨風險"],
|
||
"templates/dashboard_v2.html": ["尚無挑品建議", "先累積 PChome 比價與挑品資料"],
|
||
"templates/daily_sales.html": ["左右滑動看業績趨勢", "左右滑動看分類明細"],
|
||
"templates/sales_analysis.html": ["分析下一步", "選類別看貢獻", "適合檔期與活動回顧"],
|
||
"templates/vendor_stockout_vendor_management_v2.html": ["匯入供應商窗口名單", "確認窗口清單"],
|
||
"templates/vendor_stockout_import_v2.html": ["會先停止匯入", "處理缺貨清單"],
|
||
"templates/admin/ppt_audit_history.html": ["產出紀錄", "最近產出", "保存紀錄"],
|
||
"templates/admin/agent_orchestration.html": ["AI 分工指揮台", "建議路徑、工具與知識命中矩陣", "工具協作明細", "工具協作 × 使用情境"],
|
||
"templates/admin/ai_calls_dashboard.html": ["用量", "最近作戰素材", "情境 × 知識命中矩陣"],
|
||
"templates/admin/observability_overview.html": ["用量", "知識與工具矩陣"],
|
||
"templates/admin/business_intel.html": ["外部促銷活動監控", "PChome 解法", "AI Agent 會持續觀察", "PChome 業績提升解法矩陣", "守價", "組合", "曝光", "會員"],
|
||
"templates/cicd_dashboard.html": ["最新更新流程", "更新歷史", "修復服務", "查看更新紀錄"],
|
||
"templates/admin/budget.html": ["Top 5 成本使用情境", "尚未建立預算線"],
|
||
"templates/ai_automation_smoke.html": ["下載檢查紀錄", "健康檢查服務"],
|
||
"templates/login.html": ["安全登入", "工作階段", "資料服務"],
|
||
"templates/code_review.html": ["上線證據", "已收到上線檢查資料"],
|
||
}
|
||
forbidden_by_path = {
|
||
"templates/ai_recommend.html": ["權杖:", "AI 模型", "分析模型", "AI 路徑", "Gemini 備援", "Ollama 主路徑"],
|
||
"templates/vendor_stockout_index_v2.html": ["資料庫目前沒有缺貨資料"],
|
||
"templates/system_settings.html": ["資料表:", "資料表:", "自動建表", "匯入並建立通用資料表"],
|
||
"templates/notification_templates.html": ["模板代碼", "<code>${t.code}</code>", "CI/CD Pipeline SUCCESS"],
|
||
"templates/dashboard_v2.html": ["尚無 AI 挑品", "挑品 Agent"],
|
||
"templates/daily_sales.html": ["左右滑動查看完整圖表", "左右滑動查看完整列表"],
|
||
"templates/sales_analysis.html": ["提示:</strong>選擇條件", "點擊類別查看詳情", "點擊篩選此廠商商品"],
|
||
"templates/vendor_stockout_vendor_management_v2.html": ["拖曳檔案到此處或點擊選擇檔案", "查看廠商清單"],
|
||
"templates/vendor_stockout_import_v2.html": ["API 會拒絕匯入", "查看缺貨清單"],
|
||
"templates/admin/ppt_audit_history.html": ["DB 紀錄", "DB / 預覽", "寫入 DB", "DB 產出紀錄", "資料庫快取", "本月尚無 DB"],
|
||
"templates/admin/agent_orchestration.html": [
|
||
"Agent 指揮矩陣",
|
||
"四 Agent 矩陣",
|
||
"LLM × MCP × RAG 編排矩陣",
|
||
"權杖",
|
||
"<code>{{ m.caller }}</code>",
|
||
"<code>{{ m.server }}</code>",
|
||
"呼叫端工作量",
|
||
],
|
||
"templates/admin/ai_calls_dashboard.html": ["權杖量", "權杖/次", ">權杖<", "Agent 上下文", "RAG × MCP"],
|
||
"templates/admin/observability_overview.html": ["權杖量", "RAG × MCP"],
|
||
"templates/admin/business_intel.html": ["candidate queue", "資料表"],
|
||
"templates/cicd_dashboard.html": ["Pipeline Flow", "Pipeline History", "完整修復", "一鍵修復", "重啟 Registry", "舊叢集"],
|
||
"templates/admin/budget.html": ["燒錢呼叫端", "migrations/025", "<code>{{ c.caller }}</code>"],
|
||
"templates/ai_automation_smoke.html": ["JSONL", "健康檢查 API", "JSON.stringify(item.details"],
|
||
"templates/login.html": ["CSRF 防護", "Session 2h", "PostgreSQL"],
|
||
"templates/code_review.html": ["<b>提交</b>", "<b>分支</b>", "提交 ${h.commit_sha}", "🌿 ${h.branch}", "<code>${state.commit_sha"],
|
||
}
|
||
|
||
for path, markers in expected.items():
|
||
text = Path(path).read_text(encoding="utf-8")
|
||
for marker in markers:
|
||
assert marker in text
|
||
for bad in forbidden_by_path.get(path, []):
|
||
assert bad not in text
|