generate_seating.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. #!/usr/bin/env python3
  2. """
  3. Generate seating charts from Excel class data.
  4. Required input columns: 班级, 姓名, 性别, 总分
  5. """
  6. import argparse
  7. import random
  8. import sys
  9. from pathlib import Path
  10. try:
  11. from openpyxl import Workbook
  12. from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
  13. from openpyxl.utils import get_column_letter
  14. except ImportError:
  15. print("Error: openpyxl is required. Install with: pip install openpyxl")
  16. sys.exit(1)
  17. def parse_args():
  18. parser = argparse.ArgumentParser(
  19. description="根据Excel学生数据生成座位表"
  20. )
  21. parser.add_argument(
  22. "excel_file", type=str,
  23. help="输入Excel文件路径 (.xlsx)"
  24. )
  25. parser.add_argument(
  26. "-c", "--class", dest="class_name", type=str, required=True,
  27. help="要生成座位表的班级名称"
  28. )
  29. parser.add_argument(
  30. "-s", "--style", type=str, required=True,
  31. help="座位表样式,如 '2222'(4组2人桌)、'3332'(3组3人桌+1组2人桌)"
  32. )
  33. parser.add_argument(
  34. "-m", "--allow-mixed", action="store_true",
  35. help="允许男女生同桌(默认不允许)"
  36. )
  37. parser.add_argument(
  38. "-t", "--strategy", type=str, default="good-with-good",
  39. choices=["good-with-good", "good-with-bad", "random"],
  40. help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、random(无关)"
  41. )
  42. parser.add_argument(
  43. "-o", "--output", type=str, default=None,
  44. help="输出Excel文件路径(默认:<输入文件>_<班级>_座位表.xlsx)"
  45. )
  46. parser.add_argument(
  47. "-n", "--count", type=int, default=1,
  48. help="生成座位表的个数(默认1个)"
  49. )
  50. parser.add_argument(
  51. "--seed", type=int, default=None,
  52. help="随机种子(用于 random 策略的可重复性)"
  53. )
  54. parser.add_argument(
  55. "--usage", action="help",
  56. help="显示帮助信息"
  57. )
  58. return parser.parse_args()
  59. def read_students(filepath, class_name):
  60. """从Excel读取学生数据,按班级筛选。自动查找表头行。"""
  61. from openpyxl import load_workbook
  62. wb = load_workbook(filepath, data_only=True)
  63. ws = wb.active
  64. required = ["班级", "姓名", "性别", "总分"]
  65. indices = {}
  66. header_row = None
  67. # 自动查找包含所有必需列的表头行(最多扫描前10行)
  68. for r in range(1, min(ws.max_row + 1, 11)):
  69. row_vals = []
  70. for col in range(1, ws.max_column + 1):
  71. val = ws.cell(row=r, column=col).value
  72. row_vals.append(str(val).strip() if val else "")
  73. if all(col_name in row_vals for col_name in required):
  74. for col_name in required:
  75. indices[col_name] = row_vals.index(col_name)
  76. header_row = r
  77. break
  78. if header_row is None:
  79. # 输出前几行帮助诊断
  80. print("错误:Excel文件中未找到 '班级'、'姓名'、'性别'、'总分' 列。")
  81. for r in range(1, min(ws.max_row + 1, 6)):
  82. preview = [str(ws.cell(row=r, column=c).value or "") for c in range(1, ws.max_column + 1)]
  83. print(f" 第{r}行:{preview}")
  84. sys.exit(1)
  85. # 规范化班级名:去除"班"后缀以便灵活匹配
  86. class_name_clean = class_name.rstrip("班")
  87. data_start = header_row + 1
  88. students = []
  89. for row in range(data_start, ws.max_row + 1):
  90. class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip()
  91. class_val_clean = class_val.rstrip("班")
  92. if class_val_clean != class_name_clean:
  93. continue
  94. name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip()
  95. gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip()
  96. score_str = str(ws.cell(row=row, column=indices["总分"] + 1).value or "").strip()
  97. if not name:
  98. continue
  99. try:
  100. score = float(score_str)
  101. except ValueError:
  102. score = 0.0
  103. students.append({"name": name, "gender": gender, "score": score})
  104. wb.close()
  105. return students
  106. def interleave_high_low(items):
  107. """将按分数降序排列的列表重新排列为 高-低-高-低 交错顺序。"""
  108. result = []
  109. left, right = 0, len(items) - 1
  110. while left <= right:
  111. result.append(items[left])
  112. left += 1
  113. if left <= right:
  114. result.append(items[right])
  115. right -= 1
  116. return result
  117. def build_desk_groups(style_str, num_students):
  118. """根据样式字符串构建桌组大小列表。"""
  119. sizes = [int(c) for c in style_str]
  120. for s in sizes:
  121. if s < 1:
  122. print(f"错误:样式中的数字必须大于0,发现 '{s}'")
  123. sys.exit(1)
  124. row_capacity = sum(sizes)
  125. num_rows = max(1, (num_students + row_capacity - 1) // row_capacity)
  126. desk_groups = []
  127. for _ in range(num_rows):
  128. for size in sizes:
  129. desk_groups.append(size)
  130. return desk_groups
  131. def is_male(gender):
  132. return gender in ("男", "M", "m", "male", "Male")
  133. def is_female(gender):
  134. return gender in ("女", "F", "f", "female", "Female")
  135. def allocate_students(students, desk_sizes, strategy, allow_mixed, seed):
  136. """将学生分配到桌组。"""
  137. if seed is not None:
  138. random.seed(seed)
  139. males = [s for s in students if is_male(s["gender"])]
  140. females = [s for s in students if is_female(s["gender"])]
  141. others = [s for s in students if not is_male(s["gender"]) and not is_female(s["gender"])]
  142. # 无法识别性别的归入人数多的一方
  143. if others:
  144. if len(males) >= len(females):
  145. males.extend(others)
  146. else:
  147. females.extend(others)
  148. # --- 允许混坐:所有学生一起排 ---
  149. if allow_mixed:
  150. all_students = males + females
  151. if strategy == "good-with-good":
  152. all_students.sort(key=lambda s: s["score"], reverse=True)
  153. elif strategy == "good-with-bad":
  154. all_students.sort(key=lambda s: s["score"], reverse=True)
  155. all_students = interleave_high_low(all_students)
  156. else:
  157. random.shuffle(all_students)
  158. groups = []
  159. idx = 0
  160. for size in desk_sizes:
  161. group = all_students[idx:idx + size]
  162. if group:
  163. groups.append(group)
  164. idx += size
  165. if idx >= len(all_students):
  166. break
  167. return groups
  168. # --- 性别分开排 ---
  169. if strategy == "good-with-good":
  170. males.sort(key=lambda s: s["score"], reverse=True)
  171. females.sort(key=lambda s: s["score"], reverse=True)
  172. elif strategy == "good-with-bad":
  173. males.sort(key=lambda s: s["score"], reverse=True)
  174. males = interleave_high_low(males)
  175. females.sort(key=lambda s: s["score"], reverse=True)
  176. females = interleave_high_low(females)
  177. else:
  178. random.shuffle(males)
  179. random.shuffle(females)
  180. groups = []
  181. m_idx, f_idx = 0, 0
  182. for size in desk_sizes:
  183. m_rem = len(males) - m_idx
  184. f_rem = len(females) - f_idx
  185. if m_rem >= f_rem:
  186. take = min(size, m_rem)
  187. group = males[m_idx:m_idx + take]
  188. m_idx += take
  189. else:
  190. take = min(size, f_rem)
  191. group = females[f_idx:f_idx + take]
  192. f_idx += take
  193. if group:
  194. groups.append(group)
  195. return groups
  196. def generate_excel(groups, style_str, output_path, class_name):
  197. """生成座位表Excel文件。"""
  198. wb = Workbook()
  199. ws = wb.active
  200. ws.title = f"{class_name}座位表"
  201. sizes = [int(c) for c in style_str]
  202. num_groups_per_row = len(sizes)
  203. num_rows = (len(groups) + num_groups_per_row - 1) // num_groups_per_row
  204. total_cols = sum(sizes) + (num_groups_per_row - 1)
  205. thin_border = Border(
  206. left=Side(style="thin"), right=Side(style="thin"),
  207. top=Side(style="thin"), bottom=Side(style="thin"),
  208. )
  209. center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
  210. male_fill = PatternFill(start_color="D6EAF8", end_color="D6EAF8", fill_type="solid")
  211. female_fill = PatternFill(start_color="FADBD8", end_color="FADBD8", fill_type="solid")
  212. podium_fill = PatternFill(start_color="D5D8DC", end_color="D5D8DC", fill_type="solid")
  213. podium_font = Font(bold=True, size=14)
  214. name_font = Font(size=14)
  215. # score_font kept for reference but not used separately
  216. # --- 讲台行 ---
  217. ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_cols)
  218. podium_cell = ws.cell(row=1, column=1, value="讲 台")
  219. podium_cell.font = podium_font
  220. podium_cell.alignment = center_align
  221. podium_cell.fill = podium_fill
  222. for c in range(1, total_cols + 1):
  223. ws.cell(row=1, column=c).fill = podium_fill
  224. ws.cell(row=1, column=c).border = thin_border
  225. ws.row_dimensions[1].height = 35
  226. # --- 空行 ---
  227. # (直接将学生放在第3行及以下)
  228. # --- 学生座位 ---
  229. group_idx = 0
  230. for row_num in range(num_rows):
  231. excel_row = row_num + 3 # row 1 = 讲台, row 2 = blank
  232. col_offset = 1
  233. for g in range(num_groups_per_row):
  234. size = sizes[g]
  235. if group_idx < len(groups):
  236. group = groups[group_idx]
  237. group_idx += 1
  238. for i in range(size):
  239. col = col_offset + i
  240. cell = ws.cell(row=excel_row, column=col)
  241. if i < len(group):
  242. student = group[i]
  243. cell.value = student['name']
  244. cell.font = name_font
  245. cell.alignment = center_align
  246. if is_male(student["gender"]):
  247. cell.fill = male_fill
  248. elif is_female(student["gender"]):
  249. cell.fill = female_fill
  250. else:
  251. cell.value = ""
  252. cell.border = thin_border
  253. ws.column_dimensions[get_column_letter(col)].width = 13
  254. else:
  255. for i in range(size):
  256. col = col_offset + i
  257. cell = ws.cell(row=excel_row, column=col)
  258. cell.border = thin_border
  259. ws.column_dimensions[get_column_letter(col)].width = 13
  260. col_offset += size
  261. # 过道
  262. if g < num_groups_per_row - 1:
  263. aisle_col = col_offset
  264. ws.column_dimensions[get_column_letter(aisle_col)].width = 4
  265. col_offset += 1
  266. ws.row_dimensions[excel_row].height = 36
  267. wb.save(output_path)
  268. print(f"座位表已保存至:{output_path}")
  269. def main():
  270. args = parse_args()
  271. # 验证样式
  272. if not args.style.isdigit() or len(args.style) < 2:
  273. print("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'")
  274. sys.exit(1)
  275. input_path = Path(args.excel_file)
  276. if not input_path.exists():
  277. print(f"错误:文件不存在:{args.excel_file}")
  278. sys.exit(1)
  279. if args.count < 1:
  280. print("错误:生成个数必须大于0")
  281. sys.exit(1)
  282. # 确定输出路径模板
  283. if args.output:
  284. output_base = args.output
  285. else:
  286. output_base = f"{input_path.stem}_{args.class_name}_座位表.xlsx"
  287. # 读取学生(只读一次)
  288. print(f"正在读取 {args.excel_file} ...")
  289. students = read_students(args.excel_file, args.class_name)
  290. if not students:
  291. print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。")
  292. else:
  293. m_count = sum(1 for s in students if is_male(s["gender"]))
  294. f_count = sum(1 for s in students if is_female(s["gender"]))
  295. print(f"班级 '{args.class_name}' 共有 {len(students)} 名学生(男:{m_count},女:{f_count})")
  296. # 构建桌组(只算一次)
  297. desk_sizes = build_desk_groups(args.style, len(students))
  298. # 确定基础种子
  299. base_seed = args.seed if args.seed is not None else random.randint(0, 999999)
  300. strategy_names = {
  301. "good-with-good": "好+好",
  302. "good-with-bad": "好+差",
  303. "random": "与成绩无关",
  304. }
  305. # 生成多个座位表
  306. for i in range(args.count):
  307. seed = base_seed + i
  308. # 输出文件名:多个时加序号后缀
  309. if args.count > 1:
  310. p = Path(output_base)
  311. output_path = str(p.parent / f"{p.stem}_{i + 1}{p.suffix}")
  312. else:
  313. output_path = output_base
  314. groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed)
  315. generate_excel(groups, args.style, output_path, args.class_name)
  316. # 摘要
  317. print(f"\n座位表配置:")
  318. print(f" 样式:{args.style}({len(args.style)}组,每组{'-'.join(args.style)}人)")
  319. print(f" 策略:{strategy_names[args.strategy]}")
  320. print(f" 男女生同桌:{'允许' if args.allow_mixed else '不允许'}")
  321. print(f" 生成个数:{args.count}")
  322. print(f" 总桌组数:{len(groups)},总排数:{(len(groups) + len(args.style) - 1) // len(args.style)}")
  323. if __name__ == "__main__":
  324. main()