feat(growth): automate sales source reconciliation
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-14 18:37:14 +08:00
parent 24409a70b9
commit f2201e12bc
10 changed files with 705 additions and 43 deletions

View File

@@ -177,12 +177,19 @@ def test_no_candidate_with_missing_freshness_is_blocked_and_receipted(monkeypatc
_disable_remote_sources(monkeypatch)
monkeypatch.delenv("PCHOME_SALES_LOCAL_DROP_DIR", raising=False)
monkeypatch.setattr(import_service, "drive_service", EmptyDrive())
monkeypatch.setattr(acquisition, "drive_service", ReadyDrive())
result = acquisition.PChomeSalesAcquisitionService().run(trigger="test")
assert result["success"] is False
assert result["status"] == "blocked_with_safe_next_action"
assert result["safe_next_action"] == "scheduler_retry_authorized_sources"
assert result["safe_next_action"] == "scheduled_retry_and_enabled_fallback_probe"
assert result["next_retry_at"]
assert result["source_reconciliation"]["contract"] == "pchome_sales_source_reconciliation_v1"
assert result["source_reconciliation"]["manual_review_required"] is False
assert result["providers"][1]["status"] == "disabled"
assert result["providers"][2]["status"] == "disabled"
assert result["providers"][3]["status"] == "disabled"
assert result["receipt_persisted"] is True
with import_service.engine.connect() as conn:
receipt = conn.execute(text(
@@ -303,6 +310,7 @@ def test_auto_import_api_uses_governed_acquisition_service(monkeypatch):
def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch):
import scheduler
import services.agent_actions as agent_actions
import services.notification_manager as notification_manager
import services.pchome_sales_acquisition_service as acquisition
calls = []
@@ -322,8 +330,19 @@ def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch):
"data_lag_days": 1,
"decision_ready": True,
"safe_next_action": "scheduler_retry_authorized_sources",
"next_retry_at": "2026-07-14T19:00:00+08:00",
"notification_policy": {
"send": False,
"reason": "duplicate_within_cooldown",
"fingerprint": "same-state",
},
},
)
monkeypatch.setattr(
notification_manager,
"NotificationManager",
lambda: (_ for _ in ()).throw(AssertionError("suppressed notification must not send")),
)
monkeypatch.setattr(scheduler, "_save_stats", lambda name, stats: saved.update({"name": name, "stats": stats}))
scheduler.run_auto_import_task()
@@ -332,3 +351,5 @@ def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch):
assert saved["name"] == "auto_import_task"
assert saved["stats"]["status"] == "Degraded"
assert saved["stats"]["receipt_persisted"] is True
assert saved["stats"]["next_retry_at"] == "2026-07-14T19:00:00+08:00"
assert saved["stats"]["notification_policy"]["send"] is False

View File

@@ -0,0 +1,126 @@
from datetime import datetime, timedelta, timezone
from services.pchome_sales_source_reconciliation import build_source_reconciliation
NOW = datetime(2026, 7, 14, 18, 30, tzinfo=timezone(timedelta(hours=8)))
def _source(source_id, *, enabled=True, ready=True, status="ready"):
return {
"id": source_id,
"enabled": enabled,
"ready": ready,
"status": status,
}
def _receipt(provider, *, completed_at, reconciliation=None):
stages = {"providers": [provider]}
if reconciliation:
stages["source_reconciliation"] = reconciliation
return {
"status": "blocked_with_safe_next_action",
"completed_at": completed_at,
"stages": stages,
}
def test_stale_single_source_schedules_retry_and_reports_fallback_gap():
payload = build_source_reconciliation(
sources=[
_source("google_drive"),
_source("authorized_https", enabled=False, ready=False, status="disabled"),
],
provider_attempts=[
{"source": "google_drive", "status": "upstream_stale", "file_count": 0},
{"source": "authorized_https", "status": "disabled"},
],
prior_receipts=[],
freshness={"latest_sales_date": "2026-06-24", "data_lag_days": 20, "decision_ready": False},
now=NOW,
)
assert payload["contract"] == "pchome_sales_source_reconciliation_v1"
assert payload["status"] == "degraded"
assert payload["fallback_chain"] == ["google_drive"]
assert payload["single_source_dependency"] is True
assert payload["next_retry_at"] == "2026-07-14T19:00:00+08:00"
assert payload["safe_next_action"] == "scheduled_retry_and_enabled_fallback_probe"
assert payload["notification_policy"]["severity"] == "critical"
assert payload["notification_policy"]["send"] is True
assert payload["sources"][0]["consecutive_empty_runs"] == 1
assert payload["sources"][1]["runtime_state"] == "disabled"
def test_same_state_within_cooldown_suppresses_duplicate_notification():
first = build_source_reconciliation(
sources=[_source("google_drive")],
provider_attempts=[{"source": "google_drive", "status": "upstream_stale"}],
prior_receipts=[],
freshness={"data_lag_days": 20, "decision_ready": False},
now=NOW - timedelta(minutes=10),
)
history = [_receipt(
{"source": "google_drive", "status": "upstream_stale"},
completed_at=(NOW - timedelta(minutes=10)).isoformat(),
reconciliation=first,
)]
second = build_source_reconciliation(
sources=[_source("google_drive")],
provider_attempts=[{"source": "google_drive", "status": "upstream_stale"}],
prior_receipts=history,
freshness={"data_lag_days": 20, "decision_ready": False},
now=NOW,
)
assert second["notification_policy"]["fingerprint"] == first["notification_policy"]["fingerprint"]
assert second["notification_policy"]["send"] is False
assert second["notification_policy"]["reason"] == "duplicate_within_cooldown"
assert second["sources"][0]["consecutive_empty_runs"] == 2
def test_provider_failures_use_bounded_exponential_backoff():
failed = {"source": "authorized_https", "status": "failed", "error_kind": "http_fetch_failed"}
history = [
_receipt(failed, completed_at=(NOW - timedelta(minutes=30)).isoformat()),
_receipt(failed, completed_at=(NOW - timedelta(minutes=60)).isoformat()),
]
payload = build_source_reconciliation(
sources=[_source("authorized_https")],
provider_attempts=[failed],
prior_receipts=history,
freshness={"data_lag_days": 5, "decision_ready": False},
now=NOW,
retry_interval_minutes=30,
max_failure_backoff_minutes=240,
)
source = payload["sources"][0]
assert source["runtime_state"] == "failed"
assert source["consecutive_failure_runs"] == 3
assert source["retry_minutes"] == 120
assert source["next_retry_at"] == "2026-07-14T20:30:00+08:00"
assert source["action"] == "retry_with_bounded_backoff_and_probe_fallback"
def test_duplicate_source_remains_on_scheduled_retry_without_writing():
payload = build_source_reconciliation(
sources=[_source("google_drive")],
provider_attempts=[{
"source": "google_drive",
"status": "duplicate",
"duplicate_count": 1,
}],
prior_receipts=[],
freshness={"data_lag_days": 10, "decision_ready": False},
now=NOW,
)
source = payload["sources"][0]
assert source["runtime_state"] == "duplicate"
assert source["retry_minutes"] == 30
assert source["next_retry_at"] == "2026-07-14T19:00:00+08:00"
assert payload["safe_next_action"] == "scheduled_retry_and_enabled_fallback_probe"