package controllers import ( "encoding/json" "strconv" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendJobRankController 职级管理接口。 // // 职级(Job Rank)定义「职位等级」:每个部门可以各设一套职级阶梯, // department_id 指向 yz_backend_organization.id,department_id = 0 表示 // 全公司通用职级,任何部门都能选用。 // // 职级的使用链路: // // 员工 → 职位(yz_backend_position.rank_id) → 职级 // 薪酬方案(yz_backend_oa_compensation_schemes.rank_id) → 职级 // // 所有查询与写入都强制带上 JWT 中的租户ID(tid),实现租户间隔离; // 前端传入的 tid / tenant_id 一律忽略,避免越权读取其它租户的数据。 type BackendJobRankController struct { beego.Controller } // jobRankDTO 职级返回结构。 type jobRankDTO struct { ID uint64 `json:"id"` Tid uint64 `json:"tid"` TenantID uint64 `json:"tenant_id"` RankCode string `json:"rank_code"` RankName string `json:"rank_name"` LevelNo int `json:"level_no"` DepartmentID uint64 `json:"department_id"` DepartmentName string `json:"department_name"` SalaryMin float64 `json:"salary_min"` SalaryMax float64 `json:"salary_max"` DefaultRoleID uint64 `json:"default_role_id"` Status int8 `json:"status"` Sort int `json:"sort"` Remark string `json:"remark"` CreateTime string `json:"create_time"` } // jobRankPayload 职级新增/编辑入参。全部使用指针,区分「没传」与「传了零值」。 type jobRankPayload struct { RankCode *string `json:"rank_code"` RankName *string `json:"rank_name"` LevelNo *int `json:"level_no"` DepartmentID *uint64 `json:"department_id"` SalaryMin *float64 `json:"salary_min"` SalaryMax *float64 `json:"salary_max"` DefaultRoleID *uint64 `json:"default_role_id"` Status *int8 `json:"status"` Sort *int `json:"sort"` Remark *string `json:"remark"` } // --------------------------------------------------------------------------- // 认证与响应 // --------------------------------------------------------------------------- // claims 解析 Authorization 头中的 JWT,要求是租户端(backend)用户且带有效租户ID。 func (c *BackendJobRankController) claims() (*jwtutil.Claims, bool) { claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend) if err != nil || claims.TenantId <= 0 { return nil, false } return claims, true } // pathUint64 读取路径参数中的无符号整数。 func (c *BackendJobRankController) pathUint64(name string) (uint64, bool) { id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64) return id, err == nil && id > 0 } // nowString 当前时间字符串(软删除用)。 func (c *BackendJobRankController) nowString() string { return time.Now().Format("2006-01-02 15:04:05") } // tenantID 取当前登录租户ID,未通过认证时直接输出 401 并返回 false。 func (c *BackendJobRankController) tenantID() (uint64, bool) { claims, ok := c.claims() if !ok { c.jsonError(401, "未登录或登录已过期") return 0, false } return uint64(claims.TenantId), true } func (c *BackendJobRankController) jsonOK(data interface{}) { resp := map[string]interface{}{"code": 200, "msg": "success"} if data != nil { resp["data"] = data } c.Data["json"] = resp _ = c.ServeJSON() } func (c *BackendJobRankController) jsonError(code int, msg string) { if code == 401 { c.Ctx.Output.SetStatus(401) } c.Data["json"] = map[string]interface{}{"code": code, "msg": msg} _ = c.ServeJSON() } // --------------------------------------------------------------------------- // 查询基座 // --------------------------------------------------------------------------- func (c *BackendJobRankController) rankQuery(tid uint64) orm.QuerySeter { return models.Orm.QueryTable(new(models.BackendJobRank)). Filter("tid", tid). Filter("delete_time__isnull", true) } // jobRankPayloadOf 解析请求体(JSON 优先,兼容表单提交)。 func (c *BackendJobRankController) jobRankPayloadOf() (jobRankPayload, bool) { var payload jobRankPayload contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type")) if strings.Contains(contentType, "json") && len(c.Ctx.Input.RequestBody) > 0 { if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil { return payload, false } return payload, true } // 表单提交:逐字段回退 getString := func(key string) *string { v := strings.TrimSpace(c.GetString(key)) if v == "" { return nil } return &v } getFloat := func(key string) *float64 { v := strings.TrimSpace(c.GetString(key)) if v == "" { return nil } f, err := strconv.ParseFloat(v, 64) if err != nil { return nil } return &f } getInt := func(key string) *int { v := strings.TrimSpace(c.GetString(key)) if v == "" { return nil } i, err := strconv.Atoi(v) if err != nil { return nil } return &i } getUint64 := func(key string) *uint64 { v := strings.TrimSpace(c.GetString(key)) if v == "" { return nil } i, err := strconv.ParseUint(v, 10, 64) if err != nil { return nil } return &i } payload.RankCode = getString("rank_code") payload.RankName = getString("rank_name") payload.LevelNo = getInt("level_no") payload.DepartmentID = getUint64("department_id") payload.SalaryMin = getFloat("salary_min") payload.SalaryMax = getFloat("salary_max") payload.DefaultRoleID = getUint64("default_role_id") payload.Sort = getInt("sort") payload.Remark = getString("remark") if v := getString("status"); v != nil { status := int8(0) if s, err := strconv.Atoi(*v); err == nil { status = int8(s) } payload.Status = &status } return payload, true } // --------------------------------------------------------------------------- // 职级 CRUD // --------------------------------------------------------------------------- // List 职级列表。 // GET /backend/jobrank/list // // 参数: // keyword 职级名称 / 编码 模糊匹配 // department_id 适用部门筛选 // status 状态筛选(1 启用 / 0 禁用) // scope=assign 返回「该部门可用」的职级:部门自身 + 全公司通用(department_id=0)。 // 职位、薪酬方案选择职级时用这个口径;不传则返回租户全部职级。 func (c *BackendJobRankController) List() { tid, ok := c.tenantID() if !ok { return } cond := orm.NewCondition().And("tid", tid).And("delete_time__isnull", true) departmentID, _ := c.GetUint64("department_id") scope := strings.ToLower(strings.TrimSpace(c.GetString("scope"))) if scope == "assign" { // 可选范围 = 本部门职级 + 全公司通用职级; // 尚未指定部门时不按部门收窄,先把全部职级摆出来供选择。 if departmentID > 0 { cond = cond.And("department_id__in", []uint64{0, departmentID}) } } else if departmentID > 0 { cond = cond.And("department_id", departmentID) } if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" { cond = cond.AndCond(orm.NewCondition(). Or("rank_name__icontains", keyword). Or("rank_code__icontains", keyword)) } if raw := strings.TrimSpace(c.GetString("status")); raw != "" { if status, err := strconv.Atoi(raw); err == nil { cond = cond.And("status", int8(status)) } } var rows []models.BackendJobRank if _, err := models.Orm.QueryTable(new(models.BackendJobRank)). SetCond(cond).OrderBy("level_no", "sort", "id").All(&rows); err != nil { c.jsonError(500, "查询职级失败: "+err.Error()) return } deptNames := jobRankDeptNameMap(tid) list := make([]jobRankDTO, 0, len(rows)) for _, row := range rows { list = append(list, jobRankDTOOf(row, deptNames)) } c.jsonOK(list) } // Detail 职级详情。 // GET /backend/jobrank/detail/:id func (c *BackendJobRankController) Detail() { tid, ok := c.tenantID() if !ok { return } id, valid := c.pathUint64(":id") if !valid { c.jsonError(400, "无效ID") return } var row models.BackendJobRank if err := c.rankQuery(tid).Filter("id", id).One(&row); err != nil { c.jsonError(404, "职级不存在") return } c.jsonOK(jobRankDTOOf(row, jobRankDeptNameMap(tid))) } // Create 新增职级。 // POST /backend/jobrank/create func (c *BackendJobRankController) Create() { tid, ok := c.tenantID() if !ok { return } payload, ok := c.jobRankPayloadOf() if !ok { c.jsonError(400, "参数错误") return } rankName := "" if payload.RankName != nil { rankName = strings.TrimSpace(*payload.RankName) } if rankName == "" { c.jsonError(400, "职级名称不能为空") return } rankCode := "" if payload.RankCode != nil { rankCode = strings.TrimSpace(*payload.RankCode) } if rankCode == "" { c.jsonError(400, "职级编码不能为空") return } count, err := c.rankQuery(tid).Filter("rank_code", rankCode).Count() if err != nil { c.jsonError(500, "校验职级编码失败: "+err.Error()) return } if count > 0 { c.jsonError(400, "职级编码已存在") return } departmentID := uint64(0) if payload.DepartmentID != nil { departmentID = *payload.DepartmentID } if departmentID > 0 && !jobRankOrgExists(tid, departmentID) { c.jsonError(400, "适用部门不存在") return } salaryMin, salaryMax := 0.0, 0.0 if payload.SalaryMin != nil { salaryMin = *payload.SalaryMin } if payload.SalaryMax != nil { salaryMax = *payload.SalaryMax } if salaryMin < 0 || salaryMax < 0 { c.jsonError(400, "标准薪资不能为负数") return } if salaryMax > 0 && salaryMax < salaryMin { c.jsonError(400, "标准薪资上限不能小于下限") return } row := models.BackendJobRank{ Tid: tid, RankCode: rankCode, RankName: rankName, LevelNo: 0, DepartmentID: departmentID, SalaryMin: salaryMin, SalaryMax: salaryMax, DefaultRoleID: 0, Status: 1, Sort: 0, } if payload.LevelNo != nil { row.LevelNo = *payload.LevelNo } if payload.DefaultRoleID != nil { row.DefaultRoleID = *payload.DefaultRoleID } if payload.Status != nil { row.Status = int8(1) if *payload.Status == 0 { row.Status = 0 } } if payload.Sort != nil { row.Sort = *payload.Sort } if payload.Remark != nil { row.Remark = strPtrIfNotEmpty(strings.TrimSpace(*payload.Remark)) } id, err := models.Orm.Insert(&row) if err != nil { c.jsonError(500, "创建职级失败: "+err.Error()) return } c.jsonOK(map[string]interface{}{"id": id, "rank_code": rankCode}) } // Update 更新职级。 // POST /backend/jobrank/update/:id func (c *BackendJobRankController) Update() { tid, ok := c.tenantID() if !ok { return } id, valid := c.pathUint64(":id") if !valid { c.jsonError(400, "无效ID") return } if !c.rankQuery(tid).Filter("id", id).Exist() { c.jsonError(404, "职级不存在") return } payload, ok := c.jobRankPayloadOf() if !ok { c.jsonError(400, "参数错误") return } update := orm.Params{} if payload.RankCode != nil { code := strings.TrimSpace(*payload.RankCode) if code == "" { c.jsonError(400, "职级编码不能为空") return } count, err := c.rankQuery(tid).Filter("rank_code", code).Exclude("id", id).Count() if err != nil { c.jsonError(500, "校验职级编码失败: "+err.Error()) return } if count > 0 { c.jsonError(400, "职级编码已存在") return } update["rank_code"] = code } if payload.RankName != nil { name := strings.TrimSpace(*payload.RankName) if name == "" { c.jsonError(400, "职级名称不能为空") return } update["rank_name"] = name } if payload.LevelNo != nil { update["level_no"] = *payload.LevelNo } if payload.DepartmentID != nil { departmentID := *payload.DepartmentID if departmentID > 0 && !jobRankOrgExists(tid, departmentID) { c.jsonError(400, "适用部门不存在") return } update["department_id"] = departmentID } if payload.SalaryMin != nil || payload.SalaryMax != nil { // 薪资区间必须整体校验:只传一端时用另一端的现值补齐。 var row models.BackendJobRank if err := c.rankQuery(tid).Filter("id", id).One(&row); err != nil { c.jsonError(404, "职级不存在") return } salaryMin, salaryMax := row.SalaryMin, row.SalaryMax if payload.SalaryMin != nil { salaryMin = *payload.SalaryMin } if payload.SalaryMax != nil { salaryMax = *payload.SalaryMax } if salaryMin < 0 || salaryMax < 0 { c.jsonError(400, "标准薪资不能为负数") return } if salaryMax > 0 && salaryMax < salaryMin { c.jsonError(400, "标准薪资上限不能小于下限") return } update["salary_min"] = salaryMin update["salary_max"] = salaryMax } if payload.DefaultRoleID != nil { // 预留字段:本期不启用职级→角色权限绑定,仅落库保存。 update["default_role_id"] = *payload.DefaultRoleID } if payload.Status != nil { status := int8(1) if *payload.Status == 0 { status = 0 } update["status"] = status } if payload.Sort != nil { update["sort"] = *payload.Sort } if payload.Remark != nil { update["remark"] = nullableString(strings.TrimSpace(*payload.Remark)) } if len(update) == 0 { c.jsonError(400, "无更新字段") return } if _, err := c.rankQuery(tid).Filter("id", id).Update(update); err != nil { c.jsonError(500, "更新职级失败: "+err.Error()) return } c.jsonOK(nil) } // Delete 删除职级(软删除)。 // DELETE /backend/jobrank/delete/:id func (c *BackendJobRankController) Delete() { tid, ok := c.tenantID() if !ok { return } id, valid := c.pathUint64(":id") if !valid { c.jsonError(400, "无效ID") return } // 还有职位引用该职级时不允许删除:否则职级消失后职位会指向空职级, // 薪酬/权限按职级取数时拿不到任何标准值。 positionCount, err := models.Orm.QueryTable(new(models.BackendPosition)). Filter("tid", tid). Filter("rank_id", id). Filter("delete_time__isnull", true). Count() if err != nil { c.jsonError(500, "校验职级引用失败: "+err.Error()) return } if positionCount > 0 { c.jsonError(400, "该职级已被 "+strconv.FormatInt(positionCount, 10)+" 个职位引用,请先调整职位的职级") return } // 薪酬方案同样引用职级:删掉职级会让方案拿不到标准薪资区间, // 历史方案也会指向空职级,因此一并拦下。 schemeCount, err := models.Orm.QueryTable(new(models.BackendOaCompensationScheme)). Filter("tid", tid). Filter("rank_id", id). Filter("is_deleted", 0). Count() if err != nil { c.jsonError(500, "校验职级引用失败: "+err.Error()) return } if schemeCount > 0 { c.jsonError(400, "该职级已被 "+strconv.FormatInt(schemeCount, 10)+" 个薪酬方案引用,请先调整方案的职级") return } num, err := c.rankQuery(tid).Filter("id", id). Update(orm.Params{"delete_time": c.nowString(), "status": int8(0)}) if err != nil { c.jsonError(500, "删除职级失败: "+err.Error()) return } if num == 0 { c.jsonError(404, "职级不存在") return } c.jsonOK(nil) } // CheckCodeUnique 检查职级编码唯一性。 // GET /backend/jobrank/checkCodeUnique?rank_code=xx&exclude_id=1 func (c *BackendJobRankController) CheckCodeUnique() { tid, ok := c.tenantID() if !ok { return } code := strings.TrimSpace(c.GetString("rank_code")) if code == "" { c.jsonError(400, "职级编码不能为空") return } excludeID, _ := c.GetUint64("exclude_id") qs := c.rankQuery(tid).Filter("rank_code", code) if excludeID > 0 { qs = qs.Exclude("id", excludeID) } count, err := qs.Count() if err != nil { c.jsonError(500, "检查编码唯一性失败: "+err.Error()) return } c.jsonOK(map[string]interface{}{"unique": count == 0, "message": uniqueMessage(count == 0, "编码")}) } // --------------------------------------------------------------------------- // 供其它模块复用的辅助函数 // --------------------------------------------------------------------------- // jobRankDTOOf 把模型行转成 DTO,并补上部门名称。 func jobRankDTOOf(row models.BackendJobRank, deptNames map[uint64]string) jobRankDTO { deptName := "" if row.DepartmentID > 0 { deptName = deptNames[row.DepartmentID] } if row.DepartmentID == 0 { deptName = "全公司通用" } return jobRankDTO{ ID: row.ID, Tid: row.Tid, TenantID: row.Tid, RankCode: row.RankCode, RankName: row.RankName, LevelNo: row.LevelNo, DepartmentID: row.DepartmentID, DepartmentName: deptName, SalaryMin: row.SalaryMin, SalaryMax: row.SalaryMax, DefaultRoleID: row.DefaultRoleID, Status: row.Status, Sort: row.Sort, Remark: derefString(row.Remark), CreateTime: formatDateTime(&row.CreateTime), } } // jobRankDeptNameMap 组织ID -> 名称,用于展示职级适用部门。 func jobRankDeptNameMap(tid uint64) map[uint64]string { result := map[uint64]string{} var rows []models.BackendOrganization if _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("delete_time__isnull", true). All(&rows, "ID", "OrgName"); err != nil { return result } for _, row := range rows { result[row.ID] = row.OrgName } return result } // jobRankOrgExists 判断部门是否存在(同租户)。 func jobRankOrgExists(tid, id uint64) bool { if id == 0 { return false } return models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("id", id). Filter("delete_time__isnull", true). Exist() } // JobRankMap 返回租户内的职级映射(ID -> 模型行),供职位、薪酬等模块复用。 func JobRankMap(tid uint64) map[uint64]models.BackendJobRank { result := map[uint64]models.BackendJobRank{} if tid == 0 { return result } var rows []models.BackendJobRank if _, err := models.Orm.QueryTable(new(models.BackendJobRank)). Filter("tid", tid). Filter("delete_time__isnull", true). All(&rows); err != nil { return result } for _, row := range rows { result[row.ID] = row } return result } // ResolveEmployeeRankID 解析员工对应的职级: // 员工上记录的职位是「职位名称」,先按 部门+职位名 找到职位,再取职位上的职级。 // 找不到职位或职位未设置职级时返回 0。 func ResolveEmployeeRankID(tid uint64, employee models.BackendEmployee) uint64 { positionName := strings.TrimSpace(derefString(employee.Position)) if positionName == "" { return 0 } qs := models.Orm.QueryTable(new(models.BackendPosition)). Filter("tid", tid). Filter("position_name", positionName). Filter("delete_time__isnull", true) if departmentID, err := strconv.ParseUint(strings.TrimSpace(derefString(employee.Department)), 10, 64); err == nil && departmentID > 0 { qs = qs.Filter("department_id", departmentID) } var row models.BackendPosition if err := qs.OrderBy("id").One(&row); err != nil { return 0 } return row.RankID }