generate_seating.py 14 KB

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