aps-agent/tests/golden/test_folder_pack.py

416 lines
16 KiB
Python
Raw Normal View History

# ============================================================
# 工程目录深度解析 + 按目录排产意图
# ============================================================
from __future__ import annotations
import os
from pathlib import Path
from server.agent_core.intent import parse_fast
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_schedule_intent_from_messy_zh():
world = seed_world()
assert parse_fast("以上数据根据排产你要怎么数据", world).intent == "folder.schedule"
assert parse_fast("解析工程目录", world).intent == "folder.analyze"
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.agent_core.intent import IntentResult
from server.aps_domain.workflow import handle_intent
return await handle_intent(
store,
session_id,
IntentResult(
intent="folder.analyze",
params={"query": "分析文件夹"},
confidence=1.0,
source="RULE_FAST",
),
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": [],
}
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"))
assert store.saved is True
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 {b.type for b in reply.blocks} == {"folder-pack", "project-analyze"}
project_block = next(b for b in reply.blocks if b.type == "project-analyze")
assert project_block.props["knowledgeIngest"].get("assetId")
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",
query=f'解析 "{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_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"]