From f19c1bfa277cda70b13a6229f85b4cde0b59b7f1 Mon Sep 17 00:00:00 2001 From: wangtian <1582714703@qq.com> Date: Tue, 22 Sep 2026 17:01:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=B1=E4=B8=9A=E7=AE=A1=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/employment_api.py | 82 +++++++++++++++++ dao/employment_dao.py | 172 ++++++++++++++++++++++++++++++++++++ main.py | 7 +- model/employment_model.py | 34 +++++++ schema/common.py | 9 ++ schema/employment_schema.py | 95 ++++++++++++++++++++ 6 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 api/employment_api.py create mode 100644 dao/employment_dao.py create mode 100644 model/employment_model.py create mode 100644 schema/common.py create mode 100644 schema/employment_schema.py diff --git a/api/employment_api.py b/api/employment_api.py new file mode 100644 index 0000000..11bc044 --- /dev/null +++ b/api/employment_api.py @@ -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) \ No newline at end of file diff --git a/dao/employment_dao.py b/dao/employment_dao.py new file mode 100644 index 0000000..a704a69 --- /dev/null +++ b/dao/employment_dao.py @@ -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, + } \ No newline at end of file diff --git a/main.py b/main.py index cb62681..166d584 100644 --- a/main.py +++ b/main.py @@ -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,8 +17,10 @@ app.add_middleware( ) app.include_router(class_api,tags=['班级接口'],prefix='/stutent') +app.include_router(employment_router) + if __name__ == '__main__': import uvicorn - uvicorn.run("main:app", host='127.0.0.1', port=12345) + uvicorn.run("main:app", host='127.0.0.1', port=12345) \ No newline at end of file diff --git a/model/employment_model.py b/model/employment_model.py new file mode 100644 index 0000000..0f26094 --- /dev/null +++ b/model/employment_model.py @@ -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="更新时间") \ No newline at end of file diff --git a/schema/common.py b/schema/common.py new file mode 100644 index 0000000..b574aab --- /dev/null +++ b/schema/common.py @@ -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 \ No newline at end of file diff --git a/schema/employment_schema.py b/schema/employment_schema.py new file mode 100644 index 0000000..034f561 --- /dev/null +++ b/schema/employment_schema.py @@ -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="薪资建议") \ No newline at end of file