"""成绩业务规则(需求 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 ], }