2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 现场「完整生产路线」导入(moduleId: domain-kangni-intake, 可重生 ✅)
|
|
|
|
|
|
# 将康尼现场 Excel 落到 flex* 键,替换演示种子,供 PoolEngine 排产。
|
|
|
|
|
|
# 口径:docs/product/demand-data-intake.md;缺口用显式推断并写入 flexParams.siteInferences。
|
2026-09-08 00:07:26 +08:00
|
|
|
|
# 客户差异(工厂命名/工时推断规则)收敛在 server/importers/profiles/kangni.json,
|
|
|
|
|
|
# 现场路径通过环境变量或 API/脚本参数注入,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
# 本模块仅保留解析逻辑 + 兜底默认值(profile 缺失时可独立运行)。
|
2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
import os
|
2026-07-23 13:38:43 +08:00
|
|
|
|
import re
|
2026-08-26 00:25:46 +08:00
|
|
|
|
from copy import deepcopy
|
2026-07-23 13:38:43 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
World = dict[str, Any]
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
class KangniDataMappingError(ValueError):
|
|
|
|
|
|
"""康尼源表无法唯一、安全映射到订单时抛出。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
# 现场路径优先取环境变量;未配置时只指向仓库内可移植的样例占位目录。
|
|
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
DEFAULT_DATA_DIR = Path(
|
|
|
|
|
|
os.environ.get("KANGNI_DATA_DIR")
|
|
|
|
|
|
or _P.get("dataDir")
|
|
|
|
|
|
or (_REPO_ROOT / "demo-data" / "kangni")
|
|
|
|
|
|
).expanduser()
|
|
|
|
|
|
DEFAULT_ROUTE_XLSX = Path(
|
|
|
|
|
|
os.environ.get("KANGNI_ROUTE_XLSX")
|
|
|
|
|
|
or _P.get("defaultRouteXlsx")
|
|
|
|
|
|
or (DEFAULT_DATA_DIR / "完整生产路线.xlsx")
|
|
|
|
|
|
).expanduser()
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
# 工时/产能缺口推断(源表「待维护」时启用;单位:分钟/件)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
_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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def _sequence(v: Any, fallback: int) -> int:
|
|
|
|
|
|
s = _clean(v)
|
|
|
|
|
|
if not s:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(float(s))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if not _is_pending(raw_hours):
|
|
|
|
|
|
h = _num(raw_hours, 0.0)
|
|
|
|
|
|
if h > 0:
|
|
|
|
|
|
return (h * 60.0, False)
|
|
|
|
|
|
t = _clean(op_type)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for key, mins in rules.items():
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if key != "_default" and key in t:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
return (float(mins), True)
|
|
|
|
|
|
return (float(rules.get("_default", 30.0)), True)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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": ("工艺路线", "路线"),
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"stdtime": ("工时",),
|
|
|
|
|
|
"equipmap": ("设备能力",),
|
|
|
|
|
|
"moldmap": ("模具适配",),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
def parse_complete_route_xlsx(path: str | Path, infer_map: dict[str, float] | None = None) -> dict[str, Any]:
|
|
|
|
|
|
"""解析「订单XXXX_完整生产路线.xlsx」→ 单订单包(infer_map=profile 工时推断规则)。"""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)", "标准工时")
|
2026-07-28 02:12:46 +08:00
|
|
|
|
std_min, inferred = _infer_std_min(op_type, raw_std, infer_map)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
|
|
|
|
|
|
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))
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if not rows:
|
|
|
|
|
|
return []
|
|
|
|
|
|
headers = [_clean(c) for c in rows[0]]
|
|
|
|
|
|
col = {h: i for i, h in enumerate(headers) if h}
|
2026-08-26 00:25:46 +08:00
|
|
|
|
if "工序编号" not in col:
|
|
|
|
|
|
return []
|
|
|
|
|
|
ops: list[dict[str, Any]] = []
|
|
|
|
|
|
std_times = std_times or {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
for row in rows[1:]:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
code = _clean(_cell(row, col, "工序编号"))
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if not code:
|
|
|
|
|
|
continue
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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 "实测"
|
2026-07-23 13:38:43 +08:00
|
|
|
|
ops.append({
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"seq": _sequence(
|
|
|
|
|
|
_cell(row, col, "排序号", "序号"),
|
|
|
|
|
|
(len(ops) + 1) * 10,
|
|
|
|
|
|
),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"operationCode": code,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"operationName": _clean(_cell(row, col, "工序名称")) or code,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"opType": op_type,
|
|
|
|
|
|
"stdTimePerUnit": std_min,
|
|
|
|
|
|
"stdTimeInferred": inferred,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"stdTimeSource": std_source,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"resourceGroup": "",
|
|
|
|
|
|
"requireMold": False,
|
|
|
|
|
|
})
|
|
|
|
|
|
return ops
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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 必须由工时表规范工序序列唯一证明。"""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
import openpyxl
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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:
|
2026-07-23 13:38:43 +08:00
|
|
|
|
wb.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
def _bom_row_signature(row: dict[str, Any]) -> tuple[Any, ...]:
|
|
|
|
|
|
return (
|
|
|
|
|
|
row["materialCode"],
|
|
|
|
|
|
row["materialName"],
|
|
|
|
|
|
float(row["quantity"]),
|
|
|
|
|
|
row["consumeOp"],
|
|
|
|
|
|
bool(row["isKey"]),
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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:
|
2026-07-23 13:38:43 +08:00
|
|
|
|
continue
|
2026-08-26 00:25:46 +08:00
|
|
|
|
material = _clean(_cell(row, col, "物料编号", "物料编码"))
|
|
|
|
|
|
if not material:
|
2026-07-23 13:38:43 +08:00
|
|
|
|
continue
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
inferences: list[str] = []
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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} 个订单复用内容完全相同的多候选)"
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
chg_map = prof.get("changeoverMin") or {"部装": 15, "_default": 10}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
ops_map: dict[str, dict] = {}
|
|
|
|
|
|
for pkg in packages:
|
|
|
|
|
|
for op in pkg["operations"]:
|
|
|
|
|
|
code = op["operationCode"]
|
|
|
|
|
|
if code not in ops_map:
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
ops_map[code] = {
|
|
|
|
|
|
"code": code,
|
|
|
|
|
|
"name": op["operationName"],
|
|
|
|
|
|
"isBottleneck": False,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"changeoverMin": chg,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
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())
|
2026-08-26 00:25:46 +08:00
|
|
|
|
inferences.append(
|
|
|
|
|
|
f"导入对账:{len(packages)} 个订单 / {routing_record_count} 条工序记录 / "
|
|
|
|
|
|
f"{len(all_op_codes)} 个工序种 / {bom_source_row_count} 条 BOM 源行"
|
|
|
|
|
|
)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
zone_cfg = prof.get("zone") or {}
|
|
|
|
|
|
zone = {"code": zone_cfg.get("code") or "ZONE-CG", "name": zone_cfg.get("name") or "城轨机构装配区"}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
op_std: dict[str, float] = {}
|
|
|
|
|
|
for pkg in packages:
|
|
|
|
|
|
for op in pkg["operations"]:
|
|
|
|
|
|
op_std.setdefault(op["operationCode"], op["stdTimePerUnit"])
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
equipment, molds, mold_ops, res_inferences = build_flex_equipment_and_molds(
|
|
|
|
|
|
data_dir, packages, op_std, all_op_codes, prof,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
materials: dict[str, dict] = {}
|
2026-08-26 00:25:46 +08:00
|
|
|
|
material_source_orders: dict[str, set[str]] = {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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"])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
for op in sorted(pkg["operations"], key=lambda x: x["seq"]):
|
|
|
|
|
|
routings.append({
|
|
|
|
|
|
"productCode": pc,
|
|
|
|
|
|
"productName": pn,
|
|
|
|
|
|
"seq": op["seq"],
|
|
|
|
|
|
"operationCode": op["operationCode"],
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"requireMold": op["operationCode"] in mold_ops,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"stdTimePerUnit": op["stdTimePerUnit"],
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"stdTimeSource": op.get("stdTimeSource") or ("推断" if op.get("stdTimeInferred") else "实测"),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
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)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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']}"
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if mc not in materials:
|
|
|
|
|
|
materials[mc] = {
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"code": mc, "name": material_name, "type": "RAW_MATERIAL",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
material_source_orders.setdefault(mc, set()).add(pkg["orderNo"])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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")),
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"_orderNo": pkg["orderNo"],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
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 "",
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"kitStatus": pkg.get("kitStatus") or "",
|
|
|
|
|
|
"requiredSkillLevel": (prof.get("team") or {}).get("skillLevel") or "L3",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
# 产品 BOM 保留消耗工序维度;同一投影键跨订单不一致时失败关闭。
|
|
|
|
|
|
seen_bom: dict[tuple[str, str, str], dict[str, Any]] = {}
|
|
|
|
|
|
uniq_bom: list[dict[str, Any]] = []
|
2026-07-23 13:38:43 +08:00
|
|
|
|
for b in bom_rows:
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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"])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
continue
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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"})
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
team_cfg = prof.get("team") or {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
teams = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"code": team_cfg.get("code") or "T-CG-ASSY",
|
|
|
|
|
|
"name": team_cfg.get("name") or "城轨机构装配班组",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"memberCount": max(station_count, 4),
|
|
|
|
|
|
"supportOps": list(all_op_codes),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"skillLevel": team_cfg.get("skillLevel") or "L3",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
cal_cfg = prof.get("calendar") or {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
calendar = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"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],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
|
|
|
|
|
# 去重推断文案
|
|
|
|
|
|
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},
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"siteProfile": source_mode,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"siteSource": str(route_path),
|
|
|
|
|
|
"siteInferences": uniq_inf,
|
|
|
|
|
|
"demoDataCleared": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"flexZones": [zone],
|
|
|
|
|
|
"flexOperations": list(ops_map.values()),
|
|
|
|
|
|
"flexEquipment": equipment,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"flexMolds": molds,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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),
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"routingRecordCount": routing_record_count,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"bomCount": len(uniq_bom),
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"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,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"inferences": uniq_inf,
|
|
|
|
|
|
"primaryOrder": primary["orderNo"],
|
|
|
|
|
|
"source": str(route_path),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
def build_fixed_master(
|
|
|
|
|
|
packages: list[dict[str, Any]],
|
|
|
|
|
|
*,
|
|
|
|
|
|
station_count: int = _DEFAULT_STATION_COUNT,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
profile: dict[str, Any] | None = None,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""把现场订单包映射到主数据页/订单池读取的固定轨表(materials/operations/salesOrders…)。"""
|
|
|
|
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|
|
|
|
|
|
|
|
|
|
|
if not packages:
|
|
|
|
|
|
raise ValueError("无订单包可写入主数据")
|
|
|
|
|
|
primary = packages[0]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
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"
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
factories = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"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",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
|
|
|
|
|
workshops = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"id": 1, "factoryId": 1, "code": w_cfg.get("code") or "CG-WS01",
|
|
|
|
|
|
"name": w_cfg.get("name") or "城轨机构装配车间", "status": "ACTIVE",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
|
|
|
|
|
lines = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"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": [],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
|
|
|
|
|
workstations = []
|
|
|
|
|
|
equipment = []
|
|
|
|
|
|
for i in range(station_count):
|
|
|
|
|
|
wsid = i + 1
|
2026-07-28 02:12:46 +08:00
|
|
|
|
code = f"{ws_prefix}{i + 1:02d}"
|
2026-07-23 13:38:43 +08:00
|
|
|
|
workstations.append({
|
|
|
|
|
|
"id": wsid, "lineId": 1, "code": code,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"name": f"{ws_name}#{i + 1}", "sequenceNo": i + 1, "status": "ACTIVE",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
equipment.append({
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"id": wsid, "code": f"{eq_prefix}{i + 1:02d}", "name": f"装配工位设备#{i + 1}",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"model": "SITE-ASSY", "workstationId": wsid, "capacityPerHour": 2,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"efficiencyFactor": 0.95, "availabilityRate": avail, "status": "RUNNING",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 工序库(全订单并集)
|
|
|
|
|
|
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": "套",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"productFamily": family,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"procurementLeadTime": lead_days,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"setupTime": float(setup_cfg.get("first", 15.0)) if seq_i == 1 else float(setup_cfg.get("rest", 10.0)),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"runTimePerUnit": float(op["stdTimePerUnit"]),
|
|
|
|
|
|
"waitTime": 0, "transferTime": 0, "isExternal": False,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"stdTimeSource": op.get("stdTimeSource") or (
|
|
|
|
|
|
"推断" if op.get("stdTimeInferred") else "实测"
|
|
|
|
|
|
),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
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,
|
2026-08-26 00:25:46 +08:00
|
|
|
|
"kitStatus": pkg.get("kitStatus") or "",
|
|
|
|
|
|
"requiredSkillLevel": (prof.get("team") or {}).get("skillLevel") or "L3",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
team_cfg = prof.get("team") or {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
teams = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"id": 1, "code": team_cfg.get("code") or "T-CG-ASSY",
|
|
|
|
|
|
"name": team_cfg.get("name") or "城轨机构装配班组",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"workshopId": 1, "memberCount": max(station_count, 4),
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"skillLevel": team_cfg.get("skillLevel") or "L3", "status": "ACTIVE",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}]
|
2026-07-28 02:12:46 +08:00
|
|
|
|
cal_cfg = prof.get("calendar") or {}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
shifts = [{
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"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"}],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"isOvertime": False, "status": "ACTIVE",
|
|
|
|
|
|
}]
|
|
|
|
|
|
base = today0()
|
|
|
|
|
|
shift_calendar = []
|
2026-07-28 02:12:46 +08:00
|
|
|
|
for i in range(int(prof.get("calendarDays") or 45)):
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
profile: dict[str, Any] | None = None,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""一键:清空演示 → 写入固定轨主数据+订单池 + flex* → 返回摘要。"""
|
2026-07-28 02:12:46 +08:00
|
|
|
|
prof = profile if profile is not None else _P
|
|
|
|
|
|
infer_map = prof.get("inferStdMin") or None
|
2026-07-23 13:38:43 +08:00
|
|
|
|
route_path = Path(route_path) if route_path else DEFAULT_ROUTE_XLSX
|
|
|
|
|
|
data_dir = Path(data_dir) if data_dir else DEFAULT_DATA_DIR
|
|
|
|
|
|
|
2026-08-26 00:25:46 +08:00
|
|
|
|
std_time_map = load_std_time_map(data_dir)
|
|
|
|
|
|
std_route_map = load_std_route_map(data_dir)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
primary = parse_complete_route_xlsx(route_path, infer_map)
|
2026-08-26 00:25:46 +08:00
|
|
|
|
_apply_std_time_map(primary, std_time_map, override_existing=True)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
packages = [primary]
|
|
|
|
|
|
if include_sibling_orders and data_dir.exists():
|
2026-08-26 00:25:46 +08:00
|
|
|
|
packages, _ = _collect_kangni_packages(
|
|
|
|
|
|
primary,
|
|
|
|
|
|
route_path,
|
|
|
|
|
|
data_dir,
|
|
|
|
|
|
infer_map,
|
|
|
|
|
|
std_time_map,
|
|
|
|
|
|
std_route_map,
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
if clear_all:
|
|
|
|
|
|
clear_demo_world(world)
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
fixed = build_fixed_master(packages, station_count=station_count, profile=prof)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
profile=prof,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
# 若带了 siblings,重建 flex 订单集以与 packages 对齐
|
|
|
|
|
|
if len(packages) > 1:
|
|
|
|
|
|
bundle = build_flex_bundle(
|
|
|
|
|
|
route_path, data_dir,
|
|
|
|
|
|
include_sibling_orders=True,
|
|
|
|
|
|
station_count=station_count,
|
2026-07-28 02:12:46 +08:00
|
|
|
|
profile=prof,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
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
|