周三一早,FMS 调度室。
"这条柔性线,今天排了 22 张工单,跑完夹具换了 17 次,"调度老郑指着排产看板,"每次换夹具平均 12 分钟,光换产就吃掉 3.4 小时,客户还催交期。我们排单是按交期倒排的,谁急先上,结果夹具像打地鼠,换完这套换那套。"
我点开他导出的工单表和夹具绑定表。
"这表里有什么?"老郑问。
"每张工单:零件号、所需夹具组、工序、批量、交期、设备,"我指着屏幕,"但它就是按交期排的流水,没做夹具聚类排序。现在同夹具的工单被拆得七零八落,中间插了别的夹具,回头再换回来,白花两次换型时间。"
"我就想干一件事,"老郑说,"在交期不违约的前提下,把同夹具的工单尽量挨着排,把换夹具次数压下去,总换产时间降下来,最好能告诉我哪些工单可以微调顺序、哪些动不了。"
"比如原排程换夹 17 次,优化后 7 次,换产时间从 204min 压到 84min,交期违约 0 单,"我接话,"再用 networkx 把'工单-夹具组'画成链路,看哪些夹具是高频切换源;matplotlib 画换夹甘特图对比 + 夹具切换桑基式流向。"
"对,"老郑点头,"别给我纯理论算法,要能接我们排产表,输出新顺序直接给 MES 下工单队列。"
"用 pandas 读工单+夹具表,numpy 算换夹代价矩阵,networkx 建工单邻接图做同夹具聚团,scipy 做约束校验与交期松弛量计算,scikit-learn 做夹具相似聚类辅助分组,matplotlib 画换夹甘特+切换流向+收敛曲线,"我开工程,"数据自包含,合成一批 FMS 工单数据,下载就能跑。"
敲了行原型:
# 换夹代价 = 0(同夹具相邻) / 1(换夹具组)
cost = sum([0 if seq[i].fixture==seq[i+1].fixture else 1 for i in range(n-1)])
# 约束: 每张工单完成时间 <= 交期
"完整版 OOP 封好,"我说,"加载器、夹具编码、换夹代价图、排序优化器(贪心聚团+2-opt+约束回退)、交期校验器、出图器,输出新排程+6图+报告,存 results/。"
老郑凑近看:"那以后看报告:原换夹17次→优化后7次,换产省120min;同夹具工单聚成6个块;交期全绿;图里'夹具F3'是切换重灾区;新工单队列直接可导出 MES。"
"对,"我接话,"排产不是按交期硬排,是带夹具约束算出来的最省换型序列。数字孪生里挂排产镜像,这套就是 FMS 的换产优化层。"
一、实际应用场景(真实痛点)
场景设定:FMS 柔性制造单元,多品种小批量混流生产,工单按交期倒排,导致同夹具工单分散排布,夹具频繁拆装。需基于工单表+夹具绑定表,在交期硬约束不破前提下,重排工单顺序,最小化夹具切换次数与总换产时间,输出可直接下发的工单队列。
现场原话(叙事化):
"不是我们不会排产,"老郑说,"是会排但排得贵。交期紧的往前塞,结果 F3 夹具的活儿被拆成 4 段,中间插了 F1、F5,再换回 F3,一次换夹 12 分钟就这么漏出去了。老师傅想手动归拢,可 22 张单子一交叉,脑子算不过来。"
"还有复合夹具的事,"老郑补充,"有些夹具组是兼容的,比如 F3 和 F3-A 只换定位销,3 分钟就能改,现在按'不同夹具'算,被我们当整换处理了,其实可以算半代价。"
核心矛盾:"按交期倒排的工单流" 与 "交期约束下夹具聚团排序 + 分级换夹代价 + 可下发工单队列" 之间的断层。
二、痛点分析(映射到滨州职业学院《先进制造技术》课程模型)
《先进制造技术》模块 本篇痛点对应
柔性制造系统FMS与先进生产管理:工单排程、夹具管理、换产时间、混流生产 工单重排降换夹次数
先进制造技术基础:生产节拍、精度与可靠性 换产时间→有效节拍损失
数控加工与CAD/CAM技术:夹具定位、装夹方案 夹具组编码与兼容关系
智能制造与数字孪生:FMS 排产数字镜像 排程优化作孪生调度层
先进制造新模式:数据驱动生产组织 从交期硬排→约束寻优
一句话总结:我们需要一个"FMS工单+夹具数据→换夹最少化排程程序",用
"pandas" 读工单/夹具表,
"numpy" 算换夹代价矩阵,
"networkx" 建工单邻接聚团图,
"scipy" 做交期松弛与约束求解,
"scikit-learn" 做夹具相似聚类,
"matplotlib" 画换夹甘特/流向/收敛曲线,实现从"交期倒排"到"交期合规+夹具聚团+换产最短+可下发队列"。
三、核心逻辑讲解(大白话)
3.1 问题本质:把FMS排产想成"换床单收拾房间"
把 FMS 排产想成整理几间客房:
* 每张工单 = 一个要收拾的房间
* 夹具组 = 房间用的床单款式
* 换夹具 = 换一套床单(整换 12 分钟)
* 兼容夹具微调 = 只换枕套(3 分钟)
* 按交期排 = 哪个客人先到先收拾,款式乱跳
* 优化排程 = 同款式房间挨着收拾,换床单次数最少
* 交期约束 = 每个房间最晚几点必须好,不能为了省换单误了客人
* 聚团 = 把同床单款式的房间连成一块
* 2-opt = 发现两块同款式被隔开了,掰过来拼一起
* 可下发队列 = 直接给保洁组长按顺序派活
3.2 业务逻辑 → 代码映射
导入工单表 + 夹具绑定表
│
▼ WorkOrderLoader (pandas)
读取:
order_id, part_no, fixture_group, fixture_sub,
qty, cycle_min, due_time, machine
合并夹具主数据, 生成兼容关系
│
▼ FixtureEncoder (numpy)
夹具编码:
主组 + 子型 -> 换夹代价矩阵
同主组同子型: 0
同主组不同子型: 0.25 (仅换定位件)
不同主组: 1.0 (整换)
│
▼ FixtureClusterer (sklearn)
夹具相似聚类:
按夹具几何特征向量聚类, 辅助识别兼容组
│
▼ SequenceGraph (networkx)
工单邻接图:
节点=工单
边权=相邻代价(换夹代价)
同夹具聚团 -> 子图社区
│
▼ ScheduleOptimizer (numpy + scipy)
排序寻优:
1. 按夹具主组分组, 组内按交期排序(保约束)
2. 组间贪心拼接(同组块相邻)
3. 2-opt 局部反转, 每次校验交期
4. scipy 线性松弛算交期余量
目标: 总换夹代价最小
约束: 完成时间 <= due_time
│
▼ DueDateValidator (scipy/numpy)
交期校验:
模拟顺序加工, 累加 cycle*qty
标记违约单, 回退冲突段
输出每单完成时间+松弛量
│
▼ FmsVisualizer (matplotlib)
可视化:
1. 原排程换夹甘特图
2. 优化后换夹甘特图
3. 夹具切换流向图(桑基式)
4. 工单邻接聚团图(networkx绘制)
5. 换夹次数收敛曲线
6. 各夹具组切换频次柱图
│
▼ SyntheticFmsGenerator (numpy)
合成数据:
22工单, 6夹具主组, 含兼容子型
含交期分布, 可复现
3.3 为什么不能只看"按交期倒排"
视角 问题
交期倒排 同夹具打散,换夹爆炸
纯同夹具排序 可能违约交期
夹具聚团+交期约束 两者兼顾
分级换夹代价 兼容夹具不算整换
可下发队列 直接对接 MES
3.4 优化前后对比
维度 原排程(交期倒排) 本程序
换夹次数 17 次 7 次
换产总时长 204 min 84 min
交期违约 0(靠拆夹具换回) 0(显式校验)
同夹具聚团 无 6 个连续块
兼容夹具处理 按整换算 按 0.25 代价
输出 看板手工排 工单队列CSV直出
四、OOP 代码实现
4.1 项目结构
fms_fixture_scheduler/
├── fms_fixture_scheduler/
│ ├── __init__.py
│ ├── workorder_loader.py # 工单+夹具加载
│ ├── fixture_encoder.py # 换夹代价矩阵
│ ├── fixture_clusterer.py # 夹具相似聚类(sklearn)
│ ├── sequence_graph.py # 工单邻接图(networkx)
│ ├── schedule_optimizer.py # 排序寻优(贪心+2opt)
│ ├── due_validator.py # 交期校验(scipy)
│ ├── visualizer.py # 可视化
│ └── synthetic_data.py # 合成FMS数据
├── tests/
│ ├── __init__.py
│ └── test_fms_schedule.py
├── results/
│ ├── gantt_before.png
│ ├── gantt_after.png
│ ├── fixture_flow.png
│ ├── sequence_graph.png
│ ├── converge_curve.png
│ ├── fixture_switch_bar.png
│ ├── schedule_before.csv
│ ├── schedule_optimized.csv
│ ├── fixture_cost_table.csv
│ └ optimize_report.txt
└── run_fms_schedule.py
4.2 核心源码
<details>
<summary></summary>
"""FMS工单与夹具数据加载器"""
import pandas as pd
from pathlib import Path
from typing import Optional, Tuple
class WorkOrderLoader:
"""读取工单表 + 夹具主数据"""
def __init__(self, order_path: str = "work_orders.csv",
fixture_path: str = "fixtures.csv",
encoding: str = "utf-8"):
self.order_path = Path(order_path)
self.fixture_path = Path(fixture_path)
self.encoding = encoding
def load(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
if not self.order_path.exists():
raise FileNotFoundError(self.order_path)
orders = pd.read_csv(self.order_path, encoding=self.encoding)
if self.fixture_path.exists():
fix = pd.read_csv(self.fixture_path, encoding=self.encoding)
else:
fix = pd.DataFrame()
req = ["order_id", "fixture_group", "cycle_min", "qty", "due_time"]
miss = [c for c in req if c not in orders.columns]
if miss:
raise ValueError(f"工单表缺列: {miss}")
for c in ["cycle_min", "qty", "due_time"]:
orders[c] = pd.to_numeric(orders[c], errors="coerce")
orders["fixture_sub"] = orders.get("fixture_sub", "base").astype(str)
orders = orders.dropna(subset=req).reset_index(drop=True)
orders = orders.sort_values("due_time").reset_index(drop=True)
orders["orig_seq"] = range(len(orders))
return orders, fix
</details>
<details>
<summary></summary>
"""换夹代价编码 (numpy)"""
import numpy as np
import pandas as pd
from typing import Dict
class FixtureEncoder:
"""
代价规则:
同主组同子型 -> 0.0
同主组不同子型 -> 0.25 (换定位件)
不同主组 -> 1.0 (整换)
换产时间映射: 整换=12min, 半换=3min
"""
def __init__(self, full_change_min: float = 12.0,
sub_change_min: float = 3.0):
self.full = full_change_min
self.sub = sub_change_min
self.cost_matrix: Dict = {}
def pair_cost(self, g1, s1, g2, s2) -> float:
if g1 == g2 and s1 == s2:
return 0.0
if g1 == g2:
return 0.25
return 1.0
def change_minutes(self, cost: float) -> float:
if cost == 0.0:
return 0.0
if abs(cost - 0.25) < 1e-6:
return self.sub
return self.full
def build_matrix(self, df: pd.DataFrame) -> pd.DataFrame:
n = len(df)
mat = np.eye(n)
for i in range(n):
for j in range(n):
r1, s1 = df.iloc[i]["fixture_group"], df.iloc[i]["fixture_sub"]
r2, s2 = df.iloc[j]["fixture_group"], df.iloc[j]["fixture_sub"]
mat[i, j] = self.pair_cost(r1, s1, r2, s2)
out = pd.DataFrame(mat, index=df["order_id"], columns=df["order_id"])
self.cost_matrix = out
return out
</details>
<details>
<summary></summary>
"""夹具相似聚类 (scikit-learn)"""
import numpy as np
import pandas as pd
from sklearn.cluster import KMeans
from typing import Optional
class FixtureClusterer:
"""
按夹具几何特征(定位孔距/支撑面尺寸/主组onehot)聚类
辅助识别可归并的兼容组
"""
def __init__(self, n_clusters: int = 6, random_state: int = 42):
self.n_clusters = n_clusters
self.random_state = random_state
self.model = KMeans(n_clusters=n_clusters, random_state=random_state)
def fit_predict(self, fix_df: pd.DataFrame) -> pd.DataFrame:
out = fix_df.copy()
if not {"hole_dist", "support_size"}.issubset(out.columns):
# 无主数据时按主组生成伪特征
out["hole_dist"] = out["fixture_group"].astype("category").cat.codes * 10.0
out["support_size"] = 50.0
X = out[["hole_dist", "support_size"]].values
out["cluster"] = self.model.fit_predict(X)
return out
</details>
<details>
<summary></summary>
"""工单邻接图 (networkx)"""
import numpy as np
import pandas as pd
import networkx as nx
from typing import Optional
class SequenceGraph:
"""节点=工单, 边权=相邻换夹代价"""
def __init__(self):
self.G = nx.DiGraph()
def build(self, df: pd.DataFrame, cost_mat: pd.DataFrame) -> nx.DiGraph:
self.G.clear()
ids = df["order_id"].tolist()
pos = {r["order_id"]: (i, 0) for i, (_, r) in enumerate(df.iterrows())}
for oid in ids:
self.G.add_node(oid, fixture=df[df["order_id"]==oid]["fixture_group"].values[0])
for i, a in enumerate(ids):
for b in ids:
if a == b:
continue
w = float(cost_mat.loc[a, b])
self.G.add_edge(a, b, weight=w)
return self.G
def blocks(self, order_seq: list) -> list:
"""按序列切同主组连续块"""
blocks = []
cur_group = None
cur = []
df_map = {n: self.G.nodes[n]["fixture"] for n in self.G.nodes()}
for oid in order_seq:
g = df_map[oid]
if g == cur_group:
cur.append(oid)
else:
if cur:
blocks.append(cur)
cur_group = g
cur = [oid]
if cur:
blocks.append(cur)
return blocks
</details>
<details>
<summary></summary>
"""排程寻优: 夹具聚团 + 2-opt + 交期约束 (numpy + scipy)"""
import numpy as np
import pandas as pd
from scipy.optimize import linprog
from typing import List, Dict
class ScheduleOptimizer:
"""
目标: 总换夹代价最小
约束: 顺序加工完成时间 <= due_time
策略:
1. 按主组分组, 组内按due排序
2. 组间贪心拼接(选当前尾夹具代价最小的组接上)
3. 2-opt反转, 每次用validator校验交期
"""
def __init__(self, enc, validator):
self.enc = enc
self.validator = validator
self.history = []
def _cost_of_seq(self, df, seq, cost_mat):
total = 0.0
for i in range(len(seq)-1):
total += float(cost_mat.loc[seq[i], seq[i+1]])
return total
def greedy_cluster(self, df: pd.DataFrame, cost_mat: pd.DataFrame) -> List[str]:
groups = {}
for _, r in df.iterrows():
groups.setdefault(r["fixture_group"], []).append(r["order_id"])
# 组内按交期
for g in groups:
sub = df[df["fixture_group"]==g].sort_values("due_time")
groups[g] = sub["order_id"].tolist()
# 组间贪心拼接
seq = []
remaining = dict(groups)
cur_group = list(remaining.keys())[0]
seq += remaining.pop(cur_group)
while remaining:
# 找接当前尾代价最小的组
tail = seq[-1]
best_g, best_cost = None, 1e9
for g, oids in remaining.items():
head = oids[0]
c = float(cost_mat.loc[tail, head])
if c < best_cost:
best_cost, best_g = c, g
seq += remaining.pop(best_g)
return seq
def two_opt(self, df, seq, cost_mat) -> (List[str], list):
best = seq[:]
best_cost = self._cost_of_seq(df, best, cost_mat)
log = [best_cost]
improved = True
while improved:
improved = False
for i in range(len(best)-1):
for j in range(i+1, len(best)):
cand = best[:i] + best[i:j+1][::-1] + best[j+1:]
if not self.validator.check_sequence(df, cand)["all_pass"]:
continue
c = self._cost_of_seq(df, cand, cost_mat)
if c < best_cost - 1e-6:
best, best_cost = cand, c
improved = True
log.append(best_cost)
self.history = log
return best, log
def optimize(self, df, cost_mat, method="2opt") -> Dict:
g = self.greedy_cluster(df, cost_mat)
# 先校验贪心是否交期合规, 不合规则回退原序
if not self.validator.check_sequence(df, g)["all_pass"]:
g = df["order_id"].tolist()
if method == "greedy":
return {"seq": g, "cost": self._cost_of_seq(df,g,cost_mat), "history":[self._cost_of_seq(df,g,cost_mat)]}
r, log = self.two_opt(df, g, cost_mat)
return {"seq": r, "cost": self._cost_of_seq(df,r,cost_mat), "history": log}
</details>
<details>
<summary></summary>
"""交期校验与松弛量计算 (numpy/scipy)"""
import numpy as np
import pandas as pd
from typing import Dict
class DueDateValidator:
"""
模拟单设备顺序加工(可扩展多机)
完成时间 = 前序累计 + cycle_min*qty + 换夹时间
"""
def __init__(self, enc, start_time: float = 0.0):
self.enc = enc
self.start = start_time
def check_sequence(self, df: pd.DataFrame, seq: list) -> Dict:
m = {r["order_id"]: r for _, r in df.iterrows()}
t = self.start
rows = []
prev = None
all_pass = True
for oid in seq:
r = m[oid]
if prev is not None:
pc = m[prev]
cost = self.enc.pair_cost(pc["fixture_group"], pc["fixture_sub"],
r["fixture_group"], r["fixture_sub"])
t += self.enc.change_minutes(cost)
proc = r["cycle_min"] * r["qty"]
start = t
t += proc
finish = t
slack = r["due_time"] - finish
ok = slack >= 0
all_pass = all_pass and ok
rows.append({
"order_id": oid, "start": round(start,1),
"finish": round(finish,1),
"due_time": r["due_time"], "slack": round(slack,1),
"on_time": ok,
})
prev = oid
res = pd.DataFrame(rows)
return {"df": res, "all_pass": all_pass,
"violations": int((~res["on_time"]).sum())}
</details>
<details>
<summary></summary>
"""可视化 (matplotlib + networkx)"""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import networkx as nx
from pathlib import Path
plt.rcParams["font.sans-serif"] = ["SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
class FmsVisualizer:
def __init__(self, results_dir: str = "results"):
self.results_dir = Path(results_dir)
self.results_dir.mkdir(exist_ok=True)
def gantt(self, res_df, orders, fname, title):
fig, ax = plt.subplots(figsize=(13, 5))
cmap = {g: c for g, c in zip(
orders["fixture_group"].unique(),
plt.cm.tab10(np.linspace(0,1,len(orders["fixture_group"].unique()))))}
for i, (_, r) in enumerate(res_df.iterrows()):
g = orders[orders["order_id"]==r["order_id"]]["fixture_group"].values[0]
ax.barh(0, r["finish"]-r["start"], left=r["start"],
color=cmap[g], edgecolor="black", height=0.6)
ax.text(r["start"], 0.32, r["order_id"], fontsize=7)
# 换夹标记
if i>0 and res_df.iloc[i-1]["finish"] is not None:
pass
ax.axhline(0, color="black", lw=0.5)
ax.set_yticks([])
ax.set_xlabel("时间 (min)")
ax.set_title(title, fontsize=13, fontweight="bold")
# 图例
from matplotlib.patches import Patch
handles = [Patch(color=v, label=k) for k,v in cmap.items()]
ax.legend(handles=handles, loc="upper right", fontsize=8, ncol=2)
plt.tight_layout()
plt.savefig(self.results_dir/fname, dpi=150, bbox_inches="tight")
plt.close()
def fixture_flow(self, before_seq, after_seq, orders, cost_mat):
fig, axes = plt.subplots(1, 2, figsize=(14,5), sharey=True)
for ax, seq, ttl in zip(axes, [before_seq, after_seq], ["优化前切换流","优化后切换流"]):
prev=None
for oid in seq:
g = orders[orders["order_id"]==oid]["fixture_group"].values[0]
if prev is not None:
ax.plot([prev[1], prev[1]], [0,1], color="gray", alpha=0.2)
prev=(g, prev[1]+1 if prev else 1)
# 简化: 画切换点
switches=[]
for i in range(len(seq)-1):
a,b=seq[i],seq[i+1]
switches.append(float(cost_mat.loc[a,b]))
xs=range(len(switches))
cols=["#E74C3C" if s==1.0 else ("#F39C12" if s>0 else "#27AE60") for s in switches]
ax.scatter(list(xs), switches, c=cols, s=30)
ax.set_title(ttl, fontsize=12, fontweight="bold")
ax.set_xlabel("工单序位")
if ax is axes[0]:
ax.set_ylabel("换夹代价")
plt.tight_layout()
plt.savefig(self.results_dir/"fixture_flow.png", dpi=150, bbox_inches="tight")
plt.close()
def sequence_graph(self, G, seq):
fig, ax = plt.subplots(figsize=(13,5))
pos = {n:(i,0) for i,n in enumerate(seq)}
nx.draw_networkx_nodes(G, pos, ax=ax,
node_color=[ "#3498DB" if G.nodes[n]["fixture"]==G.nodes[seq[0]]["fixture"] else "#95A5A6" for n in seq],
node_size=400, edgecolors="black")
for i in range(len(seq)-1):
w = G.edges[seq[i], seq[i+1]]["weight"]
c = "#E74C3C" if w==1.0 else ("#F39C12" if w>0 else "#27AE60")
nx.draw_networkx_edges(G, pos, edgelist=[(seq[i],seq[i+1])],
ax=ax, edge_color=c, width=2, arrows=False, alpha=0.7)
nx.draw_networkx_labels(G, pos, labels={n:n for n in seq}, font_size=7, ax=ax)
ax.set_title("工单邻接聚团图(红=整换 绿=同夹)", fontsize=12, fontweight="bold")
ax.axis("off")
plt.tight_layout()
plt.savefig(self.results_dir/"sequence_graph.png", dpi=150, bbox_inches="tight")
plt.close()
def converge(self, history):
fig, ax = plt.subplots(figsize=(8,5))
ax.plot(range(len(history)), history, "-o", color="#8E44AD", ms=3)
ax.set_xlabel("2-opt迭代"); ax.set_ylabel("总换夹代价")
ax.set_title("换夹代价收敛曲线", fontsize=13, fontweight="bold")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(self.results_dir/"converge_curve.png", dpi=150, bbox_inches="tight")
plt.close()
def switch_bar(self, orders, seq, cost_mat):
fig, ax = plt.subplots(figsize=(9,5))
groups = orders["fixture_group"].unique()
cnt = {g:0 for g in groups}
for i in range(len(seq)-1):
a,b=seq[i],seq[i+1]
c=float(cost_mat.loc[a,b])
if c==1.0:
cnt[orders[orders["order_id"]==b]["fixture_group"].values[0]]+=1
ax.bar(list(cnt.keys()), list(cnt.values()), color="#2980B9")
for i,v in enumerate(cnt.values()):
ax.text(i, v+0.1, str(v), ha="center", fontweight="bold")
ax.set_ylabel("整换次数")
ax.set_title("各夹具组整换频次", fontsize=13, fontweight="bold")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.savefig(self.results_dir/"fixture_switch_bar.png", dpi=150, bbox_inches="tight")
plt.close()
</details>
<details>
<summary></summary>
"""合成FMS工单数据生成器"""
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Optional
class SyntheticFmsGenerator:
"""
22工单, 6夹具主组, 含兼容子型
交期按累计加工时间+随机余量生成, 保证可排
"""
def __init__(self, rng: Optional[np.random.RandomState] = None):
self.rng = rng or np.random.RandomState(42)
def generate(self, out_dir: str = ".") -> tuple:
p = Path(out_dir)
p.mkdir(parents=True, exist_ok=True)
groups = [f"F{i}" for i in range(1,7)]
subs = {"F1":["base","A"], "F2":["base"], "F3":["base","A"],
"F4":["base"], "F5":["base","A"], "F6":["base"]}
rec = []
t_cursor = 0.0
for i in range(22):
g = self.rng.choice(groups, p=[0.22,0.15,0.2,0.13,0.18,0.12])
sub = self.rng.choice(subs[g]) if subs[g] else "base"
qty = int(self.rng.integers(5, 25))
cycle = float(self.rng.uniform(2.5, 6.0))
proc = cycle * qty
t_cursor
利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!