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