Files
test/app/dao/statistics_dao.py
2026-09-21 19:03:31 +08:00

317 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统计分析 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()]