aps-agent/server/aps_domain/kangni_intake.py

1068 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 现场「完整生产路线」导入(moduleId: domain-kangni-intake, 可重生 ✅)
# 将康尼现场 Excel 落到 flex* 键,替换演示种子,供 PoolEngine 排产。
# 口径:docs/product/demand-data-intake.md;缺口用显式推断并写入 flexParams.siteInferences。
# 客户差异(工厂命名/工时推断规则/默认路径)收敛在 server/importers/profiles/kangni.json,
# 本模块仅保留解析逻辑 + 兜底默认值(profile 缺失时可独立运行)。
# ============================================================
from __future__ import annotations
import re
from 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