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