From 954c729da231bebd3b49419a917bb33112f3c1f1 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 10:01:55 +0800 Subject: [PATCH] fix(growth): normalize nested marketplace packs --- config.py | 2 +- services/marketplace_product_matcher.py | 45 +++++++++++-- tests/test_marketplace_product_matcher.py | 17 +++++ tests/test_pchome_same_item_reconciliation.py | 65 ++++++++++++++++--- 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/config.py b/config.py index c6bcdee..ac9d407 100644 --- a/config.py +++ b/config.py @@ -414,7 +414,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.797" +SYSTEM_VERSION = "V10.798" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index b3571ea..35538ec 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -858,6 +858,8 @@ PRODUCT_TYPES = { COUNT_UNITS = {"入", "組", "瓶", "支", "條", "盒", "包", "袋", "片", "顆", "粒", "錠", "枚", "件", "罐", "杯", "本", "刀把", "刀片", "刀頭", "蕊"} COUNT_UNIT_PATTERN = r"(?:刀把|刀片|刀頭|入|組|瓶|支|條|盒|包|袋|片|張|顆|粒|錠|枚|件|罐|杯|本|蕊)" PIECE_UNITS = {"包", "袋", "片", "張", "顆", "粒", "錠", "枚"} +ATOMIC_PIECE_UNITS = {"片", "張", "顆", "粒", "錠", "枚"} +PACK_MULTIPLIER_UNITS = {"包", "袋"} CONTAINER_UNITS = {"入", "組", "盒", "罐", "杯", "本", "瓶", "支", "條", "件"} COUNT_UNIT_FAMILIES = { "刀片": "blade", @@ -1210,14 +1212,32 @@ def _extract_specs( counts.append((2, "入")) total_piece_count = None - explicit_total = re.search(r"共\s*(\d+)\s*([包袋片顆粒錠枚])", text) + explicit_total = re.search(r"共\s*(\d+)\s*([包袋片張顆粒錠枚])", text) if explicit_total: total_piece_count = int(explicit_total.group(1)) else: + atomic_piece_counts = [ + count for count, unit in counts if unit in ATOMIC_PIECE_UNITS + ] piece_counts = [count for count, unit in counts if unit in PIECE_UNITS] - container_counts = [count for count, unit in counts if unit in CONTAINER_UNITS] - if piece_counts and container_counts: - total_piece_count = max(piece_counts) * max(container_counts) + container_multiplier_by_unit = { + unit: max(count for count, candidate_unit in counts if candidate_unit == unit) + for unit in CONTAINER_UNITS + if any(candidate_unit == unit for _, candidate_unit in counts) + } + multiplier_by_unit = { + unit: max(count for count, candidate_unit in counts if candidate_unit == unit) + for unit in PACK_MULTIPLIER_UNITS | CONTAINER_UNITS + if any(candidate_unit == unit for _, candidate_unit in counts) + } + if atomic_piece_counts and multiplier_by_unit: + total_piece_count = max(atomic_piece_counts) + for multiplier in multiplier_by_unit.values(): + total_piece_count *= multiplier + elif piece_counts and container_multiplier_by_unit: + total_piece_count = max(piece_counts) + for multiplier in container_multiplier_by_unit.values(): + total_piece_count *= multiplier elif piece_counts: total_piece_count = max(piece_counts) @@ -1782,6 +1802,17 @@ def _has_overlapping_base_spec(left: ProductIdentity, right: ProductIdentity) -> return False return _close_number(left_weights[0], right_weights[0]) + if left.total_piece_count or right.total_piece_count: + if not left.total_piece_count or not right.total_piece_count: + return False + left_units = { + unit for _, unit in left.counts if unit in ATOMIC_PIECE_UNITS + } + right_units = { + unit for _, unit in right.counts if unit in ATOMIC_PIECE_UNITS + } + return bool(left_units & right_units) + return False @@ -1798,7 +1829,11 @@ def _single_unit_total(identity: ProductIdentity) -> tuple[Optional[str], Option multiplier = identity.total_piece_count or _pack_multiplier(identity) return "g", weights[0] * multiplier, "ok" if identity.total_piece_count: - return "入", float(identity.total_piece_count), "ok" + atomic_units = { + unit for _, unit in identity.counts if unit in ATOMIC_PIECE_UNITS + } + unit = next(iter(atomic_units)) if len(atomic_units) == 1 else "入" + return unit, float(identity.total_piece_count), "ok" return None, None, "missing_single_unit" diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index 0465d43..53feedc 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -3443,6 +3443,7 @@ def test_marketplace_search_terms_prefer_readable_brand_core_spec(): def test_marketplace_sheet_count_is_structured_for_search_and_identity(): from services.marketplace_product_matcher import ( + build_unit_price_comparison, build_search_terms, parse_product_identity, score_marketplace_match, @@ -3450,15 +3451,21 @@ def test_marketplace_sheet_count_is_structured_for_search_and_identity(): pchome_name = "GATSBY 潔面濕紙巾超值包42張入" same_pack = "【GATSBY】潔面濕紙巾超值包42張入(2款任選)" + multi_pack = "【GATSBY】潔面濕紙巾超值包42張入*4包(4款任選)" different_pack = "【GATSBY】潔面濕紙巾15張入(3款任選)" identity = parse_product_identity(pchome_name) + multi_pack_identity = parse_product_identity(multi_pack) terms = build_search_terms(pchome_name, max_terms=5) exact = score_marketplace_match(same_pack, pchome_name) + normalized = score_marketplace_match(multi_pack, pchome_name) + unit_price = build_unit_price_comparison(multi_pack, pchome_name, 495, 199) rejected = score_marketplace_match(different_pack, pchome_name) assert (42, "張") in identity.counts assert identity.total_piece_count == 42 + assert (4, "包") in multi_pack_identity.counts + assert multi_pack_identity.total_piece_count == 168 assert terms[0] == "gatsby 潔面濕紙巾 42張" assert not any(term in {"gatsby 張入", "張入"} for term in terms) assert exact.hard_veto is False @@ -3467,6 +3474,16 @@ def test_marketplace_sheet_count_is_structured_for_search_and_identity(): assert exact.alert_tier == "price_alert_exact" assert exact.spec_score == 1.0 assert "spec_name_alignment" in exact.reasons + assert normalized.hard_veto is True + assert normalized.comparison_mode == "unit_comparable" + assert normalized.match_type == "same_product_different_pack" + assert normalized.price_basis == "unit_price" + assert "count_conflict" in normalized.reasons + assert "pack_quantity_difference" in normalized.reasons + assert unit_price["comparable"] is True + assert unit_price["unit_label"] == "張" + assert unit_price["momo_total_quantity"] == 168 + assert unit_price["competitor_total_quantity"] == 42 assert rejected.hard_veto is True assert "count_conflict" in rejected.reasons diff --git a/tests/test_pchome_same_item_reconciliation.py b/tests/test_pchome_same_item_reconciliation.py index 6d70b47..6e960ee 100644 --- a/tests/test_pchome_same_item_reconciliation.py +++ b/tests/test_pchome_same_item_reconciliation.py @@ -117,7 +117,7 @@ def test_independent_verifier_promotes_exact_and_unit_candidates_but_rejects_con assert result["blocked_candidates"][0]["reasons"] -def test_independent_verifier_promotes_exact_sheet_pack_and_blocks_wrong_count(): +def test_independent_verifier_prefers_exact_sheet_pack_over_unit_candidate(): from services.pchome_growth_same_item_reconciliation import ( verify_same_item_candidates, ) @@ -160,14 +160,63 @@ def test_independent_verifier_promotes_exact_sheet_pack_and_blocks_wrong_count() assert result["selected_candidate_count"] == 1 assert result["verified_candidates"][0]["product_id"] == "M-GATSBY-42" assert result["verified_candidates"][0]["auto_compare_type"] == "total_price" - rejected = next( - item - for item in result["blocked_candidates"] - if item["source_product_id"] == "M-GATSBY-15" + assert result["verified_candidate_count"] == 2 + assert result["blocked_candidate_count"] == 0 + + unit_result = verify_same_item_candidates( + [target], + [candidates[1]], + identity={ + "trace_id": "trace-gatsby-unit", + "run_id": "run-gatsby-unit", + "work_item_id": "GROWTH-P0-001-B", + }, ) - assert rejected["hard_veto"] is True - assert rejected["search_term"] == "gatsby 潔面濕紙巾 42張" - assert "count_conflict" in rejected["reasons"] + assert unit_result["selected_candidate_count"] == 1 + unit_candidate = unit_result["verified_candidates"][0] + assert unit_candidate["auto_compare_type"] == "unit_price" + assert unit_candidate["target_price_basis"] == "unit_price" + assert unit_candidate["target_unit_price_comparison"]["comparable"] is True + assert unit_candidate["target_unit_price_comparison"]["unit_label"] == "張" + + +def test_independent_verifier_never_treats_outer_sheet_pack_as_exact_total_price(): + from services.pchome_growth_same_item_reconciliation import ( + verify_same_item_candidates, + ) + + target = _target( + "P-GATSBY-42", + "GATSBY 潔面濕紙巾超值包42張入", + 199, + 1, + ) + result = verify_same_item_candidates( + [target], + [{ + "product_id": "M-GATSBY-42-X4", + "name": "【GATSBY】潔面濕紙巾超值包42張入*4包(4款任選)", + "price": 495, + "target_pchome_product_id": "P-GATSBY-42", + "target_search_term": "gatsby 潔面濕紙巾 42張", + "auto_compare_type": "total_price", + }], + identity={ + "trace_id": "trace-gatsby-outer-pack", + "run_id": "run-gatsby-outer-pack", + "work_item_id": "GROWTH-P0-001-B", + }, + ) + + assert result["selected_candidate_count"] == 1 + candidate = result["verified_candidates"][0] + assert candidate["auto_compare_type"] == "unit_price" + assert candidate["same_item_reconciliation"]["candidate_claim_drift"] is True + assert candidate["target_match_type"] == "same_product_different_pack" + assert candidate["target_price_basis"] == "unit_price" + assert candidate["target_unit_price_comparison"]["unit_label"] == "張" + assert candidate["target_unit_price_comparison"]["momo_total_quantity"] == 168 + assert candidate["target_unit_price_comparison"]["competitor_total_quantity"] == 42 def test_coverage_post_verifier_requires_exact_source_readback_and_reports_revenue_delta():