Files
yunzerwebsiteallinone/go/controllers/backend_job_rank.go
T

676 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}