aps-agent/tests/golden/test_master_data.py

156 lines
7.1 KiB
Python
Raw Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# MD-01/02/03 主数据维护黄金测试(moduleId: golden-master-data, 发版门禁 §14.3)
# 固化主数据写入 → 新排产输入变化的最小闭环:
# M1 停用产线后新排产不再选择该线(引擎 ACTIVE 过滤)
# M2 物料库存调零后缺料冲突增加(齐套输入联动)
# M3 新增维保窗口产生 EQUIPMENT 冲突;取消维保后冲突消失
# M4 确认卡摘要含影响面;非法载荷被校验拒绝
# M5 apply 写入后审计链可校验
# ============================================================
from __future__ import annotations
import pytest
from server.agent_core.audit import write_audit
from server.agent_core.registry import verify_audit_chain
from server.aps_domain.masterdata import (
apply_master_action, confirmation_for_master_action, master_overview,
)
from server.engines import get_engine
from server.engines.base import EngineParams
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, today0
def _next_id_factory(world):
"""按当前世界最大 ID 起号(与 test_order_management 同构)。"""
tables = {
"audit": "auditEvents",
"conflict": "conflicts",
"log": "logs",
"maintenance": "maintenance",
"productionOrder": "productionOrders",
"salesOrder": "salesOrders",
"scheduleVersion": "scheduleVersions",
"workOrder": "workOrders",
}
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
if kind not in counters:
rows = world.get(tables.get(kind, kind + "s"), [])
counters[kind] = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0)
counters[kind] += 1
return counters[kind]
return next_id
def _run(world):
"""执行一次规则引擎排产,返回 (result, 本版本冲突列表)。"""
params = EngineParams(
orderIds=[],
engineType="RULE",
strategyTemplate="COMPREHENSIVE",
planningHorizonDays=14,
startDate=fmt_date(add_minutes(today0(), 24 * 60)),
)
result = get_engine("RULE").solve(world, params, _next_id_factory(world))
version_id = world["scheduleVersions"][-1]["id"]
conflicts = [c for c in world["conflicts"] if c.get("versionId") == version_id]
return result, conflicts
def test_inactive_line_excluded_from_scheduling():
"""M1:停用产线后,新排产不再把工单排到该线(历史不回写,订单走替代线)。"""
world = seed_world()
applied = apply_master_action(world, _next_id_factory(world), "master.line.upsert",
{"id": 3, "status": "INACTIVE"})
assert applied["afterStatus"] == "INACTIVE"
result, _ = _run(world)
version_id = world["scheduleVersions"][-1]["id"]
new_wos = [w for w in world["workOrders"]
if any(p["id"] == w["productionOrderId"] and p["schedulingVersionId"] == version_id
for p in world["productionOrders"])]
assert new_wos, "排产应产出工单"
assert all(w["lineId"] != 3 for w in new_wos), "停用产线不得出现新工单"
assert result.orderCount == 7 # 订单仍全部参与(走替代产线)
def test_material_stock_zero_increases_shortage():
"""M2:把通用原料库存与在途调零后,新排产缺料冲突必须多于基线。"""
baseline_world = seed_world()
_, baseline_conflicts = _run(baseline_world)
baseline_shortage = sum(1 for c in baseline_conflicts if c["conflictType"] == "MATERIAL_SHORTAGE")
world = seed_world()
apply_master_action(world, _next_id_factory(world), "master.material.upsert",
{"id": 10, "stock": 0, "inTransit": 0, "safetyStock": 500, "procurementLeadTime": 7})
_, conflicts = _run(world)
shortage = sum(1 for c in conflicts if c["conflictType"] == "MATERIAL_SHORTAGE")
assert shortage > baseline_shortage, "库存调零后缺料冲突应增加"
def test_maintenance_window_roundtrip_equipment_conflict():
"""M3:新增覆盖排产期的维保窗口 → EQUIPMENT 冲突出现;取消后再排 → 冲突消失。"""
world = seed_world()
start = fmt_date(add_minutes(today0(), 24 * 60))
end = fmt_date(add_minutes(today0(), 10 * 24 * 60))
applied = apply_master_action(world, _next_id_factory(world), "master.maintenance.upsert", {
"equipmentId": 6, # SMT-02(产线 B 首道工序工位设备)
"plannedStart": f"{start} 08:00",
"plannedEnd": f"{end} 23:00",
"description": "golden 大修窗口",
})
_, conflicts = _run(world)
eq_conflicts = [c for c in conflicts if c["conflictType"] == "EQUIPMENT"
and c.get("resourceName", "").startswith("SMT-02")]
assert eq_conflicts, "覆盖排产期的维保窗口应产生 EQUIPMENT 冲突"
apply_master_action(world, _next_id_factory(world), "master.maintenance.upsert",
{"id": applied["id"], "status": "CANCELLED"})
_, conflicts2 = _run(world)
eq_conflicts2 = [c for c in conflicts2 if c["conflictType"] == "EQUIPMENT"
and c.get("resourceName", "").startswith("SMT-02")]
assert not eq_conflicts2, "取消维保后新版本不应再有该设备冲突"
def test_confirmation_summary_and_validation():
"""M4:确认卡摘要必须可解释影响面;非法载荷(负库存/时间倒挂/未知资源)被拒绝。"""
world = seed_world()
title, lines = confirmation_for_master_action(world, "master.line.upsert",
{"id": 2, "status": "INACTIVE"})
summary = "\n".join(lines)
assert "产线" in title
assert "停用" in summary and "P2" in summary
with pytest.raises(ValueError):
confirmation_for_master_action(world, "master.material.upsert", {"id": 10, "stock": -5})
with pytest.raises(ValueError):
confirmation_for_master_action(world, "master.maintenance.upsert", {
"equipmentId": 1, "plannedStart": "2026-08-01 12:00", "plannedEnd": "2026-08-01 09:00",
})
with pytest.raises(ValueError):
confirmation_for_master_action(world, "master.line.upsert", {"id": 999})
def test_master_write_audit_chain_intact():
"""M5:主数据写入配套的审计事件入链后,哈希链完整性可校验;投影含引用计数。"""
world = seed_world()
next_id = _next_id_factory(world)
applied = apply_master_action(world, next_id, "master.line.upsert",
{"id": 1, "capacityPerDay": 1200})
write_audit(world, next_id, actor="tester", category="WORLD_WRITE",
action="master.line.upsert",
target={"type": "MASTER_LINE", "id": applied["id"], "name": applied["name"]},
power="P2", rationale={"confirmId": "unit-test"})
assert verify_audit_chain(world["auditEvents"])["ok"] is True
overview = master_overview(world)
lines = [ln for f in overview["factories"] for w in f["workshops"] for ln in w["lines"]]
assert any(ln["capacityPerDay"] == 1200 for ln in lines)
assert all("openWorkOrders" in ln for ln in lines)