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