aps-agent/tests/golden/test_folder_pack.py

509 lines
21 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.

# ============================================================
# 工程目录深度解析 + 按目录排产意图
# ============================================================
from __future__ import annotations
import os
from pathlib import Path
from server.aps_domain.folder_pack import analyze_work_dir
from server.state.seed import seed_world
def test_analyze_work_dir_reads_xlsx(tmp_path: Path, monkeypatch):
# 最小可解析 CSV 订单表
orders = tmp_path / "订单.csv"
orders.write_text(
"订单号,产品编码,数量,交期,客户\n"
"SO001,P-1,10,2026-08-01,甲\n",
encoding="utf-8-sig",
)
mats = tmp_path / "物料.csv"
mats.write_text(
"编码,名称,类型,单位,库存\n"
"P-1,成品A,FINISHED_PRODUCT,件,100\n",
encoding="utf-8-sig",
)
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": "p1", "name": "测", "workDir": str(tmp_path)}],
"sessions": [{"id": "s1", "projectId": "p1"}],
"files": [],
}
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
world = seed_world()
report = analyze_work_dir(world, "s1")
assert report["ok"] is True
assert report["totalOk"] >= 1
assert any(f["name"] == "订单.csv" for f in report["files"])
assert "字段" in report["markdown"] or any(f.get("headers") for f in report["files"])
assert "样例" in report["markdown"] or any(f.get("samples") for f in report["files"])
def test_kangni_style_headers_soft_preview():
"""康尼现场表头(料号/订单数量/计划结束时间…)应被 soft 识别为有效行。"""
from server.aps_domain.importers import preview_file, detect_field_map
world = seed_world()
csv = (
"订单代码,WBS号,料号,物料描述,计划结束时间,订单数量,项目名称\n"
"102285668,WBS1,28200003654300,承载机构,2026-08-08,1,穗莞深项目\n"
).encode("utf-8-sig")
preview = preview_file("订单.csv", csv, world, soft=True, kind_hint="orders")
assert preview["totalOk"] == 1
row = preview["batches"][0]["okRows"][0]
assert row["productCode"] == "28200003654300"
assert row["quantity"] == 1
assert row["deliveryDate"].startswith("2026-08-08")
assert row["customerName"] == "穗莞深项目"
fmap = detect_field_map("orders", ["订单代码", "料号", "订单数量", "计划结束时间", "项目名称"])
targets = {m["target"] for m in fmap}
assert {"productCode", "quantity", "deliveryDate", "customerName", "orderNo"} <= targets
async def _run_folder_analyze(store, session_id: str):
from server.aps_domain.workflow import handle_intent
from server.contracts import IntentResult
return await handle_intent(
store,
session_id,
IntentResult(
intent="folder.analyze",
params={"query": "分析文件夹"},
confidence=1.0,
source="LLM",
),
actor="test",
)
def test_folder_analyze_writes_master_data_and_knowledge(tmp_path: Path, monkeypatch):
import asyncio
import uuid
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.knowledge.assets import KnowledgeStore
from server.state.seed import empty_world
(tmp_path / "订单.csv").write_text(
"订单号,产品编码,数量,交期,客户\n"
"SO-FOLDER-1,P-FOLDER-1,12,2026-08-15,锐扬\n",
encoding="utf-8-sig",
)
(tmp_path / "物料.csv").write_text(
"编码,名称,类型,单位,库存\n"
"P-FOLDER-1,文件夹成品,FINISHED_PRODUCT,件,30\n",
encoding="utf-8-sig",
)
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": "p-folder", "name": "锐扬目录项目", "workDir": str(tmp_path)}],
"sessions": [{"id": "s-folder", "projectId": "p-folder"}],
"files": [],
}
def active_world_key(self) -> str:
return "p-folder"
class FakeStore:
def __init__(self):
self.data = empty_world()
self.saved = False
self._ids: dict[str, int] = {}
def next_id(self, kind: str) -> int:
self._ids[kind] = self._ids.get(kind, 0) + 1
return self._ids[kind]
def save(self) -> None:
self.saved = True
identity = IdentityContext(
user_id=77,
username="folder_owner",
fullname="目录项目用户",
tenant_uuid=f"tenant-folder-{uuid.uuid4().hex[:10]}",
roles=("planner",),
)
token = bind_identity(identity)
try:
kb = KnowledgeStore(path=str(tmp_path / "knowledge.json"))
kb.assets = []
kb._write()
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
monkeypatch.setattr("server.knowledge.assets.get_knowledge", lambda: kb)
store = FakeStore()
reply = asyncio.run(_run_folder_analyze(store, "s-folder"))
# P0 分析只出预览:没有人工批准前不得写主数据、订单或归属信息。
assert store.saved is True
assert store.data.get("flexMaterials") == []
assert store.data.get("flexOrders") == []
assert not (store.data.get("meta") or {}).get("ownerUserId")
assert {b.type for b in reply.blocks} == {
"folder-pack", "project-analyze", "guidance", "confirm-card",
}
folder_block = next(b for b in reply.blocks if b.type == "folder-pack")
assert folder_block.props["totalOk"] >= 2
assert folder_block.props["files"]
assert not folder_block.props.get("error")
project_block = next(b for b in reply.blocks if b.type == "project-analyze")
assert project_block.props["knowledgeIngest"] == {}
guidance_block = next(b for b in reply.blocks if b.type == "guidance")
assert guidance_block.props["mode"] == "data-missing"
assert guidance_block.props["steps"]
assert kb.assets == []
# P2 采用获批后才写主数据、登记归属,并把这次分析写进租户知识库。
card = next(b for b in reply.blocks if b.type == "confirm-card")
assert card.props["action"] == "import.commit"
from server.aps_domain.workflow import execute_confirmed
message = execute_confirmed(
store, card.props["confirmId"], approve=True, actor="approver",
)
assert "采用" in message
assert any(m.get("code") == "P-FOLDER-1" for m in store.data.get("flexMaterials") or [])
assert any(o.get("orderNo") == "SO-FOLDER-1" for o in store.data.get("flexOrders") or [])
assert store.data["meta"]["ownerUserId"] == 77
assert kb.assets
finally:
reset_identity(token)
def test_explicit_csv_maps_annotated_headers_and_preserves_unknown_fields(tmp_path: Path, monkeypatch):
import uuid
from server.aps_domain.project_analyze import analyze_project_deep
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.knowledge.assets import KnowledgeStore
from server.state.seed import empty_world
path = tmp_path / "供应商杂项字段.csv"
path.write_text(
"物料编码(必填),物料名称*,物料分类,计量单位,当前库存,供应商颜色\n"
"MAT-VARY-001,通用铜排,RAW_MATERIAL,件,18,深绿色\n",
encoding="utf-8-sig",
)
kb = KnowledgeStore(path=str(tmp_path / "vary-knowledge.json"))
kb.assets = []
kb._write()
monkeypatch.setattr("server.aps_domain.project_analyze._project_ctx", lambda _sid: (None, ""))
monkeypatch.setattr("server.knowledge.assets.get_knowledge", lambda: kb)
monkeypatch.setattr("server.knowledge.embedding.index_units", lambda _units: None)
identity = IdentityContext(
user_id=90,
username="vary_owner",
fullname="多字段导入用户",
tenant_uuid=f"tenant-vary-{uuid.uuid4().hex[:10]}",
roles=("planner",),
)
token = bind_identity(identity)
try:
world = empty_world()
result = analyze_project_deep(
world,
"missing-project-session",
input_path=str(path),
next_id=lambda _kind: 1,
)
finally:
reset_identity(token)
material = next(row for row in world["flexMaterials"] if row.get("code") == "MAT-VARY-001")
assert material["name"] == "通用铜排"
assert material["unit"] == "件"
assert material["stock"] == 18
assert result["knowledgeIngest"].get("assetId")
raw_text = "\n".join(
chunk.get("text", "")
for asset in kb.assets
for chunk in (asset.get("chunks") or [])
)
assert "供应商颜色" in raw_text
assert "深绿色" in raw_text
def test_project_analyze_rejects_query_path_inference(tmp_path: Path, monkeypatch):
from server.aps_domain.project_analyze import analyze_project_deep
from server.state.seed import empty_world
monkeypatch.setattr("server.aps_domain.project_analyze._project_ctx", lambda _sid: (None, ""))
result = analyze_project_deep(
empty_world(),
"missing-project-session",
query=f'分析 "{tmp_path}"',
)
assert result["ok"] is False
assert "input_path" in result["error"]
def test_analyze_work_dir_separates_total_errors_from_skipped_rows(tmp_path: Path, monkeypatch):
"""round-63 E-TEST: aggregate diagnostics while preserving legacy fields."""
sheet_routing = "\u0030\u0032-\u5de5\u827a\u6a21\u578b"
sheet_equipment = "\u0030\u0037-\u8bbe\u5907\u6a21\u578b"
sheet_quality = "\u0030\u0033-\u8d28\u68c0\u65b9\u6848"
workbook = tmp_path / "ruiyang-mom.xlsx"
workbook.write_bytes(b"round-63 diagnostics fixture")
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": "p-r63", "name": "Ruiyang", "workDir": str(tmp_path)}],
"sessions": [{"id": "s-r63", "projectId": "p-r63"}],
"files": [],
}
warning_diagnostics = [
{
"sheet": sheet_equipment,
"excelRow": row_no,
"kind": "equipment",
"code": "EQUIPMENT_CAPABILITIES_DEFAULTED",
"severity": "warning",
"message": "Equipment capabilities defaulted to GENERAL",
"rawSummary": {"financialCode": f"EQ-{row_no:03d}", "internalCode": ""},
}
for row_no in range(10, 57)
]
embedded_ignored = [
{
"sheet": sheet_routing,
"excelRow": 5,
"kind": "routing",
"code": "SECONDARY_SEQUENCE_HEADER",
"severity": "ignored",
"message": "Secondary routing sequence header",
"rawSummary": {"routingSequence": "01 / 02 / 03"},
},
*[
{
"sheet": sheet_equipment,
"excelRow": row_no,
"kind": "equipment",
"code": "EMBEDDED_SECTION_ROW",
"severity": "ignored",
"message": "Embedded subsection or dictionary row",
"rawSummary": {"firstCell": "embedded section"},
}
for row_no in (137, 142, 151, 152)
],
]
blocking_diagnostic = {
"sheet": sheet_equipment,
"excelRow": 130,
"kind": "equipment",
"code": "EQUIPMENT_CODE_MISSING",
"severity": "blocking",
"message": "All allowed equipment code fields are empty",
"rawSummary": {
"equipmentName": "real equipment",
"financialCode": "",
"fixedAssetCode": "",
"internalCode": "",
"factoryNo": "FACTORY-NO-MUST-NOT-BECOME-CODE",
},
}
non_scheduling_diagnostics = [
{
"sheet": sheet_quality,
"excelRow": row_no,
"kind": "materials",
"code": "REQUIRED_FIELDS_MISSING",
"severity": "blocking",
"message": "Folder aggregation must reclassify this non-scheduling row",
"rawSummary": {"inspectionItem": f"item-{row_no}"},
}
for row_no in range(1, 975)
]
def fake_preview_file(*_args, **_kwargs):
diagnostics = [
*warning_diagnostics,
*embedded_ignored,
blocking_diagnostic,
*non_scheduling_diagnostics,
]
return {
"totalOk": 1404,
"totalErrors": 975,
"diagnostics": diagnostics,
"diagnosticCounts": {"ignored": 5, "warning": 47, "blocking": 975},
"batches": [
{
"sheet": sheet_equipment,
"kind": "equipment",
"okCount": 1404,
"errorCount": 0,
"okRows": [{"code": "EQ-R63", "name": "valid equipment", "capabilities": ["GENERAL"]}],
"errors": [],
"warnings": ["Equipment capabilities defaulted to GENERAL"],
"diagnostics": warning_diagnostics,
"diagnosticCounts": {"ignored": 0, "warning": 47, "blocking": 0},
"fieldMap": [],
"headersRaw": ["financialCode", "internalCode", "equipmentName"],
},
{
"sheet": f"{sheet_routing}#diagnostics",
"kind": "routing",
"okCount": 0,
"errorCount": 0,
"okRows": [],
"errors": [],
"warnings": [],
"diagnostics": embedded_ignored[:1],
"diagnosticCounts": {"ignored": 1, "warning": 0, "blocking": 0},
"fieldMap": [],
"headersRaw": ["routingSequence"],
},
{
"sheet": f"{sheet_equipment}#diagnostics",
"kind": "equipment",
"okCount": 0,
"errorCount": 1,
"okRows": [],
"errors": ["row 130: equipment code missing"],
"warnings": [],
"diagnostics": [*embedded_ignored[1:], blocking_diagnostic],
"diagnosticCounts": {"ignored": 4, "warning": 0, "blocking": 1},
"fieldMap": [],
"headersRaw": ["financialCode", "fixedAssetCode", "internalCode", "factoryNo"],
},
{
"sheet": sheet_quality,
"kind": "materials",
"okCount": 0,
"errorCount": 974,
"okRows": [],
"errors": ["non-scheduling block must not count as errors"],
"warnings": [],
"diagnostics": non_scheduling_diagnostics,
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 974},
"fieldMap": [],
"headersRaw": ["inspectionItem"],
},
],
}
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
monkeypatch.setattr("server.aps_domain.folder_pack.preview_file", fake_preview_file)
report = analyze_work_dir(seed_world(), "s-r63")
# Legacy API fields and commit-batch semantics remain unchanged.
assert report["totalOk"] == 1404
assert report["totalErrors"] == 1
assert report["skippedRows"] == 974
assert isinstance(report["batches"], list)
assert all(batch.get("okRows") for batch in report["batches"])
assert len(report["batches"]) == 1
assert report["batches"][0]["okRows"][0]["code"] == "EQ-R63"
# Warning and ignored diagnostics never inflate the blocking-only error count.
assert report["diagnosticCounts"] == {"ignored": 979, "warning": 47, "blocking": 1}
diagnostics = report["diagnostics"]
assert sum(d["severity"] == "warning" for d in diagnostics) == 47
assert sum(d["severity"] == "ignored" for d in diagnostics) == 979
assert sum(d["severity"] == "blocking" for d in diagnostics) == 1
# Diagnostics from zero-okRows batches remain visible at the report top level.
assert any(
d["sheet"] == sheet_routing
and d["excelRow"] == 5
and d["code"] == "SECONDARY_SEQUENCE_HEADER"
and d["severity"] == "ignored"
for d in diagnostics
)
for row_no in (137, 142, 151, 152):
assert any(
d["sheet"] == sheet_equipment
and d["excelRow"] == row_no
and d["code"] == "EMBEDDED_SECTION_ROW"
and d["severity"] == "ignored"
for d in diagnostics
)
blocking = [d for d in diagnostics if d["severity"] == "blocking"]
assert [(d["sheet"], d["excelRow"], d["code"]) for d in blocking] == [
(sheet_equipment, 130, "EQUIPMENT_CODE_MISSING")
]
assert blocking[0]["rawSummary"]["factoryNo"] == "FACTORY-NO-MUST-NOT-BECOME-CODE"
# Legacy skippedRows remains exclusive to the complete non-scheduling block.
skipped = [d for d in diagnostics if d["code"] == "NON_SCHEDULING_BLOCK"]
assert len(skipped) == 974
assert all(d["severity"] == "ignored" and d["sheet"] == sheet_quality for d in skipped)
skipped_sheet = next(sheet for sheet in report["files"][0]["sheets"] if sheet["sheet"] == sheet_quality)
assert skipped_sheet["skip"] is True
assert skipped_sheet["errorCount"] == 0
assert report["files"][0]["errorCount"] == 1
assert report["files"][0]["skipRows"] == 974
assert "\u53e6\u6709 974 \u884c\u6765\u81ea\u975e\u6392\u4ea7\u5757" in report["markdown"]
assert "\u5df2\u8df3\u8fc7\uff0c\u4e0d\u8ba1\u5165\u95ee\u9898\u884c" in report["markdown"]
def test_analyze_work_dir_excludes_ok_rows_from_non_scheduling_blocks(tmp_path: Path, monkeypatch):
"""带有效行的非排产 sheet 也不能进 batches/kindCounts(锐扬精简演示 xlsx)。"""
workbook = tmp_path / "ruiyang.xlsx"
workbook.write_bytes(b"folder-skip fixture")
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": "p-skip", "name": "Ruiyang", "workDir": str(tmp_path)}],
"sessions": [{"id": "s-skip", "projectId": "p-skip"}],
"files": [],
}
def fake_preview_file(*_args, **_kwargs):
return {
"totalOk": 4,
"totalErrors": 0,
"diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"batches": [
{
"sheet": "工厂资源", "kind": "materials", "okCount": 2, "errorCount": 0,
"okRows": [
{"code": "BU4", "name": "工厂", "type": "RAW_MATERIAL"},
{"code": "BJCJ", "name": "车间", "type": "RAW_MATERIAL"},
],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["资源类型", "编码", "名称"],
},
{
"sheet": "设备", "kind": "equipment", "okCount": 1, "errorCount": 0,
"okRows": [{"code": "EQ-1", "name": "切割机", "capabilities": ["CUT"], "status": "RUNNING"}],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["设备编码", "设备名称", "能力"],
},
{
"sheet": "销售订单", "kind": "orders", "okCount": 1, "errorCount": 0,
"okRows": [{"orderNo": "SO-1", "productCode": "P-1", "quantity": 1,
"deliveryDate": "2026-08-01"}],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["订单号", "产品编码", "数量", "交期"],
},
],
}
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
monkeypatch.setattr("server.aps_domain.folder_pack.preview_file", fake_preview_file)
report = analyze_work_dir(seed_world(), "s-skip")
assert report["totalOk"] == 2
assert report["skippedRows"] == 2
assert report["kindCounts"].get("materials", 0) == 0
assert all(batch.get("sheet") != "工厂资源" for batch in report["batches"])
assert [batch["kind"] for batch in report["batches"]] == ["equipment", "orders"]