generate_seating.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  1. #!/usr/bin/env python3
  2. """
  3. Generate seating charts from Excel class data.
  4. Required input columns: 班级, 姓名, 性别, 总分
  5. """
  6. import argparse
  7. import itertools
  8. import random
  9. import sys
  10. from pathlib import Path
  11. try:
  12. from openpyxl import Workbook
  13. from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
  14. from openpyxl.utils import get_column_letter
  15. except ImportError:
  16. print("Error: openpyxl is required. Install with: pip install openpyxl")
  17. sys.exit(1)
  18. def parse_args():
  19. parser = argparse.ArgumentParser(
  20. description="根据Excel学生数据生成座位表"
  21. )
  22. parser.add_argument(
  23. "excel_file", type=str,
  24. help="输入Excel文件路径 (.xlsx)"
  25. )
  26. parser.add_argument(
  27. "-c", "--class", dest="class_name", type=str, required=True,
  28. help="要生成座位表的班级名称"
  29. )
  30. parser.add_argument(
  31. "-s", "--style", type=str, required=True,
  32. help="座位表样式,如 '2222'(4组2人桌)、'3332'(3组3人桌+1组2人桌)"
  33. )
  34. parser.add_argument(
  35. "-m", "--allow-mixed", action="store_true",
  36. help="允许男女生同桌(默认不允许)"
  37. )
  38. parser.add_argument(
  39. "-t", "--strategy", type=str, default="good-with-good",
  40. choices=["good-with-good", "good-with-bad", "random", "good-with-good-advance"],
  41. help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、"
  42. "random(无关)、good-with-good-advance(好+好进阶:好生集中,"
  43. "差生正前方或正后方必有优生,同桌同性)"
  44. )
  45. parser.add_argument(
  46. "--score-column", type=str, default="赋分成绩",
  47. help="排序用的成绩列(默认:赋分成绩,即「总分」组下的赋分子列;找不到则用总分列)"
  48. )
  49. parser.add_argument(
  50. "--good-ratio", type=float, default=1 / 3,
  51. help="优生比例(仅 good-with-good-advance 策略,默认 1/3)"
  52. )
  53. parser.add_argument(
  54. "--bad-ratio", type=float, default=1 / 3,
  55. help="差生比例(仅 good-with-good-advance 策略,默认 1/3)"
  56. )
  57. parser.add_argument(
  58. "-o", "--output", type=str, default=None,
  59. help="输出Excel文件路径(默认:<输入文件>_<班级>_座位表.xlsx)"
  60. )
  61. parser.add_argument(
  62. "-n", "--count", type=int, default=1,
  63. help="生成座位表的个数(默认1个)"
  64. )
  65. parser.add_argument(
  66. "--seed", type=int, default=None,
  67. help="随机种子(用于 random 策略的可重复性)"
  68. )
  69. parser.add_argument(
  70. "-S", "--sheet", type=str, default=None,
  71. help="指定要读取的工作表名称(默认:第一个工作表)"
  72. )
  73. parser.add_argument(
  74. "--usage", action="help",
  75. help="显示帮助信息"
  76. )
  77. return parser.parse_args()
  78. def find_score_column(ws, header_row, total_col, score_column):
  79. """在「总分」分组表头下方找到子表头为 score_column 的那一列(1-based)。
  80. 排行榜表格第2行是分组表头(总分/语数外/…),第3行才是子表头
  81. (原始成绩/赋分成绩/班级排名)。直接取「总分」列拿到的是原始成绩,
  82. 这里按子表头选列。返回 (列号, 是否按子表头命中);找不到就用「总分」列。
  83. """
  84. sub_row = header_row + 1
  85. if sub_row <= ws.max_row:
  86. # 只在「总分」组内往后找,避免串到后面的科目分组
  87. for c in range(total_col, min(total_col + 4, ws.max_column + 1)):
  88. val = ws.cell(row=sub_row, column=c).value
  89. if val and str(val).strip() == score_column:
  90. return c, True
  91. return total_col, False
  92. def read_students(filepath, class_name, sheet_name=None, score_column="赋分成绩"):
  93. """从Excel读取学生数据,按班级筛选。自动查找表头行。"""
  94. from openpyxl import load_workbook
  95. wb = load_workbook(filepath, data_only=True)
  96. if sheet_name:
  97. if sheet_name not in wb.sheetnames:
  98. print(f"错误:工作表 '{sheet_name}' 不存在。可用工作表:{wb.sheetnames}")
  99. sys.exit(1)
  100. ws = wb[sheet_name]
  101. else:
  102. ws = wb[wb.sheetnames[0]]
  103. required = ["班级", "姓名", "性别", "总分"]
  104. indices = {}
  105. header_row = None
  106. # 自动查找包含所有必需列的表头行(最多扫描前10行)
  107. for r in range(1, min(ws.max_row + 1, 11)):
  108. row_vals = []
  109. for col in range(1, ws.max_column + 1):
  110. val = ws.cell(row=r, column=col).value
  111. row_vals.append(str(val).strip() if val else "")
  112. if all(col_name in row_vals for col_name in required):
  113. for col_name in required:
  114. indices[col_name] = row_vals.index(col_name)
  115. header_row = r
  116. break
  117. if header_row is None:
  118. # 输出前几行帮助诊断
  119. print("错误:Excel文件中未找到 '班级'、'姓名'、'性别'、'总分' 列。")
  120. for r in range(1, min(ws.max_row + 1, 6)):
  121. preview = [str(ws.cell(row=r, column=c).value or "") for c in range(1, ws.max_column + 1)]
  122. print(f" 第{r}行:{preview}")
  123. sys.exit(1)
  124. # 规范化班级名:去除"班"后缀以便灵活匹配
  125. class_name_clean = class_name.rstrip("班")
  126. data_start = header_row + 1
  127. total_col = indices["总分"] + 1
  128. score_col, hit = find_score_column(ws, header_row, total_col, score_column)
  129. if hit:
  130. print(f"排序成绩列:第 {score_col} 列({score_column})")
  131. else:
  132. print(f"排序成绩列:第 {total_col} 列(未找到「{score_column}」子列,改用表头「总分」列)")
  133. students = []
  134. for row in range(data_start, ws.max_row + 1):
  135. class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip()
  136. class_val_clean = class_val.rstrip("班")
  137. if class_val_clean != class_name_clean:
  138. continue
  139. name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip()
  140. gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip()
  141. score_str = str(ws.cell(row=row, column=score_col).value or "").strip()
  142. if not name:
  143. continue
  144. try:
  145. score = float(score_str)
  146. except ValueError:
  147. score = 0.0
  148. students.append({"name": name, "gender": gender, "score": score})
  149. wb.close()
  150. return students
  151. def interleave_high_low(items):
  152. """将按分数降序排列的列表重新排列为 高-低-高-低 交错顺序。"""
  153. result = []
  154. left, right = 0, len(items) - 1
  155. while left <= right:
  156. result.append(items[left])
  157. left += 1
  158. if left <= right:
  159. result.append(items[right])
  160. right -= 1
  161. return result
  162. def build_desk_groups(style_str, num_students):
  163. """根据样式字符串构建桌组大小列表。"""
  164. sizes = [int(c) for c in style_str]
  165. for s in sizes:
  166. if s < 1:
  167. print(f"错误:样式中的数字必须大于0,发现 '{s}'")
  168. sys.exit(1)
  169. row_capacity = sum(sizes)
  170. num_rows = max(1, (num_students + row_capacity - 1) // row_capacity)
  171. desk_groups = []
  172. for _ in range(num_rows):
  173. for size in sizes:
  174. desk_groups.append(size)
  175. return desk_groups
  176. def is_male(gender):
  177. return gender in ("男", "M", "m", "male", "Male")
  178. def is_female(gender):
  179. return gender in ("女", "F", "f", "female", "Female")
  180. def allocate_students(students, desk_sizes, strategy, allow_mixed, seed):
  181. """将学生分配到桌组。"""
  182. if seed is not None:
  183. random.seed(seed)
  184. males = [s for s in students if is_male(s["gender"])]
  185. females = [s for s in students if is_female(s["gender"])]
  186. others = [s for s in students if not is_male(s["gender"]) and not is_female(s["gender"])]
  187. # 无法识别性别的归入人数多的一方
  188. if others:
  189. if len(males) >= len(females):
  190. males.extend(others)
  191. else:
  192. females.extend(others)
  193. # --- 允许混坐:所有学生一起排 ---
  194. if allow_mixed:
  195. all_students = males + females
  196. if strategy == "good-with-good":
  197. all_students.sort(key=lambda s: s["score"], reverse=True)
  198. elif strategy == "good-with-bad":
  199. all_students.sort(key=lambda s: s["score"], reverse=True)
  200. all_students = interleave_high_low(all_students)
  201. else:
  202. random.shuffle(all_students)
  203. groups = []
  204. idx = 0
  205. for size in desk_sizes:
  206. group = all_students[idx:idx + size]
  207. if group:
  208. groups.append(group)
  209. idx += size
  210. if idx >= len(all_students):
  211. break
  212. return groups
  213. # --- 性别分开排 ---
  214. if strategy == "good-with-good":
  215. males.sort(key=lambda s: s["score"], reverse=True)
  216. females.sort(key=lambda s: s["score"], reverse=True)
  217. elif strategy == "good-with-bad":
  218. males.sort(key=lambda s: s["score"], reverse=True)
  219. males = interleave_high_low(males)
  220. females.sort(key=lambda s: s["score"], reverse=True)
  221. females = interleave_high_low(females)
  222. else:
  223. random.shuffle(males)
  224. random.shuffle(females)
  225. groups = []
  226. m_idx, f_idx = 0, 0
  227. for size in desk_sizes:
  228. m_rem = len(males) - m_idx
  229. f_rem = len(females) - f_idx
  230. if m_rem >= f_rem:
  231. take = min(size, m_rem)
  232. group = males[m_idx:m_idx + take]
  233. m_idx += take
  234. else:
  235. take = min(size, f_rem)
  236. group = females[f_idx:f_idx + take]
  237. f_idx += take
  238. if group:
  239. groups.append(group)
  240. return groups
  241. # ------------------------------------------------------- 分层排布(进阶策略)
  242. TIER_GOOD = "优"
  243. TIER_MID = "中"
  244. TIER_BAD = "差"
  245. TIER_CYCLE = (TIER_GOOD, TIER_BAD, TIER_MID) # 排层次循环:优生排紧跟差生排
  246. def gender_key(student):
  247. """把性别归一成「女」/「男」两池(无法识别的归入男生池)。"""
  248. return "女" if is_female(student["gender"]) else "男"
  249. def assign_tiers(students, good_ratio, bad_ratio):
  250. """按成绩排名划分层次:前 good_ratio 为优生,后 bad_ratio 为差生,其余为中。"""
  251. ordered = sorted(students, key=lambda s: (-s["score"], s["name"]))
  252. n = len(ordered)
  253. n_good = min(n, max(0, int(round(n * good_ratio))))
  254. n_bad = min(n - n_good, max(0, int(round(n * bad_ratio))))
  255. for i, stu in enumerate(ordered):
  256. if i < n_good:
  257. stu["tier"] = TIER_GOOD
  258. elif i >= n - n_bad:
  259. stu["tier"] = TIER_BAD
  260. else:
  261. stu["tier"] = TIER_MID
  262. return ordered
  263. def _subset_sums(widths):
  264. """一组桌宽能凑出的所有座位数。"""
  265. sums = {0}
  266. for w in widths:
  267. sums |= {s + w for s in sums}
  268. return sorted(sums)
  269. def _choose_counts(rows_bands, total, rng):
  270. """给每一排定一个女座数,使合计等于 total,并尽量让女生分散到各排。"""
  271. options = [_subset_sums([w for _, w in rb]) for rb in rows_bands]
  272. caps = [sum(w for _, w in rb) for rb in rows_bands]
  273. total_cap = sum(caps) or 1
  274. best = None
  275. def walk(i, remain, acc):
  276. nonlocal best
  277. if i == len(rows_bands):
  278. if remain != 0:
  279. return
  280. score = sum(abs(acc[j] - total * caps[j] / total_cap) for j in range(len(caps)))
  281. if best is None or score < best[0] - 1e-9 or (
  282. abs(score - best[0]) < 1e-9 and rng.random() < 0.5):
  283. best = (score, list(acc))
  284. return
  285. for v in options[i]:
  286. if v <= remain:
  287. walk(i + 1, remain - v, acc + [v])
  288. walk(0, total, [])
  289. return best[1] if best else None
  290. def _choose_bands(bands, target, rng):
  291. """在一排里挑出若干桌组坐女生,座位数之和等于 target,尽量打散到更多组。"""
  292. for size in range(len(bands), -1, -1):
  293. hits = [combo for combo in itertools.combinations(range(len(bands)), size)
  294. if sum(bands[i][1] for i in combo) == target]
  295. if hits:
  296. return {bands[i][0] for i in rng.choice(hits)}
  297. return None
  298. def _tail_positions(sizes, count):
  299. """从最右侧座位往左数 count 个,返回 [(组, 组内位置), ...]。"""
  300. out = []
  301. for b in range(len(sizes) - 1, -1, -1):
  302. for p in range(sizes[b] - 1, -1, -1):
  303. out.append((b, p))
  304. if len(out) >= count:
  305. return out
  306. return out
  307. def plan_tiered_layout(students, sizes, good_ratio, bad_ratio, seed):
  308. """分层排布:优生集中,差生正前方或正后方必有优生。
  309. 1. 排层次按「优-差-中」循环,所以每排差生的正前方恰好是优生排;
  310. 每排各桌组同层次、同性别(同桌必定同性)。
  311. 2. 各层次座位容量减去人数得到空位数。优生排的空位必须和它正后方
  312. 差生排的「同组、同位置」一起挖空,否则会出现差生正前方变成空位、
  313. 前后都没有优生的情况。
  314. 3. 按层次把女生的座位数分配到各排,再决定排内哪几个组坐女生。
  315. 4. 每个 (性别, 层次) 池内按成绩降序,从前往后、从左往右填座,
  316. 所以优生里分数最高的坐在最前排。
  317. """
  318. rng = random.Random(seed)
  319. ordered = assign_tiers(students, good_ratio, bad_ratio)
  320. n = len(ordered)
  321. row_capacity = sum(sizes)
  322. unknown = [s["name"] for s in ordered if not is_male(s["gender"]) and not is_female(s["gender"])]
  323. if unknown:
  324. print(f"警告:{len(unknown)} 名学生性别无法识别({'、'.join(unknown[:5])}"
  325. f"{' 等' if len(unknown) > 5 else ''}),已按男生编排。")
  326. if n == 0:
  327. return {
  328. "groups": [[] for _ in range(len(sizes))],
  329. "grid": {}, "voids": set(), "row_roles": [TIER_CYCLE[0]],
  330. "num_rows": 1, "ordered": [],
  331. }
  332. num_rows = max(1, (n + row_capacity - 1) // row_capacity)
  333. row_roles = [TIER_CYCLE[r % len(TIER_CYCLE)] for r in range(num_rows)]
  334. capacity = {t: 0 for t in TIER_CYCLE}
  335. for t in row_roles:
  336. capacity[t] += row_capacity
  337. need = {t: 0 for t in TIER_CYCLE}
  338. for stu in ordered:
  339. need[stu["tier"]] += 1
  340. deficit = {t: capacity[t] - need[t] for t in TIER_CYCLE}
  341. for t in TIER_CYCLE:
  342. if deficit[t] < 0:
  343. print(f"错误:{num_rows} 排座位装不下 {need[t]} 名「{t}」等生(该层次只有 {capacity[t]} 座)。")
  344. print(" 可调整 --good-ratio / --bad-ratio,或换一个座位样式。")
  345. sys.exit(1)
  346. if deficit[TIER_GOOD] != deficit[TIER_BAD]:
  347. print(f"错误:优生空位({deficit[TIER_GOOD]})与差生空位({deficit[TIER_BAD]})不相等,")
  348. print(" 无法保证每个差生的正前或正后方都是优生。请让优生、差生比例保持一致。")
  349. sys.exit(1)
  350. # --- 空位:优生排与其正后方的差生排按同组同位置配对挖空 ---
  351. voids = set()
  352. pair = min(deficit[TIER_GOOD], row_capacity)
  353. if pair:
  354. anchor = None
  355. for r in range(num_rows - 1, -1, -1):
  356. if row_roles[r] == TIER_GOOD and r + 1 < num_rows and row_roles[r + 1] == TIER_BAD:
  357. anchor = r
  358. break
  359. if anchor is None:
  360. print("错误:排数不足以形成「优生排 + 差生排」的配对空位。")
  361. sys.exit(1)
  362. for b, p in _tail_positions(sizes, pair):
  363. voids.add((anchor, b, p))
  364. voids.add((anchor + 1, b, p))
  365. # --- 空位:中等生排从后往前挖 ---
  366. left = deficit[TIER_MID]
  367. for r in range(num_rows - 1, -1, -1):
  368. if left <= 0:
  369. break
  370. if row_roles[r] != TIER_MID:
  371. continue
  372. free = [(b, p) for b in range(len(sizes)) for p in range(sizes[b])
  373. if (r, b, p) not in voids]
  374. for b, p in reversed(free):
  375. if left <= 0:
  376. break
  377. voids.add((r, b, p))
  378. left -= 1
  379. if left > 0:
  380. print(f"错误:还有 {left} 个「{TIER_MID}」等生空位没地方安排。")
  381. sys.exit(1)
  382. # --- 每排实际可用的桌组与座位数 ---
  383. row_bands = {}
  384. for r in range(num_rows):
  385. bands = [(b, sum(1 for p in range(sizes[b]) if (r, b, p) not in voids))
  386. for b in range(len(sizes))]
  387. row_bands[r] = [item for item in bands if item[1] > 0]
  388. # --- 男女分配:先定每排女座数,再定排内哪几个组坐女生 ---
  389. female_need = {t: sum(1 for s in ordered if s["tier"] == t and gender_key(s) == "女")
  390. for t in TIER_CYCLE}
  391. band_gender = {}
  392. for t in TIER_CYCLE:
  393. rows = [r for r in range(num_rows) if row_roles[r] == t]
  394. if not rows:
  395. continue
  396. counts = _choose_counts([row_bands[r] for r in rows], female_need[t], rng)
  397. if counts is None:
  398. print(f"错误:「{t}」等生的男女人数排不进对应的桌组,请调整比例或样式。")
  399. sys.exit(1)
  400. for r, want in zip(rows, counts):
  401. female_bands = _choose_bands(row_bands[r], want, rng)
  402. if female_bands is None:
  403. print(f"错误:第 {r + 1} 排无法排出 {want} 个女生座位。")
  404. sys.exit(1)
  405. for b, _ in row_bands[r]:
  406. band_gender[(r, b)] = "女" if b in female_bands else "男"
  407. # --- 填座 ---
  408. pools = {(g, t): [s for s in ordered if gender_key(s) == g and s["tier"] == t]
  409. for g in ("女", "男") for t in TIER_CYCLE}
  410. idx = {key: 0 for key in pools}
  411. grid = {}
  412. for t in TIER_CYCLE:
  413. for r in range(num_rows):
  414. if row_roles[r] != t:
  415. continue
  416. for b, _ in row_bands[r]:
  417. g = band_gender[(r, b)]
  418. pool = pools[(g, t)]
  419. for p in range(sizes[b]):
  420. if (r, b, p) in voids or idx[(g, t)] >= len(pool):
  421. continue
  422. grid[(r, b, p)] = pool[idx[(g, t)]]
  423. idx[(g, t)] += 1
  424. unseen = {key: len(pools[key]) - idx[key] for key in pools if len(pools[key]) != idx[key]}
  425. if unseen:
  426. print(f"错误:有学生没排下:{unseen}")
  427. sys.exit(1)
  428. groups = []
  429. for r in range(num_rows):
  430. for b in range(len(sizes)):
  431. groups.append([grid[(r, b, p)] for p in range(sizes[b]) if (r, b, p) in grid])
  432. return {
  433. "groups": groups,
  434. "grid": grid,
  435. "voids": voids,
  436. "row_roles": row_roles,
  437. "num_rows": num_rows,
  438. "ordered": ordered,
  439. }
  440. def verify_tiered_layout(layout, sizes):
  441. """校验硬性约束:同桌同性、差生正前或正后方同列有优生、落座人数。"""
  442. grid = layout["grid"]
  443. num_rows = layout["num_rows"]
  444. problems = []
  445. mixed = 0
  446. for r in range(num_rows):
  447. for b in range(len(sizes)):
  448. genders = {gender_key(grid[(r, b, p)]) for p in range(sizes[b]) if (r, b, p) in grid}
  449. if len(genders) > 1:
  450. mixed += 1
  451. problems.append(f"第 {r + 1} 排第 {b + 1} 组同桌男女混坐")
  452. tiers = {grid[(r, b, p)]["tier"] for p in range(sizes[b]) if (r, b, p) in grid}
  453. if len(tiers) > 1:
  454. problems.append(f"第 {r + 1} 排第 {b + 1} 组层次不统一")
  455. roster = sorted(s["name"] for s in layout["ordered"])
  456. seated = sorted(stu["name"] for stu in grid.values())
  457. if seated != roster:
  458. problems.append(f"落座名单与班级名单不符(落座 {len(seated)} 人,班级 {len(roster)} 人)")
  459. bad_total = bad_ok = 0
  460. for (r, b, p), stu in sorted(grid.items()):
  461. if stu["tier"] != TIER_BAD:
  462. continue
  463. bad_total += 1
  464. if any(grid.get((r + dr, b, p)) and grid[(r + dr, b, p)]["tier"] == TIER_GOOD
  465. for dr in (-1, 1)):
  466. bad_ok += 1
  467. else:
  468. problems.append(f"第 {r + 1} 排第 {b + 1} 组「{stu['name']}」正前/正后方没有优生")
  469. return {
  470. "mixed": mixed,
  471. "bad_total": bad_total,
  472. "bad_ok": bad_ok,
  473. "placed": len(grid),
  474. "empty": num_rows * sum(sizes) - len(grid),
  475. "problems": problems,
  476. }
  477. def print_tiered_report(layout, stats, sizes):
  478. """打印分层排布的每排构成与约束校验结论。"""
  479. grid = layout["grid"]
  480. num_rows = layout["num_rows"]
  481. print("\n分层排布结果(good-with-good-advance):")
  482. for r in range(num_rows):
  483. counts = {}
  484. for b in range(len(sizes)):
  485. for p in range(sizes[b]):
  486. stu = grid.get((r, b, p))
  487. if stu:
  488. key = (stu["tier"], gender_key(stu))
  489. counts[key] = counts.get(key, 0) + 1
  490. detail = ",".join(f"{t}生{k} {v}人" for (t, k), v in sorted(counts.items()))
  491. empty = sum(1 for b in range(len(sizes)) for p in range(sizes[b])
  492. if (r, b, p) not in grid)
  493. tail = f",空 {empty} 座" if empty else ""
  494. print(f" 第 {r + 1:2d} 排({layout['row_roles'][r]}生排){detail}{tail}")
  495. ok_gender = "通过" if stats["mixed"] == 0 else "不通过"
  496. ok_frontback = "通过" if stats["bad_ok"] == stats["bad_total"] else "不通过"
  497. print("\n约束校验:")
  498. print(f" 同桌同性:{ok_gender}(混坐 {stats['mixed']} 组)")
  499. print(f" 差生正前或正后方有优生:{ok_frontback}({stats['bad_ok']}/{stats['bad_total']} 人)")
  500. print(f" 落座 {stats['placed']} 人,空 {stats['empty']} 座")
  501. if stats["problems"]:
  502. print(" 未通过明细(最多列 20 条):")
  503. for msg in stats["problems"][:20]:
  504. print(f" - {msg}")
  505. def generate_excel(groups, style_str, output_path, class_name):
  506. """生成座位表Excel文件。"""
  507. wb = Workbook()
  508. ws = wb.active
  509. ws.title = f"{class_name}座位表"
  510. sizes = [int(c) for c in style_str]
  511. num_groups_per_row = len(sizes)
  512. num_rows = (len(groups) + num_groups_per_row - 1) // num_groups_per_row
  513. total_cols = sum(sizes) + (num_groups_per_row - 1)
  514. thin_border = Border(
  515. left=Side(style="thin"), right=Side(style="thin"),
  516. top=Side(style="thin"), bottom=Side(style="thin"),
  517. )
  518. center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
  519. male_fill = PatternFill(start_color="D6EAF8", end_color="D6EAF8", fill_type="solid")
  520. female_fill = PatternFill(start_color="FADBD8", end_color="FADBD8", fill_type="solid")
  521. podium_fill = PatternFill(start_color="D5D8DC", end_color="D5D8DC", fill_type="solid")
  522. podium_font = Font(bold=True, size=14)
  523. name_font = Font(size=14)
  524. # score_font kept for reference but not used separately
  525. # --- 讲台行 ---
  526. ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_cols)
  527. podium_cell = ws.cell(row=1, column=1, value="讲 台")
  528. podium_cell.font = podium_font
  529. podium_cell.alignment = center_align
  530. podium_cell.fill = podium_fill
  531. for c in range(1, total_cols + 1):
  532. ws.cell(row=1, column=c).fill = podium_fill
  533. ws.cell(row=1, column=c).border = thin_border
  534. ws.row_dimensions[1].height = 35
  535. # --- 空行 ---
  536. # (直接将学生放在第3行及以下)
  537. # --- 学生座位 ---
  538. group_idx = 0
  539. for row_num in range(num_rows):
  540. excel_row = row_num + 3 # row 1 = 讲台, row 2 = blank
  541. col_offset = 1
  542. for g in range(num_groups_per_row):
  543. size = sizes[g]
  544. if group_idx < len(groups):
  545. group = groups[group_idx]
  546. group_idx += 1
  547. for i in range(size):
  548. col = col_offset + i
  549. cell = ws.cell(row=excel_row, column=col)
  550. if i < len(group):
  551. student = group[i]
  552. cell.value = student['name']
  553. cell.font = name_font
  554. cell.alignment = center_align
  555. if is_male(student["gender"]):
  556. cell.fill = male_fill
  557. elif is_female(student["gender"]):
  558. cell.fill = female_fill
  559. else:
  560. cell.value = ""
  561. cell.border = thin_border
  562. ws.column_dimensions[get_column_letter(col)].width = 13
  563. else:
  564. for i in range(size):
  565. col = col_offset + i
  566. cell = ws.cell(row=excel_row, column=col)
  567. cell.border = thin_border
  568. ws.column_dimensions[get_column_letter(col)].width = 13
  569. col_offset += size
  570. # 过道
  571. if g < num_groups_per_row - 1:
  572. aisle_col = col_offset
  573. ws.column_dimensions[get_column_letter(aisle_col)].width = 4
  574. col_offset += 1
  575. ws.row_dimensions[excel_row].height = 36
  576. wb.save(output_path)
  577. print(f"座位表已保存至:{output_path}")
  578. def main():
  579. args = parse_args()
  580. # 验证样式
  581. if not args.style.isdigit() or len(args.style) < 2:
  582. print("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'")
  583. sys.exit(1)
  584. input_path = Path(args.excel_file)
  585. if not input_path.exists():
  586. print(f"错误:文件不存在:{args.excel_file}")
  587. sys.exit(1)
  588. if args.count < 1:
  589. print("错误:生成个数必须大于0")
  590. sys.exit(1)
  591. # 确定输出路径模板
  592. if args.output:
  593. output_base = args.output
  594. else:
  595. output_base = f"{input_path.stem}_{args.class_name}_座位表.xlsx"
  596. # 读取学生(只读一次)
  597. print(f"正在读取 {args.excel_file} ...")
  598. students = read_students(args.excel_file, args.class_name, args.sheet, args.score_column)
  599. if not students:
  600. print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。")
  601. else:
  602. m_count = sum(1 for s in students if is_male(s["gender"]))
  603. f_count = sum(1 for s in students if is_female(s["gender"]))
  604. print(f"班级 '{args.class_name}' 共有 {len(students)} 名学生(男:{m_count},女:{f_count})")
  605. # 构建桌组(只算一次)
  606. desk_sizes = build_desk_groups(args.style, len(students))
  607. # 确定基础种子
  608. base_seed = args.seed if args.seed is not None else random.randint(0, 999999)
  609. strategy_names = {
  610. "good-with-good": "好+好",
  611. "good-with-bad": "好+差",
  612. "random": "与成绩无关",
  613. "good-with-good-advance": "好+好进阶(分层排布,差生前后排必有优生)",
  614. }
  615. if args.strategy == "good-with-good-advance" and args.allow_mixed:
  616. print("提示:good-with-good-advance 固定「同桌同性」,-m/--allow-mixed 对它无效。")
  617. # 生成多个座位表
  618. for i in range(args.count):
  619. seed = base_seed + i
  620. # 输出文件名:多个时加序号后缀
  621. if args.count > 1:
  622. p = Path(output_base)
  623. output_path = str(p.parent / f"{p.stem}_{i + 1}{p.suffix}")
  624. else:
  625. output_path = output_base
  626. if args.strategy == "good-with-good-advance":
  627. sizes = [int(c) for c in args.style]
  628. layout = plan_tiered_layout(
  629. students, sizes, args.good_ratio, args.bad_ratio, seed
  630. )
  631. groups = layout["groups"]
  632. generate_excel(groups, args.style, output_path, args.class_name)
  633. stats = verify_tiered_layout(layout, sizes)
  634. print_tiered_report(layout, stats, sizes)
  635. else:
  636. groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed)
  637. generate_excel(groups, args.style, output_path, args.class_name)
  638. # 摘要
  639. print(f"\n座位表配置:")
  640. print(f" 样式:{args.style}({len(args.style)}组,每组{'-'.join(args.style)}人)")
  641. print(f" 策略:{strategy_names[args.strategy]}")
  642. print(f" 男女生同桌:{'允许' if args.allow_mixed else '不允许'}")
  643. print(f" 生成个数:{args.count}")
  644. print(f" 总桌组数:{len(groups)},总排数:{(len(groups) + len(args.style) - 1) // len(args.style)}")
  645. if __name__ == "__main__":
  646. main()