361 lines
11 KiB
Python
361 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from contextlib import contextmanager
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from gyxx_flow.catalog import WorkflowCatalog
|
|
from gyxx_flow.modules.supply_chain.orchestrator.arrival_sync_queue import (
|
|
ArrivalSyncQueue,
|
|
ArrivalSyncTask,
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts import (
|
|
PurchaseOrderUpdate,
|
|
sync_replenishment_arrivals,
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts.PurchaseOrderUpdate import (
|
|
ERPLoginManager,
|
|
)
|
|
from gyxx_flow.modules.supply_chain.orchestrator.scripts.sync_replenishment_arrivals import (
|
|
arrival_date_text,
|
|
)
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class _Cursor:
|
|
def __init__(self, connection: "_Connection") -> None:
|
|
self.connection = connection
|
|
|
|
def __enter__(self) -> "_Cursor":
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def execute(self, sql: str, params: tuple[object, ...]) -> None:
|
|
self.connection.calls.append((sql, params))
|
|
|
|
def fetchall(self) -> list[dict[str, object]]:
|
|
return self.connection.claim_rows
|
|
|
|
def fetchone(self) -> dict[str, object] | None:
|
|
return self.connection.return_row
|
|
|
|
|
|
class _Connection:
|
|
def __init__(self, *, claim_rows: list[dict[str, object]] | None = None) -> None:
|
|
self.claim_rows = claim_rows or []
|
|
self.return_row: dict[str, object] | None = {"id": 1}
|
|
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
|
|
|
def __enter__(self) -> "_Connection":
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
@contextmanager
|
|
def cursor(self, **_kwargs: object):
|
|
yield _Cursor(self)
|
|
|
|
|
|
def test_arrival_date_text_uses_date_part_and_supports_clear() -> None:
|
|
assert arrival_date_text(datetime(2026, 8, 20, 15, 30)) == "2026-08-20"
|
|
assert arrival_date_text(date(2026, 8, 21)) == "2026-08-21"
|
|
assert arrival_date_text("2026-08-22T00:00:00") == "2026-08-22"
|
|
assert arrival_date_text(None) == ""
|
|
|
|
|
|
def test_arrival_date_text_rejects_invalid_values() -> None:
|
|
with pytest.raises(ValueError, match="格式无效"):
|
|
arrival_date_text("not-a-date")
|
|
|
|
|
|
def test_detail_row_lookup_uses_loaded_sku_row_not_ambiguous_tab_name() -> None:
|
|
class _Cell:
|
|
def __init__(self, text: str) -> None:
|
|
self.text = text
|
|
|
|
def inner_text(self) -> str:
|
|
return self.text
|
|
|
|
class _Cells:
|
|
def __init__(self, values: list[str]) -> None:
|
|
self.values = values
|
|
|
|
def count(self) -> int:
|
|
return len(self.values)
|
|
|
|
def nth(self, index: int) -> _Cell:
|
|
return _Cell(self.values[index])
|
|
|
|
class _Row:
|
|
def __init__(self, values: list[str]) -> None:
|
|
self.values = values
|
|
|
|
def locator(self, selector: str) -> _Cells:
|
|
assert selector == "._jt_cell"
|
|
return _Cells(self.values)
|
|
|
|
class _Rows:
|
|
def __init__(self, rows: list[_Row]) -> None:
|
|
self.rows = rows
|
|
|
|
def count(self) -> int:
|
|
return len(self.rows)
|
|
|
|
def nth(self, index: int) -> _Row:
|
|
return self.rows[index]
|
|
|
|
class _Frame:
|
|
def __init__(self, rows: list[_Row]) -> None:
|
|
self.rows = rows
|
|
|
|
def locator(self, selector: str) -> _Rows:
|
|
assert selector == "div._jt_row._jt_rh, div._jt_row"
|
|
return _Rows(self.rows)
|
|
|
|
irrelevant_tab0 = _Frame([_Row(["973798", "采购单列表"])] )
|
|
purchase_detail = _Frame([_Row(["10504002", "星迹2", "时野绿中号"])] )
|
|
page = SimpleNamespace(frames=[irrelevant_tab0, purchase_detail])
|
|
|
|
frame, row = PurchaseOrderUpdate.PurchaseManager._find_target_detail_row(
|
|
page, "10504002"
|
|
)
|
|
|
|
assert frame is purchase_detail
|
|
assert row is not None
|
|
|
|
|
|
def test_detail_filter_renders_the_target_sku_in_a_virtualized_grid() -> None:
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
class _SearchBox:
|
|
first: "_SearchBox"
|
|
|
|
def __init__(self) -> None:
|
|
self.first = self
|
|
|
|
@staticmethod
|
|
def count() -> int:
|
|
return 1
|
|
|
|
def fill(self, value: str) -> None:
|
|
calls.append(("fill", value))
|
|
|
|
def press(self, value: str) -> None:
|
|
calls.append(("press", value))
|
|
|
|
class _Frame:
|
|
@staticmethod
|
|
def locator(selector: str) -> _SearchBox:
|
|
assert selector == "#sku_search"
|
|
return _SearchBox()
|
|
|
|
page = SimpleNamespace(frames=[_Frame()])
|
|
|
|
assert PurchaseOrderUpdate.PurchaseManager._filter_purchase_detail_by_sku(
|
|
page, "10504002"
|
|
)
|
|
assert calls == [("fill", "10504002"), ("press", "Enter")]
|
|
|
|
|
|
def test_retryable_item_failures_do_not_fail_the_worker() -> None:
|
|
result = {
|
|
"errors": ["SKU 10465007: 搜索商品编码失败"],
|
|
"retryable_failures": ["SKU 10465007: 搜索商品编码失败"],
|
|
"execution_errors": [],
|
|
}
|
|
|
|
assert sync_replenishment_arrivals._worker_exit_code(result) == 0
|
|
|
|
|
|
def test_execution_errors_still_fail_the_worker() -> None:
|
|
result = {
|
|
"errors": ["队列 PostgreSQL 连接失败"],
|
|
"retryable_failures": [],
|
|
"execution_errors": ["队列 PostgreSQL 连接失败"],
|
|
}
|
|
|
|
assert sync_replenishment_arrivals._worker_exit_code(result) == 1
|
|
|
|
|
|
def test_run_records_item_failure_for_retry_without_failing_worker(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config = tmp_path / "config.json"
|
|
config.write_text("{}", encoding="utf-8")
|
|
task = ArrivalSyncTask(
|
|
id=8,
|
|
tenant_id=1,
|
|
sku_code="SKU-8",
|
|
expected_arrival_time=datetime(2026, 8, 20, 9, 0),
|
|
)
|
|
|
|
class _Queue:
|
|
def __init__(self) -> None:
|
|
self.failed: list[tuple[ArrivalSyncTask, str]] = []
|
|
|
|
def claim(self, **_kwargs: object) -> list[ArrivalSyncTask]:
|
|
return [task]
|
|
|
|
def mark_failed(self, claimed_task: ArrivalSyncTask, error: str) -> bool:
|
|
self.failed.append((claimed_task, error))
|
|
return True
|
|
|
|
class _Manager:
|
|
_acceptance_cookie_skipped = False
|
|
|
|
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
|
return None
|
|
|
|
def login_with_browser(self) -> bool:
|
|
return True
|
|
|
|
def open_purchase_page(self) -> bool:
|
|
return True
|
|
|
|
def search_sku(self, _sku_code: str) -> bool:
|
|
return False
|
|
|
|
def close_browser(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(sync_replenishment_arrivals, "ArrivalSyncQueue", _Queue)
|
|
monkeypatch.setattr(sync_replenishment_arrivals, "CONFIG_PATH", config)
|
|
monkeypatch.setattr(
|
|
sync_replenishment_arrivals,
|
|
"WORK_ROOT",
|
|
tmp_path / "work",
|
|
)
|
|
monkeypatch.setattr(
|
|
sync_replenishment_arrivals,
|
|
"current_acceptance_policy",
|
|
lambda: SimpleNamespace(enabled=False),
|
|
)
|
|
monkeypatch.setattr(PurchaseOrderUpdate, "PurchaseManager", _Manager)
|
|
monkeypatch.setenv("GYXX_RUN_ID", "retryable-item-failure")
|
|
|
|
assert sync_replenishment_arrivals.run(cdp_port=22139) == 0
|
|
|
|
result_path = (
|
|
tmp_path
|
|
/ "work"
|
|
/ "arrival-sync"
|
|
/ "run_id=retryable-item-failure"
|
|
/ "result.json"
|
|
)
|
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
assert result["failed"] == 1
|
|
assert result["retryable_failures"] == ["SKU SKU-8: 搜索商品编码失败"]
|
|
assert result["execution_errors"] == []
|
|
|
|
|
|
def test_arrival_sync_workflow_uses_daily_python_browser_entry() -> None:
|
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
|
workflow = next(
|
|
item
|
|
for item in catalog.scheduled_workflows()
|
|
if item.workflow_id == "supply.replenishment_arrival_sync.daily"
|
|
)
|
|
|
|
assert workflow.steps[0].entry == (
|
|
"orchestrator/scripts/collect_replenishment_arrival_sync.py"
|
|
)
|
|
assert workflow.steps[0].replay_policy == "idempotent"
|
|
assert catalog.schedule_for(workflow.workflow_id).at == "09:00"
|
|
|
|
|
|
def test_queue_claims_rows_with_skip_locked_and_maps_tasks() -> None:
|
|
connection = _Connection(
|
|
claim_rows=[
|
|
{
|
|
"id": 8,
|
|
"tenant_id": 1,
|
|
"sku_code": " SKU-8 ",
|
|
"expected_arrival_time": datetime(2026, 8, 20, 9, 0),
|
|
}
|
|
]
|
|
)
|
|
queue = ArrivalSyncQueue(
|
|
{"GYXX_ARRIVAL_SYNC_OPERATOR": "worker"},
|
|
connect_factory=lambda: connection,
|
|
)
|
|
|
|
tasks = queue.claim(tenant_id=1, stale_after_seconds=60)
|
|
|
|
assert tasks == [
|
|
ArrivalSyncTask(
|
|
id=8,
|
|
tenant_id=1,
|
|
sku_code="SKU-8",
|
|
expected_arrival_time=datetime(2026, 8, 20, 9, 0),
|
|
)
|
|
]
|
|
assert "FOR UPDATE SKIP LOCKED" in connection.calls[0][0]
|
|
assert "LIMIT" not in connection.calls[0][0]
|
|
assert "deleted = 0" in connection.calls[0][0]
|
|
assert "deleted = FALSE" not in connection.calls[0][0]
|
|
assert connection.calls[0][1] == (1, 60, "worker")
|
|
|
|
|
|
def test_queue_completion_is_fenced_by_the_claimed_target_timestamp() -> None:
|
|
connection = _Connection()
|
|
queue = ArrivalSyncQueue(connect_factory=lambda: connection)
|
|
task = ArrivalSyncTask(
|
|
id=8,
|
|
tenant_id=1,
|
|
sku_code="SKU-8",
|
|
expected_arrival_time=None,
|
|
)
|
|
|
|
assert queue.mark_success(task) is True
|
|
success_sql, success_params = connection.calls[-1]
|
|
assert "IS NOT DISTINCT FROM" in success_sql
|
|
assert success_params == ("gyxx-flow", 8, 1, None)
|
|
|
|
connection.return_row = None
|
|
assert queue.mark_failed(task, " 浏览器失败\n原因 ") is False
|
|
failed_sql, failed_params = connection.calls[-1]
|
|
assert "IS NOT DISTINCT FROM" in failed_sql
|
|
assert failed_params == ("浏览器失败 原因", "gyxx-flow", 8, 1, None)
|
|
|
|
|
|
def test_cdp_session_is_checked_before_optional_password_validation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config = tmp_path / "config.json"
|
|
config.write_text(
|
|
'{"login": {"url": "https://login.example.test", '
|
|
'"username": "erp-user"}}',
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False)
|
|
monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False)
|
|
|
|
class _Page:
|
|
class _Context:
|
|
@staticmethod
|
|
def cookies() -> list[dict[str, str]]:
|
|
return [{"name": "token", "value": "session"}]
|
|
|
|
context = _Context()
|
|
|
|
manager = ERPLoginManager(str(config), cdp_port=22139)
|
|
page = _Page()
|
|
monkeypatch.setattr(manager, "_prepare_login_page", lambda *_args, **_kwargs: page)
|
|
monkeypatch.setattr(
|
|
manager,
|
|
"_page_has_purchase_access",
|
|
lambda *_args, **_kwargs: True,
|
|
)
|
|
|
|
assert manager.login_with_browser() is True
|