aps-agent/tests/golden/test_importers.py

331 lines
14 KiB
Python
Raw Normal View History

# ============================================================
# MD-04 Excel/CSV 导入管线黄金测试
# ============================================================
from __future__ import annotations
from server.aps_domain.importers import apply_import_commit, preview_file, validate_batch
from server.state.seed import seed_world
def _next_id_factory(world):
tables = {
"audit": "auditEvents",
"salesOrder": "salesOrders",
"material": "materials",
}
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
if kind not in counters:
rows = world.get(tables.get(kind, kind + "s"), [])
counters[kind] = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0)
counters[kind] += 1
return counters[kind]
return next_id
def test_validate_equipment_batch():
world = seed_world()
rows = [
{"code": "EQ-X1", "name": "试验压接机", "capabilities": "OP-PRESS,OP-WELD",
"opStdTime": "OP-PRESS:2,OP-WELD:5", "movable": "是", "zone": "ZONE-A",
"availabilityRate": "0.95", "status": "RUNNING"},
]
batch = validate_batch("equipment", rows, world)
assert batch["okCount"] == 1
assert batch["errorCount"] == 0
assert "OP-PRESS" in batch["okRows"][0]["capabilities"]
def test_preview_csv_orders_and_commit():
world = seed_world()
product = next(m for m in world["materials"] if m.get("type") == "FINISHED_PRODUCT")
before = len(world["salesOrders"])
csv_text = (
"订单号,客户,产品编码,数量,交期,优先级\n"
f"SO-IMP-1,导入客户,{product['code']},50,2026-08-01,3\n"
).encode("utf-8")
preview = preview_file("orders.csv", csv_text, world)
assert preview["canCommit"] is True
assert preview["totalOk"] >= 1
assert preview["batches"][0]["kind"] == "orders"
applied = apply_import_commit(world, _next_id_factory(world), preview["batches"])
assert applied["total"] >= 1
assert len(world["salesOrders"]) == before + 1
assert any(o.get("customerName") == "导入客户" for o in world["salesOrders"])
def test_commit_flex_equipment():
world = seed_world()
batches = [{
"kind": "equipment",
"sheet": "设备",
"okRows": [{
"code": "EQ-NEW-99", "name": "新压接", "capabilities": ["OP-PRESS"],
"opStdTime": {"OP-PRESS": 1.5}, "movable": True, "moveTimeMin": 15,
"zone": "ZONE-A", "availabilityRate": 1.0, "status": "RUNNING",
"adaptableMolds": [],
}],
}]
applied = apply_import_commit(world, _next_id_factory(world), batches)
assert applied["summary"].get("flexEquipment", 0) == 1
assert any(e["code"] == "EQ-NEW-99" for e in world["flexEquipment"])
def test_xlsx_mom_template_multiblock_headers_soft_preview():
"""锐扬 MOM 收集表模板:多行标题/分块表头也能识别(round-55)。
- 「02-工艺模型」标题行 + 第 4 行真实表头(序号/ERP品号/产品名称/单套用量/单位)
- 「04-物料模型」物料组块(第 1 行)+ 物料主数据块(第 5 行)
- 「07-设备模型」第 1 行表头用「财务编号/设备名称」(无可执行工序 → soft GENERAL)
"""
import io
import openpyxl
from server.aps_domain.importers import preview_file
from server.state.seed import seed_world
wb = openpyxl.Workbook()
ws1 = wb.active
ws1.title = "02-工艺模型"
ws1.append(["湖南锐扬创科智能科技有限公司\n物料清单", "", "", "", "", "", ""])
ws1.append(["", "", "", "", "", "", ""])
ws1.append(["客户代码", "", "R001", "", "", "", ""])
ws1.append(["序号", "层次", "ERP品号", "产品名称", "单套用量", "单位",
"制造流程(根据工艺代码表填写对应的工艺代码)"])
ws1.append(["", "", "", "", "", "", "01", "02", "03"])
ws1.append([1, 1, "A0050101-00280", "外壳", 1, "PCS",
"ZP01组装", "LM01POP拉铆", "BZ01机柜包装"])
ws1.append([2, 1, "B0050121-22551", "柜体焊接组件", 1, "PCS",
"ZP01组装", "WELD焊接"])
ws4 = wb.create_sheet("04-物料模型")
ws4.append(["物料组", "物料组名称", "物料组编码", "所属工厂"])
ws4.append(["", "示例:成品组", "CP", "湖南锐扬"])
ws4.append(["物料主数据", "物料名称", "物料简称", "物料编码", "物料组名称", "物料组编码", "单位"])
ws4.append(["", "外壳", "外壳", "A0050101-00280", "成品", "CP", "PCS"])
ws4.append(["", "冷轧板", "冷轧板", "C0010102-00021", "原材料", "YCL", "KG"])
ws7 = wb.create_sheet("07-设备模型")
ws7.append(["设备台账管理", "序号", "财务编号", "设备名称", "设备型号"])
ws7.append(["", "1", "A00076", "光纤激光切割机", "D-FAST1530FCCBD300"])
buf = io.BytesIO()
wb.save(buf)
world = seed_world()
preview = preview_file("湖南锐扬MOM主数据收集表.xlsx", buf.getvalue(), world, soft=True)
assert preview["canCommit"] is True
batches = {b["sheet"]: b for b in preview["batches"]}
assert batches["02-工艺模型"]["okCount"] == 2
row = batches["02-工艺模型"]["okRows"][0]
assert row["code"] == "A0050101-00280" and row["name"] == "外壳"
# 制造流程列 → routing 视图(round-57)
rts = batches["02-工艺模型#routing"]
assert rts["kind"] == "routing" and rts["okCount"] == 5
by_key = {(r["productCode"], r["seq"]): r for r in rts["okRows"]}
first = by_key[("A0050101-00280", 1)]
assert first["operationCode"] == "ZP01" and first["operationName"] == "组装"
assert by_key[("A0050101-00280", 2)]["operationName"] == "拉铆"
assert by_key[("B0050121-22551", 2)]["operationCode"] == "WELD"
assert batches["04-物料模型"]["okCount"] == 2
by_code = {r["code"]: r for r in batches["04-物料模型"]["okRows"]}
assert by_code["A0050101-00280"]["type"] == "FINISHED_PRODUCT"
assert by_code["C0010102-00021"]["type"] == "RAW_MATERIAL"
eq = batches["07-设备模型"]
assert eq["okCount"] == 1
assert eq["okRows"][0]["code"] == "A00076"
assert eq["okRows"][0]["capabilities"] == ["GENERAL"]
def test_xlsx_ruiyang_structured_diagnostics_preserve_source_rows_and_legacy_contract():
"""锐扬 53 行语义缩比:可恢复解析噪声、结构行和唯一真实阻塞应分离。"""
import io
from collections import Counter
import openpyxl
from server.aps_domain.importers import preview_file
from server.state.seed import seed_world
wb = openpyxl.Workbook()
# 02-工艺模型:第 5 行是制造流程二级序号表头,不是 materials 数据。
ws2 = wb.active
ws2.title = "02-工艺模型"
ws2.append(["湖南锐扬创科智能科技有限公司\n物料清单"] + [""] * 27)
ws2.append([""] * 28)
ws2.append(["客户代码", "", "R001"] + [""] * 25)
ws2.append([
"序号", "层次", "ERP品号", "客户图号", "产品名称", "客户版本",
"单套用量", "出数/底数", "单位", "品号属性", "仓库代码", "材质",
"厚度", "展开长*宽", "面积", "表面处理",
"制造流程(根据工艺代码表填写对应的工艺代码)",
] + [""] * 11)
for col in range(1, 17):
ws2.merge_cells(start_row=4, start_column=col, end_row=5, end_column=col)
ws2.merge_cells("Q4:AB4")
for offset in range(12):
ws2.cell(row=5, column=17 + offset, value=f"{offset + 1:02d}")
material_row = [None] * 28
material_row[0] = 1
material_row[1] = 1
material_row[2] = "MAT-001"
material_row[4] = "合成外壳"
material_row[6] = 1
material_row[8] = "PCS"
material_row[16] = "ZP01组装"
ws2.append(material_row)
# 07-设备模型:2 条重复 code 别名噪声、4 条嵌入 section、1 条真实缺码。
ws7 = wb.create_sheet("07-设备模型")
ws7.append([
"设备台账管理", "序号", "财务编号", "固定资产编号", "设备名称",
"设备型号", "厂内编号", "出厂编号", "位置/一级保养责任人", "供应商",
"购置年月", "设备类型", "设备状态", "备注", "", "设备状态",
])
ws7.append(["", 1, "FIN-VALID", "FA-VALID", "厂内编码设备", "MODEL-A",
"INTERNAL-VALID", "SERIAL-A", "A区", "供应商A", None,
"生产设备", "使用中"])
ws7.append(["", 2, "FIN-001", "FA-001", "财务编码设备一", "MODEL-B",
None, "SERIAL-B", "B区", "供应商B", None, "生产设备", "使用中"])
ws7.append(["", 3, "FIN-002", "FA-002", "财务编码设备二", "MODEL-C",
None, "SERIAL-C", "C区", "供应商C", None, "生产设备", "使用中"])
ws7.append(["", 4, None, None, "唯一缺码设备", "MODEL-D",
None, "SERIAL-D", "D区", "供应商D", None, "生产设备", "使用中"])
ws7.merge_cells("A1:A5")
ws7.append(["备品备件管理", "备件名称", "备件编码", "规格型号", "单位", "备注"])
ws7.append([None] * 16)
ws7.merge_cells("A6:A7")
ws7.append(["设备部件管理", "部件名称", "部件编码", "规格型号", "单位", "备注"])
ws7.append([None] * 16)
ws7.merge_cells("A8:A9")
ws7.append(["设备类型", "类型名称", "类型编码", "备注"])
ws7.append(["", "生产设备", "SBLX001"])
ws7.append([None] * 16)
ws7.merge_cells("A10:A12")
buf = io.BytesIO()
wb.save(buf)
preview = preview_file(
"湖南锐扬MOM主数据收集表.xlsx",
buf.getvalue(),
seed_world(),
soft=True,
)
# 兼容性:原 preview/batch 字段必须继续存在。
assert {"filename", "batches", "totalOk", "totalErrors", "canCommit"} <= preview.keys()
batches = {b["sheet"]: b for b in preview["batches"]}
materials = batches["02-工艺模型"]
equipment = batches["07-设备模型"]
legacy_batch_fields = {
"sheet", "kind", "fieldMap", "headersRaw", "okRows", "errors",
"warnings", "okCount", "errorCount",
}
assert legacy_batch_fields <= materials.keys()
assert legacy_batch_fields <= equipment.keys()
assert isinstance(materials["diagnostics"], list)
assert isinstance(equipment["diagnostics"], list)
# 02 row 5:SECONDARY_SEQUENCE_HEADER 必须忽略且保留真实 Excel 行号。
secondary = [
d for d in materials["diagnostics"]
if d.get("code") == "SECONDARY_SEQUENCE_HEADER"
]
assert len(secondary) == 1
assert secondary[0]["classification"] == "ignorable_section"
assert secondary[0]["excelRow"] == 5
assert materials["okCount"] == 1
assert materials["errorCount"] == 0
# 重复 code 别名:最后一个非空编码优先;后续空列不能覆盖已有编码。
equipment_by_code = {row["code"]: row for row in equipment["okRows"]}
assert {"INTERNAL-VALID", "FA-001", "FA-002"} <= equipment_by_code.keys()
assert equipment["okCount"] == 3
assert equipment["errorCount"] == 1
assert preview["totalErrors"] == 1
# 真实 47/5/1 的小型缩比:2 条 parser_noise、5 条结构忽略、1 条 blocking。
all_diagnostics = materials["diagnostics"] + equipment["diagnostics"]
primary_classes = Counter(
d.get("classification") for d in all_diagnostics
if d.get("classification") in {"parser_noise", "ignorable_section", "blocking"}
)
assert primary_classes == Counter({
"parser_noise": 2,
"ignorable_section": 5,
"blocking": 1,
})
assert {
d["excelRow"] for d in equipment["diagnostics"]
if d.get("classification") == "parser_noise"
} == {3, 4}
assert {
d["excelRow"] for d in equipment["diagnostics"]
if d.get("classification") == "ignorable_section"
} == {6, 8, 10, 11}
blocking = [
d for d in equipment["diagnostics"]
if d.get("classification") == "blocking"
]
assert len(blocking) == 1 and blocking[0]["excelRow"] == 5
# soft 模式下 GENERAL warning 与成功行共存,不能把成功行重新算成 error。
assert all(equipment_by_code[code]["capabilities"] == ["GENERAL"]
for code in ("INTERNAL-VALID", "FA-001", "FA-002"))
assert any("GENERAL" in str(warning) for warning in equipment["warnings"])
general_diagnostics = [
d for d in equipment["diagnostics"]
if d.get("classification") == "warning" and "GENERAL" in str(d)
]
assert general_diagnostics
assert all(isinstance(d.get("excelRow"), int) for d in all_diagnostics)
# __source__ 只供内部追踪,不能泄漏到兼容的 okRows 领域行。
assert all(
"__source__" not in row
for batch in preview["batches"]
for row in batch["okRows"]
)
def test_xlsx_flat_header_row_0_behavior_unchanged():
"""平铺表第 1 行即表头:探测不得改变既有行为(round-55 回归)。"""
import io
import openpyxl
from server.aps_domain.importers import preview_file
from server.state.seed import seed_world
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "物料"
ws.append(["编码", "名称", "类型", "单位", "库存"])
ws.append(["M-FLAT-1", "平铺物料", "FINISHED_PRODUCT", "件", "10"])
buf = io.BytesIO()
wb.save(buf)
world = seed_world()
preview = preview_file("物料.xlsx", buf.getvalue(), world, soft=True)
assert preview["canCommit"] is True
assert preview["batches"][0]["okRows"][0]["code"] == "M-FLAT-1"
def test_order_csv_rush_column_maps_to_is_rush():
"""订单表「加急」列 → isRush(round-58,现场订单表常用列)。"""
from server.aps_domain.importers import preview_file
from server.state.seed import seed_world
world = seed_world()
product = next(m for m in world["materials"] if m.get("type") == "FINISHED_PRODUCT")
csv_text = (
"订单号,客户,产品编码,数量,交期,优先级,加急\n"
f"SO-RUSH-1,现场客户,{product['code']},10,2026-08-10,3,是\n"
).encode("utf-8")
preview = preview_file("订单.csv", csv_text, world, soft=True)
assert preview["totalOk"] >= 1
row = preview["batches"][0]["okRows"][0]
assert row["isRush"] is True
assert row["orderNo"] == "SO-RUSH-1"