# ============================================================ # 只读分析 → P2 采用:SQL 数据包 / MOM 主数据表 # SQL 用可解析的最小数据包;MOM 解析器按契约打桩(本机无专家数据), # 门禁、冻结与写入路径全部走真实实现。 # ============================================================ from __future__ import annotations import asyncio import json import uuid from pathlib import Path import pytest from server.state.seed import empty_world MINI_SQL = """ CREATE TABLE `pl_order` ( `id` int, `code` varchar(64), `business_code` varchar(64), `customer_business_code` varchar(64), `material_code` varchar(64), `material_name` varchar(64), `parent_code` varchar(64), `craftl_code` varchar(64), `machine_model` varchar(64), `dept` varchar(64), `people` varchar(64), `drawing_code` varchar(64), `quantity` decimal(10,2), `man_quantity` decimal(10,2), `all_quantity` decimal(10,2), `status` varchar(8), `stock_status` varchar(8), `distribution_status` varchar(8), `type` varchar(8), `description` varchar(64), `planned_end_time` datetime, `is_delete` varchar(8), `level` int ) ENGINE=InnoDB; CREATE TABLE `r_production_craftl` ( `id` int, `craftl_code` varchar(64), `craftl_name` varchar(64), `procedure_code` varchar(64), `procedure_name` varchar(64), `output_material_code` varchar(64), `output_material_name` varchar(64), `working_hours` decimal(10,2), `sort_no` int, `is_delete` varchar(8) ) ENGINE=InnoDB; CREATE TABLE `md_equipment` ( `id` int, `code` varchar(64), `name` varchar(64), `is_delete` varchar(8) ) ENGINE=InnoDB; CREATE TABLE `md_material` ( `id` int, `code` varchar(64), `name` varchar(64), `type` varchar(32), `unit` varchar(8), `is_delete` varchar(8) ) ENGINE=InnoDB; INSERT INTO `pl_order` VALUES (1,'ORD1',NULL,'C1','FG001','Product 1',NULL,'CRAFT1',NULL,NULL,NULL,NULL,10,NULL,NULL,'0','0','0','S',NULL,'2026-08-01 00:00:00','0',5); INSERT INTO `r_production_craftl` VALUES (1,'CRAFT1','Product 1','OP10','Cut','WIP-X','Semi',0.5,10,'0'), (2,'CRAFT1','Product 1','OP20','Finish','FG001','Product 1',0.8,20,'0'); INSERT INTO `md_equipment` VALUES (1,'EQ1','Cutter','0'); INSERT INTO `md_material` VALUES (1,'FG001','Product 1','Product','pc','0'); """ MOM_PACK = { "filename": "MOM主数据收集表.xlsx", "modelSheetHits": 4, "stats": {"materials": 1, "bom": 1, "equipment": 1, "orders": 1, "routings": 1, "rootProduct": "MOM-FG"}, "flexMaterials": [ {"code": "MOM-FG", "name": "样例成品", "type": "FINISHED_PRODUCT", "unit": "件", "stock": 5}, ], "flexBom": [{"productCode": "MOM-FG", "materialCode": "MOM-RM", "quantity": 1}], "flexEquipment": [{"code": "MOM-EQ", "name": "示例设备"}], "flexOrders": [{"orderNo": "MOM-1", "productCode": "MOM-FG", "quantity": 3, "dueDate": "2026-08-30"}], "flexRoutings": [{"productCode": "MOM-FG", "seq": 10, "operationCode": "CUT", "operationName": "Cut", "stdTimePerUnit": 1.0}], } class _Store: def __init__(self) -> None: self.data = empty_world() self.saved = 0 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 += 1 async def _run_analyze(store: _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={}, confidence=1.0, source="LLM"), actor="test", ) def _prepare_env(tmp_path: Path, monkeypatch, *, project_id: str, session_id: str): from server.auth.context import IdentityContext, bind_identity from server.knowledge.assets import KnowledgeStore class FakePS: def snapshot(self, include_messages=False): return { "projects": [{"id": project_id, "name": "采用项目", "workDir": str(tmp_path)}], "sessions": [{"id": session_id, "projectId": project_id}], "files": [], } def active_world_key(self) -> str: return project_id 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) monkeypatch.setattr( "server.aps_domain.project_analyze._project_ctx_readonly", lambda _session_id: ( {"id": project_id, "name": "采用项目", "workDir": str(tmp_path)}, str(tmp_path), ), ) identity = IdentityContext( user_id=99, username="adopt_owner", fullname="采用用户", tenant_uuid=f"tenant-adopt-{uuid.uuid4().hex[:10]}", roles=("planner",), ) token = bind_identity(identity) return _Store(), kb, token def _adoption_card(reply): return next(block for block in reply.blocks if block.type == "confirm-card") _MASTER_KEYS = ("flexMaterials", "flexOrders") def _seed_master_data(store: _Store) -> dict[str, str]: """既有业务主数据(采用整表替换时绝不允许被清空)。""" store.data["flexMaterials"] = [ {"code": "KEEP-RM", "name": "既有物料", "unit": "件"}, ] store.data["flexOrders"] = [ {"orderNo": "KEEP-1", "productCode": "KEEP-RM", "quantity": 1}, ] return _master_snapshot(store) def _master_snapshot(store: _Store) -> dict[str, str]: return { key: json.dumps(store.data.get(key), ensure_ascii=False, sort_keys=True, default=str) for key in _MASTER_KEYS } def test_sql_analysis_stages_p2_adoption_and_writes_after_approval(tmp_path: Path, monkeypatch): from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity (tmp_path / "mini.sql").write_text(MINI_SQL, encoding="utf-8") store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-sql", session_id="s-sql", ) try: reply = asyncio.run(_run_analyze(store, "s-sql")) # 只读分析:确认前不得写入任何排产数据,但必须给出采用卡。 assert store.data["flexOrders"] == [] assert store.data["flexEquipment"] == [] assert store.data["flexRoutings"] == [] card = _adoption_card(reply) assert card.props["action"] == "import.commit" message = execute_confirmed(store, card.props["confirmId"], True, "approver") assert "采用" in message assert store.data["flexOrders"] assert store.data["flexEquipment"] assert store.data["flexRoutings"] finally: reset_identity(token) def test_mom_analysis_adoption_is_gated_and_fails_closed_on_changed_file( tmp_path: Path, monkeypatch, ): from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity mom_path = tmp_path / "MOM主数据收集表.xlsx" mom_path.write_bytes(b"mom-source-v1") monkeypatch.setattr("server.importers.mom_pack.is_mom_workbook", lambda _path: True) monkeypatch.setattr( "server.importers.mom_pack.parse_mom_workbook", lambda _path: dict(MOM_PACK), ) store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-mom", session_id="s-mom", ) try: reply = asyncio.run(_run_analyze(store, "s-mom")) assert store.data["flexMaterials"] == [] assert store.data["flexOrders"] == [] card = _adoption_card(reply) assert card.props["action"] == "import.commit" message = execute_confirmed(store, card.props["confirmId"], True, "approver") assert "采用" in message assert any(row.get("code") == "MOM-FG" for row in store.data["flexMaterials"]) # 出卡后来源文件被改写:批准必须 fail closed,世界保持最后一次采用结果。 stale = asyncio.run(_run_analyze(store, "s-mom")) stale_card = _adoption_card(stale) before = json.dumps(store.data.get("flexMaterials"), sort_keys=True, default=str) mom_path.write_bytes(b"mom-source-v2") with pytest.raises(PermissionError): execute_confirmed(store, stale_card.props["confirmId"], True, "approver") after = json.dumps(store.data.get("flexMaterials"), sort_keys=True, default=str) assert before == after finally: reset_identity(token) def test_empty_mom_workbook_is_not_adoptable_and_never_clears_master_data( tmp_path: Path, monkeypatch, ): """空表/仅表头(含只是文件名带 MOM 的普通工作簿)不得升级成整表替换的采用卡。""" from server.auth.context import reset_identity mom_path = tmp_path / "MOM2026资料.xlsx" mom_path.write_bytes(b"empty-mom-template") monkeypatch.setattr("server.importers.mom_pack.is_mom_workbook", lambda _path: True) monkeypatch.setattr( "server.importers.mom_pack.parse_mom_workbook", lambda _path: { "filename": mom_path.name, "modelSheetHits": 2, "stats": {"materials": 0, "bom": 0, "equipment": 0, "orders": 0, "routings": 0}, "flexMaterials": [], "flexBom": [], "flexEquipment": [], "flexOrders": [], "flexRoutings": [], }, ) store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-empty-mom", session_id="s-empty-mom", ) try: before = _seed_master_data(store) reply = asyncio.run(_run_analyze(store, "s-empty-mom")) assert not [block for block in reply.blocks if block.type == "confirm-card"] assert _master_snapshot(store) == before finally: reset_identity(token) def test_sql_adoption_rejects_world_drift_after_card_is_staged( tmp_path: Path, monkeypatch, ): """出卡后目标主数据被改动:批准必须拒绝整包替换(卡片同时被消费,需重新分析)。""" from server.agent_core import harness from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity (tmp_path / "mini.sql").write_text(MINI_SQL, encoding="utf-8") store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-sql-drift", session_id="s-sql-drift", ) try: reply = asyncio.run(_run_analyze(store, "s-sql-drift")) card = _adoption_card(reply) frozen = harness.pending_confirmation(card.props["confirmId"]) assert frozen["params"].get("targetWorldFingerprint") store.data["flexMaterials"].append({"code": "LATE-EDIT", "name": "审批期间新增"}) with pytest.raises(PermissionError): execute_confirmed(store, card.props["confirmId"], True, "approver") # 拒绝后世界保持拒绝前状态:SQL 冻结载荷没有被写入。 assert [row.get("code") for row in store.data["flexMaterials"]] == ["LATE-EDIT"] assert store.data["flexOrders"] == [] assert store.data["flexEquipment"] == [] finally: reset_identity(token) def test_mom_adoption_rejects_world_drift_after_card_is_staged( tmp_path: Path, monkeypatch, ): from server.agent_core import harness from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity mom_path = tmp_path / "MOM主数据收集表.xlsx" mom_path.write_bytes(b"mom-source-v1") monkeypatch.setattr("server.importers.mom_pack.is_mom_workbook", lambda _path: True) monkeypatch.setattr( "server.importers.mom_pack.parse_mom_workbook", lambda _path: dict(MOM_PACK), ) store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-mom-drift", session_id="s-mom-drift", ) try: before = _seed_master_data(store) reply = asyncio.run(_run_analyze(store, "s-mom-drift")) card = _adoption_card(reply) frozen = harness.pending_confirmation(card.props["confirmId"]) assert frozen["params"].get("targetWorldFingerprint") store.data["flexOrders"].append( {"orderNo": "LATE-EDIT", "productCode": "KEEP-RM", "quantity": 2}) with pytest.raises(PermissionError): execute_confirmed(store, card.props["confirmId"], True, "approver") snapshot = _master_snapshot(store) assert snapshot["flexMaterials"] == before["flexMaterials"] assert "LATE-EDIT" in snapshot["flexOrders"] assert "MOM-FG" not in snapshot["flexMaterials"] finally: reset_identity(token) def test_zero_row_mom_card_fails_closed_at_execution(tmp_path: Path, monkeypatch): """即使出卡环节被绕过(直接 stage),零业务行的 MOM 卡在批准时也必须拒绝写入。""" from server.agent_core import harness from server.agent_core.fallback_highrisk import file_sha256 from server.aps_domain.folder_pack import folder_schedule_world_fingerprint from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity mom_path = tmp_path / "MOM主数据收集表.xlsx" mom_path.write_bytes(b"mom-empty-v1") monkeypatch.setattr( "server.importers.mom_pack.parse_mom_workbook", lambda _path: {"filename": mom_path.name, "modelSheetHits": 2, "stats": {}, "flexMaterials": [], "flexOrders": []}, ) store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-mom-zero", session_id="s-mom-zero", ) try: before = _seed_master_data(store) block = harness.stage_confirmation( "s-mom-zero", "import.commit", {"filename": mom_path.name, "momPath": str(mom_path), "momSha256": file_sha256(mom_path), "targetWorldFingerprint": folder_schedule_world_fingerprint(store.data)}, title="采用分析资料(测试)", summary_lines=["零业务行必须拒绝"], ) with pytest.raises(PermissionError): execute_confirmed(store, block.props["confirmId"], True, "approver") assert _master_snapshot(store) == before finally: reset_identity(token) def test_sql_adoption_uses_frozen_payload_after_source_file_is_removed( tmp_path: Path, monkeypatch, ): """SQL 采用以冻结载荷为准:出卡后来源文件删除不影响已批准的写入。""" from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity source = tmp_path / "mini.sql" source.write_text(MINI_SQL, encoding="utf-8") store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-sql-frozen", session_id="s-sql-frozen", ) try: reply = asyncio.run(_run_analyze(store, "s-sql-frozen")) card = _adoption_card(reply) source.unlink() message = execute_confirmed(store, card.props["confirmId"], True, "approver") assert "采用" in message assert any(row.get("code") == "FG001" for row in store.data["flexMaterials"]) assert store.data["flexOrders"] finally: reset_identity(token) def test_real_parser_ignores_empty_mom_named_workbook(tmp_path: Path, monkeypatch): """真实解析器路径(不打桩):仅文件名带 MOM、无业务行的工作簿不得出采用卡。 解析器对任何工作簿都会生成 5 条钣金默认模板工序,因此业务行口径必须排除 flexOperations,否则空表会重新变成可触发整表替换的采用来源。 """ from openpyxl import Workbook from server.auth.context import reset_identity from server.importers.mom_pack import mom_pack_row_count, parse_mom_workbook mom_path = tmp_path / "MOM2026资料.xlsx" wb = Workbook() wb.active.title = "数据说明" wb.active["A1"] = "本工作簿没有任何 MOM 主数据行" wb.save(mom_path) pack = parse_mom_workbook(str(mom_path)) assert mom_pack_row_count(pack) == 0 # 模板占位工序不算业务行 assert pack.get("flexOperations") # 但解析器确实会生成模板占位工序 assert pack.get("modelSheetHits") == 0 # 也没有任何模型表结构证据 store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-empty-real-mom", session_id="s-empty-real-mom", ) try: before = _seed_master_data(store) reply = asyncio.run(_run_analyze(store, "s-empty-real-mom")) assert not [block for block in reply.blocks if block.type == "confirm-card"] assert _master_snapshot(store) == before finally: reset_identity(token) def test_real_parser_zone_only_workbook_cannot_clear_master_data( tmp_path: Path, monkeypatch, ): """只有 01-生产模型 一行车间记录的工作簿:辅助台账不算主干,不得出卡、不得整表替换。""" from openpyxl import Workbook from server.agent_core import harness from server.agent_core.fallback_highrisk import file_sha256 from server.aps_domain.folder_pack import folder_schedule_world_fingerprint from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity from server.importers.mom_pack import mom_pack_row_count, parse_mom_workbook mom_path = tmp_path / "MOM车间清单.xlsx" wb = Workbook() ws = wb.active ws.title = "01-生产模型" ws.append(["车间管理"]) ws.append(["", "", "车间名称", "", "装配车间"]) ws.append(["", "", "装配车间", "", "WZ-01"]) wb.save(mom_path) pack = parse_mom_workbook(str(mom_path)) assert pack.get("flexZones") # 解析器确实产出车间(辅助台账)行 assert pack.get("flexOperations") # 以及固定模板工序 assert mom_pack_row_count(pack) == 0 # 但主干行数为 0 assert pack.get("modelSheetHits") == 1 # 且只有 1 张模型表 store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-zone-mom", session_id="s-zone-mom", ) try: before = _seed_master_data(store) reply = asyncio.run(_run_analyze(store, "s-zone-mom")) assert not [block for block in reply.blocks if block.type == "confirm-card"] assert _master_snapshot(store) == before # 绕过出卡直接 stage 的卡也必须被执行期门槛拒绝。 block = harness.stage_confirmation( "s-zone-mom", "import.commit", {"filename": mom_path.name, "momPath": str(mom_path), "momSha256": file_sha256(mom_path), "targetWorldFingerprint": folder_schedule_world_fingerprint(store.data)}, title="采用分析资料(测试)", summary_lines=["辅助台账必须拒绝"], ) with pytest.raises(PermissionError): execute_confirmed(store, block.props["confirmId"], True, "approver") assert _master_snapshot(store) == before finally: reset_identity(token) def test_real_parser_rejects_duplicate_header_rows_and_single_sheet_mom( tmp_path: Path, monkeypatch, ): """重复表头不得造出「伪物料」;只有 1 张模型表的结构证据也不足以背书整表替换。""" from openpyxl import Workbook from server.agent_core import harness from server.agent_core.fallback_highrisk import file_sha256 from server.aps_domain.folder_pack import folder_schedule_world_fingerprint from server.aps_domain.workflow import execute_confirmed from server.auth.context import reset_identity from server.importers.mom_pack import ( mom_pack_is_adoptable, mom_pack_row_count, parse_mom_workbook, ) mom_path = tmp_path / "MOM重复表头.xlsx" wb = Workbook() ws = wb.active ws.title = "02-工艺模型" ws.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws.append(["物料编码", "物料名称", "1", "1", "件"]) # 重复表头(畸形来源) ws.append(["物料编码", "物料名称", "1", "1", "件"]) ws.append(["FG-001", "演示成品", "1", "1", "件"]) # 唯一真实主干行 wb.save(mom_path) pack = parse_mom_workbook(str(mom_path)) codes = {row.get("code") for row in pack.get("flexMaterials") or []} assert "物料编码" not in codes # 表头文本被过滤,不会造出伪物料 assert "FG-001" in codes assert mom_pack_row_count(pack) > 0 # 主干行确实存在(物料 + 根订单) assert pack.get("modelSheetHits") == 1 # 结构证据不足(只有 1 张模型表) assert mom_pack_is_adoptable(pack) is False store, _kb, token = _prepare_env( tmp_path, monkeypatch, project_id="p-header-mom", session_id="s-header-mom", ) try: before = _seed_master_data(store) reply = asyncio.run(_run_analyze(store, "s-header-mom")) # 退化到普通表格采用(逐行预览、合并语义)可以;升级成 MOM 整表替换卡不行。 cards = [block for block in reply.blocks if block.type == "confirm-card"] assert not any( ((harness.pending_confirmation(card.props["confirmId"]) or {}).get("params") or {}) .get("momPath") for card in cards ) assert _master_snapshot(store) == before block = harness.stage_confirmation( "s-header-mom", "import.commit", {"filename": mom_path.name, "momPath": str(mom_path), "momSha256": file_sha256(mom_path), "targetWorldFingerprint": folder_schedule_world_fingerprint(store.data)}, title="采用分析资料(测试)", summary_lines=["结构证据不足必须拒绝"], ) with pytest.raises(PermissionError): execute_confirmed(store, block.props["confirmId"], True, "approver") assert _master_snapshot(store) == before finally: reset_identity(token) def test_real_parser_counts_sheets_not_hints_and_filters_material_model_headers(tmp_path: Path): """结构证据按工作表张数计;04-物料模型的别名重复表头同样不得造出伪物料。""" from openpyxl import Workbook from server.importers.mom_pack import ( mom_pack_is_adoptable, parse_mom_workbook, ) # 一个复合名称的单表:命中 2 个 hint,但只有 1 张工作表,不构成结构证据。 composite = tmp_path / "MOM复合表名.xlsx" wb = Workbook() ws = wb.active ws.title = "生产模型-工艺模型" ws.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws.append(["FG-001", "演示成品", "1", "1", "件"]) wb.save(composite) pack = parse_mom_workbook(str(composite)) assert pack.get("modelSheetHits") == 1 assert mom_pack_is_adoptable(pack) is False # 两张模型表(结构证据成立),但 04-物料模型 只有别名重复表头、没有真实数据。 dual = tmp_path / "MOM双表.xlsx" wb = Workbook() ws = wb.active ws.title = "02-工艺模型" ws.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws.append(["FG-001", "演示成品", "1", "1", "件"]) ws2 = wb.create_sheet("04-物料模型") ws2.append(["品号", "物料名称", "物料组", "单位", "图号"]) ws2.append(["品号", "物料名称", "物料组", "单位", "图号"]) # 重复表头 wb.save(dual) pack2 = parse_mom_workbook(str(dual)) codes = {row.get("code") for row in pack2.get("flexMaterials") or []} assert codes == {"FG-001"} # 表头标签没有被当成物料 assert pack2.get("modelSheetHits") == 2 assert mom_pack_is_adoptable(pack2) is True # 真实主干行 + 结构证据 def test_real_parser_ignores_template_sentinel_rows(tmp_path: Path): """两张模型表但只有模板示例行:结构证据成立、真实主数据为 0,不得准入。""" from openpyxl import Workbook from server.importers.mom_pack import ( mom_pack_is_adoptable, mom_pack_row_count, parse_mom_workbook, ) mom_path = tmp_path / "MOM模板示例.xlsx" wb = Workbook() ws = wb.active ws.title = "01-生产模型" ws.append(["工厂信息", "说明"]) ws2 = wb.create_sheet("02-工艺模型") ws2.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws2.append(["示例", "示例", "1", "1", "件"]) # 模板哨兵行 wb.save(mom_path) pack = parse_mom_workbook(str(mom_path)) assert pack.get("modelSheetHits") == 2 # 结构证据成立 assert not pack.get("flexMaterials") # 但没有任何真实物料 assert mom_pack_row_count(pack) == 0 # 也没有自动生成的订单 assert mom_pack_is_adoptable(pack) is False def test_real_parser_ignores_instruction_rows_as_evidence(tmp_path: Path): """「填写说明」类填写指引行:两张模型表但只有说明文字,不得准入、不得造伪数据。""" from openpyxl import Workbook from server.importers.mom_pack import ( mom_pack_is_adoptable, mom_pack_row_count, parse_mom_workbook, ) # 1) 典型模板指引行:命中填写指引词 → 解析期就跳过(不入库,也不派生假订单)。 guide_path = tmp_path / "MOM填写指引.xlsx" wb = Workbook() ws = wb.active ws.title = "01-生产模型" ws.append(["工厂信息", "说明"]) ws2 = wb.create_sheet("02-工艺模型") ws2.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws2.append(["填写说明", "请填写真实物料信息", "1", "1", "件"]) wb.save(guide_path) pack = parse_mom_workbook(str(guide_path)) assert pack.get("modelSheetHits") == 2 assert not pack.get("flexMaterials") assert mom_pack_row_count(pack) == 0 assert mom_pack_is_adoptable(pack) is False # 2) 不含指引词、也不是业务编码形态的中文占位文本:主干证据不成立(证据口径兜底)。 vague_path = tmp_path / "MOM中文占位.xlsx" wb = Workbook() ws = wb.active ws.title = "01-生产模型" ws.append(["工厂信息", "说明"]) ws2 = wb.create_sheet("02-工艺模型") ws2.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws2.append(["内容", "内容", "1", "1", "件"]) wb.save(vague_path) pack2 = parse_mom_workbook(str(vague_path)) assert mom_pack_row_count(pack2) == 0 # 无字母数字编码 → 不算主干证据 assert mom_pack_is_adoptable(pack2) is False # 3) 真实形态不受影响:中文物料名 + 字母数字编码照常准入。 real_path = tmp_path / "MOM中文物料名.xlsx" wb = Workbook() ws = wb.active ws.title = "02-工艺模型" ws.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws.append(["FG-001", "垫片", "1", "1", "件"]) ws2 = wb.create_sheet("04-物料模型") ws2.append(["品号", "物料名称", "物料组", "单位", "图号"]) ws2.append(["RM-0001", "钢带", "原材料", "件", ""]) wb.save(real_path) pack3 = parse_mom_workbook(str(real_path)) assert mom_pack_row_count(pack3) > 0 assert mom_pack_is_adoptable(pack3) is True # 4) 常见演示/测试编码(TEST-001):同样不算主干证据,不得出「整表替换」采用卡。 test_path = tmp_path / "MOM测试编码.xlsx" wb = Workbook() ws = wb.active ws.title = "01-生产模型" ws.append(["工厂信息", "说明"]) ws2 = wb.create_sheet("02-工艺模型") ws2.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws2.append(["TEST-001", "测试物料", "1", "1", "件"]) wb.save(test_path) pack4 = parse_mom_workbook(str(test_path)) assert mom_pack_row_count(pack4) == 0 assert mom_pack_is_adoptable(pack4) is False def test_real_parser_keeps_real_rows_whose_text_merely_looks_template_like(tmp_path: Path): """「说明书」「测试夹具」这类真实物料:占位判定是前缀/整值匹配,不得被误杀。""" from openpyxl import Workbook from server.importers.mom_pack import ( mom_pack_is_adoptable, mom_pack_row_count, parse_mom_workbook, ) mom_path = tmp_path / "MOM真实物料.xlsx" wb = Workbook() ws = wb.active ws.title = "02-工艺模型" ws.append(["物料编码", "物料名称", "层级", "数量", "单位"]) ws.append(["DOC-01", "说明书", "1", "1", "件"]) ws.append(["FX-001", "测试夹具", "2", "2", "件"]) ws2 = wb.create_sheet("04-物料模型") ws2.append(["品号", "物料名称", "物料组", "单位"]) ws2.append(["RM-0001", "钢板", "原材料", "件"]) wb.save(mom_path) pack = parse_mom_workbook(str(mom_path)) codes = {row.get("code") for row in pack.get("flexMaterials") or []} assert {"DOC-01", "FX-001", "RM-0001"} <= codes assert mom_pack_row_count(pack) > 0 assert mom_pack_is_adoptable(pack) is True