Compare commits

...

2 Commits

Author SHA1 Message Date
Dawn1Ocean a4724343e7 fix: bugs when available times less than duties wanted 2026-04-26 20:28:14 +08:00
Dawn1Ocean 7bd2394d2c fix: bugs when available times less than duties wanted 2026-04-26 20:27:08 +08:00
7 changed files with 120 additions and 21 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ other/
*.egg-info/ *.egg-info/
*.pdf *.pdf
*.spec *.spec
.DS_Store

View File

@ -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
View File

@ -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
) )
# 连接信号 # 连接信号

View File

@ -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)

View File

@ -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:

View File

@ -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.