fix: bugs when available times less than duties wanted
parent
6ab0ed9f00
commit
7bd2394d2c
|
|
@ -48,9 +48,9 @@ uv run pyinstaller --onefile --windowed --name=EVA_duty_arrange_tool --hidden-im
|
||||||
- **目标5**:每一班各部门人数的平均程度
|
- **目标5**:每一班各部门人数的平均程度
|
||||||
|
|
||||||
在本问题中,主要约束是:
|
在本问题中,主要约束是:
|
||||||
1. 让每位同学每周的班次数符合意愿(特别地,选择3次的同学可以安排2-3次)
|
1. 让每位同学每周的班次数符合意愿(特别地,选择3次的同学可以安排2-3次;若某同学的可用班次数少于其期望值班次数,则自动降至可用数量)
|
||||||
2. 让每位同学只在自己有空的时间段值班
|
2. 让每位同学只在自己有空的时间段值班
|
||||||
3. 每班次的总人数在指定范围内
|
3. 每班次的总人数在指定范围内(无人选择的班次豁免下限约束,视为无人值班)
|
||||||
4. 每班次技术部老人数量在指定范围内
|
4. 每班次技术部老人数量在指定范围内
|
||||||
5. 每班次老人总数在指定范围内
|
5. 每班次老人总数在指定范围内
|
||||||
6. 每班次小朋友总数在指定范围内
|
6. 每班次小朋友总数在指定范围内
|
||||||
|
|
@ -166,3 +166,4 @@ OR-Tools 支持 `C++`、`Python`、`C#`、`Java` 等多种语言,并提供跨
|
||||||
- 如果你想更改 Excel 的读取、写入相关的功能,应该修改 `utils.py` 中的相关函数。
|
- 如果你想更改 Excel 的读取、写入相关的功能,应该修改 `utils.py` 中的相关函数。
|
||||||
- 如果你想更改软件的前端界面,应该修改 `main.py` 中 `MyWidget` 这个类相关的代码。
|
- 如果你想更改软件的前端界面,应该修改 `main.py` 中 `MyWidget` 这个类相关的代码。
|
||||||
- 如果你想更换排班问题的建模方式、更换求解器、增减限制条件,应该修改 `solve.py` 中的相关代码。
|
- 如果你想更换排班问题的建模方式、更换求解器、增减限制条件,应该修改 `solve.py` 中的相关代码。
|
||||||
|
- 如果你想更改异常数据(缺班、可用班次不足)的检测和处理逻辑,应该修改 `main.py` 中 `magic` 方法内对应的检查块,以及 `solve.py` 中 `solve_program` 的 `exempt_shifts` 参数相关逻辑。
|
||||||
80
main.py
80
main.py
|
|
@ -42,7 +42,7 @@ class SolverThread(QtCore.QThread):
|
||||||
error_signal = QtCore.Signal(str) # 发送错误消息
|
error_signal = QtCore.Signal(str) # 发送错误消息
|
||||||
|
|
||||||
def __init__(self, preference_mat, depart_mat, want_num_array, is_new_array,
|
def __init__(self, preference_mat, depart_mat, want_num_array, is_new_array,
|
||||||
auto_mode, manual_params=None, parent=None):
|
auto_mode, manual_params=None, exempt_shifts=None, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.preference_mat = preference_mat
|
self.preference_mat = preference_mat
|
||||||
self.depart_mat = depart_mat
|
self.depart_mat = depart_mat
|
||||||
|
|
@ -50,6 +50,7 @@ class SolverThread(QtCore.QThread):
|
||||||
self.is_new_array = is_new_array
|
self.is_new_array = is_new_array
|
||||||
self.auto_mode = auto_mode
|
self.auto_mode = auto_mode
|
||||||
self.manual_params = manual_params or {}
|
self.manual_params = manual_params or {}
|
||||||
|
self.exempt_shifts = exempt_shifts or []
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -80,6 +81,7 @@ class SolverThread(QtCore.QThread):
|
||||||
depart_mat=self.depart_mat,
|
depart_mat=self.depart_mat,
|
||||||
want_num_array=self.want_num_array,
|
want_num_array=self.want_num_array,
|
||||||
is_new_array=self.is_new_array,
|
is_new_array=self.is_new_array,
|
||||||
|
exempt_shifts=self.exempt_shifts,
|
||||||
**test_params
|
**test_params
|
||||||
)
|
)
|
||||||
if vars:
|
if vars:
|
||||||
|
|
@ -102,6 +104,7 @@ class SolverThread(QtCore.QThread):
|
||||||
want_num_array=self.want_num_array,
|
want_num_array=self.want_num_array,
|
||||||
depart_mat=self.depart_mat,
|
depart_mat=self.depart_mat,
|
||||||
is_new_array=self.is_new_array,
|
is_new_array=self.is_new_array,
|
||||||
|
exempt_shifts=self.exempt_shifts,
|
||||||
**self.manual_params
|
**self.manual_params
|
||||||
)
|
)
|
||||||
final_params = self.manual_params
|
final_params = self.manual_params
|
||||||
|
|
@ -303,6 +306,77 @@ class MyWidget(QtWidgets.QWidget):
|
||||||
self.text_solve.append(f"\t平均每班人数:{all_sum/20}")
|
self.text_solve.append(f"\t平均每班人数:{all_sum/20}")
|
||||||
self.text_solve.append(f"\t所有班次中最少拥有意愿数:{min_prefer}")
|
self.text_solve.append(f"\t所有班次中最少拥有意愿数:{min_prefer}")
|
||||||
|
|
||||||
|
# 检查是否有班次没有任何同学选择
|
||||||
|
CLASS_NAMES = [
|
||||||
|
"周一第一班", "周一第二班", "周一第三班",
|
||||||
|
"周二第一班", "周二第二班", "周二第三班",
|
||||||
|
"周三第一班", "周三第二班", "周三第三班",
|
||||||
|
"周四第一班", "周四第二班", "周四第三班",
|
||||||
|
"周五第一班", "周五第二班", "周五第三班",
|
||||||
|
"周六第一班", "周六第二班", "周六第三班",
|
||||||
|
"周日第一班", "周日第二班"
|
||||||
|
]
|
||||||
|
empty_classes = [
|
||||||
|
j for j in range(class_num)
|
||||||
|
if sum(preference_mat[i][j] for i in range(len(preference_mat))) == 0
|
||||||
|
]
|
||||||
|
if empty_classes:
|
||||||
|
empty_names = [
|
||||||
|
CLASS_NAMES[j] if j < len(CLASS_NAMES) else f"第{j+1}班"
|
||||||
|
for j in empty_classes
|
||||||
|
]
|
||||||
|
warning_detail = "、".join(empty_names)
|
||||||
|
self.text_solve.append(f"警告:以下班次没有任何同学选择:{warning_detail}")
|
||||||
|
reply = QtWidgets.QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"警告:存在无人选择的班次",
|
||||||
|
f"以下班次没有任何同学选择:\n\n{warning_detail}\n\n"
|
||||||
|
f"点击「确定」将这些班次视为无人值班并继续排班,\n"
|
||||||
|
f"点击「取消」放弃排班。",
|
||||||
|
QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel,
|
||||||
|
QtWidgets.QMessageBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
if reply == QtWidgets.QMessageBox.StandardButton.Cancel:
|
||||||
|
self.text_solve.append("排班已取消。")
|
||||||
|
self.button_solve.setEnabled(True)
|
||||||
|
self.button_solve.setText("开始排班!")
|
||||||
|
return
|
||||||
|
self.text_solve.append(f"继续排班,以下班次将视为无人值班:{warning_detail}")
|
||||||
|
|
||||||
|
# 检查是否有同学选择的可用班次少于期望值班次数
|
||||||
|
want_num_adjusted = list(want_num_array)
|
||||||
|
infeasible_students = []
|
||||||
|
for i in range(len(preference_mat)):
|
||||||
|
avail = sum(preference_mat[i][j] for j in range(class_num))
|
||||||
|
want = want_num_array[i]
|
||||||
|
min_want = 2 if want == 3 else want
|
||||||
|
if avail < min_want:
|
||||||
|
infeasible_students.append((i, self.index_to_name_dict[i], avail, want))
|
||||||
|
|
||||||
|
if infeasible_students:
|
||||||
|
student_details = "\n".join(
|
||||||
|
f" {name}:期望 {want} 班,仅选了 {avail} 个可用班次"
|
||||||
|
for _, name, avail, want in infeasible_students
|
||||||
|
)
|
||||||
|
self.text_solve.append(f"警告:以下同学的可用班次数少于期望值班次数:{', '.join(n for _, n, _, _ in infeasible_students)}")
|
||||||
|
reply = QtWidgets.QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"警告:存在同学可用班次不足",
|
||||||
|
f"以下同学选择的可用班次数少于其期望值班次数:\n\n{student_details}\n\n"
|
||||||
|
f"点击「确定」按实际可用班次数安排他们并继续排班,\n"
|
||||||
|
f"点击「取消」放弃排班。",
|
||||||
|
QtWidgets.QMessageBox.StandardButton.Ok | QtWidgets.QMessageBox.StandardButton.Cancel,
|
||||||
|
QtWidgets.QMessageBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
if reply == QtWidgets.QMessageBox.StandardButton.Cancel:
|
||||||
|
self.text_solve.append("排班已取消。")
|
||||||
|
self.button_solve.setEnabled(True)
|
||||||
|
self.button_solve.setText("开始排班!")
|
||||||
|
return
|
||||||
|
for i, name, avail, want in infeasible_students:
|
||||||
|
want_num_adjusted[i] = avail
|
||||||
|
self.text_solve.append(f"调整:{name} 的期望班次由 {want} 调整为 {avail}")
|
||||||
|
|
||||||
def get_weight_value(key):
|
def get_weight_value(key):
|
||||||
text = self.weight_widgets[key].text()
|
text = self.weight_widgets[key].text()
|
||||||
return float(text) if text else 0.0
|
return float(text) if text else 0.0
|
||||||
|
|
@ -326,8 +400,8 @@ class MyWidget(QtWidgets.QWidget):
|
||||||
|
|
||||||
# 创建并启动求解线程
|
# 创建并启动求解线程
|
||||||
self.solver_thread = SolverThread(
|
self.solver_thread = SolverThread(
|
||||||
preference_mat, depart_mat, want_num_array, is_new_array,
|
preference_mat, depart_mat, want_num_adjusted, is_new_array,
|
||||||
self._auto_mode, params, self
|
self._auto_mode, params, empty_classes, self
|
||||||
)
|
)
|
||||||
|
|
||||||
# 连接信号
|
# 连接信号
|
||||||
|
|
|
||||||
8
solve.py
8
solve.py
|
|
@ -5,12 +5,14 @@ def solve_program(preference_mat:list, depart_mat:list, want_num_array:list, is_
|
||||||
num_tech_old_min=None, num_tech_old_max=None,
|
num_tech_old_min=None, num_tech_old_max=None,
|
||||||
num_old_min=None, num_old_max=None,
|
num_old_min=None, num_old_max=None,
|
||||||
num_new_min=None, num_new_max=None,
|
num_new_min=None, num_new_max=None,
|
||||||
weights=[1.0, 1.0, 1.0, 0.5, 0.5]
|
weights=[1.0, 1.0, 1.0, 0.5, 0.5],
|
||||||
|
exempt_shifts=None
|
||||||
):
|
):
|
||||||
|
|
||||||
is_old_array = [not is_new for is_new in is_new_array]
|
is_old_array = [not is_new for is_new in is_new_array]
|
||||||
is_tech_array = [depart_mat[i][0] == 1 or depart_mat[i][1] == 1 for i in range(len(depart_mat))]
|
is_tech_array = [depart_mat[i][0] == 1 or depart_mat[i][1] == 1 for i in range(len(depart_mat))]
|
||||||
is_hr_array = [depart_mat[i][2] == 1 for i in range(len(depart_mat))]
|
is_hr_array = [depart_mat[i][2] == 1 for i in range(len(depart_mat))]
|
||||||
|
exempt_set = set(exempt_shifts or [])
|
||||||
|
|
||||||
# 使用 SCIP 求解器求解组合优化问题
|
# 使用 SCIP 求解器求解组合优化问题
|
||||||
solver = pywraplp.Solver.CreateSolver("SCIP")
|
solver = pywraplp.Solver.CreateSolver("SCIP")
|
||||||
|
|
@ -115,11 +117,11 @@ def solve_program(preference_mat:list, depart_mat:list, want_num_array:list, is_
|
||||||
else:
|
else:
|
||||||
solver.Add(total_shifts == want_num_array[i])
|
solver.Add(total_shifts == want_num_array[i])
|
||||||
|
|
||||||
# 添加约束的辅助函数
|
# 添加约束的辅助函数(exempt_set 中的班次跳过下限约束)
|
||||||
def add_shift_constraint(array, min_val, max_val):
|
def add_shift_constraint(array, min_val, max_val):
|
||||||
for j in range(M):
|
for j in range(M):
|
||||||
shift_count = sum(variables[i][j] * array[i] for i in range(N))
|
shift_count = sum(variables[i][j] * array[i] for i in range(N))
|
||||||
if min_val is not None:
|
if min_val is not None and j not in exempt_set:
|
||||||
solver.Add(shift_count >= min_val)
|
solver.Add(shift_count >= min_val)
|
||||||
if max_val is not None:
|
if max_val is not None:
|
||||||
solver.Add(shift_count <= max_val)
|
solver.Add(shift_count <= max_val)
|
||||||
|
|
|
||||||
2
utils.py
2
utils.py
|
|
@ -91,6 +91,8 @@ def save_to_excel(variables, all_data, index_to_name_dict, file_path):
|
||||||
hr_new_index.append(idx)
|
hr_new_index.append(idx)
|
||||||
|
|
||||||
# 选择值班组长(优先级:人资部小朋友 > 非技术部小朋友 > 小朋友 > 所有人)
|
# 选择值班组长(优先级:人资部小朋友 > 非技术部小朋友 > 小朋友 > 所有人)
|
||||||
|
# 无人值班的班次跳过组长选择
|
||||||
|
if all_index:
|
||||||
if hr_new_index:
|
if hr_new_index:
|
||||||
duty_monitor_index = choice(hr_new_index)
|
duty_monitor_index = choice(hr_new_index)
|
||||||
elif none_tech_new_index:
|
elif none_tech_new_index:
|
||||||
|
|
|
||||||
23
使用手册.md
23
使用手册.md
|
|
@ -1,4 +1,4 @@
|
||||||
# EVA 值班排班工具使用手册 - v2.0.0
|
# EVA 值班排班工具使用手册 - v2.1.0
|
||||||
|
|
||||||
## 前言
|
## 前言
|
||||||
这是浙江大学学生E志者协会“排班工具软件”的使用手册,将简要地介绍该软件的使用方法和注意事项。请注意,这是面向使用者而非开发者的手册,如果想了解该工具的开发流程和所使用的技术,请移步至[此仓库](https://git.zjueva.net/happywind/EVA_duty_arrange_tool)的说明手册。
|
这是浙江大学学生E志者协会“排班工具软件”的使用手册,将简要地介绍该软件的使用方法和注意事项。请注意,这是面向使用者而非开发者的手册,如果想了解该工具的开发流程和所使用的技术,请移步至[此仓库](https://git.zjueva.net/happywind/EVA_duty_arrange_tool)的说明手册。
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
- 第 10 ~ 29 列,表示对于每个班次的意愿(1 代表有时间,0 代表没时间)。因为从周一第一班到周日第二班一共有 20 班,所以总共有 20 列。
|
- 第 10 ~ 29 列,表示对于每个班次的意愿(1 代表有时间,0 代表没时间)。因为从周一第一班到周日第二班一共有 20 班,所以总共有 20 列。
|
||||||
- 第 30 列,表示愿意排几班,用一个数字表示。
|
- 第 30 列,表示愿意排几班,用一个数字表示。
|
||||||
|
|
||||||
**我们提供了一个名为“问卷星结果_样例输入.xlsx”的文件,以作为正确输入格式的参考,以及一个名为“问卷星结果_较极端案例.xlsx”的文件,以作为程序运行的测试。**
|
**我们提供了一个名为”问卷星结果_样例输入.xlsx”的文件,以作为正确输入格式的参考,以及一个名为”问卷星结果_较极端案例.xlsx”和”问卷星结果_缺班样例.xlsx”的文件,以作为程序运行的测试。**
|
||||||
|
|
||||||
**本软件可以自动处理同一名同学多次填写问卷的情况**,只要保证多次填写中“姓名”保持一致即可,软件将会自动选择最后一次填写的结果作为最终意愿。由于“姓名”是每位同学的唯一标识符,如果出现了某位同学“姓名”填错的情况,需要手动删除该姓名的记录。
|
**本软件可以自动处理同一名同学多次填写问卷的情况**,只要保证多次填写中“姓名”保持一致即可,软件将会自动选择最后一次填写的结果作为最终意愿。由于“姓名”是每位同学的唯一标识符,如果出现了某位同学“姓名”填错的情况,需要手动删除该姓名的记录。
|
||||||
|
|
||||||
|
|
@ -95,3 +95,22 @@
|
||||||
点击"开始排班!"按钮开始计算:
|
点击"开始排班!"按钮开始计算:
|
||||||
- 输出结果以 Excel 表格形式保存,文件名为 `result_<当前时间戳>.xlsx`
|
- 输出结果以 Excel 表格形式保存,文件名为 `result_<当前时间戳>.xlsx`
|
||||||
- **在输出结果中,组长会被标黄**
|
- **在输出结果中,组长会被标黄**
|
||||||
|
- **无人值班的班次在输出结果中对应格为空**
|
||||||
|
|
||||||
|
## 异常数据处理
|
||||||
|
|
||||||
|
软件在排班前会自动对输入数据进行两项检查,遇到异常时会弹出对话框提示用户选择处理方式。
|
||||||
|
|
||||||
|
### 情况一:某班次无人选择
|
||||||
|
|
||||||
|
若某个(或某几个)班次没有任何同学选择,软件会弹出警告,列出所有无人选择的班次名称,并提供两个选项:
|
||||||
|
|
||||||
|
- **确定**:将这些班次视为无人值班,继续进行其余班次的排班。输出结果中这些班次对应格为空。
|
||||||
|
- **取消**:放弃本次排班,返回主界面。
|
||||||
|
|
||||||
|
### 情况二:某同学的可用班次数少于期望值班次数
|
||||||
|
|
||||||
|
若某位同学填写的期望值班次数多于其实际选择的可用班次数(例如希望值 2 班但只选了 1 个有空的时间段),软件会弹出警告,列出受影响的同学姓名及具体数据,并提供两个选项:
|
||||||
|
|
||||||
|
- **确定**:自动将这些同学的期望值班次数调整为其实际可用班次数,继续排班。调整详情会在日志中显示。
|
||||||
|
- **取消**:放弃本次排班,返回主界面,以便手动核查问卷数据。
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue