Merge pull request '就业管理模块' (#2) from pzz into master
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from dao import employment_dao
|
||||
from database import get_db
|
||||
from schema.common import ApiResponse
|
||||
from schema.employment_schema import (
|
||||
ClassEmploymentStats,
|
||||
EmploymentOut,
|
||||
EmploymentUpdate,
|
||||
EmploymentUpsert,
|
||||
SalaryMarketComparison,
|
||||
)
|
||||
|
||||
app = APIRouter(prefix="/employment", tags=["就业管理"])
|
||||
|
||||
|
||||
# ==================== 基础 CRUD ====================
|
||||
|
||||
@app.get("/list", response_model=ApiResponse[list[EmploymentOut]], summary="获取就业记录列表")
|
||||
def list_employment(
|
||||
student_id: str | None = Query(None, description="学生ID"),
|
||||
company_name: str | None = Query(None, description="公司名称(模糊)"),
|
||||
salary_min: float | None = Query(None, description="最低月薪"),
|
||||
salary_max: float | None = Query(None, description="最高月薪"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return ApiResponse(data=employment_dao.list_employment(db, {
|
||||
"student_id": student_id, "company_name": company_name,
|
||||
"salary_min": salary_min, "salary_max": salary_max,
|
||||
}))
|
||||
|
||||
@app.get("/student/{student_id}", response_model=ApiResponse[list[EmploymentOut]], summary="获取指定学生全部就业记录")
|
||||
def list_student_employment(student_id: str, db: Session = Depends(get_db)):
|
||||
data = employment_dao.list_by_student(db, student_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="该学生暂无就业记录")
|
||||
return ApiResponse(data=data)
|
||||
|
||||
@app.get("/class/{class_id}", response_model=ApiResponse[list[EmploymentOut]], summary="获取班级全部就业记录")
|
||||
def list_class_employment(class_id: str, db: Session = Depends(get_db)):
|
||||
return ApiResponse(data=employment_dao.list_by_class(db, class_id))
|
||||
|
||||
@app.post("/students/{student_id}", response_model=ApiResponse[EmploymentOut], summary="新增学生就业信息")
|
||||
def create_employment(student_id: str, payload: EmploymentUpsert, db: Session = Depends(get_db)):
|
||||
data_dict = payload.model_dump(exclude_unset=True, exclude_none=True)
|
||||
return ApiResponse(data=employment_dao.create_by_student(db, student_id, data_dict))
|
||||
|
||||
@app.put("/{emp_id}", response_model=ApiResponse[EmploymentOut], summary="修改就业信息")
|
||||
def update_employment(emp_id: str, payload: EmploymentUpdate, db: Session = Depends(get_db)):
|
||||
updates = payload.model_dump(exclude_unset=True, exclude_none=True)
|
||||
data = employment_dao.update_by_emp_id(db, emp_id, updates)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="就业记录不存在")
|
||||
return ApiResponse(data=data)
|
||||
|
||||
@app.delete("/{emp_id}", response_model=ApiResponse, summary="删除就业信息")
|
||||
def delete_employment(emp_id: str, db: Session = Depends(get_db)):
|
||||
if not employment_dao.delete_by_emp_id(db, emp_id):
|
||||
raise HTTPException(status_code=404, detail="就业记录不存在")
|
||||
return ApiResponse()
|
||||
|
||||
|
||||
# ==================== 新增功能点 ====================
|
||||
|
||||
@app.get("/stats/class/{class_id}", response_model=ApiResponse[ClassEmploymentStats], summary="班级就业统计仪表盘")
|
||||
def get_class_stats(class_id: str, db: Session = Depends(get_db)):
|
||||
return ApiResponse(data=employment_dao.get_class_employment_stats(db, class_id))
|
||||
|
||||
@app.get("/warnings", response_model=ApiResponse[list[dict]], summary="就业跟进预警")
|
||||
def get_warnings(
|
||||
days: int = Query(30, description="简历开放后多少天未签约算超时"),
|
||||
class_id: str | None = Query(None, description="按班级筛选"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return ApiResponse(data=employment_dao.get_employment_warnings(db, days, class_id))
|
||||
|
||||
@app.get("/salary-market/{student_id}", response_model=ApiResponse[SalaryMarketComparison], summary="薪资行情对标")
|
||||
def salary_market_compare(student_id: str, db: Session = Depends(get_db)):
|
||||
data = employment_dao.compare_salary_with_market(db, student_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="该学生无有效就业记录")
|
||||
return ApiResponse(data=data)
|
||||
@@ -0,0 +1,172 @@
|
||||
import uuid
|
||||
from datetime import date, timedelta, datetime as dt
|
||||
from typing import Optional
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
TABLE = "employment_info"
|
||||
COLS = ["emp_id", "student_id", "class_id", "education", "code",
|
||||
"job_title", "company_name", "salary", "offer_date",
|
||||
"resume_open_date", "employment_int", "is_deleted",
|
||||
"create_time", "update_time"]
|
||||
SELECT_COLS = ", ".join(COLS)
|
||||
|
||||
|
||||
def _convert(d: dict) -> dict:
|
||||
if d is None:
|
||||
return None
|
||||
out = {}
|
||||
for col, val in d.items():
|
||||
if val is None:
|
||||
out[col] = None
|
||||
elif isinstance(val, dt):
|
||||
out[col] = val.isoformat()
|
||||
elif isinstance(val, date):
|
||||
out[col] = val.isoformat()
|
||||
elif hasattr(val, '__float__') and not isinstance(val, bool):
|
||||
try:
|
||||
out[col] = float(val)
|
||||
except:
|
||||
out[col] = val
|
||||
else:
|
||||
out[col] = val
|
||||
return out
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
return _convert(dict(row._mapping)) if row else None
|
||||
|
||||
|
||||
def _rows_to_list(rows) -> list[dict]:
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
# ==================== 基础 CRUD ====================
|
||||
|
||||
def get_by_emp_id(db: Session, emp_id: str) -> Optional[dict]:
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE emp_id = :emp_id AND is_deleted = 0")
|
||||
return _row_to_dict(db.execute(sql, {"emp_id": emp_id}).fetchone())
|
||||
|
||||
def list_by_student(db: Session, student_id: str) -> list[dict]:
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE student_id = :sid AND is_deleted = 0 ORDER BY offer_date DESC")
|
||||
return _rows_to_list(db.execute(sql, {"sid": student_id}).fetchall())
|
||||
|
||||
def list_by_class(db: Session, class_id: str) -> list[dict]:
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE class_id = :cid AND is_deleted = 0 ORDER BY offer_date DESC")
|
||||
return _rows_to_list(db.execute(sql, {"cid": class_id}).fetchall())
|
||||
|
||||
def list_employment(db: Session, filters: dict) -> list[dict]:
|
||||
conds = ["is_deleted = 0"]
|
||||
params = {}
|
||||
if filters.get("student_id"):
|
||||
conds.append("student_id = :sid"); params["sid"] = filters["student_id"]
|
||||
if filters.get("company_name"):
|
||||
conds.append("company_name LIKE :cn"); params["cn"] = f"%{filters['company_name']}%"
|
||||
if filters.get("salary_min") is not None:
|
||||
conds.append("salary >= :smin"); params["smin"] = filters["salary_min"]
|
||||
if filters.get("salary_max") is not None:
|
||||
conds.append("salary <= :smax"); params["smax"] = filters["salary_max"]
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE {' AND '.join(conds)} ORDER BY offer_date DESC")
|
||||
return _rows_to_list(db.execute(sql, params).fetchall())
|
||||
|
||||
def create_by_student(db: Session, student_id: str, data_dict: dict) -> dict:
|
||||
emp_id = f"EMP{student_id}_{uuid.uuid4().hex[:8].upper()}"
|
||||
data_dict.pop("student_id", None)
|
||||
all_cols = ["emp_id", "student_id", "is_deleted"] + list(data_dict.keys())
|
||||
all_vals = [emp_id, student_id, 0] + list(data_dict.values())
|
||||
placeholders = ", ".join(f":{c}" for c in all_cols)
|
||||
col_names = ", ".join(all_cols)
|
||||
sql = text(f"INSERT INTO {TABLE} ({col_names}) VALUES ({placeholders})")
|
||||
db.execute(sql, dict(zip(all_cols, all_vals)))
|
||||
db.commit()
|
||||
return get_by_emp_id(db, emp_id)
|
||||
|
||||
def update_by_emp_id(db: Session, emp_id: str, updates: dict) -> Optional[dict]:
|
||||
sql = text(f"UPDATE {TABLE} SET is_deleted = 0 WHERE emp_id = :emp_id")
|
||||
db.execute(sql, {"emp_id": emp_id})
|
||||
db.commit()
|
||||
if not get_by_emp_id(db, emp_id):
|
||||
return None
|
||||
set_clause = ", ".join(f"{k} = :{k}" for k in updates.keys())
|
||||
sql = text(f"UPDATE {TABLE} SET {set_clause} WHERE emp_id = :emp_id")
|
||||
db.execute(sql, {"emp_id": emp_id, **updates})
|
||||
db.commit()
|
||||
return get_by_emp_id(db, emp_id)
|
||||
|
||||
def delete_by_emp_id(db: Session, emp_id: str) -> bool:
|
||||
sql = text(f"UPDATE {TABLE} SET is_deleted = 0 WHERE emp_id = :emp_id")
|
||||
db.execute(sql, {"emp_id": emp_id})
|
||||
db.commit()
|
||||
if not get_by_emp_id(db, emp_id):
|
||||
return False
|
||||
db.execute(text(f"UPDATE {TABLE} SET is_deleted = 1 WHERE emp_id = :emp_id"), {"emp_id": emp_id})
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ==================== 新增功能点 ====================
|
||||
|
||||
def get_latest_by_student(db: Session, student_id: str) -> Optional[dict]:
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE student_id = :sid AND is_deleted = 0 ORDER BY offer_date DESC LIMIT 1")
|
||||
return _row_to_dict(db.execute(sql, {"sid": student_id}).fetchone())
|
||||
|
||||
def get_class_employment_stats(db: Session, class_id: str) -> dict:
|
||||
base_sql = text(f"SELECT employment_int, COUNT(*) as cnt FROM {TABLE} WHERE class_id = :cid AND is_deleted = 0 GROUP BY employment_int")
|
||||
salary_sql = text(f"SELECT AVG(salary), MAX(salary), MIN(salary), COUNT(*) FROM {TABLE} WHERE class_id = :cid AND is_deleted = 0")
|
||||
job_sql = text(f"SELECT job_title, COUNT(*) FROM {TABLE} WHERE class_id = :cid AND is_deleted = 0 GROUP BY job_title")
|
||||
rows = db.execute(base_sql, {"cid": class_id}).fetchall()
|
||||
salary_row = db.execute(salary_sql, {"cid": class_id}).fetchone()
|
||||
job_rows = db.execute(job_sql, {"cid": class_id}).fetchall()
|
||||
stats = {r[0]: r[1] for r in rows}
|
||||
total = sum(stats.values())
|
||||
return {
|
||||
"class_id": class_id,
|
||||
"total_count": total,
|
||||
"employed_count": stats.get(1, 0),
|
||||
"graduate_count": stats.get(2, 0),
|
||||
"flexible_count": stats.get(3, 0),
|
||||
"employment_rate": round((stats.get(1, 0) + stats.get(2, 0) + stats.get(3, 0)) / total * 100, 2) if total > 0 else 0.0,
|
||||
"avg_salary": salary_row[0], "max_salary": salary_row[1], "min_salary": salary_row[2],
|
||||
"job_title_distribution": {r[0]: r[1] for r in job_rows},
|
||||
}
|
||||
|
||||
def get_employment_warnings(db: Session, days: int = 30, class_id: str | None = None) -> list[dict]:
|
||||
cutoff = date.today() - timedelta(days=days)
|
||||
conds = ["is_deleted = 0", "resume_open_date IS NOT NULL", "resume_open_date < :cutoff", "salary IS NULL"]
|
||||
params = {"cutoff": cutoff}
|
||||
if class_id:
|
||||
conds.append("class_id = :cid"); params["cid"] = class_id
|
||||
sql = text(f"SELECT {SELECT_COLS} FROM {TABLE} WHERE {' AND '.join(conds)}")
|
||||
result = []
|
||||
for row in db.execute(sql, params).fetchall():
|
||||
emp = _row_to_dict(row)
|
||||
overdue = (date.today() - date.fromisoformat(emp["resume_open_date"])).days - days
|
||||
result.append({
|
||||
"emp_id": emp["emp_id"], "student_id": emp["student_id"], "class_id": emp["class_id"],
|
||||
"job_title": emp["job_title"], "company_name": emp["company_name"],
|
||||
"resume_open_date": emp["resume_open_date"],
|
||||
"warning_reason": f"简历开放{days}天内未签约,已超{overdue}天",
|
||||
"days_overdue": overdue,
|
||||
})
|
||||
return result
|
||||
|
||||
def compare_salary_with_market(db: Session, student_id: str) -> Optional[dict]:
|
||||
emp = get_latest_by_student(db, student_id)
|
||||
if not emp or not emp.get("code") or not emp.get("job_title"):
|
||||
return None
|
||||
market_sql = text(f"SELECT COUNT(*), AVG(salary), MAX(salary), MIN(salary) FROM {TABLE} WHERE code = :code AND job_title = :jt AND salary IS NOT NULL AND is_deleted = 0")
|
||||
m = db.execute(market_sql, {"code": emp["code"], "jt": emp["job_title"]}).fetchone()
|
||||
deviation = None; advice = "无"
|
||||
if m[1] and emp.get("salary"):
|
||||
deviation = round((float(emp["salary"]) - float(m[1])) / float(m[1]) * 100, 2)
|
||||
if deviation >= 10: advice = "薪资高于行情10%+,非常优秀"
|
||||
elif deviation >= 0: advice = "薪资符合市场行情"
|
||||
elif deviation >= -10: advice = "薪资略低于行情,可考虑谈薪"
|
||||
else: advice = "薪资明显低于行情,建议与HR沟通"
|
||||
return {
|
||||
"code": emp["code"], "job_title": emp["job_title"],
|
||||
"sample_count": m[0], "avg_salary": m[1], "max_salary": m[2], "min_salary": m[3],
|
||||
"current_student_id": student_id, "current_salary": emp["salary"],
|
||||
"deviation_percent": deviation, "advice": advice,
|
||||
}
|
||||
@@ -2,7 +2,10 @@ from fastapi import FastAPI
|
||||
from database import engine, Base
|
||||
Base.metadata.create_all(engine)
|
||||
from api.calss import class_api
|
||||
from api.employment_api import app as employment_router
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
@@ -14,6 +17,8 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(class_api,tags=['班级接口'],prefix='/stutent')
|
||||
app.include_router(employment_router)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""就业信息 ORM 实体"""
|
||||
from sqlalchemy import Column, String, ForeignKey, Numeric, Date, DateTime, Integer
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
class Employment(Base):
|
||||
__tablename__ = "employment_info"
|
||||
__table_args__ = {
|
||||
"comment": "学生就业信息管理",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_general_ci",
|
||||
}
|
||||
emp_id = Column(String(32), primary_key=True, comment="就业记录ID")
|
||||
student_id = Column(String(20), ForeignKey("student_info.uk_student_id"),
|
||||
nullable=False, index=True, comment="学号")
|
||||
class_id = Column(String(64), nullable=True, comment="班级ID")
|
||||
code = Column(String(12), ForeignKey("region.code"),
|
||||
nullable=False, index=True, comment="就业城市编码")
|
||||
job_title = Column(String(50), nullable=False, comment="就业岗位名称")
|
||||
company_name = Column(String(128), nullable=False, comment="就业公司名称")
|
||||
salary = Column(Numeric(12, 2), nullable=True, comment="就业月薪(元)")
|
||||
offer_date = Column(Date, nullable=False, comment="offer下发/签约时间")
|
||||
resume_open_date = Column(Date, nullable=True, comment="就业开放时间")
|
||||
employment_int = Column(Integer, nullable=False, default=1,
|
||||
comment="就业状态:1=已就业,2=升学,3=灵活就业")
|
||||
is_deleted = Column(Integer, nullable=False, default=0,
|
||||
comment="逻辑删除:0=未删除,1=已删除")
|
||||
create_time = Column(DateTime, nullable=False, server_default=func.now(),
|
||||
comment="创建时间")
|
||||
update_time = Column(DateTime, nullable=False, server_default=func.now(),
|
||||
onupdate=func.now(), comment="更新时间")
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一响应包装体"""
|
||||
from typing import Generic, Optional, TypeVar
|
||||
from pydantic import BaseModel
|
||||
T = TypeVar("T")
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
code: int = 200
|
||||
msg: str = "success"
|
||||
data: Optional[T] = None
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EmploymentBase(BaseModel):
|
||||
student_id: str = Field(..., max_length=20, description="学生ID")
|
||||
class_id: Optional[str] = Field(None, max_length=64, description="班级ID")
|
||||
education: Optional[str] = Field(None, max_length=20, description="学历:专科/本科/硕士/博士")
|
||||
code: str = Field(..., max_length=12, description="就业城市编码")
|
||||
job_title: str = Field(..., max_length=50, description="就业岗位名称")
|
||||
company_name: str = Field(..., max_length=128, description="就业公司名称")
|
||||
salary: Optional[float] = Field(None, description="就业月薪(元)")
|
||||
offer_date: date = Field(..., description="offer下发/签约时间")
|
||||
resume_open_date: Optional[date] = Field(None, description="简历开放时间")
|
||||
employment_int: int = Field(..., description="就业状态:1=已就业,2=升学,3=灵活就业")
|
||||
|
||||
|
||||
class EmploymentUpsert(EmploymentBase):
|
||||
pass
|
||||
|
||||
|
||||
class EmploymentUpdate(BaseModel):
|
||||
class_id: Optional[str] = Field(None, max_length=64, description="班级ID")
|
||||
education: Optional[str] = Field(None, max_length=20, description="学历")
|
||||
code: Optional[str] = Field(None, max_length=12, description="就业城市编码")
|
||||
job_title: Optional[str] = Field(None, max_length=50, description="就业岗位名称")
|
||||
company_name: Optional[str] = Field(None, max_length=128, description="就业公司名称")
|
||||
salary: Optional[float] = Field(None, description="就业月薪(元)")
|
||||
offer_date: Optional[date] = Field(None, description="offer下发/签约时间")
|
||||
resume_open_date: Optional[date] = Field(None, description="简历开放时间")
|
||||
employment_int: Optional[int] = Field(None, description="就业状态:1=已就业,2=升学,3=灵活就业")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"class_id": "c01",
|
||||
"education": "本科",
|
||||
"code": "310115",
|
||||
"job_title": "AI开发工程师",
|
||||
"company_name": "上海拼多多",
|
||||
"salary": 25000,
|
||||
"offer_date": "2026-08-01",
|
||||
"resume_open_date": "2026-05-15",
|
||||
"employment_int": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EmploymentOut(BaseModel):
|
||||
emp_id: str = Field(..., max_length=32, description="就业记录ID")
|
||||
student_id: str = Field(..., max_length=20, description="学生ID")
|
||||
class_id: Optional[str] = Field(None, max_length=64, description="班级ID")
|
||||
education: Optional[str] = Field(None, max_length=20, description="学历")
|
||||
code: str = Field(..., max_length=12, description="就业城市编码")
|
||||
job_title: str = Field(..., max_length=50, description="就业岗位名称")
|
||||
company_name: str = Field(..., max_length=128, description="就业公司名称")
|
||||
salary: Optional[float] = Field(None, description="就业月薪(元)")
|
||||
offer_date: date = Field(..., description="offer下发/签约时间")
|
||||
resume_open_date: Optional[date] = Field(None, description="简历开放时间")
|
||||
employment_int: int = Field(..., description="就业状态")
|
||||
is_deleted: int = Field(..., description="是否删除 0否1是")
|
||||
create_time: datetime = Field(..., description="创建时间")
|
||||
update_time: datetime = Field(..., description="更新时间")
|
||||
|
||||
|
||||
# ==================== 新增功能点 ====================
|
||||
|
||||
class ClassEmploymentStats(BaseModel):
|
||||
class_id: str = Field(..., description="班级ID")
|
||||
total_count: int = Field(..., description="班级总就业记录数")
|
||||
employed_count: int = Field(..., description="已就业")
|
||||
graduate_count: int = Field(..., description="升学")
|
||||
flexible_count: int = Field(..., description="灵活就业")
|
||||
employment_rate: float = Field(..., description="就业率(%)")
|
||||
avg_salary: Optional[float] = Field(None, description="平均薪资")
|
||||
max_salary: Optional[float] = Field(None, description="最高薪资")
|
||||
min_salary: Optional[float] = Field(None, description="最低薪资")
|
||||
job_title_distribution: dict = Field(..., description="各岗位人数分布")
|
||||
|
||||
|
||||
class SalaryMarketComparison(BaseModel):
|
||||
code: str = Field(..., description="城市编码")
|
||||
job_title: str = Field(..., description="岗位名称")
|
||||
sample_count: int = Field(..., description="行情样本数")
|
||||
avg_salary: Optional[float] = Field(None, description="行情平均薪资")
|
||||
max_salary: Optional[float] = Field(None, description="行情最高薪资")
|
||||
min_salary: Optional[float] = Field(None, description="行情最低薪资")
|
||||
current_student_id: str = Field(..., description="学生ID")
|
||||
current_salary: Optional[float] = Field(None, description="当前学生薪资")
|
||||
deviation_percent: Optional[float] = Field(None, description="偏差百分比")
|
||||
advice: str = Field(..., description="薪资建议")
|
||||
Reference in New Issue
Block a user