"""统计分析接口(需求 2.6、2.7.2、4.4),统一挂在 /statistics 前缀下。""" from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Query from app.core.config import settings from app.core.deps import DbSession, ReadAccount from app.core.response import Resp, ok from app.dao.student_dao import StudentDao from app.schema.common import PageResult from app.schema.statistics_schema import ( AllAboveItem, ClassAvgDurationItem, ClassAvgScoreItem, ClassOverviewItem, FailDetailItem, FunnelItem, OverviewOut, SalaryTopItem, StudentDurationItem, VolatilityItem, ) from app.schema.student_schema import StudentOut from app.service.statistics_service import StatisticsService router = APIRouter(prefix="/statistics", tags=["2.6 统计分析"]) # ==================================================================== 2.6.1 @router.get( "/students/by-age", response_model=Resp[PageResult[StudentOut]], summary="2.6.1 动态年龄范围查询(gt/gte/lt/lte/eq/between)", ) def students_by_age( db: DbSession, _: ReadAccount, operator: Annotated[str, Query(description="gt / gte / lt / lte / eq / ne / between")] = "gte", value: Annotated[int | None, Query(ge=1, le=100, description="年龄阈值")] = None, value2: Annotated[int | None, Query(ge=1, le=100, description="between 时的第二个值")] = None, class_id: Annotated[int | None, Query()] = None, gender: Annotated[int | None, Query(ge=1, le=2)] = None, status: Annotated[int | None, Query(ge=1, le=3)] = None, advisor_id: Annotated[int | None, Query()] = None, education: Annotated[str | None, Query()] = None, page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1, le=200)] = 10, order_by: Annotated[str, Query(description="支持按 age 排序")] = "age", order: Annotated[str, Query(pattern="^(asc|desc)$")] = "asc", ): stmt = StatisticsService.age_query( db, operator, value, value2, class_id=class_id, gender=gender, status=status, advisor_id=advisor_id, education=education, order_by=order_by, order=order, ) items, total, page, pages = StudentDao.paginate(db, stmt, page, page_size) return ok({ "total": total, "page": page, "page_size": page_size, "pages": pages, "items": [StudentOut.model_validate(s) for s in items], }) @router.get("/classes/overview", response_model=Resp[list[ClassOverviewItem]], summary="2.6.1 多维度班级统计(人数 + 性别分布)") def class_overview(db: DbSession, _: ReadAccount): return ok(StatisticsService.class_overview(db)) @router.get("/students/age-distribution", summary="年龄分布(图表用)") def age_distribution(db: DbSession, _: ReadAccount): from app.dao.statistics_dao import StatisticsDao return ok(StatisticsDao.age_distribution(db)) # ==================================================================== 2.6.2 @router.get("/scores/all-above", response_model=Resp[list[AllAboveItem]], summary="2.6.2 每次考核都在分数线以上的学生") def scores_all_above( db: DbSession, _: ReadAccount, threshold: Annotated[float, Query(ge=0, le=1000, description="分数线")] = 80, class_id: Annotated[int | None, Query()] = None, ): rows = StatisticsService.all_above(db, threshold, class_id) return ok(rows, msg=f"{threshold:g} 分以上(每次考核都达标)的学生共 {len(rows)} 人") @router.get("/scores/failures", response_model=Resp[list[FailDetailItem]], summary="2.6.2 不及格次数达到 N 次的学生及明细") def score_failures( db: DbSession, _: ReadAccount, threshold: Annotated[float, Query(ge=0, le=1000, description="不及格线")] = 60, min_times: Annotated[int, Query(ge=1, le=50, description="至少不及格几次")] = 2, ): return ok(StatisticsService.fail_students(db, threshold, min_times)) @router.get("/scores/class-average", response_model=Resp[list[ClassAvgScoreItem]], summary="2.6.2 每场考核每个班级的平均分(可动态排序)") def class_average( db: DbSession, _: ReadAccount, exam_seq: Annotated[int | None, Query(ge=1, description="只看某一场;不传=全部场次")] = None, class_id: Annotated[int | None, Query()] = None, order: Annotated[str, Query(pattern="^(asc|desc)$", description="按平均分升降序")] = "desc", pass_line: Annotated[float | None, Query(ge=0, le=1000, description="及格线,用于算及格率")] = None, ): return ok( StatisticsService.class_exam_avg( db, exam_seq, class_id, order, pass_line if pass_line is not None else settings.SCORE_PASS_LINE ) ) # ==================================================================== 2.6.3 @router.get("/employment/top-salary", response_model=Resp[list[SalaryTopItem]], summary="2.6.3 就业薪资 Top N") def top_salary( db: DbSession, _: ReadAccount, top_n: Annotated[int, Query(ge=1, le=100, description="取前几名")] = 5, ): return ok(StatisticsService.salary_top(db, top_n)) @router.get("/employment/durations", response_model=Resp[list[StudentDurationItem]], summary="2.6.3 每个学生的就业时长") def student_durations( db: DbSession, _: ReadAccount, class_id: Annotated[int | None, Query()] = None, ): return ok(StatisticsService.student_durations(db, class_id)) @router.get("/employment/class-avg-duration", response_model=Resp[list[ClassAvgDurationItem]], summary="2.6.3 每个班级的平均就业时长") def class_avg_duration(db: DbSession, _: ReadAccount): return ok(StatisticsService.class_avg_duration(db)) # ==================================================================== 2.7.2 @router.get("/scores/volatility", response_model=Resp[list[VolatilityItem]], summary="2.7.2 成绩波动最大的 Top N(标准差 / 最大分差)") def score_volatility( db: DbSession, _: ReadAccount, top_n: Annotated[int, Query(ge=1, le=50)] = 5, metric: Annotated[str, Query(description="stddev=标准差,range=最大分差")] = "stddev", ): return ok(StatisticsService.score_volatility(db, top_n, metric)) @router.get("/employment/funnel", response_model=Resp[list[FunnelItem]], summary="2.7.2 班级就业漏斗(按就业率降序)") def employment_funnel( db: DbSession, _: ReadAccount, high_line: Annotated[float | None, Query(ge=0, description="高薪线,默认取配置值")] = None, ): return ok(StatisticsService.employment_funnel(db, high_line)) @router.get("/overview", response_model=Resp[OverviewOut], summary="仪表盘总览") def overview(db: DbSession, _: ReadAccount): return ok(StatisticsService.overview(db))