from __future__ import annotations import copy import hashlib import json from pathlib import Path from typing import Any import pytest from server.agent_core import harness from server.agent_core.approval_store import ApprovalStore from server.aps_domain.folder_pack import folder_schedule_payload_digest from server.auth.context import IdentityContext from tests.golden.test_folder_schedule_security import ( _CheckpointStore, _ProjectStore, _Store, _approve, _stage, ) from tests.external_data import external_dir KANGNI_DATA_DIR = external_dir("KANGNI_DATA_DIR", "kangni") KANGNI_WORKBOOKS = ( "订单.xlsx", "工艺路线.xlsx", "工时.xlsx", "BOM.xlsx", "设备.xlsx", "模具.xlsx", "物料.xlsx", "设备能力映射模板.xlsx", "模具适配映射模板.xlsx", ) def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _hashes(root: Path) -> dict[str, str]: return {name: _sha256(root / name) for name in KANGNI_WORKBOOKS} @pytest.fixture def kangni_folder_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): missing = [name for name in KANGNI_WORKBOOKS if not (KANGNI_DATA_DIR / name).is_file()] if missing: pytest.skip(f"康尼现场只读数据不完整:{missing}") source_hashes = _hashes(KANGNI_DATA_DIR) upload_dir = tmp_path / "uploaded" upload_dir.mkdir() for name in KANGNI_WORKBOOKS: (upload_dir / name).write_bytes((KANGNI_DATA_DIR / name).read_bytes()) tenant = "tenant-kangni-folder" identity = IdentityContext( user_id=2101, username="planner", fullname="Planner", tenant_uuid=tenant, roles=("planner", "approver", "admin"), ) project_store = _ProjectStore(upload_dir) checkpoints = _CheckpointStore() store = _Store(tenant) approval_store = ApprovalStore(str(tmp_path / "approvals-kangni.json")) original_approval_store = harness._approval_store harness.configure_approval_store(store=approval_store) import server.aps_domain.workflow as workflow_module from server.agent_core import plan_orchestration monkeypatch.setattr("server.state.projects.get_project_store", lambda: project_store) monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints) monkeypatch.setattr(plan_orchestration, "stage_plan_node", lambda **_kwargs: None) monkeypatch.setattr(plan_orchestration, "decide_plan_node", lambda **_kwargs: None) try: yield { "store": store, "identity": identity, "approvals": approval_store, "checkpoints": checkpoints, "uploadDir": upload_dir, "sourceHashes": source_hashes, } finally: harness.configure_approval_store(store=original_approval_store) def test_kangni_folder_stage_materializes_frozen_bytes_and_binds_payload( kangni_folder_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch, ): from server.aps_domain import kangni_intake store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] approvals = kangni_folder_env["approvals"] upload_dir = kangni_folder_env["uploadDir"] frozen = {name: (upload_dir / name).read_bytes() for name in KANGNI_WORKBOOKS} observed: dict[str, Any] = {} original_builder = kangni_intake.build_site_payload_from_data_dir def inspect_builder(data_dir, **kwargs): materialized = Path(data_dir) observed["root"] = str(materialized) observed["files"] = { path.name: path.read_bytes() for path in sorted(materialized.glob("*.xlsx")) } payload = original_builder(data_dir, **kwargs) observed["payload"] = copy.deepcopy(payload) return payload monkeypatch.setattr(kangni_intake, "build_site_payload_from_data_dir", inspect_builder) before = harness.world_fingerprint(store.data) confirm_id, reply = _stage(store, identity) assert harness.world_fingerprint(store.data) == before assert observed["files"] == frozen assert Path(observed["root"]).resolve() != upload_dir.resolve() assert not Path(observed["root"]).exists() params = approvals.pending[confirm_id]["params"] assert params["kangniDetected"] is True assert params["trialReady"] is True assert params["productionReady"] is False assert params["kangniPayload"] == observed["payload"] assert params["kangniMeta"] == params["kangniPayload"]["meta"] assert params["folderPayloadDigest"] == folder_schedule_payload_digest(params) assert len(params["sourceManifest"]) == len(KANGNI_WORKBOOKS) encoded = json.dumps( params["kangniPayload"], ensure_ascii=False, sort_keys=True, allow_nan=False, ) assert str(upload_dir) not in encoded assert observed["root"] not in encoded assert not store.data.get("flexScheduleVersions") folder_block = next(block for block in reply.blocks if block.type == "folder-pack") assert folder_block.props["kangniDetected"] is True assert folder_block.props["trialReady"] is True assert "仅试排" in reply.text assert "共享占位" in reply.text assert "不得生产发布" in reply.text assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"] assert _hashes(upload_dir) == kangni_folder_env["sourceHashes"] def test_kangni_folder_payload_tamper_is_denied(kangni_folder_env: dict[str, Any]): store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] approvals = kangni_folder_env["approvals"] confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) payload = approvals.pending[confirm_id]["params"]["kangniPayload"] payload["flex"]["flexOrders"][0]["quantity"] += 1 approvals.save() message = _approve(store, identity, confirm_id) assert "证据校验未通过" in message assert harness.world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") def test_kangni_folder_approval_runs_trial_without_mes(kangni_folder_env: dict[str, Any], monkeypatch): from server.aps_domain import mes store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] def fail_mes(*_args, **_kwargs): raise AssertionError("folder.schedule must not dispatch MES") monkeypatch.setattr(mes, "apply_dispatch", fail_mes) confirm_id, _reply = _stage(store, identity) message = _approve(store, identity, confirm_id) assert len(store.data.get("salesOrders") or []) == 10 assert len(store.data.get("flexOrders") or []) == 10 assert len(store.data.get("flexRoutings") or []) == 72 assert len(store.data.get("flexEquipment") or []) == 41 assert len(store.data.get("flexMolds") or []) == 40 version = store.data["flexScheduleVersions"][-1] version_id = version["id"] assert len([row for row in store.data["flexVirtualLines"] if row["versionId"] == version_id]) == 10 assert len([row for row in store.data["flexWorkOrders"] if row["versionId"] == version_id]) == 72 assert version["trialOnly"] is True assert version["productionReady"] is False assert version["resourceQuality"]["sharedPlaceholder"] is True assert "仅试排" in message assert "共享占位" in message assert "不得生产发布" in message actions = {str(row.get("action") or "") for row in store.data.get("auditEvents") or []} assert "mes.dispatch" not in actions assert "schedule.publish" not in actions assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"] def test_kangni_folder_commit_syncs_counters_for_followup_trial( kangni_folder_env: dict[str, Any], ): from server.aps_domain.flex import run_flex_schedule store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] confirm_id, _reply = _stage(store, identity) message = _approve(store, identity, confirm_id) assert "完成本地试排" in message first_version_id = store.data["flexScheduleVersions"][-1]["id"] second = run_flex_schedule( store, sort_mode="BOTTLENECK", actor="planner", trial=True, ) assert second["versionId"] != first_version_id assert second["versionId"] > first_version_id for table in ( "flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts", ): ids = [row["id"] for row in store.data.get(table) or []] assert len(ids) == len(set(ids)), f"{table} contains duplicate ids" def test_kangni_folder_save_failure_restores_schedule_counters( kangni_folder_env: dict[str, Any], ): store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] confirm_id, _reply = _stage(store, identity) previous_ids = copy.deepcopy(store._ids) before = harness.world_fingerprint(store.data) store.fail_next_save = True message = _approve(store, identity, confirm_id) assert "落盘失败" in message and "恢复原业务状态" in message assert harness.world_fingerprint(store.data) == before for kind in ( "flexScheduleVersion", "flexVirtualLine", "flexWorkOrder", "flexConflict", ): assert store._ids.get(kind) == previous_ids.get(kind) @pytest.mark.parametrize("failure", ["apply", "schedule", "save"]) def test_kangni_folder_execution_failures_discard_candidate( kangni_folder_env: dict[str, Any], monkeypatch: pytest.MonkeyPatch, failure: str, ): store = kangni_folder_env["store"] identity = kangni_folder_env["identity"] confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) if failure == "apply": def fail_apply(*_args, **_kwargs): raise RuntimeError("injected Kangni payload apply failure") monkeypatch.setattr( "server.aps_domain.kangni_intake.apply_site_payload_to_world", fail_apply, ) elif failure == "schedule": def fail_schedule(*_args, **_kwargs): raise RuntimeError("injected Kangni trial schedule failure") monkeypatch.setattr("server.aps_domain.flex.run_flex_schedule", fail_schedule) else: store.fail_next_save = True message = _approve(store, identity, confirm_id) assert "失败" in message and ("回滚" in message or "恢复" in message) assert harness.world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") assert store.data["auditEvents"][-1]["result"] == "DENIED" assert _hashes(KANGNI_DATA_DIR) == kangni_folder_env["sourceHashes"]