feat(growth): close same-item reconciliation loop
This commit is contained in:
@@ -334,6 +334,13 @@ def test_sync_targeted_momo_candidates_writes_unit_price_offer(monkeypatch):
|
||||
"competitor_unit_price": 23.0,
|
||||
"unit_gap_pct": -49.13,
|
||||
},
|
||||
"same_item_reconciliation": {
|
||||
"trace_id": "trace-unit",
|
||||
"run_id": "run-unit",
|
||||
"work_item_id": "GROWTH-P0-001-B",
|
||||
"independent_verifier_passed": True,
|
||||
},
|
||||
"same_item_candidate_fingerprint": "candidate-unit-1",
|
||||
},
|
||||
{
|
||||
"product_id": "REVIEW-1",
|
||||
@@ -370,6 +377,9 @@ def test_sync_targeted_momo_candidates_writes_unit_price_offer(monkeypatch):
|
||||
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 raw_payload["same_item_reconciliation"]["run_id"] == "run-unit"
|
||||
assert raw_payload["same_item_reconciliation"]["independent_verifier_passed"] is True
|
||||
assert raw_payload["same_item_candidate_fingerprint"] == "candidate-unit-1"
|
||||
assert stale_marks == [True]
|
||||
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
|
||||
assert "force_rebuild=False" in route_source
|
||||
assert "def _load_competitor_decision_overview(session, latest_items=None)" in route_source
|
||||
assert "def _load_pchome_growth_command_center(session)" in route_source
|
||||
assert "build_pchome_growth_opportunities(engine, limit=16)" in route_source
|
||||
assert "build_pchome_growth_opportunities(engine, limit=50)" in route_source
|
||||
assert "build_pchome_direct_mapping_candidate_decision_package(" in route_source
|
||||
assert "build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_receipt_replay_package(" in route_source
|
||||
assert "build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package(" in route_source
|
||||
|
||||
@@ -287,6 +287,10 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang
|
||||
assert contract["operational_decision"]["ready"] is False
|
||||
assert contract["operational_decision"]["ready_count"] == 0
|
||||
assert "sales_data_stale" in contract["operational_decision"]["blocked_reason_codes"]
|
||||
revenue_coverage = contract["revenue_coverage"]
|
||||
assert 0 < revenue_coverage["rate"] < 100
|
||||
assert revenue_coverage["numerator"] < revenue_coverage["denominator"]
|
||||
assert revenue_coverage["state_partition"]["total"] == revenue_coverage["denominator"]
|
||||
assert contract["platform_runtime"]["denominator"] == 15
|
||||
|
||||
actions = {item["pchome_product_id"]: item["recommended_action"]["label"] for item in payload["opportunities"]}
|
||||
@@ -454,8 +458,7 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeEngine:
|
||||
pass
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
before_payload = {
|
||||
"success": True,
|
||||
@@ -463,8 +466,8 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
"opportunities": [
|
||||
{
|
||||
"pchome_product_id": "PCH-NEEDS-1",
|
||||
"product_name": "需要補對應商品一",
|
||||
"pchome_price": 920,
|
||||
"product_name": "理膚寶水 全面修復霜 B5 40ml",
|
||||
"pchome_price": 679,
|
||||
"sales_7d": 120000,
|
||||
"priority_score": 91,
|
||||
"external_price": None,
|
||||
@@ -479,8 +482,8 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
},
|
||||
{
|
||||
"pchome_product_id": "PCH-NEEDS-2",
|
||||
"product_name": "需要補對應商品二",
|
||||
"pchome_price": 760,
|
||||
"product_name": "理膚寶水 B5 全面修復霜 40ml",
|
||||
"pchome_price": 679,
|
||||
"sales_7d": 90000,
|
||||
"priority_score": 82,
|
||||
"external_price": None,
|
||||
@@ -490,8 +493,19 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
}
|
||||
after_payload = {
|
||||
"success": True,
|
||||
"stats": {"mapping_rate": 66.7, "candidate_count": 3, "mapped_count": 2},
|
||||
"opportunities": [],
|
||||
"stats": {"mapping_rate": 100, "candidate_count": 2, "mapped_count": 2},
|
||||
"opportunities": [
|
||||
{
|
||||
"pchome_product_id": "PCH-NEEDS-1",
|
||||
"sales_7d": 120000,
|
||||
"external_price": {"momo_sku": "MOMO-AUTO"},
|
||||
},
|
||||
{
|
||||
"pchome_product_id": "PCH-NEEDS-2",
|
||||
"sales_7d": 90000,
|
||||
"external_price": {"momo_sku": "MOMO-UNIT"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
payload_calls = []
|
||||
@@ -504,20 +518,29 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
captured["targets"] = targets
|
||||
captured["search_limit"] = limit
|
||||
return True, "找到候選", [
|
||||
{
|
||||
"product_id": "MOMO-AUTO",
|
||||
"auto_compare_type": "total_price",
|
||||
"can_auto_compare": True,
|
||||
},
|
||||
{
|
||||
"product_id": "MOMO-UNIT",
|
||||
"auto_compare_type": "unit_price",
|
||||
"can_auto_compare": True,
|
||||
},
|
||||
{
|
||||
"product_id": "MOMO-REVIEW",
|
||||
"auto_compare_type": "manual_review",
|
||||
"can_auto_compare": False,
|
||||
{
|
||||
"product_id": "MOMO-AUTO",
|
||||
"name": "理膚寶水 B5 修復霜 40ml",
|
||||
"price": 699,
|
||||
"target_pchome_product_id": "PCH-NEEDS-1",
|
||||
"auto_compare_type": "total_price",
|
||||
"can_auto_compare": True,
|
||||
},
|
||||
{
|
||||
"product_id": "MOMO-UNIT",
|
||||
"name": "理膚寶水 B5 全面修復霜 40ml x2 超值組",
|
||||
"price": 1199,
|
||||
"target_pchome_product_id": "PCH-NEEDS-2",
|
||||
"auto_compare_type": "unit_price",
|
||||
"can_auto_compare": True,
|
||||
},
|
||||
{
|
||||
"product_id": "MOMO-REVIEW",
|
||||
"name": "蘭蔻 玫瑰霜 60ml",
|
||||
"price": 5000,
|
||||
"target_pchome_product_id": "PCH-NEEDS-1",
|
||||
"auto_compare_type": "manual_review",
|
||||
"can_auto_compare": False,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -540,7 +563,7 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
}
|
||||
|
||||
payload = run_pchome_growth_momo_backfill(
|
||||
FakeEngine(),
|
||||
engine,
|
||||
limit=2,
|
||||
build_payload_func=fake_build_payload,
|
||||
search_func=fake_search,
|
||||
@@ -552,14 +575,16 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items()
|
||||
assert payload["data"]["scanned_products"] == 2
|
||||
assert payload["data"]["candidate_count"] == 3
|
||||
assert payload["data"]["auto_compare_count"] == 2
|
||||
assert payload["data"]["review_count"] == 1
|
||||
assert payload["data"]["review_count"] == 0
|
||||
assert payload["data"]["external_offer_sync"]["written_count"] == 2
|
||||
assert payload["data"]["review_candidate_sync"]["written_count"] == 1
|
||||
assert payload["data"]["after_stats"]["mapping_rate"] == 66.7
|
||||
assert payload["data"]["review_candidate_sync"]["written_count"] == 0
|
||||
assert payload["data"]["after_stats"]["mapping_rate"] == 100
|
||||
assert payload["data"]["post_verifier"]["all_checks_passed"] is True
|
||||
assert payload["data"]["receipt_persistence"]["readback_count"] == 2
|
||||
assert [item["product_id"] for item in captured["targets"]] == ["PCH-NEEDS-1", "PCH-NEEDS-2"]
|
||||
assert [item["price"] for item in captured["targets"]] == [920, 760]
|
||||
assert [item["price"] for item in captured["targets"]] == [679, 679]
|
||||
assert [item["product_id"] for item in captured["sync_candidates"]] == ["MOMO-AUTO", "MOMO-UNIT"]
|
||||
assert [item["product_id"] for item in captured["review_sync_candidates"]] == ["MOMO-REVIEW"]
|
||||
assert "review_sync_candidates" not in captured
|
||||
assert captured["search_limit"] == 2
|
||||
|
||||
|
||||
|
||||
281
tests/test_pchome_same_item_reconciliation.py
Normal file
281
tests/test_pchome_same_item_reconciliation.py
Normal file
@@ -0,0 +1,281 @@
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def _target(product_id, name, price, rank):
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"execution_rank": rank,
|
||||
"execution_priority_score": 90 - rank,
|
||||
"revenue_share_pct": 20,
|
||||
"target_state": "unmatched",
|
||||
}
|
||||
|
||||
|
||||
def test_revenue_weighted_targets_include_candidate_validation_and_sort_by_revenue():
|
||||
from services.pchome_growth_same_item_reconciliation import (
|
||||
build_revenue_weighted_targets,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"opportunities": [
|
||||
{
|
||||
"pchome_product_id": "LOW",
|
||||
"product_name": "低業績未配對",
|
||||
"sales_7d": 100,
|
||||
"sales_prev_7d": 100,
|
||||
"priority_score": 95,
|
||||
"external_price": None,
|
||||
"recommended_action": {"code": "map_external_product"},
|
||||
},
|
||||
{
|
||||
"pchome_product_id": "HIGH",
|
||||
"product_name": "高業績候選",
|
||||
"sales_7d": 10000,
|
||||
"sales_prev_7d": 9000,
|
||||
"priority_score": 40,
|
||||
"external_price": None,
|
||||
"review_candidate": {"momo_sku": "M-1"},
|
||||
"recommended_action": {"code": "review_external_candidate"},
|
||||
},
|
||||
{
|
||||
"pchome_product_id": "READY",
|
||||
"product_name": "已有正式比價",
|
||||
"sales_7d": 50000,
|
||||
"priority_score": 100,
|
||||
"external_price": {"momo_sku": "M-2"},
|
||||
"recommended_action": {"code": "monitor"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
targets = build_revenue_weighted_targets(payload, 10)
|
||||
|
||||
assert [item["product_id"] for item in targets] == ["HIGH", "LOW"]
|
||||
assert targets[0]["target_state"] == "candidate_validation"
|
||||
assert targets[0]["execution_rank"] == 1
|
||||
assert targets[0]["execution_priority_score"] > targets[1]["execution_priority_score"]
|
||||
|
||||
|
||||
def test_independent_verifier_promotes_exact_and_unit_candidates_but_rejects_conflict():
|
||||
from services.pchome_growth_same_item_reconciliation import (
|
||||
verify_same_item_candidates,
|
||||
)
|
||||
|
||||
targets = [
|
||||
_target("P-EXACT", "理膚寶水 全面修復霜 B5 40ml", 679, 1),
|
||||
_target("P-UNIT", "理膚寶水 全面修復霜 B5 40ml", 679, 2),
|
||||
_target("P-BLOCK", "巴黎萊雅 膠原輕盈乳霜 60ml", 1249, 3),
|
||||
]
|
||||
candidates = [
|
||||
{
|
||||
"product_id": "M-EXACT",
|
||||
"name": "理膚寶水 B5 修復霜 40ml",
|
||||
"price": 699,
|
||||
"target_pchome_product_id": "P-EXACT",
|
||||
"auto_compare_type": "manual_review",
|
||||
},
|
||||
{
|
||||
"product_id": "M-UNIT",
|
||||
"name": "理膚寶水 B5 全面修復霜 40ml x2 超值組",
|
||||
"price": 1199,
|
||||
"target_pchome_product_id": "P-UNIT",
|
||||
"auto_compare_type": "unit_price",
|
||||
},
|
||||
{
|
||||
"product_id": "M-BLOCK",
|
||||
"name": "蘭蔻 玫瑰霜 60ml",
|
||||
"price": 5000,
|
||||
"target_pchome_product_id": "P-BLOCK",
|
||||
"auto_compare_type": "total_price",
|
||||
},
|
||||
]
|
||||
identity = {
|
||||
"trace_id": "trace-1",
|
||||
"run_id": "run-1",
|
||||
"work_item_id": "GROWTH-P0-001-B",
|
||||
}
|
||||
|
||||
result = verify_same_item_candidates(targets, candidates, identity=identity)
|
||||
selected = {item["target_pchome_product_id"]: item for item in result["verified_candidates"]}
|
||||
|
||||
assert result["selected_candidate_count"] == 2
|
||||
assert result["blocked_candidate_count"] == 1
|
||||
assert result["claim_drift_count"] == 1
|
||||
assert selected["P-EXACT"]["auto_compare_type"] == "total_price"
|
||||
assert selected["P-UNIT"]["auto_compare_type"] == "unit_price"
|
||||
assert selected["P-UNIT"]["target_unit_price_comparison"]["comparable"] is True
|
||||
assert selected["P-EXACT"]["same_item_candidate_fingerprint"]
|
||||
assert result["blocked_candidates"][0]["reason_code"] == "same_item_verifier_rejected"
|
||||
|
||||
|
||||
def test_coverage_post_verifier_requires_exact_source_readback_and_reports_revenue_delta():
|
||||
from services.pchome_growth_same_item_reconciliation import (
|
||||
build_coverage_post_verifier,
|
||||
)
|
||||
|
||||
before = {
|
||||
"stats": {"mapping_rate": 0, "mapped_count": 0},
|
||||
"opportunities": [
|
||||
{"pchome_product_id": "P-1", "sales_7d": 8000, "external_price": None},
|
||||
{"pchome_product_id": "P-2", "sales_7d": 2000, "external_price": None},
|
||||
],
|
||||
}
|
||||
after = {
|
||||
"stats": {"mapping_rate": 50, "mapped_count": 1},
|
||||
"opportunities": [
|
||||
{
|
||||
"pchome_product_id": "P-1",
|
||||
"sales_7d": 8000,
|
||||
"external_price": {"momo_sku": "M-1"},
|
||||
},
|
||||
{"pchome_product_id": "P-2", "sales_7d": 2000, "external_price": None},
|
||||
],
|
||||
}
|
||||
|
||||
result = build_coverage_post_verifier(
|
||||
before_payload=before,
|
||||
after_payload=after,
|
||||
verified_candidates=[{
|
||||
"target_pchome_product_id": "P-1",
|
||||
"product_id": "M-1",
|
||||
}],
|
||||
sync_result={"written_count": 1},
|
||||
)
|
||||
|
||||
assert result["status"] == "verified"
|
||||
assert result["all_checks_passed"] is True
|
||||
assert result["comparison_ready_delta"] == 1
|
||||
assert result["count_coverage_delta"] == 50
|
||||
assert result["revenue_coverage_delta"] == 80
|
||||
|
||||
|
||||
def test_receipt_schema_apply_persistence_and_public_safe_readback():
|
||||
from services.pchome_growth_same_item_reconciliation import (
|
||||
ensure_evidence_receipt_table,
|
||||
persist_reconciliation_receipts,
|
||||
read_latest_reconciliation_receipts,
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
schema = ensure_evidence_receipt_table(engine)
|
||||
target = _target("P-1", "商品", 100, 1)
|
||||
run_receipt = {
|
||||
"generated_at": "2026-07-14T12:00:00+00:00",
|
||||
"identity": {
|
||||
"trace_id": "trace-1",
|
||||
"run_id": "run-1",
|
||||
"work_item_id": "GROWTH-P0-001-B",
|
||||
},
|
||||
"source_receipt": {"candidate_count": 1},
|
||||
"candidate_verification": {
|
||||
"verified_candidates": [{"target_pchome_product_id": "P-1"}],
|
||||
"blocked_candidates": [],
|
||||
},
|
||||
"coverage_diff": {"comparison_ready_delta": 1},
|
||||
"risk_policy": {"risk": "medium"},
|
||||
"check_mode": {"verified_candidate_count": 1},
|
||||
"execution": {"written_count": 1},
|
||||
"post_verifier": {
|
||||
"status": "verified",
|
||||
"readback": [{"target_pchome_product_id": "P-1", "passed": True}],
|
||||
},
|
||||
"terminal": {"status": "verified_with_coverage_gain"},
|
||||
}
|
||||
|
||||
persistence = persist_reconciliation_receipts(
|
||||
engine,
|
||||
targets=[target],
|
||||
run_receipt=run_receipt,
|
||||
)
|
||||
readback = read_latest_reconciliation_receipts(engine)
|
||||
|
||||
assert schema["status"] == "created_and_verified"
|
||||
assert persistence["status"] == "persisted_and_verified"
|
||||
assert persistence["written_count"] == 1
|
||||
assert readback["status"] == "ready"
|
||||
assert readback["verified_count"] == 1
|
||||
assert readback["latest_run_id"] == "run-1"
|
||||
assert readback["receipts"][0]["work_item_id"] == "GROWTH-P0-001-B"
|
||||
|
||||
|
||||
def test_targeted_momo_search_keeps_same_source_product_per_pchome_target():
|
||||
from services.momo_crawler import search_momo_products_for_pchome_products
|
||||
|
||||
class FakeCrawler:
|
||||
def search_products(self, term, limit):
|
||||
return True, "ok", [{
|
||||
"product_id": "MOMO-SAME",
|
||||
"name": "理膚寶水 B5 全面修復霜 40ml",
|
||||
"price": 699,
|
||||
}]
|
||||
|
||||
success, _, candidates = search_momo_products_for_pchome_products(
|
||||
[
|
||||
{"product_id": "P-1", "name": "理膚寶水 全面修復霜 B5 40ml", "price": 679},
|
||||
{"product_id": "P-2", "name": "理膚寶水 全面修復霜 B5 40ml", "price": 679},
|
||||
],
|
||||
limit_per_product=3,
|
||||
max_products=2,
|
||||
max_terms_per_product=2,
|
||||
crawler=FakeCrawler(),
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert len(candidates) == 2
|
||||
assert {item["target_pchome_product_id"] for item in candidates} == {"P-1", "P-2"}
|
||||
|
||||
|
||||
def test_dashboard_exposes_count_and_revenue_coverage_without_a_text_wall():
|
||||
from pathlib import Path
|
||||
|
||||
template = Path("templates/dashboard_v2.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "正式比價覆蓋" in template
|
||||
assert "業績涵蓋" in template
|
||||
assert "growth.comparison_revenue_coverage_rate" in template
|
||||
|
||||
|
||||
def test_same_item_reconciliation_route_returns_durable_readback(monkeypatch):
|
||||
from flask import Flask
|
||||
from routes import ai_routes as routes
|
||||
from services.pchome_growth_same_item_reconciliation import (
|
||||
ensure_evidence_receipt_table,
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
ensure_evidence_receipt_table(engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO external_offer_evidence_receipts (
|
||||
receipt_id, pchome_product_id, automation_decision, payload_hash,
|
||||
evidence_delta_json, apply_status
|
||||
) VALUES (
|
||||
'sameitem-route', 'P-1', 'candidate_rejected', 'hash-1',
|
||||
:evidence, 'no_write'
|
||||
)
|
||||
"""), {
|
||||
"evidence": json.dumps({
|
||||
"identity": {
|
||||
"trace_id": "trace-route",
|
||||
"run_id": "run-route",
|
||||
"work_item_id": "GROWTH-P0-001-B",
|
||||
},
|
||||
"post_verifier": {"status": "no_write_verified"},
|
||||
})
|
||||
})
|
||||
|
||||
monkeypatch.setattr(routes, "_create_icaim_dashboard_engine", lambda _: engine)
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/api/ai/pchome-growth/same-item-reconciliation?limit=5"
|
||||
):
|
||||
response = routes.api_pchome_growth_same_item_reconciliation.__wrapped__()
|
||||
|
||||
payload = response.get_json()
|
||||
assert payload["status"] == "ready"
|
||||
assert payload["receipt_count"] == 1
|
||||
assert payload["latest_run_id"] == "run-route"
|
||||
@@ -261,6 +261,31 @@ def test_pchome_growth_backfill_catchup_uses_runtime_receipt_cooldown(monkeypatc
|
||||
assert reason == "stale_receipt_4.5h"
|
||||
|
||||
|
||||
def test_pchome_growth_backfill_degraded_receipt_retries_after_one_hour(monkeypatch, tmp_path):
|
||||
run_scheduler = _load_run_scheduler(monkeypatch)
|
||||
stats_path = tmp_path / "scheduler_stats.json"
|
||||
stats_path.write_text(json.dumps({
|
||||
"pchome_growth_momo_backfill": [{
|
||||
"status": "Degraded",
|
||||
"last_run": "2026-07-10 09:00:00",
|
||||
"terminal_status": "degraded_no_safe_candidate",
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
monkeypatch.setenv("SCHEDULER_STATS_PATH", str(stats_path))
|
||||
|
||||
due, reason = run_scheduler._pchome_growth_backfill_due(
|
||||
now=datetime(2026, 7, 10, 9, 30, 0)
|
||||
)
|
||||
assert due is False
|
||||
assert reason == "cooldown_1.0h"
|
||||
|
||||
due, reason = run_scheduler._pchome_growth_backfill_due(
|
||||
now=datetime(2026, 7, 10, 10, 0, 0)
|
||||
)
|
||||
assert due is True
|
||||
assert reason == "stale_receipt_1.0h"
|
||||
|
||||
|
||||
def test_pchome_growth_backfill_catchup_executes_missing_receipt(monkeypatch, tmp_path):
|
||||
run_scheduler = _load_run_scheduler(monkeypatch)
|
||||
monkeypatch.setenv("SCHEDULER_STATS_PATH", str(tmp_path / "missing.json"))
|
||||
|
||||
Reference in New Issue
Block a user