133 lines
6.1 KiB
Python
133 lines
6.1 KiB
Python
# ============================================================
|
||
# MRP 订单分解黄金测试(moduleId: golden-mrp, 发版门禁 §14.3)
|
||
# 固化「订单 → BOM/工艺分解 → 采购/委外建议」最小闭环:
|
||
# R1 缺料物料生成采购建议,且建议下单日按前置期倒排
|
||
# R2 库存充足的物料不生成采购建议
|
||
# R3 外协工艺步骤生成委外建议;取消外协标记后建议消失(幂等重分解)
|
||
# R4 指定订单分解只影响该订单的建议;订单号不存在报错
|
||
# R5 BOM 用量编辑(master.bom.upsert)联动采购建议数量
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
|
||
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",
|
||
}
|