aps-agent/tests/golden/test_ruiyang_intake_workflo...

281 lines
16 KiB
Python
Raw Permalink Normal View History

"""Real source file through production upload, review, confirmation and editing APIs."""
from __future__ import annotations
import hashlib
import json
import pytest
from fastapi.testclient import TestClient
from tests.auth_provider import install_test_auth
from tests.pi_primary_adapter import install_fake_pi_tool_adapter
from tests.workbook_acceptance import load_expectations, source_for_test
EXPECTED = load_expectations()
DIGEST = EXPECTED["sourceSha256"]
@pytest.fixture
def intake_client(tmp_path, monkeypatch):
source = source_for_test()
monkeypatch.setenv("APS_SEED_DEMO", "0")
monkeypatch.setenv("APS_HOME", str(tmp_path))
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "master.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "data"))
install_test_auth(monkeypatch, "round87-workflow")
install_fake_pi_tool_adapter(monkeypatch)
from server.gateway.app import create_app
with TestClient(create_app()) as client:
response = client.post("/api/auth/login", json={"method": "password", "username": "planner", "password": "test"})
assert response.status_code == 200
created = client.post("/api/projects", json={"name": "锐扬资料验收"}).json()
project_id, session_id = created["project"]["id"], created["session"]["id"]
raw = source.read_bytes()
assert hashlib.sha256(raw).hexdigest() == DIGEST
upload = client.post(f"/api/projects/{project_id}/files/upload", files=[("files", (source.name, raw,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))])
assert upload.status_code == 200, upload.text
assert upload.json()["saved"] == [source.name]
yield client, project_id, session_id
assert hashlib.sha256(source.read_bytes()).hexdigest() == DIGEST
def _chat(client, project_id, session_id, text):
response = client.post("/api/chat", json={"text": text, "sessionId": session_id, "projectId": project_id})
assert response.status_code == 200, response.text
events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")]
return [event["block"] for event in events if event.get("type") == "block"], response.text
def _confirm(client, session_id, block, approve=True):
response = client.post("/api/actions/confirm", json={"sessionId": session_id,
"confirmId": block["props"]["confirmId"], "approve": approve})
assert response.status_code == 200, response.text
return response.json()
def test_complete_file_review_does_not_write_until_adoption(intake_client):
client, project_id, session_id = intake_client
before = client.get("/api/master").json()
blocks, _text = _chat(client, project_id, session_id, "分析一下数据文件")
review = next(block for block in blocks if block["type"] == "folder-pack")
assert review["props"]["summary"] == EXPECTED["reviewCounts"]
assert review["props"]["entityCounts"]["personnel"] == EXPECTED["entityCounts"]["personnel"]
assert review["props"]["entityCounts"]["sandboxOrders"] == EXPECTED["entityCounts"]["sandboxOrders"]
assert len(review["props"]["sheetSummary"]) == len(EXPECTED["sheets"])
assert client.get("/api/master").json()["materials"] == before["materials"]
card = next(block for block in blocks if block["type"] == "confirm-card")
assert card["props"]["action"] == "import.commit"
confirmed = _confirm(client, session_id, card)
assert confirmed["refresh"] is True, confirmed
assert "资料已采用" in confirmed["message"], confirmed
master = client.get("/api/master").json()
assert len(master["materials"]) == EXPECTED["entityCounts"]["materials"]
assert len(master["equipment"]) == EXPECTED["entityCounts"]["equipment"]
assert sum(len(route["steps"]) for route in master["routings"]) == EXPECTED["entityCounts"]["routing"]
flex = client.get("/api/flex/world").json()
assert len(flex["orders"]) == EXPECTED["entityCounts"]["orders"]
assert sum(material.get("inTransit") or 0 for material in flex["materials"]) == EXPECTED["inventory"]["inTransit"]
assert not flex.get("versions")
def test_imported_master_edit_survives_repeat_review_and_reimport(intake_client):
client, project_id, session_id = intake_client
blocks, _ = _chat(client, project_id, session_id, "分析一下数据文件")
adopted = _confirm(client, session_id, next(block for block in blocks if block["type"] == "confirm-card"))
assert adopted["refresh"] is True
assert "资料已采用" in adopted["message"], adopted
master = client.get("/api/master").json()
material = next(row for row in master["materials"] if row["code"] == EXPECTED["materialEditCode"])
result = client.post("/api/master/stage", json={"sessionId": session_id,
"action": "master.material.upsert", "payload": {"id": material["id"], "stock": 1234}}).json()
assert "block" in result, result
assert _confirm(client, session_id, result["block"])["refresh"] is True
blocks, text = _chat(client, project_id, session_id, "分析一下数据文件")
assert not any(block["type"] == "confirm-card" for block in blocks), text
assert next(row for row in client.get("/api/flex/world").json()["materials"]
if row["code"] == material["code"])["stock"] == 1234
step = master["routings"][0]["steps"][0]
staged = client.post("/api/master/stage", json={"sessionId": session_id,
"action": "master.routing.upsert", "payload": {"stepId": step["id"], "runTimePerUnit": 12.5}}).json()
assert "block" in staged, staged
assert _confirm(client, session_id, staged["block"])["refresh"] is True
updated = client.get("/api/master").json()
assert next(s for r in updated["routings"] for s in r["steps"] if s["id"] == step["id"])["runTimePerUnit"] == 12.5
def test_rejected_adoption_leaves_master_empty(intake_client):
client, project_id, session_id = intake_client
blocks, _ = _chat(client, project_id, session_id, "根据这些数据排产")
card = next(block for block in blocks if block["type"] == "confirm-card")
_confirm(client, session_id, card, approve=False)
assert client.get("/api/master").json()["materials"] == []
def test_trial_response_reports_actual_blocked_orders_and_keeps_edits(intake_client):
client, project_id, session_id = intake_client
blocks, _ = _chat(client, project_id, session_id, "分析一下数据文件")
_confirm(client, session_id, next(block for block in blocks if block["type"] == "confirm-card"))
blocks, text = _chat(client, project_id, session_id, "根据这些数据排产")
result = next(block for block in blocks if block["type"] == "flex-schedule")
plan = EXPECTED["trialPlan"]
assert result["props"]["trialOnly"] is True
assert result["props"]["stats"]["orderCount"] == EXPECTED["entityCounts"]["orders"]
# 试排把「未登记/未确认」资料降级为显式假设后仍要排出草稿;只有真实缺料订单未排出。
assert result["props"]["stats"]["blockedOrderCount"] == len(plan["blockedOrderNos"])
assert result["props"]["stats"]["woCount"] > 0
lines = {row["orderNo"]: row.get("status") for row in result["props"]["lines"]}
assert {no for no, status in lines.items() if status == "scheduled"} == set(plan["scheduledOrderNos"])
assert {no for no, status in lines.items() if status == "blocked"} == set(plan["blockedOrderNos"])
assumptions = [c for c in result["props"]["conflicts"] if c.get("severity") == plan["assumptionSeverity"]]
assert {c["type"] for c in assumptions} == set(plan["assumptionTypes"])
assert all(str(c.get("description") or "").startswith(plan["assumptionDescriptionPrefix"]) for c in assumptions)
blocking = [c for c in result["props"]["conflicts"] if c.get("severity") == "CRITICAL"]
assert {c["type"] for c in blocking} <= set(plan["blockedReasonTypes"])
assert {c["orderNo"] for c in blocking} == set(plan["blockedOrderNos"])
assert "完整工序计划" not in text
assert not result["props"]["downloadUrl"]
assert not any(block["type"] == "confirm-card" for block in blocks)
def test_adopted_review_checks_current_master_not_original_warnings(monkeypatch):
source = source_for_test()
from server.aps_domain.importers import apply_import_commit, preview_file
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
raw = source.read_bytes()
preview = preview_file(source.name, raw, empty_world())
store = _MemStore(empty_world())
apply_import_commit(store.data, store.next_id, preview["batches"])
store.data["flexPersonnel"].append(dict(EXPECTED["trial"]["supplementPerson"]))
report = {"files": [{"name": source.name, **{k: preview[k] for k in ("profile", "source", "entityCounts", "sheetSummary", "canCommit")}}],
"batches": preview["batches"]}
monkeypatch.setattr("server.aps_domain.folder_pack.prepare_folder_schedule", lambda *_: report)
reply = profile_intake_reply(store, "s", schedule_requested=False, schedule_current=lambda: None)
assert reply.blocks[0].props["adopted"] is True
assert not any(issue.get("code") == "NO_PERSONNEL_SKILL" for issue in reply.blocks[0].props["diagnostics"])
assert any(issue.get("code") == "NO_PERSONNEL_SKILL" for issue in reply.blocks[0].props["sourceDiagnostics"])
def _intake_file(name: str, *, can_commit: bool, digest: str) -> dict:
return {"name": name, "profile": "ruiyang-aps-v1", "canCommit": can_commit,
"source": {"sha256": digest}, "profileDigest": "profile-digest-1",
"entityCounts": {"orders": 6}, "sheetSummary": [],
"okCount": 258 if can_commit else 32, "errorCount": 0 if can_commit else 5}
def _patch_folder_report(monkeypatch, files: list[dict], batches: list[dict] | None = None) -> None:
report = {"files": files, "batches": batches or [], "projectName": "新工厂排产项目B",
"totalErrors": 5, "sourceManifestDigest": "manifest-digest"}
monkeypatch.setattr("server.aps_domain.folder_pack.prepare_folder_schedule",
lambda *_: report)
def test_broken_template_beside_valid_workbook_is_ignored(monkeypatch):
"""回归:工程目录里混着空模板时,仍然采用唯一一份可排产工作簿(2026-09-12 现场)。"""
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
valid = _intake_file("湖南锐扬APS精简演示数据.xlsx", can_commit=True, digest="a" * 64)
template = _intake_file("APS精简演示模板.xlsx", can_commit=False, digest="b" * 64)
_patch_folder_report(monkeypatch, [template, valid])
reply = profile_intake_reply(_MemStore(empty_world()), "s", schedule_requested=True,
schedule_current=lambda: None)
assert "多份数据文件" not in reply.text
assert "资料检查完成" in reply.text
assert reply.blocks[0].props["source"]["sha256"] == valid["source"]["sha256"]
assert [block.type for block in reply.blocks] == ["folder-pack", "confirm-card"]
def test_planner_named_broken_workbook_still_reported(monkeypatch):
"""用户点名哪一份就按哪一份核对:点名坏文件时给出错误卡,不静默换文件。"""
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
valid = _intake_file("湖南锐扬APS精简演示数据.xlsx", can_commit=True, digest="a" * 64)
template = _intake_file("APS精简演示模板.xlsx", can_commit=False, digest="b" * 64)
_patch_folder_report(monkeypatch, [template, valid])
reply = profile_intake_reply(
_MemStore(empty_world()), "s", schedule_requested=False,
schedule_current=lambda: None, query="用 APS精简演示模板.xlsx 核对一下",
)
assert "字段或关联错误" in reply.text
assert reply.blocks[0].props["source"]["sha256"] == template["source"]["sha256"]
assert not any(block.type == "confirm-card" for block in reply.blocks)
def test_two_usable_workbooks_ask_planner_to_pick_one(monkeypatch):
"""两份都能用时必须让用户点名,点序号后按点名那一份继续。"""
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
first = _intake_file("甲方案.xlsx", can_commit=True, digest="c" * 64)
second = _intake_file("乙方案.xlsx", can_commit=True, digest="d" * 64)
_patch_folder_report(monkeypatch, [first, second])
store = _MemStore(empty_world())
reply = profile_intake_reply(store, "s", schedule_requested=False,
schedule_current=lambda: None)
choice = next(block for block in reply.blocks if block.type == "clarify")
assert [option["label"] for option in choice.props["options"]] == ["甲方案.xlsx", "乙方案.xlsx"]
assert "请回复序号或文件名" in reply.text
picked = profile_intake_reply(store, "s", schedule_requested=False,
schedule_current=lambda: None, query="2. 乙方案.xlsx")
assert picked.blocks[0].props["source"]["sha256"] == second["source"]["sha256"]
def test_confirmation_only_carries_batches_of_the_selected_workbook(monkeypatch):
"""同格式的两份工作簿:选中的那份才进确认载荷,避免重复表导致采用失败。"""
from server.agent_core import harness
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
def _batch(source_file: str, role: str) -> dict:
return {"sourceFile": source_file, "sourceProfile": "ruiyang-aps-v1", "role": role,
"kind": role, "sheet": role, "fieldMap": []}
valid = _intake_file("湖南锐扬APS精简演示数据.xlsx", can_commit=True, digest="a" * 64)
template = _intake_file("APS精简演示模板.xlsx", can_commit=False, digest="b" * 64)
batches = [_batch(valid["name"], "orders"), _batch(template["name"], "orders")]
_patch_folder_report(monkeypatch, [template, valid], batches)
reply = profile_intake_reply(_MemStore(empty_world()), "s", schedule_requested=False,
schedule_current=lambda: None)
assert reply.blocks[0].props["totalErrors"] == 0
assert [item["params"]["batches"] for item in harness.list_pending()] == [[batches[0]]]
def test_pending_adoption_card_leads_with_conclusion_and_a_locked_scheduling_entry(monkeypatch):
"""回归:现场那张"排产资料核对"卡首屏只给结论、关键数和下一步,不再堆原始字段。"""
from server.aps_domain.planning_intake import profile_intake_reply
from server.state.seed import empty_world
from tests.golden.test_guidance import _MemStore
valid = _intake_file("湖南锐扬APS精简演示数据.xlsx", can_commit=True, digest="e" * 64)
_patch_folder_report(monkeypatch, [valid])
reply = profile_intake_reply(_MemStore(empty_world()), "s", schedule_requested=False,
schedule_current=lambda: None)
props = reply.blocks[0].props
summary = props["analysisSummary"]
assert summary["status"] == "review"
assert summary["statusLabel"] == "待核对采用"
assert summary["headline"] == "资料已读取,确认采用后才会写入项目主数据。"
assert summary["nextStep"]["label"] == "开始排产"
assert summary["nextStep"]["enabled"] is False
assert "确认采用" in summary["nextStep"]["reason"]
assert [metric["label"] for metric in summary["metrics"]] == ["订单记录", "产品和物料", "加工步骤", "设备记录"]
assert [metric["value"] for metric in summary["metrics"]] == [6, 0, 0, 0]
assert "尚未写入主数据" in summary["recap"]
assert [block.type for block in reply.blocks] == ["folder-pack", "confirm-card"]