aps-agent/server/aps_domain/kangni_intake.py

1068 lines
42 KiB
Python
Raw Normal View History

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