feat(pchome): automate authorized sales acquisition
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-11 01:30:42 +08:00
parent f68223f0ed
commit 6e65ef00bb
21 changed files with 2428 additions and 617 deletions

View File

@@ -94,12 +94,14 @@ def test_daily_sales_import_fails_when_monthly_sync_fails(monkeypatch, tmp_path)
job = session.query(import_service.ImportJob).filter_by(id=job_id).one()
assert job.status == "failed"
assert job.progress_percent == 95
assert job.current_step == "業績分析儀表板同步失敗"
assert job.current_step == "交易驗證失敗,已回滾"
assert "monthly sync boom" in job.error_message
summary = json.loads(job.import_summary)
assert summary["sync_success"] is False
assert summary["synced_to"] is None
assert "monthly sync boom" in summary["sync_error"]
assert summary["atomic"] is True
assert summary["rolled_back"] is True
finally:
session.close()
@@ -107,11 +109,45 @@ def test_daily_sales_import_fails_when_monthly_sync_fails(monkeypatch, tmp_path)
snapshot_rows = conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar()
monthly_rows = conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar()
assert snapshot_rows == 1
assert snapshot_rows == 0
assert monthly_rows == 0
assert FakeNotificationManager.sent_messages
def test_daily_sales_import_preserves_today_fallback_when_date_column_is_missing(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_daily_sales_tables(import_service)
source_df = pd.DataFrame([{
"商品ID": "A001",
"商品名稱": "測試商品",
"銷售金額": 1200,
}])
monkeypatch.setattr(
import_service,
"_read_daily_sales_excel",
lambda _path: (
source_df.copy(),
{"date_col": None, "sheet_name": "即時業績明細", "header_row": 1},
),
)
service = import_service.ImportService()
job_id = service.create_import_job("daily_sales", "drive-file-1", "daily.xlsx", 1024)
assert service.process_daily_sales_import(job_id, str(tmp_path / "daily.xlsx")) is True
expected_date = import_service.datetime.now(import_service.TAIPEI_TZ).date().isoformat()
with import_service.engine.connect() as conn:
snapshot = conn.execute(text(
'SELECT "日期", snapshot_date FROM daily_sales_snapshot'
)).one()
monthly = conn.execute(text(
'SELECT "日期" FROM realtime_sales_monthly'
)).one()
assert snapshot[0] == expected_date
assert str(snapshot[1])[:10] == expected_date
assert monthly[0] == expected_date
def test_auto_import_does_not_move_drive_file_when_import_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
@@ -145,6 +181,47 @@ def test_auto_import_does_not_move_drive_file_when_import_fails(monkeypatch, tmp
assert fake_drive.moved_files == []
def test_auto_import_reports_partial_when_drive_archive_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)
monkeypatch.chdir(tmp_path)
class FakeDriveService:
last_error_kind = None
last_error = None
def list_files_in_folder(self, folder_path, file_pattern):
return [{"id": "drive-file-1", "name": "daily.xlsx", "size": 1024}]
def download_file(self, file_id, local_path):
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as handle:
handle.write(b"test")
return True
def move_file(self, file_id, folder, create_missing=False):
return False
monkeypatch.setattr(import_service, "drive_service", FakeDriveService())
service = import_service.ImportService()
monkeypatch.setattr(service, "process_daily_sales_import", lambda job_id, path: True)
result = service.auto_import_from_drive()
assert result["success"] is False
assert result["status"] == "partial_source_finalize_failed"
assert result["imported_count"] == 1
assert result["source_finalize_ok"] is False
assert result["archive_failed_count"] == 1
session = import_service.Session()
try:
job = session.query(import_service.ImportJob).one()
assert job.status == "completed"
assert job.current_step == "匯入完成,來源封存待自動重試"
finally:
session.close()
def test_auto_import_fails_closed_when_drive_auth_fails(monkeypatch, tmp_path):
import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import_service.Base.metadata.create_all(import_service.engine)

View File

@@ -793,8 +793,15 @@ def test_utility_pages_keep_operator_copy_professional():
assert "簡報線上預覽" in ppt_preview
assert "下載簡報檔" in ppt_history
assert "送出後更新日報、成長分析與今日作戰清單" in auto_import
assert "等待系統更新任務狀態;若重複停在異常" in auto_import
assert "業績資料自動化" in auto_import
assert "正式業績可供分析與決策" in auto_import
assert "雙表已驗證" in auto_import
assert "自動安全執行" in auto_import
assert "Controlled apply" not in auto_import
assert "AUTHORIZED SOURCES" not in auto_import
assert "VERIFIED RUNS" not in auto_import
assert "手動上傳匯入" not in auto_import
assert "uploadManualFile" not in auto_import
assert "return raw ||" not in auto_import
assert "供貨風險匯入" in stockout_import
assert "缺少必要內容時,會先停止匯入" in stockout_import

View File

@@ -29,11 +29,23 @@ def test_daily_snapshot_delete_casts_text_date_column_on_postgres(monkeypatch):
def test_daily_snapshot_delete_query_uses_snapshot_date_cast_helper():
source = Path("services/import_service.py").read_text(encoding="utf-8")
assert 'snapshot_date_expr = _date_filter_expr("snapshot_date")' in source
assert "DELETE FROM {table_name} WHERE {snapshot_date_expr} IN" in source
assert 'snapshot_expr = _date_filter_expr("snapshot_date")' in source
assert "f\"WHERE {snapshot_expr} IN ({snapshot_placeholders})\"" in source
assert "DELETE FROM daily_sales_snapshot WHERE snapshot_date IN" not in source
def test_sales_table_replacement_uses_one_transaction_connection():
source = Path("services/import_service.py").read_text(encoding="utf-8")
start = source.index("def _replace_sales_tables_atomic")
end = source.index("# 資料庫設定", start)
block = source[start:end]
assert "with engine.begin() as conn:" in block
assert 'df.to_sql(\n "daily_sales_snapshot",\n conn,' in block
assert 'df_monthly.to_sql(\n "realtime_sales_monthly",\n conn,' in block
assert "conn.commit()" not in block
def test_daily_snapshot_delete_uses_iso_dates_on_sqlite(monkeypatch):
monkeypatch.setattr(import_service, "_db_dialect_name", lambda: "sqlite")

View File

@@ -728,7 +728,7 @@ def test_primary_pages_use_growth_outcome_copy_instead_of_feature_explaining():
expected = {
"templates/daily_sales.html": "找出下滑與價差壓力",
"templates/ai_recommend.html": "把價差、商品證據與趨勢轉成主推、調價、補比價動作",
"templates/auto_import_index.html": "保持 PChome 業績新鮮",
"templates/auto_import_index.html": "正式業績可供分析與決策",
"templates/price_comparison.html": "確認同款、判斷價差、決定下一步",
"templates/vendor_stockout_index_v2.html": "避免主推商品斷貨",
"templates/monthly_summary_analysis.html": "判斷成長、毛利與品類結構",
@@ -889,14 +889,14 @@ def test_governance_and_low_frequency_pages_avoid_engineering_status_copy():
"templates/403.html": ["權限守門", "未授權操作影響營運資料", "權限控管"],
"templates/maintenance.html": ["服務維護", "確認業績、比價與匯入狀態", "台北時間"],
"templates/auto_import_index.html": [
"更新日報、成長分析與今日作戰清單",
"送出後更新日報、成長分析與今日作戰清單",
"作戰清單保持新鮮",
"共更新",
"buildImportActionHint",
"重新確認 Google Drive 授權",
"改用當日業績明細檔",
"重新匯入最新檔案",
"業績資料自動化",
"來源覆蓋",
"自動安全執行",
"執行自動取得",
"雙表已驗證",
"等待最新業績",
"loadReadiness",
"/api/auto_import/run",
],
"templates/settings.html": ["比價來源同步", "補齊 MOMO 參考來源"],
"templates/system_settings.html": [

View File

@@ -0,0 +1,334 @@
import importlib
import json
import os
from datetime import datetime
import pandas as pd
import pytz
from flask import Flask
from sqlalchemy import text
TAIPEI_TZ = pytz.timezone("Asia/Taipei")
def _load_services(monkeypatch, database_url):
os.environ.setdefault("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "true")
import config
monkeypatch.setattr(config, "DATABASE_PATH", database_url)
import services.import_service as import_service
import_service = importlib.reload(import_service)
import services.pchome_sales_acquisition_service as acquisition
acquisition = importlib.reload(acquisition)
return import_service, acquisition
def _prepare_tables(import_service):
import_service.Base.metadata.create_all(import_service.engine)
with import_service.engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS daily_sales_snapshot"))
conn.execute(text("DROP TABLE IF EXISTS realtime_sales_monthly"))
conn.execute(text("""
CREATE TABLE daily_sales_snapshot (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER,
snapshot_date TEXT
)
"""))
conn.execute(text("""
CREATE TABLE realtime_sales_monthly (
"日期" TEXT,
"商品ID" TEXT,
"商品名稱" TEXT,
"銷售金額" INTEGER
)
"""))
class EmptyDrive:
last_error_kind = None
last_error = None
def list_files_in_folder(self, _folder_path, _file_pattern):
return []
class ReadyDrive(EmptyDrive):
def check_auth_readiness(self, refresh_expired=False):
return {"ready": True, "kind": "ready"}
class FailedDrive(EmptyDrive):
last_error_kind = "authentication_failed"
last_error = "credentials missing"
def _write_sales_file(path):
today = datetime.now(TAIPEI_TZ).strftime("%Y-%m-%d")
pd.DataFrame([{
"日期": today,
"商品ID": "P001",
"商品名稱": "測試商品",
"銷售金額": 1680,
}]).to_excel(path, index=False, sheet_name="即時業績明細")
def _disable_remote_sources(monkeypatch):
monkeypatch.setenv("PCHOME_SALES_HTTP_ENABLED", "false")
monkeypatch.setenv("PCHOME_SALES_IMAP_ENABLED", "false")
def test_local_drop_closes_import_and_archives_only_after_verification(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_tables(import_service)
_disable_remote_sources(monkeypatch)
drop_dir = tmp_path / "drop"
drop_dir.mkdir()
source = drop_dir / "即時業績_當日.xlsx"
_write_sales_file(source)
monkeypatch.setenv("PCHOME_SALES_LOCAL_DROP_DIR", str(drop_dir))
monkeypatch.setattr(import_service, "drive_service", EmptyDrive())
result = acquisition.PChomeSalesAcquisitionService().run(trigger="test")
assert result["success"] is True
assert result["status"] == "completed"
assert result["source_type"] == "controlled_local_drop"
assert result["total_rows"] == 1
assert result["decision_ready"] is True
assert result["receipt_persisted"] is True
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
assert result["trace_id"] == result["trace_id"].lower()
assert result["span_id"] == result["span_id"].lower()
assert not source.exists()
assert (drop_dir / "archive" / source.name).exists()
with import_service.engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM pchome_sales_acquisition_receipts")).scalar() == 1
def test_same_content_is_idempotent_and_does_not_duplicate_rows(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_tables(import_service)
_disable_remote_sources(monkeypatch)
drop_dir = tmp_path / "drop"
drop_dir.mkdir()
source = drop_dir / "即時業績_當日.xlsx"
_write_sales_file(source)
original_bytes = source.read_bytes()
monkeypatch.setenv("PCHOME_SALES_LOCAL_DROP_DIR", str(drop_dir))
monkeypatch.setattr(import_service, "drive_service", EmptyDrive())
service = acquisition.PChomeSalesAcquisitionService()
first = service.run(trigger="test")
source.write_bytes(original_bytes)
second = service.run(trigger="test")
assert first["status"] == "completed"
assert second["status"] == "completed_no_write"
assert second["duplicate_count"] == 1
assert second["imported_count"] == 0
with import_service.engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() == 1
def test_verified_import_is_partial_when_source_archive_ack_fails(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
import services.pchome_sales_acquisition_providers as providers
_prepare_tables(import_service)
_disable_remote_sources(monkeypatch)
drop_dir = tmp_path / "drop"
drop_dir.mkdir()
source = drop_dir / "即時業績_當日.xlsx"
_write_sales_file(source)
monkeypatch.setenv("PCHOME_SALES_LOCAL_DROP_DIR", str(drop_dir))
monkeypatch.setattr(import_service, "drive_service", EmptyDrive())
monkeypatch.setattr(
providers,
"_archive_file",
lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError("archive denied")),
)
result = acquisition.PChomeSalesAcquisitionService().run(trigger="test")
assert result["success"] is False
assert result["status"] == "partial"
assert result["source_finalize_failed_count"] == 1
assert result["safe_next_action"] == "retry_source_finalization"
assert source.exists()
with import_service.engine.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM daily_sales_snapshot")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() == 1
def test_no_candidate_with_missing_freshness_is_blocked_and_receipted(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_tables(import_service)
_disable_remote_sources(monkeypatch)
monkeypatch.delenv("PCHOME_SALES_LOCAL_DROP_DIR", raising=False)
monkeypatch.setattr(import_service, "drive_service", EmptyDrive())
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["receipt_persisted"] is True
with import_service.engine.connect() as conn:
receipt = conn.execute(text(
"SELECT status, decision FROM pchome_sales_acquisition_receipts"
)).one()
assert receipt.status == "blocked_with_safe_next_action"
assert receipt.decision == "no_authorized_candidate"
def test_provider_failure_keeps_product_message_compact(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_tables(import_service)
_disable_remote_sources(monkeypatch)
monkeypatch.delenv("PCHOME_SALES_LOCAL_DROP_DIR", raising=False)
monkeypatch.setattr(import_service, "drive_service", FailedDrive())
result = acquisition.PChomeSalesAcquisitionService().run(trigger="test")
assert result["success"] is False
assert result["status"] == "degraded_no_write"
assert result["message"] == "授權來源目前無法取得報表,系統將自動重試。"
assert result["providers"][0]["error_kind"] == "authentication_failed"
def test_readiness_never_exposes_provider_credentials_or_urls(monkeypatch, tmp_path):
import_service, acquisition = _load_services(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}")
_prepare_tables(import_service)
monkeypatch.setattr(acquisition, "drive_service", ReadyDrive())
monkeypatch.setenv("PCHOME_SALES_HTTP_ENABLED", "true")
monkeypatch.setenv("PCHOME_SALES_HTTP_URL", "https://reports.example.test/private/report.xlsx")
monkeypatch.setenv("PCHOME_SALES_HTTP_ALLOWED_HOSTS", "reports.example.test")
monkeypatch.setenv("PCHOME_SALES_HTTP_BEARER_TOKEN", "top-secret-http-token")
monkeypatch.setenv("PCHOME_SALES_IMAP_ENABLED", "true")
monkeypatch.setenv("PCHOME_SALES_IMAP_HOST", "mail.example.test")
monkeypatch.setenv("PCHOME_SALES_IMAP_USER", "private-user@example.test")
monkeypatch.setenv("PCHOME_SALES_IMAP_PASSWORD", "top-secret-imap-password")
payload = acquisition.PChomeSalesAcquisitionService().readiness()
public_text = json.dumps(payload, ensure_ascii=False)
assert payload["manual_review_required"] is False
assert payload["asset_coverage"]["ready"] == 3
assert "top-secret" not in public_text
assert "reports.example.test" not in public_text
assert "mail.example.test" not in public_text
assert "private-user" not in public_text
def test_https_provider_rejects_non_https_and_non_allowlisted_hosts(monkeypatch):
import services.pchome_sales_acquisition_service as acquisition
import services.pchome_sales_acquisition_providers as provider_module
service = acquisition.PChomeSalesAcquisitionService()
monkeypatch.setenv("PCHOME_SALES_HTTP_ALLOWED_HOSTS", "reports.example.test")
try:
service.providers.validate_http_url("http://reports.example.test/report.xlsx")
assert False, "HTTP URL should be rejected"
except acquisition.ProviderError as exc:
assert exc.kind == "http_url_rejected"
try:
service.providers.validate_http_url("https://untrusted.example.test/report.xlsx")
assert False, "Non-allowlisted host should be rejected"
except acquisition.ProviderError as exc:
assert exc.kind == "http_host_not_allowed"
monkeypatch.setenv("PCHOME_SALES_HTTP_ALLOWED_HOSTS", "reports.example.test")
try:
service.providers.validate_http_url("https://reports.example.test:8443/report.xlsx")
assert False, "Non-allowlisted port should be rejected"
except acquisition.ProviderError as exc:
assert exc.kind == "http_port_rejected"
monkeypatch.setattr(
provider_module.socket,
"getaddrinfo",
lambda *_args, **_kwargs: [(2, 1, 6, "", ("127.0.0.1", 443))],
)
try:
service.providers.validate_http_url("https://reports.example.test/report.xlsx")
assert False, "Private resolved address should be rejected"
except acquisition.ProviderError as exc:
assert exc.kind == "http_private_address_rejected"
def test_auto_import_api_uses_governed_acquisition_service(monkeypatch):
import routes.auto_import_routes as routes
class FakeAcquisitionService:
def readiness(self):
return {"success": True, "runtime_closure": "blocked_by_upstream_freshness"}
def run(self, *, trigger):
return {
"success": False,
"status": "blocked_with_safe_next_action",
"trigger": trigger,
"receipt_persisted": True,
}
monkeypatch.setattr(routes, "pchome_sales_acquisition_service", FakeAcquisitionService())
app = Flask(__name__)
app.register_blueprint(routes.auto_import_bp)
client = app.test_client()
readiness = client.get("/api/auto_import/readiness")
run = client.post("/api/auto_import/run")
legacy = client.post("/api/manual_import")
assert readiness.status_code == 200
assert readiness.get_json()["runtime_closure"] == "blocked_by_upstream_freshness"
assert run.get_json()["status"] == "blocked_with_safe_next_action"
assert run.get_json()["trigger"] == "operator_command"
assert legacy.get_json()["trigger"] == "operator_command"
def test_scheduler_continues_when_legacy_hitl_pause_is_set(monkeypatch):
import scheduler
import services.agent_actions as agent_actions
import services.pchome_sales_acquisition_service as acquisition
calls = []
saved = {}
monkeypatch.setattr(agent_actions, "is_task_paused", lambda _name: True)
monkeypatch.setattr(
acquisition.pchome_sales_acquisition_service,
"run",
lambda *, trigger: calls.append(trigger) or {
"success": False,
"status": "blocked_with_safe_next_action",
"message": "no candidate",
"trace_id": "trace-test",
"receipt_id": "receipt-test",
"receipt_persisted": True,
"latest_sales_date": "2026-07-10",
"data_lag_days": 1,
"decision_ready": True,
"safe_next_action": "scheduler_retry_authorized_sources",
},
)
monkeypatch.setattr(scheduler, "_save_stats", lambda name, stats: saved.update({"name": name, "stats": stats}))
scheduler.run_auto_import_task()
assert calls == ["scheduler"]
assert saved["name"] == "auto_import_task"
assert saved["stats"]["status"] == "Degraded"
assert saved["stats"]["receipt_persisted"] is True