163 lines
6.6 KiB
Python
163 lines
6.6 KiB
Python
"""统计分析接口(需求 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))
|