151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""成绩业务规则(需求 2.2):红线预警、按序次唯一、逻辑删除后复活。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.exceptions import BusinessError, ConflictError, NotFoundError
|
||
from app.core.utils import parse_date
|
||
from app.dao.score_dao import ScoreDao
|
||
from app.dao.student_dao import StudentDao
|
||
from app.model import Score, Student
|
||
from app.schema.score_schema import ScoreCreate, ScoreUpdate
|
||
|
||
|
||
class ScoreService:
|
||
# ================================================================ 红线
|
||
@staticmethod
|
||
def check_warning(score: Decimal | float) -> tuple[int, str | None]:
|
||
"""低于红线返回 (1, 提示文案)。阈值来自配置,不写死 60。"""
|
||
value = float(score)
|
||
if value < settings.SCORE_WARN_LINE:
|
||
if value < 30:
|
||
level = "严重偏低"
|
||
elif value < settings.SCORE_WARN_LINE:
|
||
level = "低于及格线"
|
||
else:
|
||
level = "偏低"
|
||
msg = (
|
||
f"成绩 {value:g} 分{level}(红线 {settings.SCORE_WARN_LINE:g} 分),"
|
||
f"已标记为需重点关注"
|
||
)
|
||
return 1, msg
|
||
return 0, None
|
||
|
||
# ================================================================ 录入
|
||
@classmethod
|
||
def create(cls, db: Session, payload: ScoreCreate) -> tuple[Score, bool, str | None]:
|
||
student = StudentDao.get(db, payload.stu_id)
|
||
if student is None:
|
||
raise NotFoundError(f"学生不存在(id={payload.stu_id})")
|
||
|
||
exam_date = parse_date(payload.exam_date, "考核日期")
|
||
flag, warning_msg = cls.check_warning(payload.score)
|
||
|
||
# (学生, 序次) 是唯一键:已存在就报冲突并告诉调用方走修改;
|
||
# 若是被逻辑删除过的老记录,直接"复活"它,避免唯一键撞车。
|
||
existing = ScoreDao.get_by_stu_seq(db, payload.stu_id, payload.exam_seq, with_deleted=True)
|
||
if existing is not None:
|
||
if existing.is_del == 0:
|
||
raise ConflictError(
|
||
f"{student.name} 的第 {payload.exam_seq} 次考核成绩已存在"
|
||
f"({float(existing.score):g} 分),请改用修改接口"
|
||
)
|
||
existing.is_del = 0
|
||
existing.score = payload.score
|
||
existing.flag = flag
|
||
existing.exam_name = payload.exam_name
|
||
existing.exam_date = exam_date
|
||
existing.remark = payload.remark
|
||
db.flush()
|
||
return existing, flag == 1, warning_msg
|
||
|
||
score = Score(
|
||
stu_id=payload.stu_id,
|
||
exam_seq=payload.exam_seq,
|
||
exam_name=payload.exam_name,
|
||
exam_date=exam_date,
|
||
score=payload.score,
|
||
flag=flag,
|
||
remark=payload.remark,
|
||
)
|
||
db.add(score)
|
||
db.flush()
|
||
|
||
if flag == 1:
|
||
cls._append_student_note(student, f"第{payload.exam_seq}次考核 {float(payload.score):g} 分")
|
||
return score, flag == 1, warning_msg
|
||
|
||
@classmethod
|
||
def batch_create(cls, db: Session, stu_id: int, items: list[ScoreCreate]) -> list[tuple[Score, bool, str | None]]:
|
||
results = []
|
||
for item in items:
|
||
if item.stu_id != stu_id:
|
||
raise BusinessError("批量录入的成绩必须属于同一个学生")
|
||
results.append(cls.create(db, item))
|
||
return results
|
||
|
||
# ================================================================ 修改
|
||
@classmethod
|
||
def update(cls, db: Session, score: Score, payload: ScoreUpdate) -> tuple[Score, bool, str | None]:
|
||
data = payload.model_dump(exclude_unset=True)
|
||
warning_msg = None
|
||
flag = score.flag
|
||
|
||
if data.get("score") is not None:
|
||
score.score = data["score"]
|
||
flag, warning_msg = cls.check_warning(score.score)
|
||
score.flag = flag
|
||
if "exam_name" in data:
|
||
score.exam_name = data["exam_name"]
|
||
if "exam_date" in data:
|
||
score.exam_date = parse_date(data["exam_date"], "考核日期")
|
||
if "remark" in data:
|
||
score.remark = data["remark"]
|
||
|
||
db.flush()
|
||
if flag == 1 and score.student is not None:
|
||
cls._append_student_note(score.student, f"第{score.exam_seq}次考核 {float(score.score):g} 分")
|
||
return score, flag == 1, warning_msg
|
||
|
||
# ================================================================ 删除
|
||
@classmethod
|
||
def delete(cls, db: Session, score: Score) -> None:
|
||
score.soft_delete()
|
||
db.flush()
|
||
|
||
# ================================================================ 辅助
|
||
@staticmethod
|
||
def _append_student_note(student: Student, note: str) -> None:
|
||
"""把预警写进学生备注,顾问打开学生列表就能看到。"""
|
||
tag = f"[{note}]"
|
||
if student.remark and tag in student.remark:
|
||
return
|
||
prefix = "成绩预警:"
|
||
student.remark = f"{student.remark} {prefix}{note}".strip() if student.remark else f"{prefix}{note}"
|
||
|
||
@staticmethod
|
||
def student_summary(db: Session, stu_id: int) -> dict:
|
||
scores = ScoreDao.list_by_student(db, stu_id)
|
||
values = [float(s.score) for s in scores]
|
||
return {
|
||
"count": len(values),
|
||
"avg": round(sum(values) / len(values), 2) if values else None,
|
||
"max": max(values) if values else None,
|
||
"min": min(values) if values else None,
|
||
"fail": len([s for s in scores if s.flag == 1]),
|
||
"scores": [
|
||
{
|
||
"exam_seq": s.exam_seq,
|
||
"exam_name": s.exam_name,
|
||
"score": float(s.score),
|
||
"flag": s.flag,
|
||
"exam_date": s.exam_date.isoformat() if s.exam_date else None,
|
||
}
|
||
for s in scores
|
||
],
|
||
}
|