368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""统计分析业务层(需求 2.6 / 2.7.2)。
|
||||
|
|
|
|||
|
|
DAO 出聚合结果,这里做二次计算:排名、比率、标准差、趋势、四舍五入。
|
|||
|
|
这样 SQL 保持"只取数",业务口径全部集中在 Python 里,改口径不用改 SQL。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import statistics
|
|||
|
|
from statistics import StatisticsError
|
|||
|
|
|
|||
|
|
from sqlalchemy import func, or_, select
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.core.config import settings
|
|||
|
|
from app.core.exceptions import BusinessError
|
|||
|
|
from app.core.utils import age_expression
|
|||
|
|
from app.dao.advisor_dao import AdvisorDao
|
|||
|
|
from app.dao.clazz_dao import ClazzDao
|
|||
|
|
from app.dao.employment_dao import EmploymentDao
|
|||
|
|
from app.dao.score_dao import ScoreDao
|
|||
|
|
from app.dao.statistics_dao import StatisticsDao
|
|||
|
|
from app.dao.student_dao import StudentDao
|
|||
|
|
from app.dao.teacher_dao import TeacherDao
|
|||
|
|
from app.model import Score, Student
|
|||
|
|
from app.model.constants import CLASS_STATUS_TEXT, STUDENT_STATUS_TEXT
|
|||
|
|
|
|||
|
|
ROUND = 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _r(value, digits: int = ROUND):
|
|||
|
|
return round(value, digits) if value is not None else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class StatisticsService:
|
|||
|
|
# ============================================================ 2.6.1
|
|||
|
|
@classmethod
|
|||
|
|
def age_query(
|
|||
|
|
cls,
|
|||
|
|
db: Session,
|
|||
|
|
operator: str,
|
|||
|
|
value: int | None = None,
|
|||
|
|
value2: int | None = None,
|
|||
|
|
**filters,
|
|||
|
|
):
|
|||
|
|
"""把"大于/小于/等于/区间"翻译成 age_min / age_max 两个参数。"""
|
|||
|
|
op = (operator or "gte").strip().lower()
|
|||
|
|
age_min = age_max = None
|
|||
|
|
|
|||
|
|
if op in ("gt", ">"):
|
|||
|
|
age_min = (value or 0) + 1
|
|||
|
|
elif op in ("gt_eq", "gte", ">="):
|
|||
|
|
age_min = value
|
|||
|
|
elif op in ("lt", "<"):
|
|||
|
|
age_max = (value or 0) - 1
|
|||
|
|
elif op in ("lt_eq", "lte", "<="):
|
|||
|
|
age_max = value
|
|||
|
|
elif op in ("eq", "=", "=="):
|
|||
|
|
age_min = age_max = value
|
|||
|
|
elif op in ("ne", "!="):
|
|||
|
|
# "不等于"用区间实现:<= v-1 或 >= v+1
|
|||
|
|
pass
|
|||
|
|
elif op in ("between", "range"):
|
|||
|
|
if value is None or value2 is None:
|
|||
|
|
raise BusinessError("区间查询需要同时提供 value 和 value2")
|
|||
|
|
age_min, age_max = min(value, value2), max(value, value2)
|
|||
|
|
else:
|
|||
|
|
raise BusinessError(
|
|||
|
|
"operator 只支持 gt/gte/lt/lte/eq/between(也接受 > >= < <= = 写法)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if age_min is None and age_max is None and op not in ("ne",):
|
|||
|
|
raise BusinessError("请提供年龄阈值 value")
|
|||
|
|
|
|||
|
|
stmt = StudentDao.build_stmt(age_min=age_min, age_max=age_max, **filters)
|
|||
|
|
if op == "ne":
|
|||
|
|
age_col = age_expression(Student.birth_date)
|
|||
|
|
stmt = stmt.where(or_(age_col < value, age_col > value))
|
|||
|
|
return stmt
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def class_overview(cls, db: Session) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.class_overview(db)
|
|||
|
|
teacher_map = {t.id: t.name for t in TeacherDao.all(db, TeacherDao.build_stmt())}
|
|||
|
|
result = []
|
|||
|
|
for row in rows:
|
|||
|
|
total = int(row["total"] or 0)
|
|||
|
|
male = int(row["male"] or 0)
|
|||
|
|
female = int(row["female"] or 0)
|
|||
|
|
result.append(
|
|||
|
|
{
|
|||
|
|
"class_id": row["id"],
|
|||
|
|
"class_no": row["class_no"],
|
|||
|
|
"class_name": row["name"],
|
|||
|
|
"status_text": {1: "在读", 2: "已结课", 3: "已解散"}.get(row["status"]),
|
|||
|
|
"head_teacher_name": None,
|
|||
|
|
"total": total,
|
|||
|
|
"male": male,
|
|||
|
|
"female": female,
|
|||
|
|
"other": total - male - female,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
# 补班主任名字(一次查全表映射,避免 N+1)
|
|||
|
|
for item, row in zip(result, rows):
|
|||
|
|
item["head_teacher_name"] = teacher_map.get(row.get("head_teacher_id")) if "head_teacher_id" in row else None
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# ============================================================ 2.6.2
|
|||
|
|
@classmethod
|
|||
|
|
def all_above(cls, db: Session, threshold: float, class_id: int | None = None) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.all_above(db, threshold, class_id)
|
|||
|
|
samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in rows])
|
|||
|
|
for row in rows:
|
|||
|
|
row["min_score"] = _r(row["min_score"])
|
|||
|
|
row["avg_score"] = _r(row["avg_score"])
|
|||
|
|
row["scores"] = samples.get(row["stu_id"], [])
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def fail_students(cls, db: Session, threshold: float, min_times: int) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.fail_students(db, threshold, min_times)
|
|||
|
|
samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in rows])
|
|||
|
|
for row in rows:
|
|||
|
|
row["avg_score"] = _r(row["avg_score"])
|
|||
|
|
row["fail_details"] = [
|
|||
|
|
s for s in samples.get(row["stu_id"], []) if s["score"] < threshold
|
|||
|
|
]
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def class_exam_avg(
|
|||
|
|
cls, db: Session, exam_seq: int | None, class_id: int | None, order: str, pass_line: float
|
|||
|
|
) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.class_exam_avg(db, exam_seq, class_id, order, pass_line)
|
|||
|
|
for row in rows:
|
|||
|
|
row["avg_score"] = _r(row["avg_score"])
|
|||
|
|
row["max_score"] = _r(row["max_score"])
|
|||
|
|
row["min_score"] = _r(row["min_score"])
|
|||
|
|
row["pass_rate"] = _r(row["pass_rate"], 1)
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
# ============================================================ 2.6.3
|
|||
|
|
@classmethod
|
|||
|
|
def salary_top(cls, db: Session, top_n: int) -> list[dict]:
|
|||
|
|
if top_n <= 0:
|
|||
|
|
raise BusinessError("top_n 必须为正整数")
|
|||
|
|
rows = StatisticsDao.salary_top(db, top_n)
|
|||
|
|
result = []
|
|||
|
|
for index, row in enumerate(rows, start=1):
|
|||
|
|
salary = float(row["salary"]) if row["salary"] is not None else 0.0
|
|||
|
|
duration = None
|
|||
|
|
if row["open_date"] and row["offer_date"]:
|
|||
|
|
duration = (row["offer_date"] - row["open_date"]).days
|
|||
|
|
result.append(
|
|||
|
|
{
|
|||
|
|
"rank": index,
|
|||
|
|
"stu_id": row["stu_id"],
|
|||
|
|
"stu_no": row["stu_no"],
|
|||
|
|
"name": row["name"],
|
|||
|
|
"class_name": row["class_name"],
|
|||
|
|
"company": row["company"],
|
|||
|
|
"salary": salary,
|
|||
|
|
"salary_wan": _r(salary / 10000),
|
|||
|
|
"offer_date": row["offer_date"],
|
|||
|
|
"duration_days": duration,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def student_durations(cls, db: Session, class_id: int | None = None) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.student_durations(db, class_id)
|
|||
|
|
for row in rows:
|
|||
|
|
row["duration_days"] = int(row["duration_days"]) if row["duration_days"] is not None else None
|
|||
|
|
row["state"] = "已拿到 offer" if row["offer_date"] else "就业中(未收到 offer)"
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def class_avg_duration(cls, db: Session) -> list[dict]:
|
|||
|
|
rows = StatisticsDao.class_avg_duration(db)
|
|||
|
|
result = []
|
|||
|
|
for row in rows:
|
|||
|
|
open_count = int(row["open_count"] or 0)
|
|||
|
|
result.append(
|
|||
|
|
{
|
|||
|
|
"class_id": row["class_id"],
|
|||
|
|
"class_name": row["class_name"],
|
|||
|
|
"student_count": int(row["student_count"] or 0),
|
|||
|
|
"open_count": open_count,
|
|||
|
|
"offer_count": int(row["offer_count"] or 0),
|
|||
|
|
"avg_duration_days": _r(row["avg_duration_days"], 1),
|
|||
|
|
"min_duration_days": int(row["min_duration_days"]) if row["min_duration_days"] is not None else None,
|
|||
|
|
"max_duration_days": int(row["max_duration_days"]) if row["max_duration_days"] is not None else None,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
# 平均时长按"有数据"的排前面,没数据的沉底
|
|||
|
|
result.sort(key=lambda x: (x["avg_duration_days"] is None, -(x["avg_duration_days"] or 0)))
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# ============================================================ 2.7.2
|
|||
|
|
@classmethod
|
|||
|
|
def score_volatility(cls, db: Session, top_n: int = 5, metric: str = "stddev") -> list[dict]:
|
|||
|
|
"""成绩波动 Top N。
|
|||
|
|
|
|||
|
|
标准差在 Python 里算(statistics.pstdev),最大分差在 SQL 里算,
|
|||
|
|
两种方式都在这一份代码里做了,答辩时可以直接对比口径差异。
|
|||
|
|
"""
|
|||
|
|
stats_rows = StatisticsDao.score_stats_per_student(db)
|
|||
|
|
if not stats_rows:
|
|||
|
|
return []
|
|||
|
|
samples = StatisticsDao.score_samples(db, [r["stu_id"] for r in stats_rows])
|
|||
|
|
|
|||
|
|
student_map = {
|
|||
|
|
s.id: s
|
|||
|
|
for s in StudentDao.all(db, StudentDao.build_stmt(order_by="id", order="asc"))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
items = []
|
|||
|
|
for row in stats_rows:
|
|||
|
|
values = [s["score"] for s in samples.get(row["stu_id"], [])]
|
|||
|
|
if len(values) < 2:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
stddev = statistics.pstdev(values)
|
|||
|
|
except StatisticsError:
|
|||
|
|
stddev = 0.0
|
|||
|
|
score_range = float(row["score_range"] or 0)
|
|||
|
|
faculty = {
|
|||
|
|
"stddev": stddev,
|
|||
|
|
"range": score_range,
|
|||
|
|
"max_minus_min": score_range,
|
|||
|
|
}
|
|||
|
|
if metric not in faculty:
|
|||
|
|
raise BusinessError("metric 只支持 stddev(标准差)或 range(最大分差)")
|
|||
|
|
trend_slope = _r(cls._trend(values)) or 0.0 # 先定稿到出参精度,再据它判方向
|
|||
|
|
student = student_map.get(row["stu_id"])
|
|||
|
|
items.append(
|
|||
|
|
{
|
|||
|
|
"stu_id": row["stu_id"],
|
|||
|
|
"stu_no": student.stu_no if student else None,
|
|||
|
|
"name": student.name if student else None,
|
|||
|
|
"class_name": student.class_name if student else None,
|
|||
|
|
"exam_count": int(row["exam_count"]),
|
|||
|
|
"avg_score": _r(row["avg_score"]),
|
|||
|
|
"stddev": _r(stddev),
|
|||
|
|
"score_range": _r(score_range),
|
|||
|
|
"trend": cls._trend_text(trend_slope, len(values)),
|
|||
|
|
"trend_slope": trend_slope,
|
|||
|
|
"scores": values,
|
|||
|
|
"_key": faculty[metric],
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
items.sort(key=lambda x: x["_key"], reverse=True)
|
|||
|
|
for index, item in enumerate(items[:top_n], start=1):
|
|||
|
|
item["rank"] = index
|
|||
|
|
item.pop("_key", None)
|
|||
|
|
return items[:top_n]
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _trend(values: list[float]) -> float:
|
|||
|
|
"""成绩趋势:对「考核序次」做最小二乘拟合,返回斜率(分/场)。
|
|||
|
|
|
|||
|
|
为什么不用「首次 vs 末次」:单次考试成绩噪声很大,首末两点一比,
|
|||
|
|
89 -> 88 这种正常抖动会被说成"下降",而 32 -> 82 这种真实回升又可能因为
|
|||
|
|
末次恰好回落而被说成"下降"。
|
|||
|
|
为什么不用「前半段 vs 后半段均值的差」:那是阶跃比较,看不出变化幅度,
|
|||
|
|
也没法排序。而且它对 5 次成绩的分段是不均的(前 2 后 3),
|
|||
|
|
会出现"首考 87.8、末考 82.0 却判为上升"这种让人不信服的结果。
|
|||
|
|
|
|||
|
|
最小二乘斜率是趋势的标准定义:用上全部数据点、抗噪声、可正可负、单位可解释。
|
|||
|
|
"""
|
|||
|
|
n = len(values)
|
|||
|
|
if n < 2:
|
|||
|
|
return 0.0
|
|||
|
|
x_mean = (n - 1) / 2
|
|||
|
|
y_mean = sum(values) / n
|
|||
|
|
numerator = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(values))
|
|||
|
|
denominator = sum((i - x_mean) ** 2 for i in range(n))
|
|||
|
|
if denominator == 0:
|
|||
|
|
return 0.0
|
|||
|
|
return numerator / denominator
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _trend_text(slope: float, n: int) -> str:
|
|||
|
|
"""把斜率翻成中文。阈值 1 分/场:一学期 5 次考核看下来涨跌不到 4 分,算原地踏步。
|
|||
|
|
|
|||
|
|
注意:调用方必须先把斜率四舍五入到出参精度(2 位小数)再传进来,
|
|||
|
|
否则会出现「标签写 基本持平、旁边数字写 +1.0/场」这种自相矛盾的显示。
|
|||
|
|
"""
|
|||
|
|
if n < 2:
|
|||
|
|
return "数据不足"
|
|||
|
|
if slope >= 1.0:
|
|||
|
|
return "上升"
|
|||
|
|
if slope <= -1.0:
|
|||
|
|
return "下降"
|
|||
|
|
return "基本持平"
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def employment_funnel(cls, db: Session, high_line: float | None = None) -> list[dict]:
|
|||
|
|
high_line = high_line or settings.HIGH_SALARY_LINE
|
|||
|
|
rows = StatisticsDao.class_funnel(db, high_line)
|
|||
|
|
result = []
|
|||
|
|
for row in rows:
|
|||
|
|
total = int(row["total"] or 0)
|
|||
|
|
employed = int(row["employed"] or 0)
|
|||
|
|
high = int(row["high_salary"] or 0)
|
|||
|
|
result.append(
|
|||
|
|
{
|
|||
|
|
"class_id": row["class_id"],
|
|||
|
|
"class_no": row["class_no"],
|
|||
|
|
"class_name": row["class_name"],
|
|||
|
|
"total": total,
|
|||
|
|
"employed": employed,
|
|||
|
|
"high_salary": high,
|
|||
|
|
"employment_rate": _r(employed / total * 100, 1) if total else 0.0,
|
|||
|
|
"high_salary_rate": _r(high / employed * 100, 1) if employed else 0.0,
|
|||
|
|
"avg_salary": _r(row["avg_salary"]),
|
|||
|
|
"avg_duration_days": _r(row["avg_duration_days"], 1),
|
|||
|
|
"high_line": high_line,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
result.sort(key=lambda x: x["employment_rate"], reverse=True)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
# ============================================================ 总览
|
|||
|
|
@classmethod
|
|||
|
|
def overview(cls, db: Session) -> dict:
|
|||
|
|
status_map = StudentDao.count_by_status(db)
|
|||
|
|
student_total = sum(status_map.values())
|
|||
|
|
|
|||
|
|
emp_stats = EmploymentDao.salary_stats(db, settings.HIGH_SALARY_LINE)
|
|||
|
|
|
|||
|
|
score_total = int(db.scalar(select(func.count(Score.id)).where(Score.alive())) or 0)
|
|||
|
|
score_avg = db.scalar(select(func.avg(Score.score)).where(Score.alive()))
|
|||
|
|
warning_ids = StudentDao.warning_student_ids(db)
|
|||
|
|
|
|||
|
|
funnel = cls.employment_funnel(db)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"student_total": student_total,
|
|||
|
|
"student_by_status": {
|
|||
|
|
STUDENT_STATUS_TEXT.get(code, str(code)): count
|
|||
|
|
for code, count in sorted(status_map.items())
|
|||
|
|
},
|
|||
|
|
"class_total": ClazzDao.count(db, ClazzDao.build_stmt()),
|
|||
|
|
"teacher_total": TeacherDao.count(db, TeacherDao.build_stmt()),
|
|||
|
|
"advisor_total": AdvisorDao.count(db, AdvisorDao.build_stmt()),
|
|||
|
|
"employment_total": emp_stats["count"],
|
|||
|
|
"employment_rate": _r(emp_stats["count"] / student_total * 100, 1) if student_total else 0.0,
|
|||
|
|
"avg_salary": _r(emp_stats["avg"]),
|
|||
|
|
"high_salary_count": emp_stats["high"],
|
|||
|
|
"score_record_total": score_total,
|
|||
|
|
"score_avg": _r(float(score_avg)) if score_avg is not None else None,
|
|||
|
|
"warning_student_count": len(warning_ids),
|
|||
|
|
"top_classes": [
|
|||
|
|
{
|
|||
|
|
"class_name": item["class_name"],
|
|||
|
|
"class_no": item["class_no"],
|
|||
|
|
"total": item["total"],
|
|||
|
|
"employed": item["employed"],
|
|||
|
|
"employment_rate": item["employment_rate"],
|
|||
|
|
}
|
|||
|
|
for item in funnel[:5]
|
|||
|
|
],
|
|||
|
|
"volatility_top": cls.score_volatility(db, 5)[:5],
|
|||
|
|
}
|