from __future__ import annotations from io import BytesIO from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter def _style_header(ws, row: int, ncols: int): fill = PatternFill("solid", fgColor="12233D") font = Font(color="F4EFE6", bold=True) for c in range(1, ncols + 1): cell = ws.cell(row, c) cell.fill = fill cell.font = font cell.alignment = Alignment(wrap_text=True, vertical="center") def _borders(ws, min_row: int = 1, max_row: int | None = None, max_col: int | None = None): thin = Side(style="thin", color="D9D1C3") border = Border(left=thin, right=thin, top=thin, bottom=thin) max_row = max_row or ws.max_row max_col = max_col or ws.max_column for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=1, max_col=max_col): for cell in row: if cell.value is not None: cell.border = border def _group_order(g: str): return 0 if g == "物理" else 1 if g == "历史" else 2 def _safe_title(text: str) -> str: return text.replace(":", "-").replace("/", "-").replace("\\", "-").replace("?", "-")[:31] def _raw_sheet(wb: Workbook, group: dict, students: list[dict]): title = _safe_title(f"{group['group']}方向人员") ws = wb.create_sheet(title) exams = group["_exams"] ws.append(["学号", "姓名", "班级", "方向", "选科", "学籍", "异动"] + exams) _style_header(ws, 1, ws.max_column) for s in students: ws.append( [ s["sid"], s["name"], s["class_name"], s["track"], s.get("subjects") or "", s.get("status") or "", s.get("move") or "", ] + [("缺考" if s["scores"].get(e) is None else s["scores"].get(e)) for e in exams] ) _borders(ws, 2) ws.freeze_panes = "A2" return ws def _probability_sheet(wb: Workbook, group: dict): title = _safe_title(f"{group['group']}类概率分析") ws = wb.create_sheet(title) exams = group["_exams"] lines = {x["id"]: x for x in group["targets"]} pair_blocks = {x["line"]: x["pairs"] for x in group["pairs"]} r = 1 def pair_for(line_id: str, a: str, b: str): line_name = lines[line_id]["name"] return next( (p for p in pair_blocks[line_name] if p["from_exam"] == a and p["to_exam"] == b), None, ) for idx, (a, b) in enumerate(zip(exams, exams[1:]), 1): top_pair = pair_for("top", a, b) control_pair = pair_for("control", a, b) if not top_pair or not control_pair: continue ws.cell(r, 1, f"情况{idx}·尖子线前{lines['top']['k']}名") ws.cell(r, 8, f"情况{idx}·特控线前{lines['control']['k']}名") r += 1 for c0, line_id in ((1, "top"), (8, "control")): ws.cell(r, c0, "分层") ws.cell(r, c0 + 1, "排名区间") ws.cell(r, c0 + 2, a) ws.cell(r, c0 + 3, b) ws.cell(r, c0 + 4, "概率") _style_header(ws, r, 12) r += 1 max_layers = max(len(top_pair["layers"]), len(control_pair["layers"])) for i in range(max_layers): for c0, pair in ((1, top_pair), (8, control_pair)): if i >= len(pair["layers"]): continue x = pair["layers"][i] ws.cell(r, c0, x["layer"]) ws.cell(r, c0 + 1, x.get("label") or f"第{x['layer']}层") ws.cell(r, c0 + 2, x["previous_n"]) ws.cell(r, c0 + 3, x["hit_n"]) ws.cell(r, c0 + 4, x["probability"]) r += 1 r += 2 ws.cell(r, 1, f"汇总·尖子线前{lines['top']['k']}名") ws.cell(r, 8, f"汇总·特控线前{lines['control']['k']}名") r += 1 for c0, line_id in ((1, "top"), (8, "control")): ws.cell(r, c0, "分层") ws.cell(r, c0 + 1, "排名区间") ws.cell(r, c0 + 2, "汇总样本") ws.cell(r, c0 + 3, "汇总命中") ws.cell(r, c0 + 4, "合成概率") ws.cell(r, c0 + 5, "拟合概率") _style_header(ws, r, 13) r += 1 top_layers = group["summary"]["top"]["layers"] control_layers = group["summary"]["control"]["layers"] for i in range(max(len(top_layers), len(control_layers))): for c0, layers in ((1, top_layers), (8, control_layers)): if i >= len(layers): continue x = layers[i] ws.cell(r, c0, x["layer"]) ws.cell(r, c0 + 1, x["label"]) ws.cell(r, c0 + 2, x["previous_n"]) ws.cell(r, c0 + 3, x["hit_n"]) ws.cell(r, c0 + 4, x["probability"]) ws.cell(r, c0 + 5, x["fitted_probability"]) r += 1 r += 1 note = ( "计算口径:勾选后的每次相邻两次考试都参与比较,最后一次也进入转移样本;同分同名次,后续名次递增;缺考未排除时按0分排名;" "汇总n/k后做加权二项logistic拟合;第1层保留合成概率,首个命中为0的层及其后取0,并强制概率逐层不上升。" ) ws.cell(r, 1, note) _borders(ws) ws.column_dimensions["A"].width = 10 ws.column_dimensions["H"].width = 10 for c in (2, 9): ws.column_dimensions[get_column_letter(c)].width = 24 for c in range(3, 8): ws.column_dimensions[get_column_letter(c)].width = 16 for c in range(10, 14): ws.column_dimensions[get_column_letter(c)].width = 16 return ws def _metric_sheet(wb: Workbook, group: dict): title = _safe_title(f"{group['group']}指标数") ws = wb.create_sheet(title) r = 1 for line in group["targets"]: ws.cell(r, 1, f"{group['group']}方向{line['name']}指标分配") r += 1 headers = ["班级"] + [f"{i+1}层人数" for i in range(group["n_layers"])] + ["加权计算", "摊派指标"] ws.cell(r, 1, headers[0]) for i, h in enumerate(headers[1:], 2): ws.cell(r, i, h) _style_header(ws, r, len(headers)) r += 1 ws.cell(r, 1, "排名区间") for i, label in enumerate(line["labels"], 2): ws.cell(r, i, label) r += 1 ws.cell(r, 1, "拟合概率") for i, p in enumerate(line["fitted"], 2): ws.cell(r, i, p) r += 1 for row in line["classes"]: ws.cell(r, 1, row["class_name"]) for i, count in enumerate(row["layer_counts"], 2): ws.cell(r, i, count) ws.cell(r, len(headers) - 1, row["expected"]) ws.cell(r, len(headers), row["assigned"]) r += 1 total_counts = [ sum(row["layer_counts"][i] for row in line["classes"]) for i in range(group["n_layers"]) ] ws.cell(r, 1, "合计") for i, count in enumerate(total_counts, 2): ws.cell(r, i, count) ws.cell(r, len(headers) - 1, line["sum_expected"]) ws.cell(r, len(headers), line["sum_assigned"]) r += 1 ws.cell(r, 1, "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。") r += 3 ws.column_dimensions["A"].width = 14 for c in range(2, ws.max_column + 1): ws.column_dimensions[get_column_letter(c)].width = 13 _borders(ws) ws.freeze_panes = "B1" return ws def build_export(result: dict) -> bytes: wb = Workbook() wb.remove(wb.active) # Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史. groups = sorted(result["groups"], key=lambda g: _group_order(g["group"])) for i, group in enumerate(groups): students = [s for s in result["students"] if s["group"] == group["group"]] group["_exams"] = result["exams"] _raw_sheet(wb, group, students) for group in groups: _probability_sheet(wb, group) for group in groups: _metric_sheet(wb, group) bio = BytesIO() wb.save(bio) return bio.getvalue()