# ============================================================ # 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", "material": "materials", "bom": "boms", "bomItem": "bomItems", "operation": "operations", "routing": "routings", "routingStep": "routingSteps", "lineProduct": "lineProducts", } 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) def test_process_model_crud_roundtrip(): """M6:工艺模型 CRUD(物料新建、BOM 增删行、工序、路线步骤、产线绑定)闭环。""" world = seed_world() next_id = _next_id_factory(world) mat = apply_master_action(world, next_id, "master.material.upsert", { "code": "RAW-NEW", "name": "新原料", "type": "RAW_MATERIAL", "unit": "件", "stock": 100, "procurementLeadTime": 3, }) assert any(m["code"] == "RAW-NEW" for m in world["materials"]) add = apply_master_action(world, next_id, "master.bom.upsert", { "bomId": 1, "materialId": mat["id"], "quantity": 2, "isKeyMaterial": True, }) assert any(i["id"] == add["id"] and i["quantity"] == 2 for i in world["bomItems"]) apply_master_action(world, next_id, "master.bom.upsert", {"itemId": add["id"], "delete": True}) assert all(i["id"] != add["id"] for i in world["bomItems"]) op = apply_master_action(world, next_id, "master.operation.upsert", { "code": "OP99", "name": "返修", "type": "INTERNAL", "standardTime": 1.5, }) step = apply_master_action(world, next_id, "master.routing.upsert", { "routingId": 1, "operationId": op["id"], "setupTime": 15, "runTimePerUnit": 1.5, }) assert any(s["id"] == step["id"] for s in world["routingSteps"]) # 解绑再重建:产品 2 在产线 1 本无绑定 bind = apply_master_action(world, next_id, "master.lineProduct.upsert", { "lineId": 1, "productId": 2, "standardCapacity": 400, "priority": 5, "setupTime": 25, }) assert any(lp["id"] == bind["id"] for lp in world["lineProducts"]) overview = master_overview(world) assert overview["operations"] and overview["lineProducts"] assert any(o["code"] == "OP99" for o in overview["operations"])