"""统计分析 DAO(需求 2.6 与 2.7.2 的 SQL 部分)。 这里只放聚合查询;需要在 Python 里做的二次计算(标准差、排名、趋势判断) 放在 ``app.service.statistics_service``。 原则: 能用一次 SQL 出结果的,绝不拉全表到 Python 里 for 循环。 """ from __future__ import annotations from sqlalchemy import Select, case, distinct, func, select from sqlalchemy.orm import Session from app.core.utils import age_expression, date_diff_days_expr from app.model import Clazz, Employment, Score, Student, class_teachers class StatisticsDao: # ================================================================ 2.6.1 @classmethod def class_overview(cls, db: Session) -> list[dict]: """每个班的总人数 + 男女分布。LEFT JOIN 保证"挂零的班"也在结果里。""" stmt = ( select( Clazz.id, Clazz.class_no, Clazz.name, Clazz.status, Clazz.head_teacher_id, func.count(Student.id).label("total"), func.sum(case((Student.gender == 1, 1), else_=0)).label("male"), func.sum(case((Student.gender == 2, 1), else_=0)).label("female"), func.sum(case((Student.gender.notin_((1, 2)), 1), else_=0)).label("other"), ) .select_from(Clazz) .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) .where(Clazz.is_del == 0) .group_by(Clazz.id, Clazz.class_no, Clazz.name, Clazz.status, Clazz.head_teacher_id) .order_by(Clazz.id.asc()) ) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def age_distribution(cls, db: Session) -> list[dict]: """年龄段分布(给饼图用)。""" age_col = age_expression(Student.birth_date).label("age") stmt = ( select(age_col, func.count(Student.id)) .where(Student.alive(), Student.birth_date.is_not(None)) .group_by(age_col) .order_by(age_col) ) return [{"age": int(a), "count": c} for a, c in db.execute(stmt).all()] # ================================================================ 2.6.2 @classmethod def all_above(cls, db: Session, threshold: float, class_id: int | None = None) -> list[dict]: """每场考核都在分数线以上的学生:GROUP BY + HAVING MIN(score) >= 阈值。""" agg = ( select( Score.stu_id.label("stu_id"), func.count(Score.id).label("exam_count"), func.min(Score.score).label("min_score"), func.avg(Score.score).label("avg_score"), func.max(Score.score).label("max_score"), ) .where(Score.alive()) .group_by(Score.stu_id) .having(func.min(Score.score) >= threshold) .subquery() ) stmt = ( select( Student.id.label("stu_id"), Student.stu_no, Student.name, Clazz.name.label("class_name"), agg.c.exam_count, agg.c.min_score, agg.c.avg_score, agg.c.max_score, ) .join(agg, agg.c.stu_id == Student.id) .outerjoin(Clazz, Clazz.id == Student.class_id) .where(Student.alive()) .order_by(agg.c.avg_score.desc()) ) if class_id: stmt = stmt.where(Student.class_id == class_id) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def fail_students(cls, db: Session, threshold: float, min_times: int) -> list[dict]: """不及格次数 >= N 的学生:SUM(CASE WHEN score < 阈值 THEN 1 ELSE 0 END) >= N。""" fail_flag = case((Score.score < threshold, 1), else_=0) agg = ( select( Score.stu_id.label("stu_id"), func.sum(fail_flag).label("fail_times"), func.count(Score.id).label("exam_count"), func.avg(Score.score).label("avg_score"), ) .where(Score.alive()) .group_by(Score.stu_id) .having(func.sum(fail_flag) >= min_times) .subquery() ) stmt = ( select( Student.id.label("stu_id"), Student.stu_no, Student.name, Clazz.name.label("class_name"), Clazz.class_no, agg.c.fail_times, agg.c.exam_count, agg.c.avg_score, ) .join(agg, agg.c.stu_id == Student.id) .outerjoin(Clazz, Clazz.id == Student.class_id) .where(Student.alive()) .order_by(agg.c.fail_times.desc()) ) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def class_exam_avg( cls, db: Session, exam_seq: int | None = None, class_id: int | None = None, order: str = "desc", pass_line: float = 60.0, ) -> list[dict]: """每场考核每个班级的平均分,支持按平均分动态升降序。 排序:`order` 是**主导**键 —— 需求 2.6.2 要的是「按分数从高到低/从低到高动态排序」, 如果让 exam_seq 排在前面,整张表就只是「每个场次内部有序」, 选「全部场次 + 从高到低」时看到的不是排名而是 5 个分块。 场次和班级 ID 只作次级稳定键。 """ pass_flag = case((Score.score >= pass_line, 1), else_=0) avg_col = func.avg(Score.score).label("avg_score") stmt = ( select( Score.exam_seq, Clazz.id.label("class_id"), Clazz.name.label("class_name"), Clazz.class_no, func.count(Score.id).label("student_count"), avg_col, func.max(Score.score).label("max_score"), func.min(Score.score).label("min_score"), (func.sum(pass_flag) * 100.0 / func.count(Score.id)).label("pass_rate"), ) .join(Student, Student.id == Score.stu_id) .join(Clazz, Clazz.id == Student.class_id) .where(Score.alive(), Student.alive(), Clazz.alive()) ) if exam_seq is not None: stmt = stmt.where(Score.exam_seq == exam_seq) if class_id is not None: stmt = stmt.where(Clazz.id == class_id) stmt = stmt.group_by(Score.exam_seq, Clazz.id, Clazz.name, Clazz.class_no) stmt = stmt.order_by( avg_col.desc() if order.lower() == "desc" else avg_col.asc(), Score.exam_seq.asc(), Clazz.id.asc(), ) return [dict(row._mapping) for row in db.execute(stmt).all()] # ================================================================ 2.6.3 @classmethod def salary_top(cls, db: Session, top_n: int) -> list[dict]: """就业薪资 Top N:ORDER BY salary DESC LIMIT N。""" stmt = ( select( Student.id.label("stu_id"), Student.stu_no, Student.name, Clazz.name.label("class_name"), Employment.company, Employment.salary, Employment.open_date, Employment.offer_date, Employment.city, ) .join(Employment, Employment.stu_id == Student.id) .outerjoin(Clazz, Clazz.id == Student.class_id) .where(Student.alive(), Employment.alive(), Employment.salary.is_not(None)) .order_by(Employment.salary.desc()) .limit(top_n) ) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def student_durations(cls, db: Session, class_id: int | None = None) -> list[dict]: """每个学生的就业时长(SQL 侧算天数)。""" duration = date_diff_days_expr(Employment.offer_date, Employment.open_date).label("duration_days") stmt = ( select( Student.id.label("stu_id"), Student.stu_no, Student.name, Clazz.name.label("class_name"), Employment.company, Employment.open_date, Employment.offer_date, duration, ) .join(Employment, Employment.stu_id == Student.id) .outerjoin(Clazz, Clazz.id == Student.class_id) .where(Student.alive(), Employment.alive(), Employment.open_date.is_not(None)) .order_by(duration.desc()) ) if class_id: stmt = stmt.where(Student.class_id == class_id) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def class_avg_duration(cls, db: Session) -> list[dict]: """每个班级的平均就业时长(只统计已开放就业的学生)。""" duration = date_diff_days_expr(Employment.offer_date, Employment.open_date) stmt = ( select( Clazz.id.label("class_id"), Clazz.class_no, Clazz.name.label("class_name"), func.count(distinct(Student.id)).label("student_count"), func.count(distinct(case((Employment.open_date.is_not(None), Student.id)))).label("open_count"), func.count(distinct(case((Employment.offer_date.is_not(None), Student.id)))).label("offer_count"), func.avg(case((Employment.offer_date.is_not(None), duration))).label("avg_duration_days"), func.min(case((Employment.offer_date.is_not(None), duration))).label("min_duration_days"), func.max(case((Employment.offer_date.is_not(None), duration))).label("max_duration_days"), ) .select_from(Clazz) .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) .outerjoin(Employment, (Employment.stu_id == Student.id) & (Employment.is_del == 0)) .where(Clazz.is_del == 0) .group_by(Clazz.id, Clazz.class_no, Clazz.name) .order_by(func.avg(case((Employment.offer_date.is_not(None), duration)))) ) return [dict(row._mapping) for row in db.execute(stmt).all()] # ================================================================ 2.7.2 @classmethod def class_funnel(cls, db: Session, high_line: float) -> list[dict]: """班级就业漏斗:总人数 -> 已就业 -> 高薪,就业率在 Python 里算完排序。""" stmt = ( select( Clazz.id.label("class_id"), Clazz.class_no, Clazz.name.label("class_name"), func.count(distinct(Student.id)).label("total"), func.count(distinct(case((Employment.offer_date.is_not(None), Student.id)))).label("employed"), func.count(distinct(case((Employment.salary >= high_line, Student.id)))).label("high_salary"), func.avg(case((Employment.offer_date.is_not(None), Employment.salary))).label("avg_salary"), func.avg( case( (Employment.offer_date.is_not(None), date_diff_days_expr(Employment.offer_date, Employment.open_date)), ) ).label("avg_duration_days"), ) .select_from(Clazz) .outerjoin(Student, (Student.class_id == Clazz.id) & (Student.is_del == 0)) .outerjoin(Employment, (Employment.stu_id == Student.id) & (Employment.is_del == 0)) .where(Clazz.is_del == 0) .group_by(Clazz.id, Clazz.class_no, Clazz.name) ) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def score_stats_per_student(cls, db: Session) -> list[dict]: """每个学生的成绩次数 / 平均 / 最高 / 最低 / 最大分差(SQL 聚合)。""" stmt = ( select( Score.stu_id, func.count(Score.id).label("exam_count"), func.avg(Score.score).label("avg_score"), func.max(Score.score).label("max_score"), func.min(Score.score).label("min_score"), (func.max(Score.score) - func.min(Score.score)).label("score_range"), ) .where(Score.alive()) .group_by(Score.stu_id) .having(func.count(Score.id) >= 2) # 只有一次成绩谈不上波动 ) return [dict(row._mapping) for row in db.execute(stmt).all()] @classmethod def score_samples(cls, db: Session, stu_ids: list[int] | None = None) -> dict[int, list[dict]]: """按学生取成绩明细(按序次升序),一次查完。""" stmt = select(Score.stu_id, Score.exam_seq, Score.score, Score.flag).where(Score.alive()) if stu_ids: stmt = stmt.where(Score.stu_id.in_(stu_ids)) stmt = stmt.order_by(Score.stu_id, Score.exam_seq) result: dict[int, list[dict]] = {} for stu_id, seq, score, flag in db.execute(stmt).all(): result.setdefault(stu_id, []).append( {"exam_seq": int(seq), "score": float(score), "flag": int(flag)} ) return result # ================================================================ 总览 @classmethod def teacher_class_load(cls, db: Session) -> list[dict]: """老师带班数量(给概览用)。""" stmt = ( select(class_teachers.c.teacher_id, func.count(class_teachers.c.class_id)) .select_from(class_teachers.join(Clazz, Clazz.id == class_teachers.c.class_id)) .where(Clazz.alive()) .group_by(class_teachers.c.teacher_id) ) return [{"teacher_id": t, "class_count": c} for t, c in db.execute(stmt).all()]