aps-agent/tests/golden/test_importers.py

473 lines
20 KiB
Python
Raw Permalink 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 empty_world, 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 = {
"id": max(m.get("id", 0) for m in world["materials"]) + 1,
"code": "RUIYANG-IMPORT-PRODUCT", "name": "锐扬导入测试成品",
"type": "FINISHED_PRODUCT", "unit": "套", "stock": 0,
}
world["materials"].append(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"])
assert any(o.get("orderNo") == "SO-IMP-1" 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 = {
"id": max(m.get("id", 0) for m in world["materials"]) + 1,
"code": "RUIYANG-IMPORT-PRODUCT", "name": "锐扬导入测试成品",
"type": "FINISHED_PRODUCT", "unit": "套", "stock": 0,
}
world["materials"].append(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"
def test_material_create_ids_do_not_collide_with_order_projection():
"""订单先入库时 sync 会直接建成品;原材料 create 不得复用外部发号器的旧值。"""
world = empty_world()
counters: dict[str, int] = {}
def stale_next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
orders = [{
"kind": "orders", "sheet": "销售订单",
"okRows": [{
"orderNo": "SO-ID-1", "productCode": "FG-ID", "productName": "成品",
"quantity": 2, "deliveryDate": "2026-08-12", "priority": 5,
}],
}]
apply_import_commit(world, stale_next_id, orders)
materials = [{
"kind": "materials", "sheet": "物料",
"okRows": [
{"code": "FG-ID", "name": "成品", "type": "FINISHED_PRODUCT", "unit": "件", "stock": 0},
{"code": "RM-ID", "name": "原料", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 0},
],
}]
apply_import_commit(world, stale_next_id, materials)
rows = world["materials"]
assert len(rows) == len({row["id"] for row in rows})
assert len([row for row in rows if row.get("code") == "RM-ID"]) == 1
def test_generic_batches_sync_flex_bom_and_routing_for_mrp():
"""通用 Excel 批次必须同步经典 BOM/工艺,否则 MRP 只能生成根制造需求。"""
world = seed_world()
product_code = "RUIYANG-SYNC-PRODUCT"
raw_code = "RUIYANG-SYNC-RAW"
batches = [
{
"kind": "materials",
"sheet": "物料",
"okRows": [
{"code": product_code, "name": "锐扬同步成品", "type": "FINISHED_PRODUCT", "unit": "套", "stock": 0},
{"code": raw_code, "name": "锐扬同步原料", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 0,
"inTransit": 0, "procurementLeadTime": 3},
],
},
{
"kind": "routing",
"sheet": "工艺路线",
"okRows": [
{"productCode": product_code, "seq": 1, "operationCode": "CUT",
"operationName": "激光下料", "stdTimePerUnit": 5.0, "sourcingType": "MAKE"},
],
},
{
"kind": "bom",
"sheet": "BOM",
"okRows": [
{"productCode": product_code, "materialCode": raw_code, "quantity": 2.0, "isKey": True},
],
},
{
"kind": "orders",
"sheet": "销售订单",
"okRows": [
{"orderNo": "SO-RUIYANG-SYNC", "productCode": product_code, "quantity": 3,
"deliveryDate": "2026-08-12", "priority": 2},
],
},
]
apply_import_commit(world, _next_id_factory(world), batches)
product = next(row for row in world["materials"] if row.get("code") == product_code)
assert any(row.get("productId") == product["id"] for row in world["boms"])
assert any(row.get("productId") == product["id"] for row in world["routings"])
assert any(row.get("orderNo") == "SO-RUIYANG-SYNC" for row in world["salesOrders"])
from server.aps_domain.mrp import decompose_orders
result = decompose_orders(world, _next_id_factory(world), "SO-RUIYANG-SYNC")
assert len(result["make"]) == 1
assert len(result["purchase"]) == 1
assert result["purchase"][0]["materialCode"] == raw_code
assert result["purchase"][0]["quantity"] == 6
def test_ruiyang_product_and_bom_header_aliases():
world = seed_world()
product = validate_batch(
"materials",
[{"productCode": "RY-P-001", "productName": "锐扬产品", "type": "成品", "unit": "套"}],
world,
soft=True,
)
assert product["okRows"][0]["code"] == "RY-P-001"
assert product["okRows"][0]["name"] == "锐扬产品"
raw = (
"父项产品编码,子项物料编码,单套用量,关键料\n"
"RY-P-001,RY-M-001,2,是\n"
).encode("utf-8-sig")
preview = preview_file("BOM.csv", raw, world, soft=True)
batch = preview["batches"][0]
assert batch["okCount"] == 1
assert batch["okRows"][0] == {
"productCode": "RY-P-001",
"materialCode": "RY-M-001",
"quantity": 2.0,
"consumeOp": "",
"isKey": True,
}
def test_calendar_csv_import_supports_continuous_demo_windows():
world = seed_world()
raw = (
"shiftCode,name,startTime,endTime,workdays,breaks,enabled\n"
"SIM08,连续仿真窗08,08:00,08:00,1/2/3/4/5/6/7,,是\n"
).encode("utf-8-sig")
preview = preview_file("06_calendar.csv", raw, world, soft=True)
batch = preview["batches"][0]
assert batch["kind"] == "calendar"
assert batch["okCount"] == 1
apply_import_commit(world, _next_id_factory(world), preview["batches"])
row = next(item for item in world["flexCalendar"] if item.get("shiftCode") == "SIM08")
assert row["startTime"] == "08:00"
assert row["endTime"] == "08:00"
assert row["workdays"] == [1, 2, 3, 4, 5, 6, 7]