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

281 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"]