This commit is contained in:
@@ -104,7 +104,7 @@ def test_dashboard_match_status_explains_unit_comparable_bundle():
|
||||
|
||||
|
||||
def test_dashboard_match_status_shows_manual_review_closure_states():
|
||||
from routes.dashboard_routes import _build_pchome_match_status
|
||||
from routes.dashboard_routes import _build_competitor_decision, _build_pchome_match_status
|
||||
|
||||
rejected = _build_pchome_match_status({
|
||||
"attempt_status": "manual_rejected",
|
||||
@@ -125,3 +125,7 @@ def test_dashboard_match_status_shows_manual_review_closure_states():
|
||||
assert "總價不可直接比較" in unit_price["summary"]
|
||||
assert needs_research["label"] == "人工要求補搜尋"
|
||||
assert "重新抓取" in needs_research["summary"]
|
||||
|
||||
decision = _build_competitor_decision(980, 899, match_status=rejected)
|
||||
assert decision["label"] == "人工已否決"
|
||||
assert decision["gap_amount"] is None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -71,6 +72,97 @@ def test_competitor_match_review_service_closes_human_review_loop():
|
||||
assert "/api/pchome-review/" in dashboard_js
|
||||
|
||||
|
||||
def test_reject_review_expires_current_formal_price():
|
||||
from sqlalchemy import create_engine, text
|
||||
from services.competitor_match_review_service import record_competitor_match_review
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE products (id INTEGER PRIMARY KEY, i_code TEXT, name TEXT)"))
|
||||
conn.execute(text("CREATE TABLE price_records (id INTEGER PRIMARY KEY, product_id INTEGER, price NUMERIC, timestamp TEXT)"))
|
||||
conn.execute(text("""
|
||||
CREATE TABLE competitor_match_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT,
|
||||
source TEXT,
|
||||
momo_product_id INTEGER,
|
||||
momo_product_name TEXT,
|
||||
momo_price NUMERIC,
|
||||
search_terms TEXT,
|
||||
candidate_count INTEGER,
|
||||
attempt_status TEXT,
|
||||
best_competitor_product_id TEXT,
|
||||
best_competitor_product_name TEXT,
|
||||
best_competitor_price NUMERIC,
|
||||
best_match_score NUMERIC,
|
||||
error_message TEXT,
|
||||
attempted_at TEXT
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
CREATE TABLE competitor_prices (
|
||||
sku TEXT,
|
||||
source TEXT,
|
||||
price NUMERIC,
|
||||
original_price NUMERIC,
|
||||
discount_pct INTEGER,
|
||||
competitor_product_id TEXT,
|
||||
competitor_product_name TEXT,
|
||||
match_score NUMERIC,
|
||||
tags TEXT,
|
||||
crawled_at TEXT,
|
||||
expires_at TEXT
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("INSERT INTO products VALUES (1, 'A005', '舒特膚 AD 乳液 200ml')"))
|
||||
conn.execute(text("INSERT INTO price_records VALUES (1, 1, 980, '2026-05-20 09:00:00')"))
|
||||
conn.execute(text("""
|
||||
INSERT INTO competitor_match_attempts
|
||||
(sku, source, momo_product_id, momo_product_name, momo_price,
|
||||
search_terms, candidate_count, attempt_status,
|
||||
best_competitor_product_id, best_competitor_product_name,
|
||||
best_competitor_price, best_match_score, error_message, attempted_at)
|
||||
VALUES
|
||||
('A005', 'pchome', 1, '舒特膚 AD 乳液 200ml', 980,
|
||||
'[]', 1, 'needs_review',
|
||||
'DDAB01-REJECT', '舒特膚 AD 乳液 200ml', 899, 0.84,
|
||||
'score=0.84', '2026-05-20 09:10:00')
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
INSERT INTO competitor_prices
|
||||
(sku, source, price, competitor_product_id, competitor_product_name,
|
||||
match_score, tags, crawled_at, expires_at)
|
||||
VALUES
|
||||
('A005', 'pchome', 899, 'DDAB01-REJECT', '舒特膚 AD 乳液 200ml',
|
||||
0.84, '["identity_v2"]', '2026-05-20 09:10:00', '2099-01-01 00:00:00')
|
||||
"""))
|
||||
|
||||
result = record_competitor_match_review(
|
||||
engine,
|
||||
sku="A005",
|
||||
review_action="reject_identity",
|
||||
reviewer_identity="pytest",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
with engine.connect() as conn:
|
||||
expires_at = conn.execute(text("""
|
||||
SELECT expires_at
|
||||
FROM competitor_prices
|
||||
WHERE sku = 'A005' AND source = 'pchome'
|
||||
""")).scalar()
|
||||
manual_status = conn.execute(text("""
|
||||
SELECT attempt_status
|
||||
FROM competitor_match_attempts
|
||||
WHERE sku = 'A005'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""")).scalar()
|
||||
|
||||
assert expires_at != "2099-01-01 00:00:00"
|
||||
assert manual_status == "manual_rejected"
|
||||
|
||||
|
||||
def test_competitor_feeder_respects_manual_rejected_candidate(monkeypatch):
|
||||
from services.competitor_price_feeder import CompetitorPriceFeeder
|
||||
from services.pchome_crawler import PChomeProduct
|
||||
@@ -132,6 +224,168 @@ def test_competitor_feeder_respects_manual_rejected_candidate(monkeypatch):
|
||||
assert "manual_review_rejected" in attempts[0]["error_message"]
|
||||
|
||||
|
||||
def test_competitor_feeder_skips_rejected_candidate_and_uses_next_best(monkeypatch):
|
||||
from services.competitor_price_feeder import CompetitorPriceFeeder
|
||||
from services.pchome_crawler import PChomeProduct
|
||||
|
||||
rejected = PChomeProduct(
|
||||
product_id="DDAB01-REJECTED",
|
||||
name="舒特膚 AD 乳液 200ml 舊候選",
|
||||
price=899,
|
||||
original_price=999,
|
||||
discount=10,
|
||||
image_url="",
|
||||
product_url="https://24h.pchome.com.tw/prod/DDAB01-REJECTED",
|
||||
stock=20,
|
||||
store="24h",
|
||||
rating=4.7,
|
||||
review_count=8,
|
||||
is_on_sale=True,
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
accepted = PChomeProduct(
|
||||
product_id="DDAB01-ACCEPTABLE",
|
||||
name="舒特膚 AD 乳液 200ml 新候選",
|
||||
price=909,
|
||||
original_price=999,
|
||||
discount=9,
|
||||
image_url="",
|
||||
product_url="https://24h.pchome.com.tw/prod/DDAB01-ACCEPTABLE",
|
||||
stock=20,
|
||||
store="24h",
|
||||
rating=4.6,
|
||||
review_count=8,
|
||||
is_on_sale=True,
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
|
||||
class FakeCrawler:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
def search_products(self, *_args, **_kwargs):
|
||||
return True, "ok", [rejected, accepted]
|
||||
|
||||
def fake_score(_momo_name, competitor_name, **_kwargs):
|
||||
score = 0.95 if "舊候選" in competitor_name else 0.84
|
||||
return SimpleNamespace(
|
||||
score=score,
|
||||
brand_score=1.0,
|
||||
token_score=0.9,
|
||||
spec_score=1.0,
|
||||
sequence_score=0.8,
|
||||
type_score=1.0,
|
||||
price_penalty=0.0,
|
||||
hard_veto=False,
|
||||
reasons=(),
|
||||
comparison_mode="exact_identity",
|
||||
tags=["identity_v2", "comparison_exact_identity"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr("services.pchome_crawler.PChomeCrawler", FakeCrawler)
|
||||
monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score)
|
||||
feeder = CompetitorPriceFeeder(engine=object())
|
||||
attempts = []
|
||||
writes = []
|
||||
monkeypatch.setattr(
|
||||
feeder,
|
||||
"_fetch_latest_manual_review_for_candidate",
|
||||
lambda _sku, candidate_id, **_kwargs: (
|
||||
{"review_action": "reject_identity"} if candidate_id == "DDAB01-REJECTED" else None
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feeder,
|
||||
"_should_upsert_competitor_price",
|
||||
lambda *_args, **_kwargs: (True, "new_match"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feeder,
|
||||
"_record_match_attempt",
|
||||
lambda *args, **kwargs: attempts.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feeder,
|
||||
"_upsert_competitor_price",
|
||||
lambda *args, **kwargs: writes.append((args, kwargs)),
|
||||
)
|
||||
|
||||
result = feeder._run_sku_items([{
|
||||
"sku": "A004",
|
||||
"name": "舒特膚 AD 乳液 200ml",
|
||||
"product_id": 4,
|
||||
"momo_price": 980,
|
||||
}])
|
||||
|
||||
assert result.matched == 1
|
||||
assert writes[0][0][1].product_id == "DDAB01-ACCEPTABLE"
|
||||
assert attempts[0]["attempt_status"] == "matched"
|
||||
assert attempts[0]["best_product"].product_id == "DDAB01-ACCEPTABLE"
|
||||
|
||||
|
||||
def test_search_candidates_does_not_stop_on_merely_acceptable_match(monkeypatch):
|
||||
from services.competitor_price_feeder import _search_pchome_candidates
|
||||
from services.pchome_crawler import PChomeProduct
|
||||
|
||||
first = PChomeProduct(
|
||||
product_id="DDAB01-FIRST",
|
||||
name="理膚寶水 B5 修復霜 40ml 普通候選",
|
||||
price=679,
|
||||
original_price=799,
|
||||
discount=15,
|
||||
image_url="",
|
||||
product_url="https://24h.pchome.com.tw/prod/DDAB01-FIRST",
|
||||
stock=20,
|
||||
store="24h",
|
||||
rating=4.7,
|
||||
review_count=8,
|
||||
is_on_sale=True,
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
second = PChomeProduct(
|
||||
product_id="DDAB01-SECOND",
|
||||
name="理膚寶水 B5 修復霜 40ml 強同款",
|
||||
price=689,
|
||||
original_price=799,
|
||||
discount=14,
|
||||
image_url="",
|
||||
product_url="https://24h.pchome.com.tw/prod/DDAB01-SECOND",
|
||||
stock=20,
|
||||
store="24h",
|
||||
rating=4.8,
|
||||
review_count=8,
|
||||
is_on_sale=True,
|
||||
crawled_at=datetime.now(),
|
||||
)
|
||||
|
||||
class FakeCrawler:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search_products(self, keyword, **_kwargs):
|
||||
self.calls.append(keyword)
|
||||
if keyword == "broad":
|
||||
return True, "ok", [first]
|
||||
return True, "ok", [second]
|
||||
|
||||
def fake_score(_momo_name, competitor_name, **_kwargs):
|
||||
score = 0.80 if "普通候選" in competitor_name else 0.95
|
||||
return SimpleNamespace(score=score)
|
||||
|
||||
crawler = FakeCrawler()
|
||||
monkeypatch.setattr("services.marketplace_product_matcher.score_marketplace_match", fake_score)
|
||||
|
||||
candidates = _search_pchome_candidates(
|
||||
crawler,
|
||||
"理膚寶水 B5 修復霜 40ml",
|
||||
keywords=["broad", "precise", "unused"],
|
||||
momo_price=699,
|
||||
)
|
||||
|
||||
assert crawler.calls == ["broad", "precise"]
|
||||
assert [candidate.product_id for candidate in candidates] == ["DDAB01-FIRST", "DDAB01-SECOND"]
|
||||
|
||||
|
||||
def test_competitor_feeder_logs_keyword_parser_fallback(monkeypatch, caplog):
|
||||
from services import competitor_price_feeder
|
||||
from services import marketplace_product_matcher
|
||||
|
||||
Reference in New Issue
Block a user