diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ba9c5ab Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 9371d2b..a49222f 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ uv run pyinstaller --onefile --windowed --name=EVA_duty_arrange_tool --hidden-im - **目标5**:每一班各部门人数的平均程度 在本问题中,主要约束是: -1. 让每位同学每周的班次数符合意愿(特别地,选择3次的同学可以安排2-3次) +1. 让每位同学每周的班次数符合意愿(特别地,选择3次的同学可以安排2-3次;若某同学的可用班次数少于其期望值班次数,则自动降至可用数量) 2. 让每位同学只在自己有空的时间段值班 -3. 每班次的总人数在指定范围内 +3. 每班次的总人数在指定范围内(无人选择的班次豁免下限约束,视为无人值班) 4. 每班次技术部老人数量在指定范围内 5. 每班次老人总数在指定范围内 6. 每班次小朋友总数在指定范围内 @@ -165,4 +165,5 @@ OR-Tools 支持 `C++`、`Python`、`C#`、`Java` 等多种语言,并提供跨 ## 维护指南 - 如果你想更改 Excel 的读取、写入相关的功能,应该修改 `utils.py` 中的相关函数。 - 如果你想更改软件的前端界面,应该修改 `main.py` 中 `MyWidget` 这个类相关的代码。 -- 如果你想更换排班问题的建模方式、更换求解器、增减限制条件,应该修改 `solve.py` 中的相关代码。 \ No newline at end of file +- 如果你想更换排班问题的建模方式、更换求解器、增减限制条件,应该修改 `solve.py` 中的相关代码。 +- 如果你想更改异常数据(缺班、可用班次不足)的检测和处理逻辑,应该修改 `main.py` 中 `magic` 方法内对应的检查块,以及 `solve.py` 中 `solve_program` 的 `exempt_shifts` 参数相关逻辑。 \ No newline at end of file diff --git a/main.py b/main.py index 57150ae..7864521 100644 --- a/main.py +++ b/main.py @@ -41,8 +41,8 @@ class SolverThread(QtCore.QThread): finished_signal = QtCore.Signal(object, dict) # 发送求解结果 (vars, params) error_signal = QtCore.Signal(str) # 发送错误消息 - def __init__(self, preference_mat, depart_mat, want_num_array, is_new_array, - auto_mode, manual_params=None, parent=None): + def __init__(self, preference_mat, depart_mat, want_num_array, is_new_array, + auto_mode, manual_params=None, exempt_shifts=None, parent=None): super().__init__(parent) self.preference_mat = preference_mat self.depart_mat = depart_mat @@ -50,6 +50,7 @@ class SolverThread(QtCore.QThread): self.is_new_array = is_new_array self.auto_mode = auto_mode self.manual_params = manual_params or {} + self.exempt_shifts = exempt_shifts or [] def run(self): try: @@ -80,6 +81,7 @@ class SolverThread(QtCore.QThread): depart_mat=self.depart_mat, want_num_array=self.want_num_array, is_new_array=self.is_new_array, + exempt_shifts=self.exempt_shifts, **test_params ) if vars: @@ -102,6 +104,7 @@ class SolverThread(QtCore.QThread): want_num_array=self.want_num_array, depart_mat=self.depart_mat, is_new_array=self.is_new_array, + exempt_shifts=self.exempt_shifts, **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所有班次中最少拥有意愿数:{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): text = self.weight_widgets[key].text() return float(text) if text else 0.0 @@ -326,8 +400,8 @@ class MyWidget(QtWidgets.QWidget): # 创建并启动求解线程 self.solver_thread = SolverThread( - preference_mat, depart_mat, want_num_array, is_new_array, - self._auto_mode, params, self + preference_mat, depart_mat, want_num_adjusted, is_new_array, + self._auto_mode, params, empty_classes, self ) # 连接信号 diff --git a/solve.py b/solve.py index 8640083..ee525e9 100644 --- a/solve.py +++ b/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_old_min=None, num_old_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_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))] + exempt_set = set(exempt_shifts or []) # 使用 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: solver.Add(total_shifts == want_num_array[i]) - # 添加约束的辅助函数 + # 添加约束的辅助函数(exempt_set 中的班次跳过下限约束) def add_shift_constraint(array, min_val, max_val): for j in range(M): 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) if max_val is not None: solver.Add(shift_count <= max_val) diff --git a/utils.py b/utils.py index 9f1278f..ed76676 100644 --- a/utils.py +++ b/utils.py @@ -91,15 +91,17 @@ def save_to_excel(variables, all_data, index_to_name_dict, file_path): hr_new_index.append(idx) # 选择值班组长(优先级:人资部小朋友 > 非技术部小朋友 > 小朋友 > 所有人) - if hr_new_index: - duty_monitor_index = choice(hr_new_index) - elif none_tech_new_index: - duty_monitor_index = choice(none_tech_new_index) - elif new_index: - duty_monitor_index = choice(new_index) - else: - duty_monitor_index = choice(all_index) - on_duty_list[duty_monitor_index]["duty_monitor"] = True + # 无人值班的班次跳过组长选择 + if all_index: + if hr_new_index: + duty_monitor_index = choice(hr_new_index) + elif none_tech_new_index: + duty_monitor_index = choice(none_tech_new_index) + elif new_index: + duty_monitor_index = choice(new_index) + else: + duty_monitor_index = choice(all_index) + on_duty_list[duty_monitor_index]["duty_monitor"] = True on_duty_list.sort(key=lambda x: x["department"]) all_result.append(on_duty_list) diff --git a/使用手册.md b/使用手册.md index 78706e1..1ef21b5 100644 --- a/使用手册.md +++ b/使用手册.md @@ -1,4 +1,4 @@ -# EVA 值班排班工具使用手册 - v2.0.0 +# EVA 值班排班工具使用手册 - v2.1.0 ## 前言 这是浙江大学学生E志者协会“排班工具软件”的使用手册,将简要地介绍该软件的使用方法和注意事项。请注意,这是面向使用者而非开发者的手册,如果想了解该工具的开发流程和所使用的技术,请移步至[此仓库](https://git.zjueva.net/happywind/EVA_duty_arrange_tool)的说明手册。 @@ -53,7 +53,7 @@ - 第 10 ~ 29 列,表示对于每个班次的意愿(1 代表有时间,0 代表没时间)。因为从周一第一班到周日第二班一共有 20 班,所以总共有 20 列。 - 第 30 列,表示愿意排几班,用一个数字表示。 - **我们提供了一个名为“问卷星结果_样例输入.xlsx”的文件,以作为正确输入格式的参考,以及一个名为“问卷星结果_较极端案例.xlsx”的文件,以作为程序运行的测试。** + **我们提供了一个名为”问卷星结果_样例输入.xlsx”的文件,以作为正确输入格式的参考,以及一个名为”问卷星结果_较极端案例.xlsx”和”问卷星结果_缺班样例.xlsx”的文件,以作为程序运行的测试。** **本软件可以自动处理同一名同学多次填写问卷的情况**,只要保证多次填写中“姓名”保持一致即可,软件将会自动选择最后一次填写的结果作为最终意愿。由于“姓名”是每位同学的唯一标识符,如果出现了某位同学“姓名”填错的情况,需要手动删除该姓名的记录。 @@ -95,3 +95,22 @@ 点击"开始排班!"按钮开始计算: - 输出结果以 Excel 表格形式保存,文件名为 `result_<当前时间戳>.xlsx` - **在输出结果中,组长会被标黄** + - **无人值班的班次在输出结果中对应格为空** + +## 异常数据处理 + +软件在排班前会自动对输入数据进行两项检查,遇到异常时会弹出对话框提示用户选择处理方式。 + +### 情况一:某班次无人选择 + +若某个(或某几个)班次没有任何同学选择,软件会弹出警告,列出所有无人选择的班次名称,并提供两个选项: + +- **确定**:将这些班次视为无人值班,继续进行其余班次的排班。输出结果中这些班次对应格为空。 +- **取消**:放弃本次排班,返回主界面。 + +### 情况二:某同学的可用班次数少于期望值班次数 + +若某位同学填写的期望值班次数多于其实际选择的可用班次数(例如希望值 2 班但只选了 1 个有空的时间段),软件会弹出警告,列出受影响的同学姓名及具体数据,并提供两个选项: + +- **确定**:自动将这些同学的期望值班次数调整为其实际可用班次数,继续排班。调整详情会在日志中显示。 +- **取消**:放弃本次排班,返回主界面,以便手动核查问卷数据。 diff --git a/问卷星结果_缺班样例.xlsx b/问卷星结果_缺班样例.xlsx new file mode 100644 index 0000000..c525264 Binary files /dev/null and b/问卷星结果_缺班样例.xlsx differ