aps-agent/tests/golden/test_analysis_adoption_sql_...

698 lines
29 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.

# ============================================================
# 只读分析 → 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