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