2026-08-11 00:54:05 +08:00
|
|
|
|
# ============================================================
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# MD-04 Excel/CSV 导入管线黄金测试
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.importers import apply_import_commit, preview_file, validate_batch
|
2026-08-11 19:01:05 +08:00
|
|
|
|
from server.state.seed import empty_world, seed_world
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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"])
|
2026-08-11 00:54:05 +08:00
|
|
|
|
assert any(o.get("orderNo") == "SO-IMP-1" for o in world["salesOrders"])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"])
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 19:01:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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]
|