#!/usr/bin/env python3 """ Generate seating charts from Excel class data. Required input columns: 班级, 姓名, 性别, 总分 """ import argparse import itertools import random import sys from pathlib import Path try: from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter except ImportError: print("Error: openpyxl is required. Install with: pip install openpyxl") sys.exit(1) def parse_args(): parser = argparse.ArgumentParser( description="根据Excel学生数据生成座位表" ) parser.add_argument( "excel_file", type=str, help="输入Excel文件路径 (.xlsx)" ) parser.add_argument( "-c", "--class", dest="class_name", type=str, required=True, help="要生成座位表的班级名称" ) parser.add_argument( "-s", "--style", type=str, required=True, help="座位表样式,如 '2222'(4组2人桌)、'3332'(3组3人桌+1组2人桌)" ) parser.add_argument( "-m", "--allow-mixed", action="store_true", help="允许男女生同桌(默认不允许)" ) parser.add_argument( "-t", "--strategy", type=str, default="good-with-good", choices=["good-with-good", "good-with-bad", "random", "good-with-good-advance"], help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、" "random(无关)、good-with-good-advance(好+好进阶:好生集中," "差生正前方或正后方必有优生,同桌同性)" ) parser.add_argument( "--score-column", type=str, default="赋分成绩", help="排序用的成绩列(默认:赋分成绩,即「总分」组下的赋分子列;找不到则用总分列)" ) parser.add_argument( "--good-ratio", type=float, default=1 / 3, help="优生比例(仅 good-with-good-advance 策略,默认 1/3)" ) parser.add_argument( "--bad-ratio", type=float, default=1 / 3, help="差生比例(仅 good-with-good-advance 策略,默认 1/3)" ) parser.add_argument( "-o", "--output", type=str, default=None, help="输出Excel文件路径(默认:<输入文件>_<班级>_座位表.xlsx)" ) parser.add_argument( "-n", "--count", type=int, default=1, help="生成座位表的个数(默认1个)" ) parser.add_argument( "--seed", type=int, default=None, help="随机种子(用于 random 策略的可重复性)" ) parser.add_argument( "-S", "--sheet", type=str, default=None, help="指定要读取的工作表名称(默认:第一个工作表)" ) parser.add_argument( "--usage", action="help", help="显示帮助信息" ) return parser.parse_args() def find_score_column(ws, header_row, total_col, score_column): """在「总分」分组表头下方找到子表头为 score_column 的那一列(1-based)。 排行榜表格第2行是分组表头(总分/语数外/…),第3行才是子表头 (原始成绩/赋分成绩/班级排名)。直接取「总分」列拿到的是原始成绩, 这里按子表头选列。返回 (列号, 是否按子表头命中);找不到就用「总分」列。 """ sub_row = header_row + 1 if sub_row <= ws.max_row: # 只在「总分」组内往后找,避免串到后面的科目分组 for c in range(total_col, min(total_col + 4, ws.max_column + 1)): val = ws.cell(row=sub_row, column=c).value if val and str(val).strip() == score_column: return c, True return total_col, False def read_students(filepath, class_name, sheet_name=None, score_column="赋分成绩"): """从Excel读取学生数据,按班级筛选。自动查找表头行。""" from openpyxl import load_workbook wb = load_workbook(filepath, data_only=True) if sheet_name: if sheet_name not in wb.sheetnames: print(f"错误:工作表 '{sheet_name}' 不存在。可用工作表:{wb.sheetnames}") sys.exit(1) ws = wb[sheet_name] else: ws = wb[wb.sheetnames[0]] required = ["班级", "姓名", "性别", "总分"] indices = {} header_row = None # 自动查找包含所有必需列的表头行(最多扫描前10行) for r in range(1, min(ws.max_row + 1, 11)): row_vals = [] for col in range(1, ws.max_column + 1): val = ws.cell(row=r, column=col).value row_vals.append(str(val).strip() if val else "") if all(col_name in row_vals for col_name in required): for col_name in required: indices[col_name] = row_vals.index(col_name) header_row = r break if header_row is None: # 输出前几行帮助诊断 print("错误:Excel文件中未找到 '班级'、'姓名'、'性别'、'总分' 列。") for r in range(1, min(ws.max_row + 1, 6)): preview = [str(ws.cell(row=r, column=c).value or "") for c in range(1, ws.max_column + 1)] print(f" 第{r}行:{preview}") sys.exit(1) # 规范化班级名:去除"班"后缀以便灵活匹配 class_name_clean = class_name.rstrip("班") data_start = header_row + 1 total_col = indices["总分"] + 1 score_col, hit = find_score_column(ws, header_row, total_col, score_column) if hit: print(f"排序成绩列:第 {score_col} 列({score_column})") else: print(f"排序成绩列:第 {total_col} 列(未找到「{score_column}」子列,改用表头「总分」列)") students = [] for row in range(data_start, ws.max_row + 1): class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip() class_val_clean = class_val.rstrip("班") if class_val_clean != class_name_clean: continue name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip() gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip() score_str = str(ws.cell(row=row, column=score_col).value or "").strip() if not name: continue try: score = float(score_str) except ValueError: score = 0.0 students.append({"name": name, "gender": gender, "score": score}) wb.close() return students def interleave_high_low(items): """将按分数降序排列的列表重新排列为 高-低-高-低 交错顺序。""" result = [] left, right = 0, len(items) - 1 while left <= right: result.append(items[left]) left += 1 if left <= right: result.append(items[right]) right -= 1 return result def build_desk_groups(style_str, num_students): """根据样式字符串构建桌组大小列表。""" sizes = [int(c) for c in style_str] for s in sizes: if s < 1: print(f"错误:样式中的数字必须大于0,发现 '{s}'") sys.exit(1) row_capacity = sum(sizes) num_rows = max(1, (num_students + row_capacity - 1) // row_capacity) desk_groups = [] for _ in range(num_rows): for size in sizes: desk_groups.append(size) return desk_groups def is_male(gender): return gender in ("男", "M", "m", "male", "Male") def is_female(gender): return gender in ("女", "F", "f", "female", "Female") def allocate_students(students, desk_sizes, strategy, allow_mixed, seed): """将学生分配到桌组。""" if seed is not None: random.seed(seed) males = [s for s in students if is_male(s["gender"])] females = [s for s in students if is_female(s["gender"])] others = [s for s in students if not is_male(s["gender"]) and not is_female(s["gender"])] # 无法识别性别的归入人数多的一方 if others: if len(males) >= len(females): males.extend(others) else: females.extend(others) # --- 允许混坐:所有学生一起排 --- if allow_mixed: all_students = males + females if strategy == "good-with-good": all_students.sort(key=lambda s: s["score"], reverse=True) elif strategy == "good-with-bad": all_students.sort(key=lambda s: s["score"], reverse=True) all_students = interleave_high_low(all_students) else: random.shuffle(all_students) groups = [] idx = 0 for size in desk_sizes: group = all_students[idx:idx + size] if group: groups.append(group) idx += size if idx >= len(all_students): break return groups # --- 性别分开排 --- if strategy == "good-with-good": males.sort(key=lambda s: s["score"], reverse=True) females.sort(key=lambda s: s["score"], reverse=True) elif strategy == "good-with-bad": males.sort(key=lambda s: s["score"], reverse=True) males = interleave_high_low(males) females.sort(key=lambda s: s["score"], reverse=True) females = interleave_high_low(females) else: random.shuffle(males) random.shuffle(females) groups = [] m_idx, f_idx = 0, 0 for size in desk_sizes: m_rem = len(males) - m_idx f_rem = len(females) - f_idx if m_rem >= f_rem: take = min(size, m_rem) group = males[m_idx:m_idx + take] m_idx += take else: take = min(size, f_rem) group = females[f_idx:f_idx + take] f_idx += take if group: groups.append(group) return groups # ------------------------------------------------------- 分层排布(进阶策略) TIER_GOOD = "优" TIER_MID = "中" TIER_BAD = "差" TIER_CYCLE = (TIER_GOOD, TIER_BAD, TIER_MID) # 排层次循环:优生排紧跟差生排 def gender_key(student): """把性别归一成「女」/「男」两池(无法识别的归入男生池)。""" return "女" if is_female(student["gender"]) else "男" def assign_tiers(students, good_ratio, bad_ratio): """按成绩排名划分层次:前 good_ratio 为优生,后 bad_ratio 为差生,其余为中。""" ordered = sorted(students, key=lambda s: (-s["score"], s["name"])) n = len(ordered) n_good = min(n, max(0, int(round(n * good_ratio)))) n_bad = min(n - n_good, max(0, int(round(n * bad_ratio)))) for i, stu in enumerate(ordered): if i < n_good: stu["tier"] = TIER_GOOD elif i >= n - n_bad: stu["tier"] = TIER_BAD else: stu["tier"] = TIER_MID return ordered def _subset_sums(widths): """一组桌宽能凑出的所有座位数。""" sums = {0} for w in widths: sums |= {s + w for s in sums} return sorted(sums) def _choose_counts(rows_bands, total, rng): """给每一排定一个女座数,使合计等于 total,并尽量让女生分散到各排。""" options = [_subset_sums([w for _, w in rb]) for rb in rows_bands] caps = [sum(w for _, w in rb) for rb in rows_bands] total_cap = sum(caps) or 1 best = None def walk(i, remain, acc): nonlocal best if i == len(rows_bands): if remain != 0: return score = sum(abs(acc[j] - total * caps[j] / total_cap) for j in range(len(caps))) if best is None or score < best[0] - 1e-9 or ( abs(score - best[0]) < 1e-9 and rng.random() < 0.5): best = (score, list(acc)) return for v in options[i]: if v <= remain: walk(i + 1, remain - v, acc + [v]) walk(0, total, []) return best[1] if best else None def _choose_bands(bands, target, rng): """在一排里挑出若干桌组坐女生,座位数之和等于 target,尽量打散到更多组。""" for size in range(len(bands), -1, -1): hits = [combo for combo in itertools.combinations(range(len(bands)), size) if sum(bands[i][1] for i in combo) == target] if hits: return {bands[i][0] for i in rng.choice(hits)} return None def _tail_positions(sizes, count): """从最右侧座位往左数 count 个,返回 [(组, 组内位置), ...]。""" out = [] for b in range(len(sizes) - 1, -1, -1): for p in range(sizes[b] - 1, -1, -1): out.append((b, p)) if len(out) >= count: return out return out def plan_tiered_layout(students, sizes, good_ratio, bad_ratio, seed): """分层排布:优生集中,差生正前方或正后方必有优生。 1. 排层次按「优-差-中」循环,所以每排差生的正前方恰好是优生排; 每排各桌组同层次、同性别(同桌必定同性)。 2. 各层次座位容量减去人数得到空位数。优生排的空位必须和它正后方 差生排的「同组、同位置」一起挖空,否则会出现差生正前方变成空位、 前后都没有优生的情况。 3. 按层次把女生的座位数分配到各排,再决定排内哪几个组坐女生。 4. 每个 (性别, 层次) 池内按成绩降序,从前往后、从左往右填座, 所以优生里分数最高的坐在最前排。 """ rng = random.Random(seed) ordered = assign_tiers(students, good_ratio, bad_ratio) n = len(ordered) row_capacity = sum(sizes) unknown = [s["name"] for s in ordered if not is_male(s["gender"]) and not is_female(s["gender"])] if unknown: print(f"警告:{len(unknown)} 名学生性别无法识别({'、'.join(unknown[:5])}" f"{' 等' if len(unknown) > 5 else ''}),已按男生编排。") if n == 0: return { "groups": [[] for _ in range(len(sizes))], "grid": {}, "voids": set(), "row_roles": [TIER_CYCLE[0]], "num_rows": 1, "ordered": [], } num_rows = max(1, (n + row_capacity - 1) // row_capacity) row_roles = [TIER_CYCLE[r % len(TIER_CYCLE)] for r in range(num_rows)] capacity = {t: 0 for t in TIER_CYCLE} for t in row_roles: capacity[t] += row_capacity need = {t: 0 for t in TIER_CYCLE} for stu in ordered: need[stu["tier"]] += 1 deficit = {t: capacity[t] - need[t] for t in TIER_CYCLE} for t in TIER_CYCLE: if deficit[t] < 0: print(f"错误:{num_rows} 排座位装不下 {need[t]} 名「{t}」等生(该层次只有 {capacity[t]} 座)。") print(" 可调整 --good-ratio / --bad-ratio,或换一个座位样式。") sys.exit(1) if deficit[TIER_GOOD] != deficit[TIER_BAD]: print(f"错误:优生空位({deficit[TIER_GOOD]})与差生空位({deficit[TIER_BAD]})不相等,") print(" 无法保证每个差生的正前或正后方都是优生。请让优生、差生比例保持一致。") sys.exit(1) # --- 空位:优生排与其正后方的差生排按同组同位置配对挖空 --- voids = set() pair = min(deficit[TIER_GOOD], row_capacity) if pair: anchor = None for r in range(num_rows - 1, -1, -1): if row_roles[r] == TIER_GOOD and r + 1 < num_rows and row_roles[r + 1] == TIER_BAD: anchor = r break if anchor is None: print("错误:排数不足以形成「优生排 + 差生排」的配对空位。") sys.exit(1) for b, p in _tail_positions(sizes, pair): voids.add((anchor, b, p)) voids.add((anchor + 1, b, p)) # --- 空位:中等生排从后往前挖 --- left = deficit[TIER_MID] for r in range(num_rows - 1, -1, -1): if left <= 0: break if row_roles[r] != TIER_MID: continue free = [(b, p) for b in range(len(sizes)) for p in range(sizes[b]) if (r, b, p) not in voids] for b, p in reversed(free): if left <= 0: break voids.add((r, b, p)) left -= 1 if left > 0: print(f"错误:还有 {left} 个「{TIER_MID}」等生空位没地方安排。") sys.exit(1) # --- 每排实际可用的桌组与座位数 --- row_bands = {} for r in range(num_rows): bands = [(b, sum(1 for p in range(sizes[b]) if (r, b, p) not in voids)) for b in range(len(sizes))] row_bands[r] = [item for item in bands if item[1] > 0] # --- 男女分配:先定每排女座数,再定排内哪几个组坐女生 --- female_need = {t: sum(1 for s in ordered if s["tier"] == t and gender_key(s) == "女") for t in TIER_CYCLE} band_gender = {} for t in TIER_CYCLE: rows = [r for r in range(num_rows) if row_roles[r] == t] if not rows: continue counts = _choose_counts([row_bands[r] for r in rows], female_need[t], rng) if counts is None: print(f"错误:「{t}」等生的男女人数排不进对应的桌组,请调整比例或样式。") sys.exit(1) for r, want in zip(rows, counts): female_bands = _choose_bands(row_bands[r], want, rng) if female_bands is None: print(f"错误:第 {r + 1} 排无法排出 {want} 个女生座位。") sys.exit(1) for b, _ in row_bands[r]: band_gender[(r, b)] = "女" if b in female_bands else "男" # --- 填座 --- pools = {(g, t): [s for s in ordered if gender_key(s) == g and s["tier"] == t] for g in ("女", "男") for t in TIER_CYCLE} idx = {key: 0 for key in pools} grid = {} for t in TIER_CYCLE: for r in range(num_rows): if row_roles[r] != t: continue for b, _ in row_bands[r]: g = band_gender[(r, b)] pool = pools[(g, t)] for p in range(sizes[b]): if (r, b, p) in voids or idx[(g, t)] >= len(pool): continue grid[(r, b, p)] = pool[idx[(g, t)]] idx[(g, t)] += 1 unseen = {key: len(pools[key]) - idx[key] for key in pools if len(pools[key]) != idx[key]} if unseen: print(f"错误:有学生没排下:{unseen}") sys.exit(1) groups = [] for r in range(num_rows): for b in range(len(sizes)): groups.append([grid[(r, b, p)] for p in range(sizes[b]) if (r, b, p) in grid]) return { "groups": groups, "grid": grid, "voids": voids, "row_roles": row_roles, "num_rows": num_rows, "ordered": ordered, } def verify_tiered_layout(layout, sizes): """校验硬性约束:同桌同性、差生正前或正后方同列有优生、落座人数。""" grid = layout["grid"] num_rows = layout["num_rows"] problems = [] mixed = 0 for r in range(num_rows): for b in range(len(sizes)): genders = {gender_key(grid[(r, b, p)]) for p in range(sizes[b]) if (r, b, p) in grid} if len(genders) > 1: mixed += 1 problems.append(f"第 {r + 1} 排第 {b + 1} 组同桌男女混坐") tiers = {grid[(r, b, p)]["tier"] for p in range(sizes[b]) if (r, b, p) in grid} if len(tiers) > 1: problems.append(f"第 {r + 1} 排第 {b + 1} 组层次不统一") roster = sorted(s["name"] for s in layout["ordered"]) seated = sorted(stu["name"] for stu in grid.values()) if seated != roster: problems.append(f"落座名单与班级名单不符(落座 {len(seated)} 人,班级 {len(roster)} 人)") bad_total = bad_ok = 0 for (r, b, p), stu in sorted(grid.items()): if stu["tier"] != TIER_BAD: continue bad_total += 1 if any(grid.get((r + dr, b, p)) and grid[(r + dr, b, p)]["tier"] == TIER_GOOD for dr in (-1, 1)): bad_ok += 1 else: problems.append(f"第 {r + 1} 排第 {b + 1} 组「{stu['name']}」正前/正后方没有优生") return { "mixed": mixed, "bad_total": bad_total, "bad_ok": bad_ok, "placed": len(grid), "empty": num_rows * sum(sizes) - len(grid), "problems": problems, } def print_tiered_report(layout, stats, sizes): """打印分层排布的每排构成与约束校验结论。""" grid = layout["grid"] num_rows = layout["num_rows"] print("\n分层排布结果(good-with-good-advance):") for r in range(num_rows): counts = {} for b in range(len(sizes)): for p in range(sizes[b]): stu = grid.get((r, b, p)) if stu: key = (stu["tier"], gender_key(stu)) counts[key] = counts.get(key, 0) + 1 detail = ",".join(f"{t}生{k} {v}人" for (t, k), v in sorted(counts.items())) empty = sum(1 for b in range(len(sizes)) for p in range(sizes[b]) if (r, b, p) not in grid) tail = f",空 {empty} 座" if empty else "" print(f" 第 {r + 1:2d} 排({layout['row_roles'][r]}生排){detail}{tail}") ok_gender = "通过" if stats["mixed"] == 0 else "不通过" ok_frontback = "通过" if stats["bad_ok"] == stats["bad_total"] else "不通过" print("\n约束校验:") print(f" 同桌同性:{ok_gender}(混坐 {stats['mixed']} 组)") print(f" 差生正前或正后方有优生:{ok_frontback}({stats['bad_ok']}/{stats['bad_total']} 人)") print(f" 落座 {stats['placed']} 人,空 {stats['empty']} 座") if stats["problems"]: print(" 未通过明细(最多列 20 条):") for msg in stats["problems"][:20]: print(f" - {msg}") def generate_excel(groups, style_str, output_path, class_name): """生成座位表Excel文件。""" wb = Workbook() ws = wb.active ws.title = f"{class_name}座位表" sizes = [int(c) for c in style_str] num_groups_per_row = len(sizes) num_rows = (len(groups) + num_groups_per_row - 1) // num_groups_per_row total_cols = sum(sizes) + (num_groups_per_row - 1) thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) center_align = Alignment(horizontal="center", vertical="center", wrap_text=True) male_fill = PatternFill(start_color="D6EAF8", end_color="D6EAF8", fill_type="solid") female_fill = PatternFill(start_color="FADBD8", end_color="FADBD8", fill_type="solid") podium_fill = PatternFill(start_color="D5D8DC", end_color="D5D8DC", fill_type="solid") podium_font = Font(bold=True, size=14) name_font = Font(size=14) # score_font kept for reference but not used separately # --- 讲台行 --- ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_cols) podium_cell = ws.cell(row=1, column=1, value="讲 台") podium_cell.font = podium_font podium_cell.alignment = center_align podium_cell.fill = podium_fill for c in range(1, total_cols + 1): ws.cell(row=1, column=c).fill = podium_fill ws.cell(row=1, column=c).border = thin_border ws.row_dimensions[1].height = 35 # --- 空行 --- # (直接将学生放在第3行及以下) # --- 学生座位 --- group_idx = 0 for row_num in range(num_rows): excel_row = row_num + 3 # row 1 = 讲台, row 2 = blank col_offset = 1 for g in range(num_groups_per_row): size = sizes[g] if group_idx < len(groups): group = groups[group_idx] group_idx += 1 for i in range(size): col = col_offset + i cell = ws.cell(row=excel_row, column=col) if i < len(group): student = group[i] cell.value = student['name'] cell.font = name_font cell.alignment = center_align if is_male(student["gender"]): cell.fill = male_fill elif is_female(student["gender"]): cell.fill = female_fill else: cell.value = "" cell.border = thin_border ws.column_dimensions[get_column_letter(col)].width = 13 else: for i in range(size): col = col_offset + i cell = ws.cell(row=excel_row, column=col) cell.border = thin_border ws.column_dimensions[get_column_letter(col)].width = 13 col_offset += size # 过道 if g < num_groups_per_row - 1: aisle_col = col_offset ws.column_dimensions[get_column_letter(aisle_col)].width = 4 col_offset += 1 ws.row_dimensions[excel_row].height = 36 wb.save(output_path) print(f"座位表已保存至:{output_path}") def main(): args = parse_args() # 验证样式 if not args.style.isdigit() or len(args.style) < 2: print("错误:样式必须为纯数字字符串,至少2位,如 '2222' 或 '3332'") sys.exit(1) input_path = Path(args.excel_file) if not input_path.exists(): print(f"错误:文件不存在:{args.excel_file}") sys.exit(1) if args.count < 1: print("错误:生成个数必须大于0") sys.exit(1) # 确定输出路径模板 if args.output: output_base = args.output else: output_base = f"{input_path.stem}_{args.class_name}_座位表.xlsx" # 读取学生(只读一次) print(f"正在读取 {args.excel_file} ...") students = read_students(args.excel_file, args.class_name, args.sheet, args.score_column) if not students: print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。") else: m_count = sum(1 for s in students if is_male(s["gender"])) f_count = sum(1 for s in students if is_female(s["gender"])) print(f"班级 '{args.class_name}' 共有 {len(students)} 名学生(男:{m_count},女:{f_count})") # 构建桌组(只算一次) desk_sizes = build_desk_groups(args.style, len(students)) # 确定基础种子 base_seed = args.seed if args.seed is not None else random.randint(0, 999999) strategy_names = { "good-with-good": "好+好", "good-with-bad": "好+差", "random": "与成绩无关", "good-with-good-advance": "好+好进阶(分层排布,差生前后排必有优生)", } if args.strategy == "good-with-good-advance" and args.allow_mixed: print("提示:good-with-good-advance 固定「同桌同性」,-m/--allow-mixed 对它无效。") # 生成多个座位表 for i in range(args.count): seed = base_seed + i # 输出文件名:多个时加序号后缀 if args.count > 1: p = Path(output_base) output_path = str(p.parent / f"{p.stem}_{i + 1}{p.suffix}") else: output_path = output_base if args.strategy == "good-with-good-advance": sizes = [int(c) for c in args.style] layout = plan_tiered_layout( students, sizes, args.good_ratio, args.bad_ratio, seed ) groups = layout["groups"] generate_excel(groups, args.style, output_path, args.class_name) stats = verify_tiered_layout(layout, sizes) print_tiered_report(layout, stats, sizes) else: groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed) generate_excel(groups, args.style, output_path, args.class_name) # 摘要 print(f"\n座位表配置:") print(f" 样式:{args.style}({len(args.style)}组,每组{'-'.join(args.style)}人)") print(f" 策略:{strategy_names[args.strategy]}") print(f" 男女生同桌:{'允许' if args.allow_mixed else '不允许'}") print(f" 生成个数:{args.count}") print(f" 总桌组数:{len(groups)},总排数:{(len(groups) + len(args.style) - 1) // len(args.style)}") if __name__ == "__main__": main()