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