368 lines
16 KiB
Python
368 lines
16 KiB
Python
"""学生业务规则:学号生成、录入校验、转班同步、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()
|