aps-agent/server/aps_domain/kangni_intake.py

2027 lines
81 KiB
Python
Raw 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.

# ============================================================
# 现场「完整生产路线」导入(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