"""就业管理接口(需求 2.3、4.3)。 需求里给的示例路径是 /employment/students/{id}、/employment/class/{id}, 这里在同一套语义下改成标准 REST 写法(资源在复数、动作用方法表达), 同时对老路径做了兼容别名,见文件末尾。 """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Query from app.core.deps import DbSession, ReadAccount, WriteAccount from app.core.exceptions import BusinessError from app.core.response import Resp, ok from app.dao.employment_dao import EmploymentDao from app.dao.student_dao import StudentDao from app.schema.common import PageResult from app.schema.employment_schema import ( EmploymentCreate, EmploymentOut, EmploymentRegisterResult, EmploymentUpdate, ) from app.service.employment_service import EmploymentService router = APIRouter(prefix="/employments", tags=["2.3 学生就业管理"]) legacy_router = APIRouter(prefix="/employment", tags=["2.3 学生就业管理(需求示例路径)"], include_in_schema=False) @router.get("", response_model=Resp[PageResult[EmploymentOut]], summary="就业信息列表(按编号/公司/薪资区间多条件查询)") def list_employments( db: DbSession, _: ReadAccount, page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1, le=200)] = 10, stu_id: Annotated[int | None, Query(description="学生ID")] = None, class_id: Annotated[int | None, Query(description="班级ID")] = None, company: Annotated[str | None, Query(description="公司名称,模糊匹配")] = None, salary_min: Annotated[float | None, Query(ge=0, description="薪资下限")] = None, salary_max: Annotated[float | None, Query(ge=0, description="薪资上限")] = None, has_offer: Annotated[bool | None, Query(description="是否已下发 offer")] = None, has_open: Annotated[bool | None, Query(description="是否已开放就业")] = None, keyword: Annotated[str | None, Query(description="姓名/学号/公司/城市")] = None, order_by: Annotated[str, Query(description="id/salary/open_date/offer_date/company/student_name")] = "id", order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", ): if salary_min is not None and salary_max is not None and salary_min > salary_max: salary_min, salary_max = salary_max, salary_min # 顺序写反了自动纠正 stmt = EmploymentDao.build_stmt( stu_id=stu_id, class_id=class_id, company=company, salary_min=salary_min, salary_max=salary_max, has_offer=has_offer, has_open=has_open, keyword=keyword, order_by=order_by, order=order, ) items, total, page, pages = EmploymentDao.paginate(db, stmt, page, page_size) return ok({ "total": total, "page": page, "page_size": page_size, "pages": pages, "items": [EmploymentOut.model_validate(e) for e in items], }) def _status_msg(action: str, changed: bool, before: str | None, after: str | None) -> str: if not changed: return action return f"{action},学生状态:{before} → {after}" @router.post("", response_model=Resp[EmploymentRegisterResult], summary="登记就业信息(事务内同步学生状态)") def register_employment(db: DbSession, _: WriteAccount, payload: EmploymentCreate): employment, created, changed, before, after = EmploymentService.register(db, payload) db.commit() db.refresh(employment) msg = _status_msg("就业信息登记成功" if created else "就业信息已更新", changed, before, after) return ok( { "employment": EmploymentOut.model_validate(employment), "created": created, "status_changed": changed, "from_status_text": before, "to_status_text": after, "msg": msg, }, msg=msg, ) @router.get("/student/{stu_id}", response_model=Resp[EmploymentOut], summary="查询某个学生的就业信息") def employment_of_student(db: DbSession, _: ReadAccount, stu_id: int): StudentDao.get_or_404(db, stu_id, "学生") employment = EmploymentDao.get_by_stu_id(db, stu_id) if employment is None: return Resp.fail("该学生还没有登记就业信息", 404) return ok(EmploymentOut.model_validate(employment)) @router.get("/class/{class_id}", summary="查询某个班级的就业情况(含班级就业率)") def employment_of_class(db: DbSession, _: ReadAccount, class_id: int): from app.dao.clazz_dao import ClazzDao klass = ClazzDao.get_or_404(db, class_id, "班级") stmt = EmploymentDao.build_stmt(class_id=class_id, order_by="salary", order="desc") items = EmploymentDao.all(db, stmt) students_total = len([s for s in StudentDao.all(db, StudentDao.build_stmt(class_id=class_id, order_by="id", order="asc"))]) offered = [e for e in items if e.offer_date] salaries = [float(e.salary) for e in items if e.salary is not None] return ok({ "class_id": klass.id, "class_name": klass.name, "student_total": students_total, "open_count": len([e for e in items if e.open_date]), "offer_count": len(offered), "employment_rate": round(len(offered) / students_total * 100, 1) if students_total else 0.0, "avg_salary": round(sum(salaries) / len(salaries), 2) if salaries else None, "items": [EmploymentOut.model_validate(e) for e in items], }) @router.put("/{employment_id}", response_model=Resp[EmploymentRegisterResult], summary="修改就业信息") def update_employment(db: DbSession, _: WriteAccount, employment_id: int, payload: EmploymentUpdate): employment = EmploymentDao.get_or_404(db, employment_id, "就业记录") employment, changed, before, after = EmploymentService.update(db, employment, payload) db.commit() db.refresh(employment) msg = _status_msg("修改成功", changed, before, after) return ok( { "employment": EmploymentOut.model_validate(employment), "created": False, "status_changed": changed, "from_status_text": before, "to_status_text": after, "msg": msg, }, msg=msg, ) @router.delete("/{employment_id}", summary="删除就业信息(学生状态回退到在读)") def delete_employment(db: DbSession, _: WriteAccount, employment_id: int): employment = EmploymentDao.get_or_404(db, employment_id, "就业记录") changed, before, after = EmploymentService.delete(db, employment) db.commit() msg = _status_msg("已删除就业记录", changed, before, after) return ok( {"status_changed": changed, "from_status_text": before, "to_status_text": after, "msg": msg}, msg=msg, ) # ----------------------------------------------------------------- 兼容老路径 @legacy_router.get("/students/{stu_id}", summary="[兼容] 获取学生就业信息") def legacy_get_student_employment(db: DbSession, account: ReadAccount, stu_id: int): return employment_of_student(db, account, stu_id) @legacy_router.post("/students/{stu_id}", summary="[兼容] 添加/更新就业信息") def legacy_add_student_employment(db: DbSession, account: WriteAccount, stu_id: int, payload: EmploymentUpdate): body = payload.model_dump(exclude_unset=True) body["stu_id"] = stu_id try: create_payload = EmploymentCreate(**body) except Exception as exc: # noqa: BLE001 raise BusinessError( "登记就业至少需要 就业开放时间 / offer 时间 / 公司 / 薪资 中的一项" ) from exc employment, created, changed, before, after = EmploymentService.register(db, create_payload) db.commit() db.refresh(employment) msg = _status_msg("登记成功" if created else "已更新", changed, before, after) return ok( { "employment": EmploymentOut.model_validate(employment), "created": created, "status_changed": changed, "from_status_text": before, "to_status_text": after, }, msg=msg, ) @legacy_router.get("/class/{class_id}", summary="[兼容] 获取班级学生就业信息") def legacy_class_employment(db: DbSession, account: ReadAccount, class_id: int): return employment_of_class(db, account, class_id)