# ============================================================ # 现场「完整生产路线」导入(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 datetime import datetime from pathlib import Path from typing import Any World = dict[str, Any] 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 _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": ("工艺路线", "路线"), "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 == "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_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 parse_routing_sheet(path: Path, order_no: str, infer_map: dict[str, float] | None = None) -> list[dict[str, Any]]: """从「工艺路线.xlsx」按订单 sheet 取工序。""" import openpyxl wb = openpyxl.load_workbook(path, data_only=True) if order_no not in wb.sheetnames: wb.close() return [] ws = wb[order_no] 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} ops = [] 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 code = _clean(g("工序编号")) if not code: continue op_type = _clean(g("工序类型", "类型")) std_min, inferred = _infer_std_min(op_type, g("标准工时"), infer_map) seq_raw = _clean(g("排序号", "序号")) try: seq = int(float(seq_raw)) if seq_raw else (len(ops) + 1) * 10 except ValueError: seq = (len(ops) + 1) * 10 ops.append({ "seq": seq, "operationCode": code, "operationName": _clean(g("工序名称")) or code, "opType": op_type, "stdTimePerUnit": std_min, "stdTimeInferred": inferred, "resourceGroup": "", "requireMold": False, }) return ops def parse_bom_sheet(path: Path, order_no: str) -> list[dict[str, Any]]: """从「BOM.xlsx」按订单 sheet 取物料。""" import openpyxl wb = openpyxl.load_workbook(path, data_only=True) if order_no not in wb.sheetnames: wb.close() return [] ws = wb[order_no] 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 mat = _clean(g("物料编号", "物料编码")) if not mat: continue out.append({ "materialCode": mat, "materialName": _clean(g("物料名称")) or mat, "quantity": _num(g("单位用量", "用量"), 1.0), "consumeOp": _clean(g("MES工序编号", "工序编号")), "isKey": _num(g("单位用量", "用量"), 1.0) >= 1.0, }) return out 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 primary = parse_complete_route_xlsx(route_path, infer_map) packages = [primary] if include_sibling_orders and data_dir.exists(): orders_xlsx = _find_file(data_dir, "orders") routing_xlsx = _find_file(data_dir, "routing") bom_xlsx = _find_file(data_dir, "bom") if orders_xlsx: for od in parse_orders_workbook(orders_xlsx): if od["orderNo"] == primary["orderNo"]: # 用源表补齐主订单字段 for k in ("wbs", "productCode", "productName", "drawingNo", "planStart", "dueDate", "quantity"): if od.get(k) and not primary.get(k): primary[k] = od[k] continue ops = parse_routing_sheet(routing_xlsx, od["orderNo"], infer_map) if routing_xlsx else [] if not ops: continue bom = parse_bom_sheet(bom_xlsx, od["orderNo"]) if bom_xlsx else [] packages.append({ **od, "kitStatus": "未知", "operations": ops, "bom": bom, "source": str(orders_xlsx), }) inferences: list[str] = [] 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()) zone_cfg = prof.get("zone") or {} zone = {"code": zone_cfg.get("code") or "ZONE-CG", "name": zone_cfg.get("name") or "城轨机构装配区"} ws_prefix = prof.get("stationCodePrefix") or "WS-CG-" ws_name = prof.get("stationNamePrefix") or "城轨机构装配工位" avail = float(prof.get("availabilityRate") or 0.95) # 共享能力池:每台工位具备全部工序能力(柔性装配台) op_std: dict[str, float] = {} for pkg in packages: for op in pkg["operations"]: op_std.setdefault(op["operationCode"], op["stdTimePerUnit"]) equipment = [] 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": zone["code"], "adaptableMolds": [], "availabilityRate": avail, "status": "RUNNING", "inferred": True, }) inferences.append( f"设备/工位源表待维护 → 合成 {station_count} 台共享装配工位 " f"(能力=全部 {len(all_op_codes)} 道工序)" ) inferences.append("模具未匹配到本产品 → requireMold=false,不占模具槽") materials: dict[str, dict] = {} 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 materials[pc] = { "code": pc, "name": pn, "type": "FINISHED_PRODUCT", "unit": "套", "stock": 0, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0, "drawingNo": pkg.get("drawingNo") or "", } for op in sorted(pkg["operations"], key=lambda x: x["seq"]): routings.append({ "productCode": pc, "productName": pn, "seq": op["seq"], "operationCode": op["operationCode"], "requireMold": False, "stdTimePerUnit": op["stdTimePerUnit"], "stdTimeSource": "推断" 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) if mc not in materials: materials[mc] = { "code": mc, "name": b["materialName"], "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) 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")), }) 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 "", }) # 去重 BOM(同产品+物料保留首条) seen_bom: set[tuple[str, str]] = set() uniq_bom = [] for b in bom_rows: key = (b["productCode"], b["materialCode"]) if key in seen_bom: continue seen_bom.add(key) uniq_bom.append(b) 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": "complete-route", "siteSource": str(route_path), "siteInferences": uniq_inf, "demoDataCleared": True, } return { "flexZones": [zone], "flexOperations": list(ops_map.values()), "flexEquipment": equipment, "flexMolds": [], "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), "bomCount": len(uniq_bom), "inferences": uniq_inf, "primaryOrder": primary["orderNo"], "source": str(route_path), }, } 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": "推断" 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, "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 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 primary = parse_complete_route_xlsx(route_path, infer_map) packages = [primary] if include_sibling_orders and data_dir.exists(): # 仅当显式要求时带同源其它单 orders_xlsx = _find_file(data_dir, "orders") routing_xlsx = _find_file(data_dir, "routing") bom_xlsx = _find_file(data_dir, "bom") if orders_xlsx: for od in parse_orders_workbook(orders_xlsx): if od["orderNo"] == primary["orderNo"]: for k in ("wbs", "productCode", "productName", "drawingNo", "planStart", "dueDate", "quantity"): if od.get(k) and not primary.get(k): primary[k] = od[k] continue ops = parse_routing_sheet(routing_xlsx, od["orderNo"], infer_map) if routing_xlsx else [] if not ops: continue bom = parse_bom_sheet(bom_xlsx, od["orderNo"]) if bom_xlsx else [] packages.append({**od, "kitStatus": "未知", "operations": ops, "bom": bom, "source": str(orders_xlsx)}) 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