aps-agent/server/aps_domain/kangni_intake.py

2027 lines
81 KiB
Python
Raw Normal View History

# ============================================================
# 现场「完整生产路线」导入(moduleId: domain-kangni-intake, 可重生 ✅)
# 将康尼现场 Excel 落到 flex* 键,替换演示种子,供 PoolEngine 排产。
# 口径:docs/product/demand-data-intake.md;缺口用显式推断并写入 flexParams.siteInferences。
# 客户差异(工厂命名/工时推断规则/默认路径)收敛在 server/importers/profiles/kangni.json,
# 本模块仅保留解析逻辑 + 兜底默认值(profile 缺失时可独立运行)。
# ============================================================
from __future__ import annotations
import re
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from typing import Any
World = dict[str, Any]
class KangniDataMappingError(ValueError):
"""康尼源表无法唯一、安全映射到订单时抛出。"""
def _profile() -> dict[str, Any]:
"""加载康尼导入 profile(配置文件缺失时返回空 dict → 走内置兜底)。"""
try:
from server.importers.excel_importer import load_profile
return load_profile("kangni")
except Exception:
return {}
_P = _profile()
# 现场默认路径(profile 可覆盖;再可被环境变量 / API / 脚本覆盖)
DEFAULT_ROUTE_XLSX = Path(_P.get("defaultRouteXlsx") or (
r"D:\ItemSpace\14.工业智核\康尼\数据\outputs"
r"\019f8987-be3f-7261-a9d3-efc1ecf9c27d\订单102285668_完整生产路线.xlsx"
))
DEFAULT_DATA_DIR = Path(_P.get("dataDir") or r"D:\ItemSpace\14.工业智核\康尼\数据")
# 工时/产能缺口推断(源表「待维护」时启用;单位:分钟/件)
_INFER_STD_MIN = {k: float(v) for k, v in (_P.get("inferStdMin") or {"部装": 45.0, "装配": 30.0, "_default": 30.0}).items()}
_DEFAULT_STATION_COUNT = int(_P.get("stationCount") or 4)
_FLEX_CLEAR_KEYS = (
"flexZones", "flexOperations", "flexEquipment", "flexMolds",
"flexMaterials", "flexBom", "flexRoutings", "flexTeams", "flexCalendar",
"flexOrders", "flexParams",
"flexScheduleVersions", "flexVirtualLines", "flexWorkOrders", "flexConflicts",
)
# 固定轨 + 排产产物:加载现场时整表清空(去掉演示垃圾)
_FIXED_CLEAR_KEYS = (
"factories", "workshops", "lines", "workstations", "equipment",
"operations", "routings", "routingSteps", "materials", "boms", "bomItems",
"lineProducts", "workstationOperations", "teams", "shifts", "shiftCalendar",
"maintenance", "salesOrders", "productionOrders", "workOrders",
"purchaseOrders", "outsourceOrders", "forecastOrders", "changeoverMatrix",
"scheduleVersions", "conflicts", "logs",
)
def _clean(v: Any) -> str:
if v is None:
return ""
return str(v).replace("\u200b", "").strip()
def _num(v: Any, default: float = 0.0) -> float:
if v is None or v == "":
return default
if isinstance(v, (int, float)):
return float(v)
s = _clean(v).replace(",", "")
try:
return float(s)
except ValueError:
return default
def _sequence(v: Any, fallback: int) -> int:
s = _clean(v)
if not s:
return fallback
try:
return int(float(s))
except ValueError:
return fallback
def _is_pending(v: Any) -> bool:
s = _clean(v)
return (not s) or ("待维护" in s) or s.lower() in ("n/a", "na", "-", "—")
def _parse_dt_date(v: Any) -> str | None:
"""任意日期/时间 → YYYY-MM-DD。"""
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.strftime("%Y-%m-%d")
s = _clean(v)
m = re.match(r"(\d{4}-\d{2}-\d{2})", s)
if m:
return m.group(1)
try:
return datetime.fromisoformat(s.replace("/", "-")).strftime("%Y-%m-%d")
except ValueError:
return None
def _infer_std_min(op_type: str, raw_hours: Any, infer_map: dict[str, float] | None = None) -> tuple[float, bool]:
"""返回 (分钟/件, 是否推断)。源表工时单位为小时;推断规则可由 profile 覆盖。"""
rules = infer_map or _INFER_STD_MIN
if not _is_pending(raw_hours):
h = _num(raw_hours, 0.0)
if h > 0:
return (h * 60.0, False)
t = _clean(op_type)
for key, mins in rules.items():
if key != "_default" and key in t:
return (float(mins), True)
return (float(rules.get("_default", 30.0)), True)
def _find_file(data_dir: Path, kind: str) -> Path | None:
"""按语义找源表(文件名乱码时靠 sheet 特征)。kind: orders|routing|bom|equipment|molds|materials。"""
if not data_dir.exists():
return None
try:
import openpyxl
except ImportError:
return None
name_hints = {
"orders": ("订单",),
"routing": ("工艺路线", "路线"),
"stdtime": ("工时",),
"equipmap": ("设备能力",),
"moldmap": ("模具适配",),
"bom": ("BOM", "bom"),
"equipment": ("设备", "Equipment"),
"molds": ("模具", "Mould", "Mold"),
"materials": ("物料",),
}
for f in sorted(data_dir.glob("*.xlsx")):
if any(h.lower() in f.name.lower() for h in name_hints.get(kind, ())):
return f
for f in sorted(data_dir.glob("*.xlsx")):
try:
wb = openpyxl.load_workbook(f, read_only=True, data_only=True)
names = wb.sheetnames
# 读首行表头
ws = wb[names[0]]
header = []
for i, row in enumerate(ws.iter_rows(values_only=True)):
header = [_clean(c) for c in row]
break
wb.close()
except Exception:
continue
hs = "".join(header)
if kind == "orders" and ("生产订单" in hs or "WBS" in hs) and "计划" in hs:
return f
if kind == "routing" and "工序编号" in hs and "标准工时" in hs and "工序类型" in hs:
return f
if kind == "stdtime" and "工序编号" in hs and "标准工时" in hs and "工序名称" in hs:
return f
if kind == "equipmap" and "设备编号" in hs and "可执行工序" in hs:
return f
if kind == "moldmap" and "模具编号" in hs and "适用工序" in hs:
return f
if kind == "bom" and "MES工序编号" in hs and "物料编号" in hs:
return f
if kind == "equipment" and ("设备编号" in hs or names == ["Equipment"]):
return f
if kind == "molds" and ("工装模具" in hs or "模具编号" in hs or "ABC分类" in hs):
return f
if kind == "materials" and "物料代码" in hs:
return f
# 工艺/BOM 多 sheet 名为订单号
if kind in ("routing", "bom") and any(re.fullmatch(r"\d{6,}", n or "") for n in names):
# 再看第一个订单 sheet 表头
try:
wb = openpyxl.load_workbook(f, read_only=True, data_only=True)
order_sheets = [n for n in wb.sheetnames if re.fullmatch(r"\d{6,}", n)]
if not order_sheets:
wb.close()
continue
ws = wb[order_sheets[0]]
hdr = []
for row in ws.iter_rows(values_only=True):
hdr = [_clean(c) for c in row]
break
wb.close()
blob = "".join(hdr)
if kind == "routing" and "工序编号" in blob:
return f
if kind == "bom" and "物料编号" in blob:
return f
except Exception:
continue
return None
def parse_std_time_sheet(path: Path, order_no: str) -> dict[str, float]:
"""从「工时.xlsx」按订单 sheet 读标准工时(分钟/件)。"""
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
try:
if order_no not in wb.sheetnames:
return {}
ws = wb[order_no]
rows = list(ws.iter_rows(values_only=True))
finally:
wb.close()
if not rows:
return {}
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
out: dict[str, float] = {}
op_col = col.get("工序编号")
std_cols = [i for n, i in col.items() if n in ("标准工时/分钟", "标准工时", "工时")]
for row in rows[1:]:
if op_col is None or op_col >= len(row):
continue
code = _clean(row[op_col])
if not code:
continue
v = 0.0
for idx in std_cols:
if idx < len(row):
v = _num(row[idx], 0.0)
if v > 0:
break
if v > 0:
out[code] = float(v)
return out
def load_std_time_map(data_dir: Path) -> dict[str, dict[str, float]]:
"""扫描「工时.xlsx」全部订单 sheet → {订单号: {工序编号: 分钟}}。"""
if not data_dir.exists():
return {}
std_xlsx = _find_file(data_dir, "stdtime")
if not std_xlsx:
return {}
import openpyxl
wb = openpyxl.load_workbook(std_xlsx, read_only=True, data_only=True)
try:
names = list(wb.sheetnames)
finally:
wb.close()
result: dict[str, dict[str, float]] = {}
for order_no in names:
times = parse_std_time_sheet(std_xlsx, order_no)
if times:
result[order_no] = times
return result
def load_std_route_map(data_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""从工时表订单命名 sheet 读取规范工序序列,供 generic routing 安全匹配。"""
if not data_dir.exists():
return {}
std_xlsx = _find_file(data_dir, "stdtime")
if not std_xlsx:
return {}
import openpyxl
wb = openpyxl.load_workbook(std_xlsx, read_only=True, data_only=True)
try:
result: dict[str, list[dict[str, Any]]] = {}
for order_no in wb.sheetnames:
rows = list(wb[order_no].iter_rows(values_only=True))
if not rows:
continue
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
specs: list[dict[str, Any]] = []
for row in rows[1:]:
code = _clean(_cell(row, col, "工序编号"))
if not code:
continue
specs.append({
"seq": _sequence(
_cell(row, col, "排序号", "序号"),
(len(specs) + 1) * 10,
),
"operationCode": code,
"operationName": _clean(_cell(row, col, "工序名称")) or code,
"opType": _clean(_cell(row, col, "工序类型", "类型")),
})
if specs:
result[_clean(order_no)] = specs
return result
finally:
wb.close()
def _apply_std_time_map(
order: dict[str, Any],
std_time_map: dict[str, dict[str, float]],
*,
override_existing: bool = False,
) -> None:
"""用工时表回填;真实主单入口可显式要求覆盖完整路线中的旧值。"""
times = (std_time_map or {}).get(order.get("orderNo") or "") or {}
if not times:
return
for op in order.get("operations") or []:
code = op.get("operationCode")
if code in times and (override_existing or op.get("stdTimeInferred")):
op["stdTimePerUnit"] = float(times[code])
op["stdTimeInferred"] = False
op["stdTimeSource"] = "工时表"
def _split_multi(v: Any) -> list[str]:
"""逗号/顿号/分号分隔的多值列。"""
return [x.strip() for x in re.split(r"[,,;;、]", _clean(v)) if x.strip()]
def _bool_yn(v: Any) -> bool:
s = _clean(v).lower()
return s in ("1", "true", "yes", "y", "是", "启用")
def _cell(row: tuple, col: dict[str, int], *names: str) -> Any:
"""按别名读单元格,避免循环内闭包。"""
for n in names:
idx = col.get(n)
if idx is not None and idx < len(row):
return row[idx]
return None
def parse_equipment_capability_map(path: Path) -> list[dict[str, Any]]:
"""读取「设备能力映射模板.xlsx」→ flexEquipment 行。"""
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
try:
rows = list(wb.active.iter_rows(values_only=True))
finally:
wb.close()
if not rows:
return []
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
out: list[dict[str, Any]] = []
for row in rows[1:]:
code = _clean(_cell(row, col, "设备编号", "设备编码"))
if not code:
continue
caps = _split_multi(_cell(row, col, "可执行工序编号", "能力工序"))
op_std: dict[str, float] = {}
op_std_raw = _clean(_cell(row, col, "单件工时", "工序工时", "单件工时(分钟,工序:分钟;多值分号)"))
for part in op_std_raw.split(";"):
if ":" in part:
k, v = part.split(":", 1)
op_std[_clean(k)] = _num(v, 0.0)
out.append({
"code": code,
"name": _clean(_cell(row, col, "设备名称")) or code,
"capabilities": caps,
"opStdTime": op_std,
"movable": _bool_yn(_cell(row, col, "是否可移动", "可移动")),
"moveTimeMin": _num(_cell(row, col, "移动耗时", "移动耗时(分钟)"), 0.0),
"zone": _clean(_cell(row, col, "区域编码", "区域")),
"adaptableMolds": _split_multi(_cell(row, col, "适配模具编号", "适配模具", "适配模具编号(逗号分隔)")),
"availabilityRate": _num(_cell(row, col, "可动率", "可用率"), 0.95) or 0.95,
"status": _clean(_cell(row, col, "状态")) or "RUNNING",
"note": _clean(_cell(row, col, "备注")),
})
return out
def parse_mold_adaptation_map(path: Path) -> list[dict[str, Any]]:
"""读取「模具适配映射模板.xlsx」→ flexMolds 行。"""
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
try:
rows = list(wb.active.iter_rows(values_only=True))
finally:
wb.close()
if not rows:
return []
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
out: list[dict[str, Any]] = []
for row in rows[1:]:
code = _clean(_cell(row, col, "模具编号", "模具编码"))
if not code:
continue
out.append({
"code": code,
"name": _clean(_cell(row, col, "模具名称")) or code,
"operationCode": _clean(_cell(row, col, "适用工序编号", "适用工序")),
"adaptableEquipment": _split_multi(_cell(row, col, "适配设备编号", "适配设备", "适配设备编号(逗号分隔)")),
"lifeTotal": _num(_cell(row, col, "寿命上限"), 0.0),
"lifeUsed": _num(_cell(row, col, "已用寿命"), 0.0),
"zone": _clean(_cell(row, col, "区域编码", "区域")),
"changeoverMin": _num(_cell(row, col, "换型耗时", "换型耗时(分钟)"), 10.0) or 10.0,
"status": _clean(_cell(row, col, "状态")) or "AVAILABLE",
"note": _clean(_cell(row, col, "备注")),
})
return out
def build_flex_equipment_and_molds(
data_dir: Path,
packages: list[dict[str, Any]],
op_std: dict[str, float],
all_op_codes: list[str],
profile: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], set[str], list[str]]:
"""优先用设备能力/模具适配映射表;缺表时回退合成共享装配工位。"""
inferences: list[str] = []
equip_map_path = _find_file(data_dir, "equipmap") if data_dir.exists() else None
mold_map_path = _find_file(data_dir, "moldmap") if data_dir.exists() else None
equipment: list[dict[str, Any]] = []
molds: list[dict[str, Any]] = []
mold_ops: set[str] = set()
zone_cfg = profile.get("zone") or {}
default_zone = zone_cfg.get("code") or "ZONE-CG"
if equip_map_path:
equipment = parse_equipment_capability_map(equip_map_path)
for i, row in enumerate(equipment):
row["id"] = i + 1
row["zone"] = row.get("zone") or default_zone
row["availabilityRate"] = float(row.get("availabilityRate") or profile.get("availabilityRate") or 0.95)
row["status"] = row.get("status") or "RUNNING"
row["inferred"] = False
times = dict(row.get("opStdTime") or {})
for cap in row.get("capabilities") or []:
times.setdefault(cap, op_std.get(cap, 30.0))
row["opStdTime"] = times
if equipment:
inferences.append(f"设备能力映射表已读取({len(equipment)} 台)")
if mold_map_path:
molds = parse_mold_adaptation_map(mold_map_path)
for i, row in enumerate(molds):
row["id"] = i + 1
row["zone"] = row.get("zone") or default_zone
row["status"] = row.get("status") or "AVAILABLE"
row["changeoverMin"] = float(row.get("changeoverMin") or 10.0)
row["lifeTotal"] = float(row.get("lifeTotal") or 0.0)
row["lifeUsed"] = float(row.get("lifeUsed") or 0.0)
row["inferred"] = False
if row.get("operationCode"):
mold_ops.add(row["operationCode"])
if molds:
inferences.append(f"模具适配映射表已读取({len(molds)} 套)")
if not equipment:
station_count = int(profile.get("stationCount") or 4)
ws_prefix = profile.get("stationCodePrefix") or "WS-CG-"
ws_name = profile.get("stationNamePrefix") or "城轨机构装配工位"
avail = float(profile.get("availabilityRate") or 0.95)
for i in range(station_count):
code = f"{ws_prefix}{i + 1:02d}"
equipment.append({
"id": i + 1,
"code": code,
"name": f"{ws_name}#{i + 1}",
"capabilities": list(all_op_codes),
"opStdTime": dict(op_std),
"movable": False,
"moveTimeMin": 0,
"zone": default_zone,
"adaptableMolds": [],
"availabilityRate": avail,
"status": "RUNNING",
"inferred": True,
})
inferences.append(
f"设备/工位源表待维护 → 合成 {station_count} 台共享装配工位 "
f"(能力=全部 {len(all_op_codes)} 道工序)"
)
if not molds:
inferences.append("模具未匹配到本产品 → requireMold=false,不占模具槽")
return equipment, molds, mold_ops, inferences
def parse_complete_route_xlsx(path: str | Path, infer_map: dict[str, float] | None = None) -> dict[str, Any]:
"""解析「订单XXXX_完整生产路线.xlsx」→ 单订单包(infer_map=profile 工时推断规则)。"""
import openpyxl
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"找不到生产路线文件: {path}")
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb.active
rows = [[ws.cell(r, c).value for c in range(1, ws.max_column + 1)]
for r in range(1, ws.max_row + 1)]
wb.close()
order: dict[str, Any] = {
"orderNo": "", "productCode": "", "productName": "", "drawingNo": "",
"quantity": 1.0, "planStart": None, "dueDate": None,
"wbs": "", "project": "", "status": "RELEASED", "kitStatus": "",
"operations": [], "bom": [], "source": str(path),
}
# ---- 头信息:扫前 15 行键值对 ----
for row in rows[:15]:
cells = [_clean(c) for c in row]
for i, cell in enumerate(cells):
nxt = cells[i + 1] if i + 1 < len(cells) else ""
if cell in ("订单代码", "订单号") and nxt:
order["orderNo"] = nxt
elif cell in ("产品料号", "料号") and nxt:
order["productCode"] = nxt
elif cell == "订单数量" and nxt:
order["quantity"] = _num(nxt, 1.0) or 1.0
elif cell in ("计划开始",) and nxt:
order["planStart"] = _parse_dt_date(nxt)
elif cell in ("计划结束", "交期") and nxt:
order["dueDate"] = _parse_dt_date(nxt)
elif cell == "WBS" and nxt:
order["wbs"] = nxt
elif cell == "项目" and nxt:
order["project"] = nxt
elif cell == "订单状态" and nxt:
order["status"] = "RELEASED" if "运行" in nxt or nxt in ("RELEASED", "已下达") else "CREATED"
elif cell == "备料状态" and nxt:
order["kitStatus"] = nxt
# 标题行补充产品名
if rows:
title = _clean(rows[1][0] if len(rows) > 1 else "")
m = re.search(r"产品\s*(\S+)[||].+?(?:图号\s*(\S+))?", title)
if m:
if not order["productCode"]:
order["productCode"] = m.group(1)
order["drawingNo"] = m.group(2) or ""
# 「产品 CODE|NAME|图号」
parts = re.split(r"[||]", title)
if len(parts) >= 2:
order["productName"] = parts[1].strip()
# ---- 工艺明细表 ----
op_header_idx = None
for i, row in enumerate(rows):
cells = [_clean(c) for c in row]
if "工序编号" in cells and ("标准工时" in "".join(cells) or "顺序" in cells):
op_header_idx = i
break
if op_header_idx is not None:
headers = [_clean(c) for c in rows[op_header_idx]]
col = {h: i for i, h in enumerate(headers) if h}
def _c(row, *names, default=""):
for n in names:
if n in col and col[n] < len(row):
return row[col[n]]
return default
for row in rows[op_header_idx + 1:]:
code = _clean(_c(row, "工序编号"))
if not code or not re.match(r"^[\w\-]+$", code):
# 遇到下一节标题则停
first = _clean(row[0]) if row else ""
if first.startswith("三、") or first.startswith("关键说明") or first.startswith("BOM"):
break
continue
name = _clean(_c(row, "工序名称"))
op_type = _clean(_c(row, "类型", "工序类型"))
raw_std = _c(row, "标准工时(h)", "标准工时")
std_min, inferred = _infer_std_min(op_type, raw_std, infer_map)
seq = int(_num(_c(row, "顺序", "序号", "排序号"), len(order["operations"]) + 1))
order["operations"].append({
"seq": seq * 10 if seq < 100 else seq,
"operationCode": code,
"operationName": name or code,
"opType": op_type,
"stdTimePerUnit": std_min,
"stdTimeInferred": inferred,
"resourceGroup": _clean(_c(row, "资源组")),
"equipmentHint": _clean(_c(row, "具体设备/工位")),
"moldHint": _clean(_c(row, "工装/模具")),
"requireMold": False, # 源表未匹配专用模具
})
# ---- BOM ----
bom_header_idx = None
for i, row in enumerate(rows):
cells = [_clean(c) for c in row]
if "物料编号" in cells or ("物料编码" in cells and "投料点" in cells):
bom_header_idx = i
break
if bom_header_idx is not None:
headers = [_clean(c) for c in rows[bom_header_idx]]
col = {h: i for i, h in enumerate(headers) if h}
def _b(row, *names, default=""):
for n in names:
if n in col and col[n] < len(row):
return row[col[n]]
return default
for row in rows[bom_header_idx + 1:]:
mat = _clean(_b(row, "物料编号", "物料编码"))
if not mat or mat.startswith("关键") or mat.startswith("BOM"):
first = _clean(row[0]) if row else ""
if first.startswith("关键") or first.startswith("四、"):
break
continue
# 过滤层级伪编码(M01 / 1.1.01 误入物料列)
if re.match(r"^M\d{2}$", mat) or re.match(r"^\d+\.\d+", mat) or len(mat) < 4:
continue
op_code = _clean(_b(row, "工序编号", "MES工序编号"))
qty = _num(_b(row, "单位用量", "用量", "数量"), 1.0)
name = _clean(_b(row, "物料名称", "名称"))
if not name and len(row) > 4:
name = _clean(row[4])
order["bom"].append({
"materialCode": mat,
"materialName": name or mat,
"quantity": qty,
"consumeOp": op_code or (order["operations"][0]["operationCode"] if order["operations"] else ""),
"isKey": qty >= 1.0,
})
if not order["orderNo"]:
raise ValueError(f"未能从 {path.name} 解析出订单号")
if not order["operations"]:
raise ValueError(f"{path.name} 无工艺工序明细,无法排产")
if not order["dueDate"]:
order["dueDate"] = order["planStart"] or datetime.now().strftime("%Y-%m-%d")
if not order["productName"]:
order["productName"] = order["productCode"] or order["orderNo"]
return order
def parse_orders_workbook(path: Path) -> list[dict[str, Any]]:
"""解析源表「订单.xlsx」。"""
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb[wb.sheetnames[0]]
rows = list(ws.iter_rows(values_only=True))
wb.close()
if not rows:
return []
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
out = []
for row in rows[1:]:
def g(*names):
for n in names:
if n in col and col[n] < len(row):
return row[col[n]]
return None
ono = _clean(g("生产订单", "订单号", "订单代码"))
if not ono:
continue
out.append({
"orderNo": ono,
"wbs": _clean(g("WBS号", "WBS")),
"productCode": _clean(g("料号", "产品料号")),
"productName": _clean(g("物料名称", "产品名称")),
"drawingNo": _clean(g("图号")),
"planStart": _parse_dt_date(g("计划开始时间", "计划开始")),
"dueDate": _parse_dt_date(g("计划结束时间", "计划结束", "交期")),
"quantity": _num(g("订单数量", "数量"), 1.0) or 1.0,
"status": "RELEASED",
})
return out
def _routing_sheet_sort_key(name: str) -> tuple[int, int | str]:
match = re.fullmatch(r"Sheet(\d+)", name, re.IGNORECASE)
return (1, int(match.group(1))) if match else (0, name)
def _read_routing_operations(
worksheet: Any,
infer_map: dict[str, float] | None,
std_times: dict[str, float] | None,
) -> list[dict[str, Any]]:
rows = list(worksheet.iter_rows(values_only=True))
if not rows:
return []
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
if "工序编号" not in col:
return []
ops: list[dict[str, Any]] = []
std_times = std_times or {}
for row in rows[1:]:
code = _clean(_cell(row, col, "工序编号"))
if not code:
continue
op_type = _clean(_cell(row, col, "工序类型", "类型"))
raw_std = _cell(row, col, "标准工时")
if code in std_times:
std_min = float(std_times[code])
inferred = False
std_source = "工时表"
else:
std_min, inferred = _infer_std_min(op_type, raw_std, infer_map)
std_source = "推断" if inferred else "实测"
ops.append({
"seq": _sequence(
_cell(row, col, "排序号", "序号"),
(len(ops) + 1) * 10,
),
"operationCode": code,
"operationName": _clean(_cell(row, col, "工序名称")) or code,
"opType": op_type,
"stdTimePerUnit": std_min,
"stdTimeInferred": inferred,
"stdTimeSource": std_source,
"resourceGroup": "",
"requireMold": False,
})
return ops
def _operation_signature(
operations: list[dict[str, Any]],
*,
full: bool,
) -> tuple[tuple[Any, ...], ...]:
ordered = sorted(
operations,
key=lambda op: (int(op.get("seq") or 0), _clean(op.get("operationCode"))),
)
if full:
return tuple((
_clean(op.get("operationCode")),
int(op.get("seq") or 0),
_clean(op.get("operationName")),
_clean(op.get("opType")),
) for op in ordered)
return tuple((
_clean(op.get("operationCode")),
int(op.get("seq") or 0),
) for op in ordered)
def parse_routing_sheet(
path: Path,
order_no: str,
infer_map: dict[str, float] | None = None,
std_times: dict[str, float] | None = None,
*,
canonical_operations: list[dict[str, Any]] | None = None,
diagnostics: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""解析 exact sheet;generic sheet 必须由工时表规范工序序列唯一证明。"""
import openpyxl
path = Path(path)
order_no = _clean(order_no)
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try:
if order_no in wb.sheetnames:
selected = order_no
candidates = [order_no]
match_mode = "exact-sheet"
ops = _read_routing_operations(wb[selected], infer_map, std_times)
if not ops:
raise KangniDataMappingError(
f"工艺路线 {path.name}/{selected} 未读到工序,订单 {order_no} 拒绝导入"
)
else:
if not canonical_operations:
raise KangniDataMappingError(
f"工艺路线缺少订单 sheet {order_no},且工时表无规范工序序列,拒绝猜测 generic sheet"
)
expected = _operation_signature(canonical_operations, full=False)
if not expected:
raise KangniDataMappingError(
f"工时表订单 {order_no} 的规范工序序列为空,拒绝匹配 generic sheet"
)
matched: dict[str, list[dict[str, Any]]] = {}
for sheet_name in wb.sheetnames:
if not re.fullmatch(r"Sheet\d+", sheet_name, re.IGNORECASE):
continue
sheet_ops = _read_routing_operations(wb[sheet_name], infer_map, std_times)
if _operation_signature(sheet_ops, full=False) == expected:
matched[sheet_name] = sheet_ops
if not matched:
raise KangniDataMappingError(
f"订单 {order_no} 没有与工时表 code+排序号一致的 generic routing sheet"
)
candidates = sorted(matched, key=_routing_sheet_sort_key)
contents = {
_operation_signature(matched[name], full=True)
for name in candidates
}
if len(contents) != 1:
raise KangniDataMappingError(
f"订单 {order_no} 匹配多个 generic routing sheet {candidates},但规范工艺内容不一致"
)
selected = candidates[0]
match_mode = "generic-std-sequence"
ops = matched[selected]
if diagnostics is not None:
diagnostics.update({
"orderNo": order_no,
"status": "matched",
"matchMode": match_mode,
"matchedSheet": selected,
"matchedSheetCandidates": candidates,
"operationCount": len(ops),
"canonicalSource": f"工时.xlsx/{order_no}",
})
return ops
finally:
wb.close()
def _bom_row_signature(row: dict[str, Any]) -> tuple[Any, ...]:
return (
row["materialCode"],
row["materialName"],
float(row["quantity"]),
row["consumeOp"],
bool(row["isKey"]),
)
def parse_bom_sheet(
path: Path,
order_no: str,
*,
diagnostics: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""独立扫描全部 BOM sheets,只按结构化工单编号归集,绝不绑定 routing 同名 sheet。"""
import openpyxl
path = Path(path)
order_no = _clean(order_no)
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try:
per_sheet: list[tuple[str, list[dict[str, Any]]]] = []
for sheet_name in wb.sheetnames:
rows = list(wb[sheet_name].iter_rows(values_only=True))
if not rows:
continue
headers = [_clean(c) for c in rows[0]]
col = {h: i for i, h in enumerate(headers) if h}
order_col = next((
col[name]
for name in ("工单编号", "生产订单", "订单号", "订单代码")
if name in col
), None)
is_exact = sheet_name == order_no
if order_col is None and not is_exact:
continue
if is_exact and order_col is not None:
foreign_orders = {
_clean(row[order_col])
for row in rows[1:]
if order_col < len(row)
and _clean(row[order_col])
and _clean(_cell(row, col, "物料编号", "物料编码"))
and _clean(row[order_col]) != order_no
}
if foreign_orders:
raise KangniDataMappingError(
f"BOM exact sheet {sheet_name} 含其它工单 {sorted(foreign_orders)},订单 {order_no} 拒绝导入"
)
parsed: list[dict[str, Any]] = []
for row in rows[1:]:
row_order = _clean(row[order_col]) if order_col is not None and order_col < len(row) else ""
if order_col is not None and row_order != order_no:
continue
material = _clean(_cell(row, col, "物料编号", "物料编码"))
if not material:
continue
quantity = _num(_cell(row, col, "单位用量", "用量"), 1.0)
parsed.append({
"materialCode": material,
"materialName": _clean(_cell(row, col, "物料名称")) or material,
"quantity": quantity,
"consumeOp": _clean(_cell(row, col, "MES工序编号", "工序编号")),
"isKey": quantity >= 1.0,
})
if parsed:
per_sheet.append((sheet_name, parsed))
matched_sheets = [name for name, _ in per_sheet]
if not per_sheet:
if diagnostics is not None:
diagnostics.update({
"orderNo": order_no,
"status": "missing",
"matchMode": "structured-order-field",
"matchedSheetCandidates": [],
"sourceRowCount": 0,
"outputRowCount": 0,
"deduplicatedRows": 0,
})
return []
merged: list[dict[str, Any]] = []
merged_by_identity: dict[tuple[str, str], dict[str, Any]] = {}
prior_signatures: set[tuple[Any, ...]] = set()
prior_sheets_by_identity: dict[tuple[str, str], set[str]] = {}
deduplicated = 0
aggregated = 0
aggregation_groups: dict[tuple[str, str], dict[str, Any]] = {}
for sheet_name, sheet_rows in per_sheet:
for row in sheet_rows:
signature = _bom_row_signature(row)
identity = (row["materialCode"], row["consumeOp"])
if signature in prior_signatures:
deduplicated += 1
prior_sheets_by_identity.setdefault(identity, set()).add(sheet_name)
continue
prior_signatures.add(signature)
prior = merged_by_identity.get(identity)
if prior is not None and prior["materialName"] != row["materialName"]:
raise KangniDataMappingError(
f"订单 {order_no} 的 BOM 在 {identity} 上物料名称冲突;"
f"已见 sheets={sorted(prior_sheets_by_identity.get(identity) or set())},"
f"当前 sheet={sheet_name}"
)
if prior is None:
projected = deepcopy(row)
merged.append(projected)
merged_by_identity[identity] = projected
aggregation_groups[identity] = {
"materialCode": identity[0],
"consumeOp": identity[1],
"sourceRows": 1,
"quantity": float(projected["quantity"]),
}
else:
prior["quantity"] = round(
float(prior["quantity"]) + float(row["quantity"]), 9
)
prior["isKey"] = float(prior["quantity"]) >= 1.0
group = aggregation_groups[identity]
group["sourceRows"] += 1
group["quantity"] = float(prior["quantity"])
aggregated += 1
prior_sheets_by_identity.setdefault(identity, set()).add(sheet_name)
if diagnostics is not None:
diagnostics.update({
"orderNo": order_no,
"status": "matched",
"matchMode": "exact-or-structured-order-field",
"matchedSheet": matched_sheets[0],
"matchedSheetCandidates": matched_sheets,
"sourceRowCount": sum(len(rows) for _, rows in per_sheet),
"outputRowCount": len(merged),
"deduplicatedRows": deduplicated,
"aggregatedRows": aggregated,
"aggregationGroups": [
aggregation_groups[key]
for key in sorted(aggregation_groups)
if aggregation_groups[key]["sourceRows"] > 1
],
})
return merged
finally:
wb.close()
def _collect_kangni_packages(
primary: dict[str, Any],
route_path: Path,
data_dir: Path,
infer_map: dict[str, float] | None,
std_time_map: dict[str, dict[str, float]],
std_route_map: dict[str, list[dict[str, Any]]],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""按订单表收集主单和兄弟单,并返回逐单 routing/BOM 映射证据。"""
packages = [primary]
routing_mappings: dict[str, dict[str, Any]] = {
primary["orderNo"]: {
"orderNo": primary["orderNo"],
"status": "matched",
"matchMode": "complete-route",
"matchedSheet": route_path.name,
"matchedSheetCandidates": [route_path.name],
"operationCount": len(primary.get("operations") or []),
"canonicalSource": f"工时.xlsx/{primary['orderNo']}",
},
}
bom_mappings: dict[str, dict[str, Any]] = {}
orders_xlsx = _find_file(data_dir, "orders")
routing_xlsx = _find_file(data_dir, "routing")
bom_xlsx = _find_file(data_dir, "bom")
if not orders_xlsx:
return packages, {
"routing": routing_mappings,
"bom": bom_mappings,
"sourceFiles": {},
}
source_orders = parse_orders_workbook(orders_xlsx)
order_numbers = [order["orderNo"] for order in source_orders]
duplicates = sorted({order_no for order_no in order_numbers if order_numbers.count(order_no) > 1})
if duplicates:
raise KangniDataMappingError(f"订单表存在重复工单编号 {duplicates},拒绝导入")
primary_seen = False
for order in source_orders:
order_no = order["orderNo"]
bom_diag: dict[str, Any] = {}
bom = parse_bom_sheet(bom_xlsx, order_no, diagnostics=bom_diag) if bom_xlsx else []
if not bom_xlsx:
bom_diag = {
"orderNo": order_no,
"status": "missing",
"matchedSheetCandidates": [],
"sourceRowCount": 0,
"outputRowCount": 0,
"deduplicatedRows": 0,
}
bom_mappings[order_no] = bom_diag
if order_no == primary["orderNo"]:
primary_seen = True
for key in ("wbs", "productCode", "productName", "drawingNo", "planStart", "dueDate", "quantity"):
if order.get(key) and not primary.get(key):
primary[key] = order[key]
if bom:
primary["bom"] = bom
continue
if not routing_xlsx:
raise KangniDataMappingError(
f"订单 {order_no} 需要兄弟单工艺,但数据目录缺少工艺路线.xlsx"
)
route_diag: dict[str, Any] = {}
operations = parse_routing_sheet(
routing_xlsx,
order_no,
infer_map,
std_times=std_time_map.get(order_no) or {},
canonical_operations=std_route_map.get(order_no),
diagnostics=route_diag,
)
routing_mappings[order_no] = route_diag
packages.append({
**order,
"kitStatus": "未知",
"operations": operations,
"bom": bom,
"source": orders_xlsx.name,
"routingSource": routing_xlsx.name,
})
if not primary_seen:
bom_diag = {}
bom = parse_bom_sheet(bom_xlsx, primary["orderNo"], diagnostics=bom_diag) if bom_xlsx else []
if bom:
primary["bom"] = bom
bom_mappings[primary["orderNo"]] = bom_diag or {
"orderNo": primary["orderNo"],
"status": "missing",
"matchedSheetCandidates": [],
"sourceRowCount": 0,
"outputRowCount": 0,
"deduplicatedRows": 0,
}
return packages, {
"routing": routing_mappings,
"bom": bom_mappings,
"sourceFiles": {
"orders": str(orders_xlsx),
"routing": str(routing_xlsx) if routing_xlsx else "",
"bom": str(bom_xlsx) if bom_xlsx else "",
},
}
def _build_flex_bundle_from_packages(
packages: list[dict[str, Any]],
source_path: str | Path,
data_dir: str | Path,
*,
station_count: int = _DEFAULT_STATION_COUNT,
profile: dict[str, Any],
mapping_evidence: dict[str, Any],
source_mode: str,
) -> dict[str, Any]:
"""从已验证订单包构建 flex*,供完整路线和 data-dir-only 两种入口复用。"""
if not packages:
raise KangniDataMappingError("无订单包可构建 flex payload")
prof = profile
primary = packages[0]
route_path = Path(source_path)
data_dir = Path(data_dir)
inferences: list[str] = []
routing_record_count = sum(len(pkg.get("operations") or []) for pkg in packages)
bom_source_row_count = sum(
int(mapping.get("sourceRowCount") or 0)
for mapping in (mapping_evidence.get("bom") or {}).values()
)
if not bom_source_row_count:
bom_source_row_count = sum(len(pkg.get("bom") or []) for pkg in packages)
std_time_source_counts: dict[str, int] = {}
for package in packages:
for operation in package.get("operations") or []:
source = operation.get("stdTimeSource") or (
"推断" if operation.get("stdTimeInferred") else "实测"
)
std_time_source_counts[source] = std_time_source_counts.get(source, 0) + 1
used_std_ops = sum(
1 for pkg in packages for op in pkg["operations"]
if op.get("stdTimeSource") == "工时表"
)
if used_std_ops:
inferences.append(f"{used_std_ops} 个工序标准工时来自 工时.xlsx(分钟/件)")
for order_no, bom_mapping in (mapping_evidence.get("bom") or {}).items():
if bom_mapping.get("status") == "missing":
inferences.append(f"订单 {order_no} 未匹配 BOM 源行,已保留为空并等待现场确认")
aggregated_bom_rows = sum(
int(mapping.get("aggregatedRows") or 0)
for mapping in (mapping_evidence.get("bom") or {}).values()
)
deduplicated_bom_rows = sum(
int(mapping.get("deduplicatedRows") or 0)
for mapping in (mapping_evidence.get("bom") or {}).values()
)
if aggregated_bom_rows or deduplicated_bom_rows:
inferences.append(
f"BOM 多行用量按订单+物料+消耗工序显式汇总:"
f"求和 {aggregated_bom_rows} 行,完全重复去重 {deduplicated_bom_rows} 行"
)
generic_mappings = [
mapping
for mapping in (mapping_evidence.get("routing") or {}).values()
if mapping.get("matchMode") == "generic-std-sequence"
]
if generic_mappings:
reused = sum(
1 for mapping in generic_mappings
if len(mapping.get("matchedSheetCandidates") or []) > 1
)
inferences.append(
f"{len(generic_mappings)} 个订单通过工时表规范序列匹配 generic routing sheet"
f"({reused} 个订单复用内容完全相同的多候选)"
)
chg_map = prof.get("changeoverMin") or {"部装": 15, "_default": 10}
ops_map: dict[str, dict] = {}
for pkg in packages:
for op in pkg["operations"]:
code = op["operationCode"]
if code not in ops_map:
chg = chg_map.get("_default", 10)
for key, mins in chg_map.items():
if key != "_default" and key in (op.get("opType") or ""):
chg = mins
break
ops_map[code] = {
"code": code,
"name": op["operationName"],
"isBottleneck": False,
"changeoverMin": chg,
}
if op.get("stdTimeInferred"):
inferences.append(
f"工序 {code} 标准工时源表待维护 → 推断 {op['stdTimePerUnit']} 分钟/件"
f"(类型={op.get('opType') or '默认'})"
)
# 部装标瓶颈(首道/部装类)
for code, op in ops_map.items():
if "部装" in op["name"] or code.endswith("Z1M") and "部装" in op["name"]:
op["isBottleneck"] = True
if not any(o["isBottleneck"] for o in ops_map.values()) and packages:
first = packages[0]["operations"][0]["operationCode"]
ops_map[first]["isBottleneck"] = True
inferences.append(f"未标注瓶颈 → 将首道工序 {first} 标为瓶颈")
all_op_codes = list(ops_map.keys())
inferences.append(
f"导入对账:{len(packages)} 个订单 / {routing_record_count} 条工序记录 / "
f"{len(all_op_codes)} 个工序种 / {bom_source_row_count} 条 BOM 源行"
)
zone_cfg = prof.get("zone") or {}
zone = {"code": zone_cfg.get("code") or "ZONE-CG", "name": zone_cfg.get("name") or "城轨机构装配区"}
op_std: dict[str, float] = {}
for pkg in packages:
for op in pkg["operations"]:
op_std.setdefault(op["operationCode"], op["stdTimePerUnit"])
equipment, molds, mold_ops, res_inferences = build_flex_equipment_and_molds(
data_dir, packages, op_std, all_op_codes, prof,
)
inferences.extend(res_inferences)
equipment_without_capability = sum(1 for row in equipment if not row.get("capabilities"))
shared_placeholder = any(row.get("code") == "EQ-SHARED-CG-01" for row in equipment)
molds_without_operation = sum(1 for row in molds if not row.get("operationCode"))
molds_without_equipment = sum(1 for row in molds if not row.get("adaptableEquipment"))
molds_without_life = sum(1 for row in molds if float(row.get("lifeTotal") or 0.0) <= 0)
if equipment_without_capability:
placeholder_note = ";EQ-SHARED-CG-01 仅为共享占位" if shared_placeholder else ""
inferences.append(
f"资源缺口:{len(equipment)} 台设备中 {equipment_without_capability} 台未维护可执行工序"
f"{placeholder_note},不代表生产能力已确认"
)
if molds and (molds_without_operation or molds_without_equipment or molds_without_life):
inferences.append(
f"资源缺口:{len(molds)} 套模具中,缺适用工序 {molds_without_operation} 套、"
f"缺适配设备 {molds_without_equipment} 套、缺寿命上限 {molds_without_life} 套;"
"模具映射未达到生产就绪"
)
resource_quality = {
"equipmentCount": len(equipment),
"equipmentWithoutCapability": equipment_without_capability,
"sharedPlaceholder": shared_placeholder,
"moldCount": len(molds),
"moldsWithoutOperation": molds_without_operation,
"moldsWithoutEquipment": molds_without_equipment,
"moldsWithoutLifeTotal": molds_without_life,
"productionReady": bool(equipment)
and equipment_without_capability == 0
and molds_without_operation == 0
and molds_without_equipment == 0
and molds_without_life == 0,
}
materials: dict[str, dict] = {}
material_source_orders: dict[str, set[str]] = {}
bom_rows: list[dict] = []
routings: list[dict] = []
orders: list[dict] = []
for pkg in packages:
pc = pkg["productCode"] or f"P-{pkg['orderNo']}"
pn = pkg.get("productName") or pc
existing_product = materials.get(pc)
if existing_product and _clean(existing_product.get("name")) != _clean(pn):
raise KangniDataMappingError(
f"物料编码 {pc} 名称冲突;已有={existing_product.get('name')} "
f"orders={sorted(material_source_orders.get(pc) or set())},"
f"当前={pn} order={pkg['orderNo']}"
)
if not existing_product:
materials[pc] = {
"code": pc, "name": pn, "type": "FINISHED_PRODUCT", "unit": "套",
"stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0,
"drawingNo": pkg.get("drawingNo") or "",
}
material_source_orders.setdefault(pc, set()).add(pkg["orderNo"])
for op in sorted(pkg["operations"], key=lambda x: x["seq"]):
routings.append({
"productCode": pc,
"productName": pn,
"seq": op["seq"],
"operationCode": op["operationCode"],
"requireMold": op["operationCode"] in mold_ops,
"stdTimePerUnit": op["stdTimePerUnit"],
"stdTimeSource": op.get("stdTimeSource") or ("推断" if op.get("stdTimeInferred") else "实测"),
})
kit_ready = "已完成" in (pkg.get("kitStatus") or "")
for b in pkg["bom"]:
mc = b["materialCode"]
need = float(b["quantity"]) * float(pkg["quantity"])
stock = max(need * 5.0, need + 10.0) if kit_ready else max(need * 2.0, 10.0)
material_name = b["materialName"]
existing_material = materials.get(mc)
if existing_material and _clean(existing_material.get("name")) != _clean(material_name):
raise KangniDataMappingError(
f"物料编码 {mc} 名称冲突;已有={existing_material.get('name')} "
f"orders={sorted(material_source_orders.get(mc) or set())},"
f"当前={material_name} order={pkg['orderNo']}"
)
if mc not in materials:
materials[mc] = {
"code": mc, "name": material_name, "type": "RAW_MATERIAL",
"unit": "件", "stock": stock, "inTransit": 0,
"safetyStock": max(1.0, need * 0.1), "procurementLeadTime": 7,
}
else:
materials[mc]["stock"] = max(float(materials[mc].get("stock") or 0), stock)
material_source_orders.setdefault(mc, set()).add(pkg["orderNo"])
consume = b.get("consumeOp") or (pkg["operations"][0]["operationCode"] if pkg["operations"] else "")
bom_rows.append({
"productCode": pc,
"materialCode": mc,
"quantity": float(b["quantity"]),
"consumeOp": consume,
"isKey": bool(b.get("isKey")),
"_orderNo": pkg["orderNo"],
})
if kit_ready:
inferences.append(f"订单 {pkg['orderNo']} 备料状态=已完成 → 原料库存按齐套放大")
orders.append({
"orderNo": pkg["orderNo"],
"productCode": pc,
"quantity": int(pkg["quantity"]) if float(pkg["quantity"]).is_integer() else float(pkg["quantity"]),
"dueDate": pkg["dueDate"],
"priority": 1 if pkg["orderNo"] == primary["orderNo"] else 2,
"wbs": pkg.get("wbs") or "",
"productionController": "现场导入",
"status": pkg.get("status") or "RELEASED",
"project": pkg.get("project") or "",
"sourceFile": pkg.get("source") or "",
"kitStatus": pkg.get("kitStatus") or "",
"requiredSkillLevel": (prof.get("team") or {}).get("skillLevel") or "L3",
})
# 产品 BOM 保留消耗工序维度;同一投影键跨订单不一致时失败关闭。
seen_bom: dict[tuple[str, str, str], dict[str, Any]] = {}
uniq_bom: list[dict[str, Any]] = []
for b in bom_rows:
key = (b["productCode"], b["materialCode"], b["consumeOp"])
signature = (float(b["quantity"]), bool(b["isKey"]))
prior = seen_bom.get(key)
if prior is not None:
if signature != prior["signature"]:
raise KangniDataMappingError(
f"flex BOM 投影冲突 productCode={key[0]} materialCode={key[1]} "
f"consumeOp={key[2]};"
f"已有={prior['signature']} orders={sorted(prior['orders'])},"
f"当前={signature} order={b['_orderNo']}"
)
prior["orders"].add(b["_orderNo"])
continue
seen_bom[key] = {"signature": signature, "orders": {b["_orderNo"]}}
uniq_bom.append({key_name: value for key_name, value in b.items() if key_name != "_orderNo"})
team_cfg = prof.get("team") or {}
teams = [{
"code": team_cfg.get("code") or "T-CG-ASSY",
"name": team_cfg.get("name") or "城轨机构装配班组",
"memberCount": max(station_count, 4),
"supportOps": list(all_op_codes),
"skillLevel": team_cfg.get("skillLevel") or "L3",
}]
cal_cfg = prof.get("calendar") or {}
calendar = [{
"shiftCode": cal_cfg.get("shiftCode") or "D",
"startTime": cal_cfg.get("startTime") or "08:00",
"endTime": cal_cfg.get("endTime") or "17:00",
"breaks": cal_cfg.get("breaks") or [{"start": "12:00", "end": "13:00"}],
"workdays": cal_cfg.get("workdays") or [1, 2, 3, 4, 5],
}]
# 去重推断文案
uniq_inf = []
for line in inferences:
if line not in uniq_inf:
uniq_inf.append(line)
params = {
"sortMode": "BOTTLENECK",
"beforeDays": 0,
"afterDays": 30,
"rollingWindows": {"realtime": "60m", "long": "7d", "mid": "2d", "short": "2h"},
"mrpControllerDays": 3,
"weights": {"tardiness": 0.4, "cost": 0.3, "utilization": 0.2, "balance": 0.1},
"siteProfile": source_mode,
"siteSource": str(route_path),
"siteInferences": uniq_inf,
"demoDataCleared": True,
}
return {
"flexZones": [zone],
"flexOperations": list(ops_map.values()),
"flexEquipment": equipment,
"flexMolds": molds,
"flexMaterials": list(materials.values()),
"flexBom": uniq_bom,
"flexRoutings": routings,
"flexTeams": teams,
"flexCalendar": calendar,
"flexOrders": orders,
"flexParams": params,
"flexScheduleVersions": [],
"flexVirtualLines": [],
"flexWorkOrders": [],
"flexConflicts": [],
"_meta": {
"orderCount": len(orders),
"operationCount": len(ops_map),
"routingRecordCount": routing_record_count,
"bomCount": len(uniq_bom),
"bomSourceRowCount": bom_source_row_count,
"stdTimeSourceCounts": std_time_source_counts,
"routingMappings": mapping_evidence.get("routing") or {},
"bomMappings": mapping_evidence.get("bom") or {},
"missingBomOrders": [
order_no
for order_no, mapping in (mapping_evidence.get("bom") or {}).items()
if mapping.get("status") == "missing"
],
"resourceQuality": resource_quality,
"inferences": uniq_inf,
"primaryOrder": primary["orderNo"],
"source": str(route_path),
},
}
def build_flex_bundle(
route_path: str | Path | None = None,
data_dir: str | Path | None = None,
*,
include_sibling_orders: bool = True,
station_count: int = _DEFAULT_STATION_COUNT,
profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""构建可写入 world 的 flex* 包 + 推断说明。"""
prof = profile if profile is not None else _P
infer_map = prof.get("inferStdMin") or None
route_path = Path(route_path) if route_path else DEFAULT_ROUTE_XLSX
data_dir = Path(data_dir) if data_dir else DEFAULT_DATA_DIR
std_time_map = load_std_time_map(data_dir)
std_route_map = load_std_route_map(data_dir)
primary = parse_complete_route_xlsx(route_path, infer_map)
_apply_std_time_map(primary, std_time_map, override_existing=True)
packages = [primary]
mapping_evidence: dict[str, Any] = {
"routing": {
primary["orderNo"]: {
"orderNo": primary["orderNo"],
"status": "matched",
"matchMode": "complete-route",
"matchedSheet": route_path.name,
"matchedSheetCandidates": [route_path.name],
"operationCount": len(primary.get("operations") or []),
"canonicalSource": f"工时.xlsx/{primary['orderNo']}",
},
},
"bom": {
primary["orderNo"]: {
"orderNo": primary["orderNo"],
"status": "matched" if primary.get("bom") else "missing",
"matchMode": "complete-route",
"matchedSheet": route_path.name if primary.get("bom") else "",
"matchedSheetCandidates": [route_path.name] if primary.get("bom") else [],
"sourceRowCount": len(primary.get("bom") or []),
"outputRowCount": len(primary.get("bom") or []),
"deduplicatedRows": 0,
},
},
"sourceFiles": {},
}
if include_sibling_orders and data_dir.exists():
packages, mapping_evidence = _collect_kangni_packages(
primary,
route_path,
data_dir,
infer_map,
std_time_map,
std_route_map,
)
return _build_flex_bundle_from_packages(
packages,
route_path,
data_dir,
station_count=station_count,
profile=prof,
mapping_evidence=mapping_evidence,
source_mode="complete-route",
)
def _collect_kangni_packages_from_data_dir(
data_dir: Path,
infer_map: dict[str, float] | None,
std_time_map: dict[str, dict[str, float]],
std_route_map: dict[str, list[dict[str, Any]]],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""仅从标准工作簿收集全部订单包;任何路线/BOM不确定性均失败关闭。"""
orders_xlsx = _find_file(data_dir, "orders")
routing_xlsx = _find_file(data_dir, "routing")
stdtime_xlsx = _find_file(data_dir, "stdtime")
bom_xlsx = _find_file(data_dir, "bom")
missing = [
name
for name, path in (
("订单.xlsx", orders_xlsx),
("工艺路线.xlsx", routing_xlsx),
("工时.xlsx", stdtime_xlsx),
("BOM.xlsx", bom_xlsx),
)
if not path
]
if missing:
raise KangniDataMappingError(f"data-dir-only 缺少核心工作簿:{missing}")
source_orders = parse_orders_workbook(orders_xlsx)
if not source_orders:
raise KangniDataMappingError("订单.xlsx 未读到生产订单")
order_numbers = [order["orderNo"] for order in source_orders]
duplicates = sorted({order_no for order_no in order_numbers if order_numbers.count(order_no) > 1})
if duplicates:
raise KangniDataMappingError(f"订单表存在重复工单编号 {duplicates},拒绝导入")
packages: list[dict[str, Any]] = []
routing_mappings: dict[str, dict[str, Any]] = {}
bom_mappings: dict[str, dict[str, Any]] = {}
for order in source_orders:
order_no = order["orderNo"]
canonical_operations = std_route_map.get(order_no)
std_times = std_time_map.get(order_no)
if not canonical_operations or not std_times:
raise KangniDataMappingError(
f"工时.xlsx 缺少订单 {order_no} 的规范工序序列或分钟值"
)
route_diag: dict[str, Any] = {}
operations = parse_routing_sheet(
routing_xlsx,
order_no,
infer_map,
std_times=std_times,
canonical_operations=canonical_operations,
diagnostics=route_diag,
)
if _operation_signature(operations, full=False) != _operation_signature(
canonical_operations,
full=False,
):
raise KangniDataMappingError(
f"订单 {order_no} 的 routing 与工时表规范工序序列不一致"
)
non_table_sources = [
operation["operationCode"]
for operation in operations
if operation.get("stdTimeSource") != "工时表"
]
if non_table_sources:
raise KangniDataMappingError(
f"订单 {order_no} 存在未由工时表提供分钟值的工序 {non_table_sources}"
)
bom_diag: dict[str, Any] = {}
bom = parse_bom_sheet(bom_xlsx, order_no, diagnostics=bom_diag)
if not bom or bom_diag.get("status") != "matched":
raise KangniDataMappingError(f"订单 {order_no} 未唯一匹配结构化 BOM")
routing_mappings[order_no] = route_diag
bom_mappings[order_no] = bom_diag
packages.append({
**order,
"kitStatus": "未知",
"operations": operations,
"bom": bom,
"source": str(orders_xlsx),
"routingSource": str(routing_xlsx),
})
return packages, {
"routing": routing_mappings,
"bom": bom_mappings,
"sourceFiles": {
"orders": orders_xlsx.name,
"routing": routing_xlsx.name,
"stdtime": stdtime_xlsx.name,
"bom": bom_xlsx.name,
"workbooks": [path.name for path in sorted(data_dir.glob("*.xlsx"))],
},
}
def _canonicalize_payload_source_refs(value: Any) -> Any:
"""移除 payload 中的机器/临时目录绝对路径,仅保留稳定逻辑文件名。"""
if isinstance(value, dict):
return {key: _canonicalize_payload_source_refs(item) for key, item in value.items()}
if isinstance(value, list):
return [_canonicalize_payload_source_refs(item) for item in value]
if isinstance(value, tuple):
return [_canonicalize_payload_source_refs(item) for item in value]
if isinstance(value, Path):
return value.name
if isinstance(value, str):
candidate = Path(value)
if candidate.is_absolute():
return candidate.name
return value
def build_site_payload_from_data_dir(
data_dir: str | Path,
*,
station_count: int = _DEFAULT_STATION_COUNT,
profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""仅凭标准工作簿构建 fixed+flex+meta 纯 JSON payload,不依赖完整路线文件。"""
data_dir = Path(data_dir)
if not data_dir.is_dir():
raise KangniDataMappingError(f"康尼数据目录不存在:{data_dir}")
prof = profile if profile is not None else _P
infer_map = prof.get("inferStdMin") or None
std_time_map = load_std_time_map(data_dir)
std_route_map = load_std_route_map(data_dir)
packages, mapping_evidence = _collect_kangni_packages_from_data_dir(
data_dir,
infer_map,
std_time_map,
std_route_map,
)
fixed = build_fixed_master(packages, station_count=station_count, profile=prof)
source_path = mapping_evidence["sourceFiles"]["orders"]
flex_bundle = _build_flex_bundle_from_packages(
packages,
source_path,
data_dir,
station_count=station_count,
profile=prof,
mapping_evidence=mapping_evidence,
source_mode="data-dir-only",
)
meta = flex_bundle.pop("_meta")
meta.update({
"payloadSchemaVersion": 1,
"sourceMode": "data-dir-only",
"sourceWorkbookCount": len(mapping_evidence["sourceFiles"]["workbooks"]),
"sourceFiles": deepcopy(mapping_evidence["sourceFiles"]),
"fixedSalesOrderCount": len(fixed.get("salesOrders") or []),
"fixedRoutingRecordCount": len(fixed.get("routingSteps") or []),
"projectedBomCount": len(flex_bundle.get("flexBom") or []),
})
return _canonicalize_payload_source_refs({"fixed": fixed, "flex": flex_bundle, "meta": meta})
def build_fixed_master(
packages: list[dict[str, Any]],
*,
station_count: int = _DEFAULT_STATION_COUNT,
profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""把现场订单包映射到主数据页/订单池读取的固定轨表(materials/operations/salesOrders…)。"""
from server.timeutil import add_minutes, fmt_date, today0
if not packages:
raise ValueError("无订单包可写入主数据")
primary = packages[0]
prof = profile if profile is not None else _P
f_cfg = prof.get("factory") or {}
w_cfg = prof.get("workshop") or {}
l_cfg = prof.get("line") or {}
ws_prefix = prof.get("stationCodePrefix") or "WS-CG-"
ws_name = prof.get("stationNamePrefix") or "城轨机构装配工位"
eq_prefix = prof.get("equipmentCodePrefix") or "EQ-CG-"
avail = float(prof.get("availabilityRate") or 0.95)
setup_cfg = prof.get("setupMin") or {"first": 15.0, "rest": 10.0}
lead_days = int(prof.get("rawMaterialLeadTimeDays") or 7)
family = prof.get("productFamily") or "CG-ASSY"
factories = [{
"id": 1, "code": f_cfg.get("code") or "CG-F01", "name": f_cfg.get("name") or "城轨机构装配工厂",
"timezone": f_cfg.get("timezone") or "Asia/Shanghai", "address": "现场导入", "status": "ACTIVE",
}]
workshops = [{
"id": 1, "factoryId": 1, "code": w_cfg.get("code") or "CG-WS01",
"name": w_cfg.get("name") or "城轨机构装配车间", "status": "ACTIVE",
}]
lines = [{
"id": 1, "workshopId": 1, "code": l_cfg.get("code") or "CG-L01",
"name": l_cfg.get("name") or "城轨机构装配线",
"capacityPerDay": max(4, station_count), "taktTime": float(l_cfg.get("taktTime") or 30.0),
"efficiencyFactor": float(l_cfg.get("efficiencyFactor") or 0.95),
"status": "ACTIVE", "alternativeLineIds": [],
}]
workstations = []
equipment = []
for i in range(station_count):
wsid = i + 1
code = f"{ws_prefix}{i + 1:02d}"
workstations.append({
"id": wsid, "lineId": 1, "code": code,
"name": f"{ws_name}#{i + 1}", "sequenceNo": i + 1, "status": "ACTIVE",
})
equipment.append({
"id": wsid, "code": f"{eq_prefix}{i + 1:02d}", "name": f"装配工位设备#{i + 1}",
"model": "SITE-ASSY", "workstationId": wsid, "capacityPerHour": 2,
"efficiencyFactor": 0.95, "availabilityRate": avail, "status": "RUNNING",
})
# 工序库(全订单并集)
ops_map: dict[str, dict] = {}
for pkg in packages:
for op in pkg["operations"]:
code = op["operationCode"]
if code not in ops_map:
ops_map[code] = {
"code": code,
"name": op["operationName"],
"type": "INTERNAL",
"standardTime": float(op["stdTimePerUnit"]),
"seq": op["seq"],
}
operations = []
for i, (_code, op) in enumerate(sorted(ops_map.items(), key=lambda x: x[1]["seq"])):
operations.append({
"id": i + 1, "code": op["code"], "name": op["name"],
"type": op["type"], "standardTime": op["standardTime"],
})
op_id_by_code = {o["code"]: o["id"] for o in operations}
materials: list[dict] = []
mat_id_by_code: dict[str, int] = {}
boms: list[dict] = []
bom_items: list[dict] = []
routings: list[dict] = []
routing_steps: list[dict] = []
line_products: list[dict] = []
sales_orders: list[dict] = []
mid = 1
bom_id = 1
rid = 1
step_id = 1
item_id = 1
lp_id = 1
for pkg in packages:
pc = pkg["productCode"] or f"P-{pkg['orderNo']}"
pn = pkg.get("productName") or pc
if pc not in mat_id_by_code:
materials.append({
"id": mid, "code": pc, "name": pn,
"spec": pkg.get("drawingNo") or "",
"type": "FINISHED_PRODUCT", "unit": "套",
"productFamily": family,
"safetyStock": 0, "procurementLeadTime": 0,
"stock": 0, "inTransit": 0, "status": "ACTIVE",
})
mat_id_by_code[pc] = mid
mid += 1
product_id = mat_id_by_code[pc]
kit_ready = "已完成" in (pkg.get("kitStatus") or "")
for b in pkg["bom"]:
mc = b["materialCode"]
need = float(b["quantity"]) * float(pkg["quantity"])
stock = max(need * 5.0, need + 10.0) if kit_ready else max(need * 2.0, 10.0)
if mc not in mat_id_by_code:
materials.append({
"id": mid, "code": mc, "name": b["materialName"],
"spec": "", "type": "RAW_MATERIAL", "unit": "件",
"productFamily": "",
"safetyStock": max(1.0, need * 0.1),
"procurementLeadTime": lead_days,
"stock": stock, "inTransit": 0, "status": "ACTIVE",
})
mat_id_by_code[mc] = mid
mid += 1
else:
# 同物料库存取较大
mrow = next(m for m in materials if m["id"] == mat_id_by_code[mc])
mrow["stock"] = max(float(mrow.get("stock") or 0), stock)
# BOM 头 + 明细(每产品一版)
boms.append({
"id": bom_id, "productId": product_id, "version": "V1.0",
"versionName": f"{pn} BOM(现场)", "isDefault": True, "status": "ACTIVE",
})
for b in pkg["bom"]:
consume = b.get("consumeOp") or ""
oid = op_id_by_code.get(consume) or (operations[0]["id"] if operations else None)
bom_items.append({
"id": item_id, "bomId": bom_id,
"materialId": mat_id_by_code[b["materialCode"]],
"quantity": float(b["quantity"]),
"operationId": oid,
"isKeyMaterial": bool(b.get("isKey")),
})
item_id += 1
bom_id += 1
# 工艺路线
routings.append({
"id": rid, "productId": product_id, "version": "V1.0",
"versionName": f"{pn} 工艺(现场)", "isDefault": True, "status": "ACTIVE",
})
prev_step = None
for seq_i, op in enumerate(sorted(pkg["operations"], key=lambda x: x["seq"]), start=1):
oid = op_id_by_code[op["operationCode"]]
routing_steps.append({
"id": step_id, "routingId": rid, "operationId": oid,
"sequenceNo": seq_i, "prevStepId": prev_step,
"setupTime": float(setup_cfg.get("first", 15.0)) if seq_i == 1 else float(setup_cfg.get("rest", 10.0)),
"runTimePerUnit": float(op["stdTimePerUnit"]),
"waitTime": 0, "transferTime": 0, "isExternal": False,
"stdTimeSource": op.get("stdTimeSource") or (
"推断" if op.get("stdTimeInferred") else "实测"
),
})
prev_step = step_id
step_id += 1
rid += 1
line_products.append({
"id": lp_id, "lineId": 1, "productId": product_id,
"standardCapacity": max(4, station_count), "priority": 1, "setupTime": 30,
})
lp_id += 1
due = pkg.get("dueDate") or fmt_date(add_minutes(today0(), 14 * 24 * 60))
order_date = pkg.get("planStart") or fmt_date(today0())
so_id = len(sales_orders) + 1
sales_orders.append({
"id": so_id,
"orderNo": str(pkg["orderNo"]),
"customerId": "SITE-001",
"customerName": pkg.get("project") or "现场生产订单",
"customerLevel": "A",
"orderDate": order_date, "deliveryDate": due,
"priority": 1 if pkg is primary or pkg.get("orderNo") == primary["orderNo"] else 2,
"manualPriority": None, "status": "APPROVED",
"source": "SITE_XLSX",
"specialRequirements": f"WBS={pkg.get('wbs') or ''}",
"totalAmount": 0,
"isRush": False, "rushStrategy": None,
"kitStatus": pkg.get("kitStatus") or "",
"requiredSkillLevel": (prof.get("team") or {}).get("skillLevel") or "L3",
"changes": [], "createdBy": "site-import",
"createdAt": order_date + " 08:00", "updatedAt": order_date + " 08:00",
"wbs": pkg.get("wbs") or "",
"items": [{
"id": so_id * 10 + 1, "orderId": so_id, "lineNo": 1,
"productId": product_id, "productName": pn, "productCode": pc,
"quantity": int(pkg["quantity"]) if float(pkg["quantity"]).is_integer() else float(pkg["quantity"]),
"unit": "套", "bomVersion": "V1.0", "routingVersion": "V1.0",
"deliveryDate": due, "status": "OPEN",
}],
})
# 工位可执行全部工序
wso = []
wid = 1
for ws in workstations:
for op in operations:
wso.append({
"id": wid, "workstationId": ws["id"], "operationId": op["id"],
"setupTime": 10, "runTimePerUnit": op["standardTime"], "isPrimary": True,
})
wid += 1
team_cfg = prof.get("team") or {}
teams = [{
"id": 1, "code": team_cfg.get("code") or "T-CG-ASSY",
"name": team_cfg.get("name") or "城轨机构装配班组",
"workshopId": 1, "memberCount": max(station_count, 4),
"skillLevel": team_cfg.get("skillLevel") or "L3", "status": "ACTIVE",
}]
cal_cfg = prof.get("calendar") or {}
shifts = [{
"id": 1, "code": cal_cfg.get("shiftCode") or "D", "name": "早班",
"startTime": cal_cfg.get("startTime") or "08:00", "endTime": cal_cfg.get("endTime") or "17:00",
"breakPeriods": cal_cfg.get("breaks") or [{"start": "12:00", "end": "13:00"}],
"isOvertime": False, "status": "ACTIVE",
}]
base = today0()
shift_calendar = []
for i in range(int(prof.get("calendarDays") or 45)):
date = add_minutes(base, i * 24 * 60)
date_str = fmt_date(date)
is_weekend = date.weekday() >= 5
shift_calendar.append({
"id": i + 1, "lineId": 1, "date": date_str, "shiftId": 1,
"isWorking": not is_weekend, "teamId": 1, "maxWorkers": max(station_count, 4),
})
return {
"factories": factories,
"workshops": workshops,
"lines": lines,
"workstations": workstations,
"equipment": equipment,
"operations": operations,
"routings": routings,
"routingSteps": routing_steps,
"materials": materials,
"boms": boms,
"bomItems": bom_items,
"lineProducts": line_products,
"workstationOperations": wso,
"teams": teams,
"shifts": shifts,
"shiftCalendar": shift_calendar,
"maintenance": [],
"salesOrders": sales_orders,
"productionOrders": [],
"workOrders": [],
"purchaseOrders": [],
"outsourceOrders": [],
"forecastOrders": [],
"changeoverMatrix": [],
"scheduleVersions": [],
"conflicts": [],
"logs": [],
}
def apply_flex_bundle(world: World, bundle: dict[str, Any], *, replace: bool = True) -> dict[str, Any]:
"""将 flex 包写入 world;replace=True 时清空演示 flex*。"""
if replace:
for k in _FLEX_CLEAR_KEYS:
world[k] = [] if k != "flexParams" else {}
meta = bundle.pop("_meta", {})
# 订单补 id
orders = bundle.get("flexOrders") or []
for i, o in enumerate(orders):
o["id"] = i + 1
o.setdefault("status", "RELEASED")
for k, v in bundle.items():
if k.startswith("_"):
continue
world[k] = v
world.setdefault("flexParams", {})["demoDataCleared"] = True
return meta
def clear_demo_world(world: World) -> None:
"""清空固定轨 + 柔性轨演示/残留数据。"""
for k in _FIXED_CLEAR_KEYS:
world[k] = []
for k in _FLEX_CLEAR_KEYS:
world[k] = [] if k != "flexParams" else {}
def apply_site_payload_to_world(
world: World,
payload: dict[str, Any],
*,
clear_all: bool = True,
) -> dict[str, Any]:
"""确定性写入 data-dir-only payload;始终深拷贝,绝不变异 payload 输入。"""
if not isinstance(payload, dict):
raise ValueError("site payload 必须是 dict")
fixed = deepcopy(payload.get("fixed"))
flex = deepcopy(payload.get("flex"))
meta = deepcopy(payload.get("meta"))
if not isinstance(fixed, dict) or not isinstance(flex, dict) or not isinstance(meta, dict):
raise ValueError("site payload 必须包含 dict 类型的 fixed、flex、meta")
if clear_all:
clear_demo_world(world)
for key, value in fixed.items():
world[key] = value
flex_with_meta = {**flex, "_meta": meta}
applied_meta = apply_flex_bundle(world, flex_with_meta, replace=False)
world.setdefault("flexParams", {})["siteProfile"] = "data-dir-only"
world["flexParams"]["fixedMasterSynced"] = True
applied_meta["clearedDemo"] = clear_all
return applied_meta
def load_site_into_world(
world: World,
*,
route_path: str | Path | None = None,
data_dir: str | Path | None = None,
include_sibling_orders: bool = False,
station_count: int = _DEFAULT_STATION_COUNT,
clear_all: bool = True,
profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""一键:清空演示 → 写入固定轨主数据+订单池 + flex* → 返回摘要。"""
prof = profile if profile is not None else _P
infer_map = prof.get("inferStdMin") or None
route_path = Path(route_path) if route_path else DEFAULT_ROUTE_XLSX
data_dir = Path(data_dir) if data_dir else DEFAULT_DATA_DIR
std_time_map = load_std_time_map(data_dir)
std_route_map = load_std_route_map(data_dir)
primary = parse_complete_route_xlsx(route_path, infer_map)
_apply_std_time_map(primary, std_time_map, override_existing=True)
packages = [primary]
if include_sibling_orders and data_dir.exists():
packages, _ = _collect_kangni_packages(
primary,
route_path,
data_dir,
infer_map,
std_time_map,
std_route_map,
)
if clear_all:
clear_demo_world(world)
fixed = build_fixed_master(packages, station_count=station_count, profile=prof)
for k, v in fixed.items():
world[k] = v
# flex 包:用同一 packages,避免再扫 siblings
bundle = build_flex_bundle(
route_path, data_dir,
include_sibling_orders=False,
station_count=station_count,
profile=prof,
)
# 若带了 siblings,重建 flex 订单集以与 packages 对齐
if len(packages) > 1:
bundle = build_flex_bundle(
route_path, data_dir,
include_sibling_orders=True,
station_count=station_count,
profile=prof,
)
meta = apply_flex_bundle(world, bundle, replace=False) # 已 clear_all
meta["fixedMaterials"] = len(world.get("materials") or [])
meta["fixedOperations"] = len(world.get("operations") or [])
meta["salesOrders"] = len(world.get("salesOrders") or [])
meta["clearedDemo"] = clear_all
# 工厂名写入审计友好字段
world.setdefault("flexParams", {})["siteProfile"] = "complete-route-full"
world["flexParams"]["fixedMasterSynced"] = True
return meta
def confirmation_for_site_load(meta: dict[str, Any]) -> tuple[str, list[str]]:
title = "加载现场完整生产路线(清空演示并写入主数据+订单)"
lines = [
f"主订单:{meta.get('primaryOrder')}",
f"订单数:{meta.get('orderCount')} · 工序:{meta.get('operationCount')} · BOM:{meta.get('bomCount')}",
f"主数据物料:{meta.get('fixedMaterials')} · 工序库:{meta.get('fixedOperations')} · 销售订单:{meta.get('salesOrders')}",
f"来源:{meta.get('source')}",
"将清空演示工厂全部垃圾数据,写入现场订单/物料/BOM/工艺/产线/柔性资源。",
]
for line in (meta.get("inferences") or [])[:6]:
lines.append(f"推断:{line}")
if len(meta.get("inferences") or []) > 6:
lines.append(f"…另有 {len(meta['inferences']) - 6} 条推断说明")
return title, lines