2026-07-28 02:12:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# M-F 黄金测试:引导式排产向导(缺路线→模板推荐→P2生成→试排放行)
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from server.agent_core import dialog, harness
|
|
|
|
|
|
from server.contracts import AgentReply, IntentResult
|
|
|
|
|
|
from server.state.seed import seed_world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeStore:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.data = seed_world()
|
|
|
|
|
|
self._counters: dict[str, int] = {}
|
|
|
|
|
|
|
|
|
|
|
|
def next_id(self, kind: str) -> int:
|
|
|
|
|
|
self._counters[kind] = self._counters.get(kind, 0) + 1
|
|
|
|
|
|
return self._counters[kind]
|
|
|
|
|
|
|
|
|
|
|
|
def save(self) -> None:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
|
def _isolate(tmp_path, monkeypatch):
|
|
|
|
|
|
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db"))
|
|
|
|
|
|
monkeypatch.delenv("APS_DB_DISABLED", raising=False)
|
|
|
|
|
|
from server.db.database import reset_engine
|
|
|
|
|
|
reset_engine()
|
|
|
|
|
|
dialog._SESSIONS.clear()
|
|
|
|
|
|
yield
|
|
|
|
|
|
dialog._SESSIONS.clear()
|
|
|
|
|
|
reset_engine()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_all_green_offers_run_then_releases_schedule_intent():
|
|
|
|
|
|
store = FakeStore()
|
|
|
|
|
|
reply = dialog.start_wizard(store, "w1")
|
|
|
|
|
|
assert isinstance(reply, AgentReply)
|
|
|
|
|
|
assert "可排" in reply.text or "齐备" in reply.text
|
|
|
|
|
|
out = dialog.pre_route(store, "w1", "排")
|
|
|
|
|
|
assert isinstance(out, IntentResult)
|
|
|
|
|
|
assert out.intent == "flex.schedule"
|
|
|
|
|
|
assert out.params.get("sortMode") == "BOTTLENECK"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_recommends_template_for_missing_routing():
|
|
|
|
|
|
store = FakeStore()
|
|
|
|
|
|
# 新产品 + 订单,无工艺路线 → 触发模板推荐
|
|
|
|
|
|
store.data["flexMaterials"].append({
|
|
|
|
|
|
"code": "SHAFT-01", "name": "传动轴(车削件)", "type": "FINISHED_PRODUCT",
|
|
|
|
|
|
"unit": "件", "stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0})
|
|
|
|
|
|
store.data["flexOrders"].append({
|
|
|
|
|
|
"id": 99, "orderNo": "FO-9901", "productCode": "SHAFT-01", "quantity": 20,
|
|
|
|
|
|
"dueDate": "2099-12-31", "priority": 1, "wbs": "", "productionController": "测试",
|
|
|
|
|
|
"status": "RELEASED"})
|
|
|
|
|
|
|
|
|
|
|
|
reply = dialog.start_wizard(store, "w2")
|
2026-08-21 07:32:13 +08:00
|
|
|
|
assert "尚未配置工艺路线" in reply.text
|
2026-07-28 02:12:46 +08:00
|
|
|
|
assert reply.blocks and reply.blocks[0].type == "wizard"
|
|
|
|
|
|
tpls = reply.blocks[0].props.get("templates") or []
|
|
|
|
|
|
assert tpls, "应推荐至少一个行业模板"
|
|
|
|
|
|
# 产品名含「车削」→ 车削模板应在推荐里
|
|
|
|
|
|
assert any(t["code"] == "TPL-TURNING" for t in tpls)
|
|
|
|
|
|
|
|
|
|
|
|
# 用户选 1 → 出 P2 确认卡
|
|
|
|
|
|
reply2 = dialog.pre_route(store, "w2", "1")
|
|
|
|
|
|
assert isinstance(reply2, AgentReply)
|
|
|
|
|
|
confirm_blocks = [b for b in reply2.blocks if b.type == "confirm-card"]
|
|
|
|
|
|
assert confirm_blocks, "选模板后应出确认卡"
|
|
|
|
|
|
confirm_id = confirm_blocks[0].props["confirmId"]
|
|
|
|
|
|
|
|
|
|
|
|
# 批准 → 路线生成(工时标「模板」)
|
|
|
|
|
|
from server.aps_domain.workflow import execute_confirmed
|
|
|
|
|
|
msg = execute_confirmed(store, confirm_id, approve=True, actor="tester")
|
|
|
|
|
|
assert "工艺路线已生成" in msg
|
|
|
|
|
|
steps = [r for r in store.data["flexRoutings"] if r["productCode"] == "SHAFT-01"]
|
|
|
|
|
|
assert steps
|
|
|
|
|
|
assert all(s["stdTimeSource"] == "模板" for s in steps)
|
|
|
|
|
|
|
|
|
|
|
|
# 继续排产 → 全绿(或仅剩警告)→ 给试排确认
|
|
|
|
|
|
reply3 = dialog.pre_route(store, "w2", "继续排产")
|
|
|
|
|
|
assert isinstance(reply3, AgentReply)
|
|
|
|
|
|
assert "可排" in reply3.text or "齐备" in reply3.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_asks_time_for_unmaintained_steps():
|
|
|
|
|
|
store = FakeStore()
|
|
|
|
|
|
# 摘掉 OP-WELD 设备默认工时 → PDU 产品该步「待维护」
|
|
|
|
|
|
for e in store.data["flexEquipment"]:
|
|
|
|
|
|
e.get("opStdTime", {}).pop("OP-WELD", None)
|
|
|
|
|
|
reply = dialog.start_wizard(store, "w3")
|
|
|
|
|
|
assert "没有工时" in reply.text or "工序" in reply.text
|
|
|
|
|
|
assert dialog._SESSIONS["w3"]["wizard"]["step"] == "fill_time"
|
|
|
|
|
|
|
|
|
|
|
|
# 回复分钟数 → 出 P2 工时确认卡
|
|
|
|
|
|
reply2 = dialog.pre_route(store, "w3", "12")
|
|
|
|
|
|
assert isinstance(reply2, AgentReply)
|
|
|
|
|
|
confirm_blocks = [b for b in reply2.blocks if b.type == "confirm-card"]
|
|
|
|
|
|
assert confirm_blocks
|
|
|
|
|
|
from server.aps_domain.workflow import execute_confirmed
|
|
|
|
|
|
msg = execute_confirmed(store, confirm_blocks[0].props["confirmId"], approve=True, actor="tester")
|
|
|
|
|
|
assert "工时已更新" in msg
|
|
|
|
|
|
weld_steps = [r for r in store.data["flexRoutings"] if r["operationCode"] == "OP-WELD"]
|
|
|
|
|
|
assert all(s.get("stdTimePerUnit") == 12.0 for s in weld_steps)
|
|
|
|
|
|
assert all(s.get("stdTimeSource") == "实测" for s in weld_steps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_default_calendar():
|
|
|
|
|
|
store = FakeStore()
|
|
|
|
|
|
store.data["flexCalendar"] = []
|
|
|
|
|
|
reply = dialog.start_wizard(store, "w4")
|
|
|
|
|
|
assert "班次日历" in reply.text
|
|
|
|
|
|
reply2 = dialog.pre_route(store, "w4", "默认日历")
|
|
|
|
|
|
assert isinstance(reply2, AgentReply)
|
2026-08-21 07:32:13 +08:00
|
|
|
|
assert "默认班次日历" in reply2.text
|
2026-07-28 02:12:46 +08:00
|
|
|
|
assert store.data["flexCalendar"], "应写入默认日历"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_cancel():
|
|
|
|
|
|
store = FakeStore()
|
|
|
|
|
|
dialog.start_wizard(store, "w5")
|
|
|
|
|
|
reply = dialog.pre_route(store, "w5", "取消")
|
|
|
|
|
|
assert isinstance(reply, AgentReply)
|
|
|
|
|
|
assert "退出" in reply.text
|
|
|
|
|
|
assert dialog._SESSIONS["w5"]["wizard"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_intent_rules():
|
|
|
|
|
|
from server.agent_core.intent import parse_fast
|
|
|
|
|
|
world = seed_world()
|
|
|
|
|
|
for phrase in ("我要排产", "怎么排产", "排产向导"):
|
|
|
|
|
|
r = parse_fast(phrase, world)
|
|
|
|
|
|
assert r is not None and r.intent == "schedule.wizard", phrase
|
|
|
|
|
|
# 「帮我排产 / 给我排产」是祈使开排,不再进向导菜单
|
|
|
|
|
|
for phrase in ("帮我排产", "给我排产", "我要你排产", "排产吧"):
|
|
|
|
|
|
r = parse_fast(phrase, world)
|
|
|
|
|
|
assert r is not None and r.intent in ("flex.schedule", "folder.schedule"), phrase
|
|
|
|
|
|
# 既有口令不受影响
|
|
|
|
|
|
r = parse_fast("柔性排产", world)
|
|
|
|
|
|
assert r is not None and r.intent == "flex.schedule"
|