aps-agent/tests/golden/test_master_data.py

156 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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)