"""学生业务规则:学号生成、录入校验、转班同步、Excel 批量导入。""" from __future__ import annotations import logging from datetime import date from io import BytesIO from typing import Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.config import settings from app.core.exceptions import BusinessError, ConflictError, NotFoundError from app.core.utils import guess_birth_date, parse_date from app.dao.advisor_dao import AdvisorDao from app.dao.clazz_dao import ClazzDao from app.dao.student_dao import StudentDao from app.model import Student from app.schema.student_schema import StudentCreate, StudentImportResult, StudentUpdate logger = logging.getLogger(__name__) class StudentService: # ================================================================ 学号 @staticmethod def build_stu_no( db: Session, class_id: int | None, enroll_date: date | None, fallback_year: int | None = None ) -> str: """学号规则:前缀(2) + 入学年份(4) + 班级序号(2) + 班内序号(3),共 11 位。 例:WL + 2026 + 01 + 007 -> WL202601007 * 前缀固定 WL(沃林),出问题一眼能看出是哪个系统的学号; * 入学年份取 enroll_date,没填就用当前年; * 班级序号取班级编号尾部两位(JAVA2026**01** -> 01),没班级就 00; * 班内序号在该"年份+班级"范围内自增,不会因为全表自增而穿透班级。 注意:会话是 autoflush=False 的,如果调用方已经 add 了几个学生但没 flush, 这里查 max(stu_no) 是看不见它们的,算出来的序号会撞车。所以先 flush 一次。 """ db.flush() if enroll_date: year = enroll_date.year else: year = fallback_year or date.today().year class_part = "00" if class_id: klass = ClazzDao.get(db, class_id) if klass: digits = "".join(ch for ch in klass.class_no if ch.isdigit()) class_part = digits[-2:].zfill(2) if len(digits) >= 2 else f"{klass.id % 100:02d}" prefix = f"{settings.STU_NO_PREFIX}{year}{class_part}" last = StudentDao.last_stu_no(db, prefix) seq = int(last[len(prefix):]) + 1 if last and last[len(prefix):].isdigit() else 1 # 兜底:万一同前缀下已经有人占了这个号(并发 / 手工导入的历史数据), # 往后顺延,绝不把"学号重复"这种错甩给调用方。 for _ in range(1000): candidate = f"{prefix}{seq:03d}" if StudentDao.get_by_stu_no(db, candidate, with_deleted=True) is None: return candidate seq += 1 raise ConflictError(f"学号前缀 {prefix} 下的编号已用尽,请检查班级编号规则") @staticmethod def ensure_unique_stu_no(db: Session, stu_no: str, exclude_id: int | None = None) -> None: exist = StudentDao.get_by_stu_no(db, stu_no, with_deleted=True) if exist and exist.id != exclude_id: raise ConflictError(f"学号 {stu_no} 已存在") # ================================================================ 创建 @classmethod def create(cls, db: Session, payload: StudentCreate) -> Student: cls._check_class_advisor(db, payload.class_id, payload.advisor_id) birth = parse_date(payload.birth_date, "出生日期") estimated = 0 if birth is None and payload.age is not None: birth = guess_birth_date(payload.age) estimated = 1 enroll = parse_date(payload.enroll_date, "入学时间") graduate = parse_date(payload.graduate_date, "毕业时间") if enroll and graduate and graduate < enroll: raise BusinessError("毕业时间不能早于入学时间") stu_no = (payload.stu_no or "").strip() or cls.build_stu_no(db, payload.class_id, enroll) cls.ensure_unique_stu_no(db, stu_no) student = Student( stu_no=stu_no, name=payload.name, gender=payload.gender, birth_date=birth, birth_date_estimated=estimated, native_place=payload.native_place, graduate_school=payload.graduate_school, major=payload.major, education=payload.education, enroll_date=enroll, graduate_date=graduate, phone=payload.phone, id_card=payload.id_card, class_id=payload.class_id, advisor_id=payload.advisor_id, status=payload.status or 1, remark=payload.remark, ) db.add(student) try: db.flush() except IntegrityError as exc: # 注意:这里**不要** db.rollback()。 # 批量导入时外面套着 SAVEPOINT,一旦整条事务回滚, # 同一次导入里前面已经成功的行会跟着一起没(踩过一次)。 # 交给外层的 savepoint / 请求结束时的会话关闭去处理。 raise ConflictError(f"学号 {stu_no} 冲突,请检查是否重复导入") from exc return student # ================================================================ 更新 @classmethod def update(cls, db: Session, student: Student, payload: StudentUpdate) -> Student: data = payload.model_dump(exclude_unset=True) if "class_id" in data: cls._check_class_advisor(db, data["class_id"], None) if "advisor_id" in data: cls._check_class_advisor(db, None, data["advisor_id"]) old_class_id = student.class_id if "birth_date" in data and data["birth_date"]: student.birth_date = parse_date(data.pop("birth_date"), "出生日期") student.birth_date_estimated = 0 if "age" in data and data["age"] and not student.birth_date: student.birth_date = guess_birth_date(data.pop("age")) student.birth_date_estimated = 1 for field in ("enroll_date", "graduate_date"): if field in data and data[field]: setattr(student, field, parse_date(data.pop(field), field)) if student.enroll_date and student.graduate_date and student.graduate_date < student.enroll_date: raise BusinessError("毕业时间不能早于入学时间") for key, value in data.items(): if value is not None and hasattr(student, key): setattr(student, key, value) db.flush() # 转班了 -> 同步就业表里的冗余班级字段,避免就业统计按旧班算 if "class_id" in payload.model_dump(exclude_unset=True) and student.class_id != old_class_id: from app.service.employment_service import EmploymentService EmploymentService.sync_class_id(db, student) return student @staticmethod def _check_class_advisor(db: Session, class_id: int | None, advisor_id: int | None) -> None: if class_id is not None and ClazzDao.get(db, class_id) is None: raise NotFoundError(f"班级不存在(id={class_id})") if advisor_id is not None and AdvisorDao.get(db, advisor_id) is None: raise NotFoundError(f"顾问不存在(id={advisor_id})") # ================================================================ 删除 @classmethod def delete(cls, db: Session, student: Student) -> None: """逻辑删除学生:成绩、就业一并逻辑删除,避免统计里留下孤儿数据。""" from app.dao.employment_dao import EmploymentDao from app.dao.score_dao import ScoreDao for score in ScoreDao.list_by_student(db, student.id): score.soft_delete() emp = EmploymentDao.get_by_stu_id(db, student.id) if emp: emp.soft_delete() student.soft_delete() db.flush() # ================================================================ Excel 导入 HEADER_MAP: dict[str, str] = { "姓名": "name", "性别": "gender", "出生日期": "birth_date", "年龄": "age", "籍贯": "native_place", "毕业院校": "graduate_school", "专业": "major", "学历": "education", "入学时间": "enroll_date", "毕业时间": "graduate_date", "联系电话": "phone", "身份证号": "id_card", "班级": "class_name", "班级编号": "class_no", "顾问": "advisor_name", "备注": "remark", } @classmethod def import_from_excel(cls, db: Session, content: bytes, dry_run: bool = False) -> StudentImportResult: """Excel 批量导入。 * 表头按中文名识别,顺序随意; * 班级列支持"班级编号"或"班级名称",顾问列支持姓名;找不到就报错而不是默默丢掉; * 逐行独立事务语义:某一行失败不影响其它行,最后把失败行号+原因整体返回。 """ try: from openpyxl import load_workbook wb = load_workbook(BytesIO(content), data_only=True) except Exception as exc: # noqa: BLE001 raise BusinessError(f"Excel 文件解析失败:{exc}") from exc ws = wb.active rows = list(ws.iter_rows(values_only=True)) if not rows: raise BusinessError("Excel 内容为空") header = [str(c).strip() if c is not None else "" for c in rows[0]] known = {h for h in header if h in cls.HEADER_MAP} if "姓名" not in known: raise BusinessError( "表头缺少必填列「姓名」。可用的列:" + "、".join(cls.HEADER_MAP.keys()) ) from app.dao.advisor_dao import AdvisorDao class_by_no = {class_no: cid for cid, class_no in ClazzDao.no_map(db).items()} class_by_name = {v: k for k, v in ClazzDao.name_map(db).items()} advisor_by_name = { a.name: a.id for a in AdvisorDao.all(db, AdvisorDao.build_stmt(order_by="id", order="asc")) } result = StudentImportResult(total=0, success=0, failed=0) seen_nos: set[str] = set() for idx, raw in enumerate(rows[1:], start=2): if raw is None or all(v is None or str(v).strip() == "" for v in raw): continue result.total += 1 data: dict[str, Any] = {} for col_idx, cell in enumerate(raw): if col_idx >= len(header): break key = cls.HEADER_MAP.get(header[col_idx]) if key: data[key] = cell try: class_id = None if data.get("class_no"): class_id = class_by_no.get(str(data["class_no"]).strip()) if class_id is None: raise BusinessError(f"班级编号 {data['class_no']} 不存在") elif data.get("class_name"): class_id = class_by_name.get(str(data["class_name"]).strip()) if class_id is None: raise BusinessError(f"班级 {data['class_name']} 不存在,请先建班") advisor_id = None if data.get("advisor_name"): advisor_id = advisor_by_name.get(str(data["advisor_name"]).strip()) if advisor_id is None: raise BusinessError(f"顾问 {data['advisor_name']} 不存在") payload = StudentCreate( name=str(data.get("name") or "").strip(), gender=data.get("gender") or 1, birth_date=cls._cell_to_date_str(data.get("birth_date")), age=int(data["age"]) if data.get("age") not in (None, "") else None, native_place=cls._cell_str(data.get("native_place")), graduate_school=cls._cell_str(data.get("graduate_school")), major=cls._cell_str(data.get("major")), education=cls._cell_str(data.get("education")), enroll_date=cls._cell_to_date_str(data.get("enroll_date")), graduate_date=cls._cell_to_date_str(data.get("graduate_date")), phone=cls._cell_str(data.get("phone")), id_card=cls._cell_str(data.get("id_card")), class_id=class_id, advisor_id=advisor_id, remark=cls._cell_str(data.get("remark")), ) if dry_run: cls.build_stu_no(db, class_id, parse_date(payload.enroll_date, "入学时间")) result.success += 1 else: # 每行一个 SAVEPOINT:这一行出错只回滚这一行,前面已成功的行不受影响。 # 千万不要在这里写 db.rollback() —— 那是整条事务回滚, # 会把之前所有成功的行一起丢掉(这个坑真踩过一次)。 with db.begin_nested(): student = cls.create(db, payload) if student.stu_no in seen_nos: raise ConflictError(f"学号 {student.stu_no} 在文件内重复") seen_nos.add(student.stu_no) result.success += 1 except Exception as exc: # noqa: BLE001 result.failed += 1 result.errors.append( {"row": idx, "name": cls._cell_str(data.get("name")), "reason": str(exc)} ) if len(result.errors) >= 50: # 错误太多就截断,避免响应体爆炸 result.errors.append({"row": "-", "name": "-", "reason": "错误过多,已截断"}) break if not dry_run: db.commit() return result @staticmethod def _cell_str(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None @classmethod def _cell_to_date_str(cls, value: Any) -> str | None: if value is None: return None if isinstance(value, date): return value.isoformat() text = str(value).strip() if not text: return None # Excel 里经常写成 2026/9/1 for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y%m%d"): try: from datetime import datetime return datetime.strptime(text, fmt).date().isoformat() except ValueError: continue raise BusinessError(f"日期「{text}」格式不对,应为 YYYY-MM-DD") # ================================================================ 模板 @staticmethod def build_import_template() -> bytes: from openpyxl import Workbook from openpyxl.styles import Alignment, Font, PatternFill wb = Workbook() ws = wb.active ws.title = "学生导入模板" headers = list(StudentService.HEADER_MAP.keys()) ws.append(headers) for cell in ws[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill("solid", fgColor="4C6EF5") cell.alignment = Alignment(horizontal="center") ws.append([ "张三", "男", "2003-05-12", "", "广东深圳", "深圳职业技术学院", "软件技术", "大专", "2024-09-01", "2027-06-30", "13800000000", "", "", "JAVA202601", "李顾问", "示例行,导入前删掉", ]) widths = [10, 8, 14, 8, 16, 22, 16, 10, 14, 14, 16, 20, 14, 16, 12, 24] for i, w in enumerate(widths, start=1): ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w buffer = BytesIO() wb.save(buffer) return buffer.getvalue()