156 lines
7.1 KiB
Python
156 lines
7.1 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 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)
|