# ============================================================ # A0050101-00280 外壳 · 紧急插单计划员自然语言验收 # # 目标:用计划员原话验证 Pi 只负责理解和调用,能否插单、影响哪些订单 # 必须来自真实 RULE 引擎;采用前不得写入订单或生成排产版本。 # ============================================================ from __future__ import annotations import json from datetime import datetime, timedelta from pathlib import Path from server.agent_core import fallback_lane from server.aps_domain import workflow as wf from server.aps_domain.project_analyze import analyze_project_deep from server.contracts import IntentResult from server.state.seed import empty_world from server.timeutil import fmt_date REPO_ROOT = Path(__file__).resolve().parents[2] DEMO_DIR = REPO_ROOT / "demo-data" / "锐扬APS演示数据" PRODUCT_CODE = "A0050101-00280" FIXED_TODAY = datetime(2026, 9, 18) # noqa: DTZ001 - 业务日历统一使用本地无时区时间 class FakeStore: def __init__(self, world: dict): self.data = 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 def _primary_intent(query: str) -> IntentResult: return IntentResult( intent="assistant.reply", params={"query": query, "_history": [], "_piPrimary": True}, confidence=1.0, source="LLM", ) def _planner_world(monkeypatch) -> dict: """加载仓库里的锐扬演示资料,并用固定时钟保证测试可复现。""" monkeypatch.setattr("server.aps_domain.rush.today0", lambda: FIXED_TODAY) monkeypatch.setattr("server.aps_domain.lns.today0", lambda: FIXED_TODAY) world = empty_world() counter = {"n": 0} def next_id(_kind: str) -> int: counter["n"] += 1 return counter["n"] report = analyze_project_deep( world, input_path=str(DEMO_DIR), next_id=next_id, apply=True, ) assert report["canSchedule"] is True assert any(m.get("code") == PRODUCT_CODE for m in world["materials"]) # 演示包的交期是历史固定日期;测试只平移交期,保留产品、BOM、工艺和设备原样。 for offset, order in enumerate(world["salesOrders"]): order["deliveryDate"] = fmt_date(FIXED_TODAY + timedelta(days=12 + offset)) world["scheduleParams"]["planningHorizonDays"] = 60 return world def _install_real_tool_runtime(monkeypatch) -> list[IntentResult]: calls: list[IntentResult] = [] async def fake_run_tool(store, session_id, intent, actor="planner"): calls.append(intent) return await wf.handle_intent(store, session_id, intent, actor) monkeypatch.setattr("server.agent_core.tool_runtime.run_tool_async", fake_run_tool) return calls def _pi_runner(captured: dict, tool: str | None, params: dict, model_text: str): def runner(task: str, work_dir: Path): captured["task"] = task if tool is None: yield { "type": "message_end", "message": { "role": "assistant", "stopReason": "stop", "content": [{"type": "text", "text": model_text}], }, } yield {"type": "agent_end", "messages": []} return mailbox = work_dir.parent / "outbox" / "chat-tools" mailbox.mkdir(parents=True, exist_ok=True) request = mailbox / f"01-{tool.replace('.', '-')}.json" request.write_text( json.dumps({"seq": 1, "tool": tool, "params": params}, ensure_ascii=False), encoding="utf-8", ) yield {"type": "harness_heartbeat"} result = request.with_name(request.stem + ".result.json") captured["tool_result"] = json.loads(result.read_text(encoding="utf-8")) yield { "type": "message_end", "message": { "role": "assistant", "stopReason": "stop", "content": [{"type": "text", "text": model_text}], }, } yield {"type": "agent_end", "messages": []} return runner def _assert_planner_language(text: str, summary: list[str] | None = None) -> None: visible = text + "\n" + "\n".join(summary or []) for term in ("P1", "P2", "DRAFT", "LNS", "evalId", "APPROVED", "isRush", "沙盒"): assert term not in visible async def test_planner_rush_natural_language_uses_engine_and_ignores_pi_guess( tmp_path, monkeypatch, ): """计划员原话进入 Pi 后,必须调用 rush.evaluate,且引擎结论覆盖模型猜测。""" monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) store = FakeStore(_planner_world(monkeypatch)) calls = _install_real_tool_runtime(monkeypatch) query = ( "湖南工业控制有限公司追加 50 套 A0050101-00280 外壳,10月14日交货," "先评估紧急插单影响,不要直接改计划。" ) params = { "customerName": "湖南工业控制有限公司", "customerLevel": "A", "productCode": PRODUCT_CODE, "quantity": 50, "deliveryDate": "2026-10-14", "priority": 1, "strategy": "DELIVERY_FIRST", } captured: dict = {} reply = await fallback_lane.propose_reply( store, "s-planner-rush", _primary_intent(query), runner=_pi_runner( captured, "rush.evaluate", params, "可以插单,而且不会影响任何订单,直接改计划就行。", ), config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert [call.intent for call in calls] == ["rush.evaluate"] assert calls[0].params["productCode"] == PRODUCT_CODE assert calls[0].params["quantity"] == 50 assert calls[0].params["deliveryDate"] == "2026-10-14" assert "必须先调用 `rush.evaluate`" in captured["task"] assert reply.text.startswith("插单影响评估完成") assert "直接改计划就行" not in reply.text assert "现有计划没有改动" in reply.text assert {block.props.get("kind") for block in reply.blocks} >= {"rush-impact"} actual = wf._LAST_RUSH_EVAL assert actual["rushOrder"]["productCode"] == PRODUCT_CODE assert actual["rushOrder"]["quantity"] == 50 assert actual["rushOrder"]["deliveryDate"] == "2026-10-14" assert actual["affectedOrderCount"] == len(actual["affectedOrders"]) assert actual["lns"]["status"] in {"LOCAL", "ESCALATE"} _assert_planner_language(reply.text) wf._LAST_RUSH_EVAL.clear() async def test_planner_rush_missing_quantity_asks_instead_of_guessing(tmp_path, monkeypatch): """缺少数量或交期时,Pi 先追问,不拿默认值代替计划员的真实要求。""" monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) store = FakeStore(_planner_world(monkeypatch)) calls = _install_real_tool_runtime(monkeypatch) captured: dict = {} question = "请补充这批 A0050101-00280 外壳的交货数量和最晚交货日期,我再按真实产能评估。" reply = await fallback_lane.propose_reply( store, "s-planner-rush-missing", _primary_intent("客户临时要加一批 A0050101-00280 外壳,先看能不能插进去。"), runner=_pi_runner(captured, None, {}, question), config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert calls == [] assert "交货数量" in reply.text and "最晚交货日期" in reply.text assert "缺少“已有订单号,或产品编码/名称 + 数量 + 交期”时先向用户追问" in captured["task"] async def test_planner_rush_unknown_order_returns_engine_rejection(tmp_path, monkeypatch): """订单号不存在时,Pi 不能改口说可以插,必须转达引擎的真实拒绝原因。""" monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) store = FakeStore(_planner_world(monkeypatch)) calls = _install_real_tool_runtime(monkeypatch) captured: dict = {} reply = await fallback_lane.propose_reply( store, "s-planner-rush-unknown", _primary_intent("把 SO-NOT-EXIST 加急,10月14日交,直接告诉我能不能插。"), runner=_pi_runner( captured, "rush.evaluate", {"orderNo": "SO-NOT-EXIST"}, "这张单可以加急,我已经安排进去了。", ), config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert [call.intent for call in calls] == ["rush.evaluate"] assert reply.text == "订单 SO-NOT-EXIST 不存在" assert "已经安排进去" not in reply.text wf._LAST_RUSH_EVAL.clear() async def test_planner_rush_apply_waits_for_confirmation(tmp_path, monkeypatch): """“采用刚才方案”只生成确认事项;确认前订单和排产版本都不变。""" monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fallback")) store = FakeStore(_planner_world(monkeypatch)) payload = { "customerName": "湖南工业控制有限公司", "customerLevel": "A", "productCode": PRODUCT_CODE, "quantity": 50, "deliveryDate": "2026-10-14", "priority": 1, "strategy": "DELIVERY_FIRST", } await wf.handle_intent( store, "s-planner-rush-apply", IntentResult(intent="rush.evaluate", params=payload, confidence=1.0, source="LLM"), "planner", ) before_orders = len(store.data["salesOrders"]) before_versions = len(store.data["scheduleVersions"]) calls = _install_real_tool_runtime(monkeypatch) captured: dict = {} reply = await fallback_lane.propose_reply( store, "s-planner-rush-apply", _primary_intent("采用刚才的插单方案,但先给我确认。"), runner=_pi_runner( captured, "rush.apply", {}, "已经采用,订单也写好并发布了。", ), config=fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home")), ) assert [call.intent for call in calls] == ["rush.apply"] assert calls[0].params == {} assert len(store.data["salesOrders"]) == before_orders assert len(store.data["scheduleVersions"]) == before_versions assert "已经写好并发布" not in reply.text cards = [block for block in reply.blocks if block.type == "confirm-card"] assert len(cards) == 1 _assert_planner_language(reply.text, cards[0].props.get("summary") or []) message = wf.execute_confirmed( store, str(cards[0].props["confirmId"]), approve=True, actor="planner", ) assert len(store.data["salesOrders"]) == before_orders + 1 assert len(store.data["scheduleVersions"]) == before_versions + 1 assert store.data["scheduleVersions"][-1]["status"] == "DRAFT" _assert_planner_language(message) wf._LAST_RUSH_EVAL.clear()