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

177 lines
8.0 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.
"""就业业务规则(需求 2.3):一个事务里完成"写就业表 + 改学生状态"。
为什么必须在一个事务里
--------------------------------------------------------------------------
需求写得很明白:"登记就业信息成功时,必须**同时**执行:就业表插入记录 +
学生表状态更新"。这两步是"要么都成、要么都不成"的关系——
中间断电/异常时,如果就业记录写进去了而学生状态没改,班级就业率就会长期偏低,
而且是那种没人会发现的错。所以这里统一 flush、由调用方一次 commit,
任何一步抛异常都由 FastAPI 的会话依赖整体回滚。
返回值约定(不用字符串拼提示语,改由调用方组装):
register -> (employment, created, status_changed, from_text, to_text)
update -> (employment, status_changed, from_text, to_text)
delete -> (status_changed, from_text, to_text)
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.exceptions import BusinessError, NotFoundError
from app.core.utils import parse_date
from app.dao.employment_dao import EmploymentDao
from app.dao.student_dao import StudentDao
from app.model import Employment, Student, StudentStatus
from app.model.constants import STUDENT_STATUS_TEXT
from app.schema.employment_schema import EmploymentCreate, EmploymentUpdate
class EmploymentService:
# ================================================================ 状态推导
@staticmethod
def derive_status(open_date, offer_date) -> int:
"""学生状态的唯一真源:有 offer = 已就业,只有开放时间 = 进入就业。"""
if offer_date is not None:
return int(StudentStatus.EMPLOYED)
if open_date is not None:
return int(StudentStatus.EMPLOYING)
return int(StudentStatus.STUDYING)
@staticmethod
def status_text(code: int | None) -> str | None:
if code is None:
return None
return STUDENT_STATUS_TEXT.get(code, str(code))
@classmethod
def apply_status(cls, student: Student, employment: Employment) -> tuple[bool, str | None, str | None]:
"""按就业记录推导并写回学生状态,返回 (是否变化, 原状态文案, 新状态文案)。"""
target = cls.derive_status(employment.open_date, employment.offer_date)
before_text = cls.status_text(student.status)
after_text = cls.status_text(target)
if student.status == target:
return False, before_text, after_text
student.status = target
return True, before_text, after_text
# ================================================================ 登记/更新
@classmethod
def register(
cls, db: Session, payload: EmploymentCreate
) -> tuple[Employment, bool, bool, str | None, str | None]:
"""登记就业信息(已存在则更新)。"""
student = StudentDao.get(db, payload.stu_id)
if student is None:
raise NotFoundError(f"学生不存在(id={payload.stu_id})")
explicit = payload.model_fields_set
employment = EmploymentDao.get_by_stu_id(db, payload.stu_id, with_deleted=True)
created = employment is None or employment.is_del == 1
if employment is None:
employment = Employment(stu_id=payload.stu_id)
db.add(employment)
# 只覆盖"调用方这次确实传了的字段":
# 老记录里已有的 offer 时间不会因为这次只补了个公司名就被清空。
open_date = (
parse_date(payload.open_date, "就业开放时间")
if "open_date" in explicit
else employment.open_date
)
offer_date = (
parse_date(payload.offer_date, "offer 下发时间")
if "offer_date" in explicit
else employment.offer_date
)
cls._check_dates(open_date, offer_date)
# 复活被删过的记录时把 is_del 归零,让它重新参与统计
employment.is_del = 0
employment.class_id = student.class_id # 冗余班级:以学生当前班级为准
employment.open_date = open_date
employment.offer_date = offer_date
for key in ("company", "salary", "position", "city", "remark"):
if key in explicit:
setattr(employment, key, getattr(payload, key))
db.flush()
changed, before, after = cls.apply_status(student, employment)
db.flush()
return employment, created, changed, before, after
@classmethod
def update(
cls, db: Session, employment: Employment, payload: EmploymentUpdate
) -> tuple[Employment, bool, str | None, str | None]:
data = payload.model_dump(exclude_unset=True)
open_date = parse_date(data.get("open_date"), "就业开放时间") if "open_date" in data else employment.open_date
offer_date = parse_date(data.get("offer_date"), "offer 下发时间") if "offer_date" in data else employment.offer_date
cls._check_dates(open_date, offer_date)
# open_date 被显式传空 = 清空(改回就业中);只传 offer_date 时保留原开放时间
if "open_date" in data:
employment.open_date = open_date
if "offer_date" in data:
employment.offer_date = offer_date
for key in ("company", "salary", "position", "city", "remark"):
if key in data:
setattr(employment, key, data[key])
# 学生转过班的话,这里顺手把冗余班级对齐
if employment.student and employment.class_id != employment.student.class_id:
employment.class_id = employment.student.class_id
db.flush()
if employment.student is None:
return employment, False, None, None
changed, before, after = cls.apply_status(employment.student, employment)
db.flush()
return employment, changed, before, after
# ================================================================ 删除
@classmethod
def delete(cls, db: Session, employment: Employment) -> tuple[bool, str | None, str | None]:
"""删除就业记录 -> 学生状态回退到"在读"。"""
student = employment.student
employment.soft_delete()
db.flush()
if student is None:
return False, None, None
before = cls.status_text(student.status)
if student.status == int(StudentStatus.STUDYING):
return False, before, before
student.status = int(StudentStatus.STUDYING)
db.flush()
return True, before, cls.status_text(int(StudentStatus.STUDYING))
# ================================================================ 冗余同步
@staticmethod
def sync_class_id(db: Session, student: Student) -> None:
"""学生转班时同步就业表的冗余班级字段。
冗余字段的代价就在这儿:不一致必须由代码兜住,不能靠"记得改"。
"""
employment = EmploymentDao.get_by_stu_id(db, student.id)
if employment and employment.class_id != student.class_id:
employment.class_id = student.class_id
db.flush()
# ================================================================ 校验
@staticmethod
def _check_dates(open_date, offer_date) -> None:
if open_date and offer_date and offer_date < open_date:
raise BusinessError(
f"offer 下发时间({offer_date})不能早于就业开放时间({open_date})"
)
if offer_date and not open_date:
# 只给 offer 时间没给开放时间:能判定已就业,但就业时长算不出来。
# 这里明确报错而不是默默按 0 天算,避免统计口径悄悄失真。
raise BusinessError("填了 offer 下发时间就必须填就业开放时间,否则无法计算就业时长")
@classmethod
def high_salary_line(cls) -> float:
return settings.HIGH_SALARY_LINE