622 lines
26 KiB
Python
622 lines
26 KiB
Python
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_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
|
||
|
||
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
|
||
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():
|
||
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
|