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