177 lines
8.0 KiB
Python
177 lines
8.0 KiB
Python
"""就业业务规则(需求 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
|