Files
test/app/service/student_service.py
T
2026-09-21 19:03:31 +08:00

368 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""学生业务规则:学号生成、录入校验、转班同步、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()