Files

368 lines
15 KiB
Python
Raw Permalink Normal View History

2026-09-21 19:03:31 +08:00
"""统计分析业务层(需求 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],
}