294 lines
10 KiB
Python
294 lines
10 KiB
Python
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,
|
|
)
|
|
|
|
|
|
KANGNI_DATA_DIR = Path(r"D:\ItemSpace\14.工业智核\康尼\数据")
|
|
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"]
|