aps-agent/tests/golden/test_importers.py

473 lines
20 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.

# ============================================================
# 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]