diff --git a/config.py b/config.py index 51f1675..f70d27e 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.636" +SYSTEM_VERSION = "V10.637" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 41cc8f6..c4ef62c 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -712,6 +712,31 @@ def _targeted_candidate_needs_review(candidate: dict[str, Any]) -> bool: return bool(reasons & review_reason_markers) +def _targeted_candidate_sync_rank(candidate: dict[str, Any]) -> tuple[float, float, float, float]: + """同一個 PChome 商品有多個候選時,挑最適合營運判斷的一筆。""" + auto_type = _targeted_candidate_auto_type(candidate) + type_rank = 3.0 if auto_type == "total_price" else 2.0 if auto_type == "unit_price" else 0.0 + try: + match_score = float(candidate.get("target_match_score") or 0.0) + except (TypeError, ValueError): + match_score = 0.0 + + unit_price_comparison = ( + candidate.get("target_unit_price_comparison") + if isinstance(candidate.get("target_unit_price_comparison"), dict) + else {} + ) + momo_total = _to_float(unit_price_comparison.get("momo_total_quantity")) + pchome_total = _to_float(unit_price_comparison.get("competitor_total_quantity")) + quantity_delta = 999999.0 + same_quantity = 0.0 + if momo_total > 0 and pchome_total > 0: + quantity_delta = abs(momo_total - pchome_total) + same_quantity = 1.0 if quantity_delta <= 0.0001 else 0.0 + + return (type_rank, same_quantity, -quantity_delta, match_score) + + def _targeted_candidate_to_external_offer( candidate: dict[str, Any], *, @@ -839,7 +864,7 @@ def sync_targeted_momo_candidates_to_external_offers( _ensure_external_market_source_seeds(conn) base_observed_at = datetime.now() - offers: list[dict[str, Any]] = [] + ranked_offers: list[tuple[dict[str, Any], tuple[float, float, float, float]]] = [] skipped_reasons: dict[str, int] = {} for index, candidate in enumerate(candidates): offer, reason = _targeted_candidate_to_external_offer( @@ -847,9 +872,16 @@ def sync_targeted_momo_candidates_to_external_offers( observed_at=base_observed_at + timedelta(microseconds=index), ) if offer: - offers.append(offer) + ranked_offers.append((offer, _targeted_candidate_sync_rank(candidate))) else: skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1 + selected_by_pchome: dict[str, tuple[dict[str, Any], tuple[float, float, float, float]]] = {} + for offer, rank in ranked_offers: + key = str(offer.get("pchome_product_id") or offer.get("source_offer_key") or "").strip() + existing = selected_by_pchome.get(key) + if existing is None or rank > existing[1]: + selected_by_pchome[key] = (offer, rank) + offers = [offer for offer, _ in selected_by_pchome.values()] if not dry_run: for offer in offers: diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 1b32a83..ec2dd0c 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -361,6 +361,74 @@ def test_sync_targeted_momo_candidates_skips_total_price_identity_review(monkeyp assert count == 0 +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