aps-agent/tests/golden/test_mrp.py

133 lines
6.1 KiB
Python
Raw Permalink Normal View History

2026-07-21 11:05:57 +08:00
# ============================================================
# MRP 订单分解黄金测试(moduleId: golden-mrp, 发版门禁 §14.3)
# 固化「订单 → BOM/工艺分解 → 采购/委外建议」最小闭环:
# R1 缺料物料生成采购建议,且建议下单日按前置期倒排
# R2 库存充足的物料不生成采购建议
# R3 外协工艺步骤生成委外建议;取消外协标记后建议消失(幂等重分解)
# R4 指定订单分解只影响该订单的建议;订单号不存在报错
# R5 BOM 用量编辑(master.bom.upsert)联动采购建议数量
# ============================================================
from __future__ import annotations
import copy
2026-07-21 11:05:57 +08:00
import pytest
from server.aps_domain.masterdata import apply_master_action
from server.aps_domain.mrp import decompose_orders, list_mrp
from server.state.seed import seed_world
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
def _next_id_factory(world):
"""按当前世界最大 ID 起号。"""
tables = {
"purchaseOrder": "purchaseOrders",
"outsourceOrder": "outsourceOrders",
}
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 test_shortage_generates_purchase_with_leadtime_backdating():
"""R1+R2:缺料生成采购建议且下单日=需求日-前置期-缓冲;充足物料零建议。"""
world = seed_world()
next_id = _next_id_factory(world)
# 把 PCB-001(多数产品共用)调为零库存零在途 → 必缺料
apply_master_action(world, next_id, "master.material.upsert",
{"id": 10, "stock": 0, "inTransit": 0, "safetyStock": 500, "procurementLeadTime": 7})
result = decompose_orders(world, next_id)
pcb = [p for p in result["purchase"] if p["materialId"] == 10]
assert pcb, "零库存物料必须生成采购建议"
row = pcb[0]
# 建议下单日 = 需求日(交期) - 前置期7天 - 缓冲1天
expected = fmt_date(add_minutes(parse_dt(row["requiredDate"] + " 08:00"), -8 * 24 * 60))
assert row["suggestedOrderDate"] == expected
assert row["status"] == "DRAFT"
# 库存极充足的物料(标签 LABEL-001 id=15,库存 2500)不应出现在建议中
assert not any(p["materialId"] == 15 for p in result["purchase"])
def test_external_step_roundtrip_outsource_suggestion():
"""R3:勾外协 → 出委外建议;取消外协 → 重分解后建议消失(DRAFT 幂等替换)。"""
world = seed_world()
next_id = _next_id_factory(world)
# 把产品 1 工艺的第 2 步(DIP插件,stepId=2)标记外协
apply_master_action(world, next_id, "master.routing.upsert",
{"stepId": 2, "isExternal": True})
result = decompose_orders(world, next_id)
outs = [o for o in result["outsource"] if o["productId"] == 1]
assert outs, "外协步骤应生成委外建议"
assert all(o["operationName"] == "DIP插件" for o in outs)
# 取消外协后重分解:旧 DRAFT 建议被替换清空
apply_master_action(world, next_id, "master.routing.upsert",
{"stepId": 2, "isExternal": False})
decompose_orders(world, next_id)
remaining = [o for o in list_mrp(world)["outsourceOrders"] if o["productId"] == 1]
assert not remaining, "取消外协并重分解后不应残留 DRAFT 委外建议"
def test_single_order_scope_and_unknown_order():
"""R4:指定订单分解只清/生成该订单的建议;未知订单号报错。"""
world = seed_world()
next_id = _next_id_factory(world)
apply_master_action(world, next_id, "master.material.upsert",
{"id": 10, "stock": 0, "inTransit": 0, "safetyStock": 500, "procurementLeadTime": 7})
decompose_orders(world, next_id) # 全量分解
all_count = len(list_mrp(world)["purchaseOrders"])
assert all_count > 1
target_no = world["salesOrders"][0]["orderNo"]
result = decompose_orders(world, next_id, target_no) # 只重分解第一张
assert result["orders"] == [target_no]
# 其它订单的建议未被清掉
others = [p for p in list_mrp(world)["purchaseOrders"] if p["salesOrderNo"] != target_no]
assert others, "指定订单分解不得清除其它订单的建议"
with pytest.raises(ValueError):
decompose_orders(world, next_id, "SO99999999999")
def test_bom_quantity_edit_changes_purchase_quantity():
"""R5:BOM 用量翻倍后,同一物料的采购建议数量增加(主数据→MRP 联动)。"""
world = seed_world()
next_id = _next_id_factory(world)
apply_master_action(world, next_id, "master.material.upsert",
{"id": 10, "stock": 0, "inTransit": 0, "safetyStock": 500, "procurementLeadTime": 7})
r1 = decompose_orders(world, next_id)
qty_before = sum(p["quantity"] for p in r1["purchase"] if p["materialId"] == 10)
# BOM 明细 1:产品 1 用 PCB-001,用量 1 → 2
apply_master_action(world, next_id, "master.bom.upsert", {"itemId": 1, "quantity": 2})
r2 = decompose_orders(world, next_id)
qty_after = sum(p["quantity"] for p in r2["purchase"] if p["materialId"] == 10)
assert qty_after > qty_before, "BOM 用量增加后采购建议数量应增加"
def test_decompose_persists_make_suggestions_without_faking_production_orders():
"""?????????????????????????????"""
world = seed_world()
next_id = _next_id_factory(world)
production_before = copy.deepcopy(world.get("productionOrders") or [])
result = decompose_orders(world, next_id)
projection = list_mrp(world)
assert result["make"]
assert projection["make"] == result["make"]
assert world["makeSuggestions"] == result["make"]
assert world.get("productionOrders") == production_before
assert set(projection) >= {
"make", "purchaseOrders", "outsourceOrders", "productionOrders",
}