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