Files
ewoooc/tests/test_external_market_offer_service.py
OoO 4b0a331d98
Some checks failed
CD Pipeline / deploy (push) Failing after 35s
feat: persist targeted momo review candidates
2026-06-19 02:43:34 +08:00

566 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from sqlalchemy import create_engine, text
def test_connector_contract_keeps_shopee_and_coupang_paused_with_manual_csv_path():
from services.external_market_offer_service import build_connector_contracts
payload = build_connector_contracts()
assert payload["success"] is True
assert payload["plain_summary"] == "所有外部市場資料都先轉成同一份商品報價格式,再進作戰清單。"
sources = {source["code"]: source for source in payload["sources"]}
assert sources["momo_reference"]["status_label"] == "正在使用"
assert sources["shopee"]["status_label"] == "先暫停"
assert sources["coupang"]["status_label"] == "先暫停"
assert "手動 CSV" in sources["shopee"]["input_methods"]
assert "官方 API" in sources["coupang"]["input_methods"]
assert "price" in payload["manual_csv"]["required_headers"]
def test_normalized_offer_payload_validates_plain_required_fields():
from services.external_market_offer_service import normalize_external_offer_payload
record, errors = normalize_external_offer_payload({
"source_code": "momo_reference",
"source_product_id": "MOMO-1",
"title": "外部參考商品",
"price": "899",
"observed_at": "2026-06-15T10:00:00",
"ingestion_method": "manual_csv",
"quality_score": 82,
"quality_note": "人工確認同款",
})
assert errors == []
assert record is not None
assert record.to_record()["source_offer_key"] == "momo_reference:MOMO-1"
assert record.to_record()["data_quality_status"] == "needs_review"
missing_record, missing_errors = normalize_external_offer_payload({
"source_code": "shopee",
"price": 0,
})
assert missing_record is None
assert "缺少外部商品 ID" in missing_errors
assert "售價必須大於 0" in missing_errors
def test_external_offer_csv_dry_run_accepts_chinese_headers_and_classifies_rows():
from services.external_market_offer_service import dry_run_external_offer_csv
csv_text = "\n".join([
"資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度",
"momo_reference,MOMO-1,可用商品,899,2026-06-15T10:00:00,manual_csv,PCH-1,verified,88",
"shopee,SHP-1,暫停來源商品,799,2026-06-15T10:01:00,manual_csv,PCH-2,verified,90",
"momo_reference,MOMO-2,缺價格商品,,2026-06-15T10:02:00,manual_csv,PCH-3,verified,90",
])
payload = dry_run_external_offer_csv(csv_text)
assert payload["success"] is True
assert payload["message"] == "CSV 預檢完成,尚未寫入資料。"
assert payload["summary"] == {
"total_rows": 3,
"ready_count": 1,
"review_count": 1,
"blocked_count": 1,
}
rows = payload["rows"]
assert rows[0]["status_label"] == "可使用"
assert rows[1]["status_label"] == "需人工確認"
assert "這個來源目前先暫停,不進告警" in rows[1]["reasons"]
assert rows[2]["status_label"] == "不能使用"
assert "缺少售價" in rows[2]["reasons"]
def test_external_offer_csv_dry_run_accepts_plain_business_headers_and_values():
from services.external_market_offer_service import dry_run_external_offer_csv
csv_text = "\n".join([
"來源,平台商品編號,商品名稱,售價,資料時間,取得方式,PChome商品編號,是否同款,可信度",
"MOMO,MOMO-1,可用商品,899,2026-06-15T10:00:00,備用資料,PCH-1,是,88",
"MOMO,MOMO-2,待確認商品,799,2026-06-15T10:01:00,備援資料,,待確認,60",
])
payload = dry_run_external_offer_csv(csv_text)
assert payload["success"] is True
assert payload["summary"]["total_rows"] == 2
assert payload["summary"]["ready_count"] == 1
assert payload["summary"]["review_count"] == 1
assert payload["summary"]["blocked_count"] == 0
rows = payload["rows"]
assert rows[0]["source_code"] == "momo_reference"
assert rows[0]["status_label"] == "可使用"
assert rows[1]["source_code"] == "momo_reference"
assert rows[1]["status_label"] == "需人工確認"
assert "尚未確認同款" in rows[1]["reasons"]
assert "缺少 PChome 商品 ID無法連到業績" in rows[1]["reasons"]
def test_external_offer_csv_dry_run_reports_empty_file_plainly():
from services.external_market_offer_service import dry_run_external_offer_csv
payload = dry_run_external_offer_csv("")
assert payload["success"] is False
assert payload["errors"] == ["CSV 內容是空的"]
assert payload["summary"]["blocked_count"] == 0
def _seed_external_offer_sync_tables(engine):
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE external_market_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
platform_code TEXT NOT NULL,
source_kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'paused',
enabled BOOLEAN NOT NULL DEFAULT 0,
write_enabled BOOLEAN NOT NULL DEFAULT 0,
allowed_input_methods_json TEXT,
quality_policy_json TEXT,
plain_note TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE external_offers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_code TEXT NOT NULL,
platform_code TEXT NOT NULL,
source_product_id TEXT NOT NULL,
source_offer_key TEXT NOT NULL,
title TEXT NOT NULL,
brand TEXT,
category_text TEXT,
product_url TEXT,
image_url TEXT,
price REAL,
original_price REAL,
currency TEXT NOT NULL DEFAULT 'TWD',
stock_status TEXT,
sold_count INTEGER,
rating REAL,
review_count INTEGER,
observed_at TEXT NOT NULL,
expires_at TEXT,
ingestion_method TEXT NOT NULL,
connector_key TEXT,
pchome_product_id TEXT,
momo_sku TEXT,
match_status TEXT NOT NULL DEFAULT 'unmatched',
quality_score REAL NOT NULL DEFAULT 0,
data_quality_status TEXT NOT NULL DEFAULT 'needs_review',
quality_notes_json TEXT,
raw_payload_json TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_code, source_product_id, observed_at, ingestion_method)
)
"""))
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, url TEXT, image_url 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 competitor_prices
(sku, source, competitor_product_id, competitor_product_name,
price, match_score, tags, crawled_at, expires_at)
VALUES
('MOMO-1', 'pchome', 'PCH-1', 'PChome 商品',
1000, 0.91, '["identity_v2"]', '2026-06-15 09:30:00', NULL),
('MOMO-2', 'pchome', 'PCH-2', '低信心商品',
600, 0.60, '["identity_v2"]', '2026-06-15 09:30:00', NULL)
"""))
conn.execute(text(
"INSERT INTO products (id, i_code, name, url, image_url, status) "
"VALUES (1, 'MOMO-1', 'MOMO 同款商品', 'https://momo.test/1', 'https://img.test/1.jpg', 'ACTIVE')"
))
conn.execute(text(
"INSERT INTO price_records (id, product_id, price, timestamp) "
"VALUES (1, 1, 900, '2026-06-15 10:00:00')"
))
def test_sync_legacy_momo_reference_offers_writes_verified_cache_to_external_offers(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)
payload = service.sync_legacy_momo_reference_offers(engine, limit=20)
assert payload["success"] is True
assert payload["status"] == "synced"
assert payload["candidate_count"] == 1
assert payload["written_count"] == 1
with engine.connect() as conn:
rows = conn.execute(text("""
SELECT source_code, platform_code, source_product_id, title, price,
pchome_product_id, momo_sku, match_status, quality_score,
data_quality_status, ingestion_method
FROM external_offers
""")).mappings().all()
assert len(rows) == 1
row = dict(rows[0])
assert row["source_code"] == "momo_reference"
assert row["platform_code"] == "momo"
assert row["source_product_id"] == "MOMO-1"
assert row["pchome_product_id"] == "PCH-1"
assert row["title"] == "MOMO 同款商品"
assert row["price"] == 900
assert row["match_status"] == "verified"
assert row["quality_score"] == 91
assert row["data_quality_status"] == "verified"
assert row["ingestion_method"] == "legacy_competitor_cache"
assert stale_marks == [True]
def test_sync_legacy_momo_reference_offers_dry_run_does_not_write():
from services.external_market_offer_service import sync_legacy_momo_reference_offers
engine = create_engine("sqlite:///:memory:")
_seed_external_offer_sync_tables(engine)
payload = sync_legacy_momo_reference_offers(engine, limit=20, dry_run=True)
assert payload["success"] is True
assert payload["status"] == "dry_run"
assert payload["candidate_count"] == 1
assert payload["written_count"] == 0
with engine.connect() as conn:
count = conn.execute(text("SELECT COUNT(*) FROM external_offers")).scalar()
assert count == 0
def test_sync_targeted_momo_candidates_writes_unit_price_offer(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)
payload = service.sync_targeted_momo_candidates_to_external_offers(engine, [
{
"product_id": "10833188",
"name": "MOMO B5 修復霜 40ml",
"price": 468,
"original_price": 835,
"product_url": "https://momo.test/10833188",
"image_url": "https://img.test/10833188.jpg",
"brand": "理膚寶水",
"target_pchome_product_id": "PCH-1",
"target_pchome_name": "PChome B5 修復霜 40ml",
"target_pchome_price": 920,
"target_match_score": 0.74,
"auto_compare_type": "unit_price",
"target_price_basis": "unit_price",
"target_match_reasons": ["unit_comparable"],
"target_comparison_mode": "unit_comparable",
"target_unit_price_comparison": {
"comparable": True,
"unit_label": "ml",
"momo_total_quantity": 40,
"competitor_total_quantity": 40,
"momo_unit_price": 11.7,
"competitor_unit_price": 23.0,
"unit_gap_pct": -49.13,
},
},
{
"product_id": "REVIEW-1",
"name": "仍需人工商品",
"price": 500,
"target_pchome_product_id": "PCH-2",
"auto_compare_type": "manual_review",
},
])
assert payload["success"] is True
assert payload["status"] == "synced"
assert payload["candidate_count"] == 2
assert payload["written_count"] == 1
assert payload["unit_price_count"] == 1
assert payload["skipped_reasons"] == {"不是可自動使用的候選": 1}
with engine.connect() as conn:
row = conn.execute(text("""
SELECT source_product_id, price, pchome_product_id, match_status,
quality_score, data_quality_status, ingestion_method,
raw_payload_json
FROM external_offers
""")).mappings().one()
raw_payload = __import__("json").loads(row["raw_payload_json"])
assert row["source_product_id"] == "10833188"
assert row["price"] == 468
assert row["pchome_product_id"] == "PCH-1"
assert row["match_status"] == "verified"
assert row["quality_score"] == 82
assert row["data_quality_status"] == "verified"
assert row["ingestion_method"] == "targeted_momo_search"
assert raw_payload["price_basis"] == "unit_price"
assert raw_payload["pchome_public_price"] == 920
assert raw_payload["unit_price_comparison"]["unit_gap_pct"] == -49.13
assert stale_marks == [True]
def test_sync_targeted_momo_candidates_skips_total_price_identity_review(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)
payload = service.sync_targeted_momo_candidates_to_external_offers(engine, [
{
"product_id": "12876190",
"name": "【LAURA MERCIER 蘿拉蜜思】煥顏透明蜜粉 29g(#Rose-國際航空版)",
"price": 809,
"target_pchome_product_id": "PCH-LAURA",
"target_pchome_name": "【Laura Mercier 蘿拉蜜思】煥顏透明蜜粉 29g",
"target_pchome_price": 899,
"target_match_score": 0.98,
"auto_compare_type": "total_price",
"target_price_basis": "manual_review",
"target_alert_tier": "identity_review",
"target_match_reasons": ["variant_selection_review", "strong_exact_spec_match"],
"target_comparison_mode": "exact_identity",
},
])
assert payload["success"] is True
assert payload["candidate_count"] == 1
assert payload["written_count"] == 0
assert payload["skipped_reasons"] == {"候選仍需人工確認": 1}
assert stale_marks == []
with engine.connect() as conn:
count = conn.execute(text("SELECT COUNT(*) FROM external_offers")).scalar()
assert count == 0
def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(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)
payload = 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,
"auto_compare_type": "manual_review",
"target_price_basis": "none",
"target_alert_tier": "identity_review",
"target_match_type": "exact",
"target_match_reasons": ["variant_selection_review", "strong_exact_spec_match"],
"target_comparison_mode": "exact_identity",
"target_gap_pct": -6.16,
},
{
"product_id": "LOW-SCORE",
"name": "低分候選",
"price": 100,
"target_pchome_product_id": "PCH-LOW",
"target_match_score": 0.4,
"target_alert_tier": "identity_review",
},
])
assert payload["success"] is True
assert payload["status"] == "synced"
assert payload["candidate_count"] == 2
assert payload["written_count"] == 1
assert payload["skipped_reasons"] == {"人工確認候選分數過低": 1}
with engine.connect() as conn:
row = conn.execute(text("""
SELECT source_product_id, price, pchome_product_id, match_status,
quality_score, data_quality_status, ingestion_method,
raw_payload_json
FROM external_offers
""")).mappings().one()
readiness = service.build_external_source_readiness(engine)
raw_payload = __import__("json").loads(row["raw_payload_json"])
assert row["source_product_id"] == "14917079"
assert row["price"] == 2618
assert row["pchome_product_id"] == "PCH-CDP"
assert row["match_status"] == "needs_review"
assert row["quality_score"] == 100
assert row["data_quality_status"] == "needs_review"
assert row["ingestion_method"] == "targeted_momo_review"
assert raw_payload["review_state"] == "needs_review"
assert raw_payload["price_basis"] == "none"
assert raw_payload["alert_tier"] == "identity_review"
assert "needs_review" in raw_payload["tags"]
assert readiness["review_offer_count"] == 1
assert stale_marks == [True]
def test_sync_targeted_momo_candidates_keeps_best_unit_quantity_match(monkeypatch):
from services import external_market_offer_service as service
monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: None)
engine = create_engine("sqlite:///:memory:")
_seed_external_offer_sync_tables(engine)
payload = service.sync_targeted_momo_candidates_to_external_offers(engine, [
{
"product_id": "MOMO-SINGLE",
"name": "雪之上 全效合一水凝霜 80g/瓶",
"price": 1380,
"target_pchome_product_id": "PCH-YUKINOUE",
"target_pchome_name": "雪之上 全效合一水凝霜 80g 3入組",
"target_pchome_price": 2960,
"target_match_score": 0.74,
"auto_compare_type": "unit_price",
"target_price_basis": "unit_price",
"target_match_reasons": ["count_conflict", "unit_comparable"],
"target_comparison_mode": "unit_comparable",
"target_unit_price_comparison": {
"comparable": True,
"unit_label": "g",
"momo_total_quantity": 80,
"competitor_total_quantity": 240,
"momo_unit_price": 17.25,
"competitor_unit_price": 12.33,
"unit_gap_pct": 39.86,
},
},
{
"product_id": "MOMO-THREE",
"name": "雪之上 全效合一水凝霜 80g X 3入瓶裝",
"price": 2680,
"target_pchome_product_id": "PCH-YUKINOUE",
"target_pchome_name": "雪之上 全效合一水凝霜 80g 3入組",
"target_pchome_price": 2960,
"target_match_score": 0.74,
"auto_compare_type": "unit_price",
"target_price_basis": "unit_price",
"target_match_reasons": ["unit_comparable"],
"target_comparison_mode": "unit_comparable",
"target_unit_price_comparison": {
"comparable": True,
"unit_label": "g",
"momo_total_quantity": 240,
"competitor_total_quantity": 240,
"momo_unit_price": 11.17,
"competitor_unit_price": 12.33,
"unit_gap_pct": -9.46,
},
},
])
assert payload["success"] is True
assert payload["candidate_count"] == 2
assert payload["written_count"] == 1
with engine.connect() as conn:
row = conn.execute(text("""
SELECT source_product_id, raw_payload_json
FROM external_offers
""")).mappings().one()
raw_payload = __import__("json").loads(row["raw_payload_json"])
assert row["source_product_id"] == "MOMO-THREE"
assert raw_payload["unit_price_comparison"]["momo_total_quantity"] == 240
def test_external_source_readiness_uses_legacy_momo_reference_cache():
from services.external_market_offer_service import build_external_source_readiness
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(text(
"CREATE TABLE competitor_prices ("
"source TEXT, competitor_product_id TEXT, price REAL, match_score REAL, "
"tags TEXT, crawled_at TEXT, expires_at TEXT)"
))
conn.execute(text("""
INSERT INTO competitor_prices
(source, competitor_product_id, price, match_score, tags, crawled_at, expires_at)
VALUES
('pchome', 'PCH-1', 1000, 0.91, '["identity_v2"]', '2026-06-15 10:00:00', NULL),
('pchome', 'PCH-2', 500, 0.50, '["identity_v2"]', '2026-06-15 10:00:00', NULL)
"""))
payload = build_external_source_readiness(engine)
assert payload["success"] is True
assert payload["schema_ready"] is False
sources = {source["code"]: source for source in payload["sources"]}
assert sources["momo_reference"]["usable_offer_count"] == 1
assert sources["momo_reference"]["plain_state"] == "已接入,可進作戰清單"
assert sources["shopee"]["plain_state"] == "先保留接口,不進告警"
assert payload["plain_summary"] == "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。"
def test_external_market_migration_creates_source_and_offer_tables():
from pathlib import Path
migration = Path("migrations/044_external_market_offer_normalization.sql").read_text(encoding="utf-8")
assert "CREATE TABLE IF NOT EXISTS external_market_sources" in migration
assert "CREATE TABLE IF NOT EXISTS external_offers" in migration
assert "momo_reference" in migration
assert "shopee" in migration
assert "coupang" in migration
assert "DROP " not in migration.upper()
assert "TRUNCATE " not in migration.upper()
def test_external_offer_csv_dry_run_route_is_registered_as_post_only():
from pathlib import Path
route_source = Path("routes/ai_routes.py").read_text(encoding="utf-8")
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
def test_external_offer_sync_is_registered_in_scheduler():
from pathlib import Path
scheduler_source = Path("scheduler.py").read_text(encoding="utf-8")
run_scheduler_source = Path("run_scheduler.py").read_text(encoding="utf-8")
assert "def run_external_offer_sync_task" in scheduler_source
assert "sync_legacy_momo_reference_offers" in scheduler_source
assert "def run_pchome_growth_momo_backfill_task" in scheduler_source
assert "run_pchome_growth_momo_backfill" in scheduler_source
assert "run_external_offer_sync_task" in run_scheduler_source
assert "run_pchome_growth_momo_backfill_task" in run_scheduler_source
assert "external_offer_sync" in run_scheduler_source
assert "pchome_growth_momo_backfill" in run_scheduler_source