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

368 lines
15 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.
"""统计分析业务层(需求 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],
}