509 lines
21 KiB
Python
509 lines
21 KiB
Python
# ============================================================
|
||
# 工程目录深度解析 + 按目录排产意图
|
||
# ============================================================
|
||
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"]
|