修复快捷工具无法显示
This commit is contained in:
1 parent
9898962836
commit
2e2b837a20
23 files changed
+1941
-38
No files matched your search
@@ -0,0 +1,675 @@
|
||||
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
|
||||
}
|
||||
@@ -73,7 +73,11 @@ type compensationItemPayload struct {
|
||||
}
|
||||
|
||||
type compensationSchemePayload struct {
|
||||
EmployeeID uint64 `json:"employee_id"`
|
||||
EmployeeID uint64 `json:"employee_id"`
|
||||
// RankID 职级ID(yz_backend_job_rank.id),0=未指定。
|
||||
// 传 0 时后端会按「员工 → 职位 → 职级」自动带出;带上后按职级的
|
||||
// 标准薪资区间给出参考薪资(base_salary 未填时自动取下限)。
|
||||
RankID uint64 `json:"rank_id"`
|
||||
SchemeName string `json:"scheme_name"`
|
||||
EffectiveDate string `json:"effective_date"`
|
||||
ExpiryDate string `json:"expiry_date"`
|
||||
@@ -299,7 +303,80 @@ func (c *BackendOaCompensationController) SchemeList() {
|
||||
if rows == nil {
|
||||
rows = []models.BackendOaCompensationScheme{}
|
||||
}
|
||||
c.compensationOK(rows)
|
||||
c.compensationOK(compensationSchemeViews(uint64(claims.TenantId), rows))
|
||||
}
|
||||
|
||||
// compensationSchemeView 给单个薪酬方案补上职级信息:
|
||||
// 职级名称与该职级的标准薪资区间,前端据此展示「按职级带出的标准薪资」。
|
||||
func compensationSchemeView(tid uint64, row models.BackendOaCompensationScheme) map[string]interface{} {
|
||||
item := map[string]interface{}{}
|
||||
if b, err := json.Marshal(row); err == nil {
|
||||
_ = json.Unmarshal(b, &item)
|
||||
}
|
||||
item["rank_name"] = ""
|
||||
item["rank_code"] = ""
|
||||
item["rank_level_no"] = 0
|
||||
item["rank_salary_min"] = float64(0)
|
||||
item["rank_salary_max"] = float64(0)
|
||||
if row.RankID > 0 {
|
||||
if rank, ok := JobRankMap(tid)[row.RankID]; ok {
|
||||
item["rank_name"] = rank.RankName
|
||||
item["rank_code"] = rank.RankCode
|
||||
item["rank_level_no"] = rank.LevelNo
|
||||
item["rank_salary_min"] = rank.SalaryMin
|
||||
item["rank_salary_max"] = rank.SalaryMax
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// compensationSchemeViews 给薪酬方案列表补上职级信息。
|
||||
func compensationSchemeViews(tid uint64, rows []models.BackendOaCompensationScheme) []map[string]interface{} {
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
list = append(list, compensationSchemeView(tid, row))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// compensationResolveRank 解析薪酬方案使用的职级:
|
||||
// - 显式传了 rank_id:必须属于当前租户,且适用部门要与员工部门一致
|
||||
// (全公司通用职级不受限);
|
||||
// - 没传:按「员工 → 职位 → 职级」自动带出。
|
||||
// 第三个返回值为错误提示,空串表示解析成功。
|
||||
func (c *BackendOaCompensationController) compensationResolveRank(
|
||||
tid uint64, employee *models.BackendEmployee, rankID uint64,
|
||||
) (uint64, models.BackendJobRank, string) {
|
||||
if employee == nil {
|
||||
return 0, models.BackendJobRank{}, "员工不存在或不属于当前租户"
|
||||
}
|
||||
ranks := JobRankMap(tid)
|
||||
|
||||
if rankID == 0 {
|
||||
// 未指定:按员工所在职位带出职级
|
||||
derived := ResolveEmployeeRankID(tid, *employee)
|
||||
if derived == 0 {
|
||||
return 0, models.BackendJobRank{}, ""
|
||||
}
|
||||
rank, ok := ranks[derived]
|
||||
if !ok {
|
||||
return 0, models.BackendJobRank{}, ""
|
||||
}
|
||||
return derived, rank, ""
|
||||
}
|
||||
|
||||
rank, ok := ranks[rankID]
|
||||
if !ok {
|
||||
return 0, models.BackendJobRank{}, "职级不存在或不属于当前租户"
|
||||
}
|
||||
// 职级适用部门必须覆盖员工所在部门
|
||||
if rank.DepartmentID != 0 {
|
||||
departmentID, err := strconv.ParseUint(strings.TrimSpace(compensationString(employee.Department)), 10, 64)
|
||||
if err == nil && departmentID > 0 && rank.DepartmentID != departmentID {
|
||||
return 0, models.BackendJobRank{}, "职级「" + rank.RankName + "」不适用于该员工所在部门"
|
||||
}
|
||||
}
|
||||
return rankID, rank, ""
|
||||
}
|
||||
|
||||
// CreateScheme POST /backend/oa/compensation/schemes
|
||||
@@ -342,9 +419,21 @@ func (c *BackendOaCompensationController) CreateScheme() {
|
||||
if status != 0 {
|
||||
status = 1
|
||||
}
|
||||
// 职级:未指定时按「员工 → 职位 → 职级」自动带出;
|
||||
// 带出后 base_salary 留空则取职级标准薪资下限作为参考值。
|
||||
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
|
||||
if rankErr != "" {
|
||||
c.compensationError(400, rankErr)
|
||||
return
|
||||
}
|
||||
baseSalary := compensationMoney(payload.BaseSalary)
|
||||
if baseSalary <= 0 && rank.SalaryMin > 0 {
|
||||
baseSalary = compensationMoney(rank.SalaryMin)
|
||||
}
|
||||
row := &models.BackendOaCompensationScheme{
|
||||
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, SchemeName: strings.TrimSpace(payload.SchemeName),
|
||||
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: compensationMoney(payload.BaseSalary),
|
||||
Tid: uint64(claims.TenantId), EmployeeID: payload.EmployeeID, RankID: rankID,
|
||||
SchemeName: strings.TrimSpace(payload.SchemeName),
|
||||
EffectiveDate: date, ExpiryDate: expiry, BaseSalary: baseSalary,
|
||||
PostAllowance: compensationMoney(payload.PostAllowance), PerformanceSalary: compensationMoney(payload.PerformanceSalary),
|
||||
TransportAllowance: compensationMoney(payload.TransportAllowance), MealAllowance: compensationMoney(payload.MealAllowance),
|
||||
CommunicationAllowance: compensationMoney(payload.CommunicationAllowance), SocialInsuranceBase: compensationMoney(payload.SocialInsuranceBase),
|
||||
@@ -355,7 +444,7 @@ func (c *BackendOaCompensationController) CreateScheme() {
|
||||
c.compensationError(500, "保存薪酬方案失败")
|
||||
return
|
||||
}
|
||||
c.compensationOK(row)
|
||||
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), *row))
|
||||
}
|
||||
|
||||
// UpdateScheme POST /backend/oa/compensation/schemes/:id
|
||||
@@ -385,10 +474,16 @@ func (c *BackendOaCompensationController) UpdateScheme() {
|
||||
c.compensationError(400, "方案内容无效")
|
||||
return
|
||||
}
|
||||
if _, ok = c.employee(uint64(claims.TenantId), payload.EmployeeID); !ok {
|
||||
employee, ok := c.employee(uint64(claims.TenantId), payload.EmployeeID)
|
||||
if !ok {
|
||||
c.compensationError(400, "员工不存在或不属于当前租户")
|
||||
return
|
||||
}
|
||||
rankID, rank, rankErr := c.compensationResolveRank(uint64(claims.TenantId), employee, payload.RankID)
|
||||
if rankErr != "" {
|
||||
c.compensationError(400, rankErr)
|
||||
return
|
||||
}
|
||||
var expiry *time.Time
|
||||
if strings.TrimSpace(payload.ExpiryDate) != "" {
|
||||
value, dateOK := validCompensationDate(payload.ExpiryDate)
|
||||
@@ -400,7 +495,12 @@ func (c *BackendOaCompensationController) UpdateScheme() {
|
||||
}
|
||||
now := time.Now()
|
||||
row.EmployeeID, row.SchemeName, row.EffectiveDate, row.ExpiryDate = payload.EmployeeID, strings.TrimSpace(payload.SchemeName), date, expiry
|
||||
row.RankID = rankID
|
||||
row.BaseSalary, row.PostAllowance, row.PerformanceSalary = compensationMoney(payload.BaseSalary), compensationMoney(payload.PostAllowance), compensationMoney(payload.PerformanceSalary)
|
||||
// 职级带出:base_salary 未填且职级有标准薪资时取下限作为参考值
|
||||
if row.BaseSalary <= 0 && rank.SalaryMin > 0 {
|
||||
row.BaseSalary = compensationMoney(rank.SalaryMin)
|
||||
}
|
||||
row.TransportAllowance, row.MealAllowance, row.CommunicationAllowance = compensationMoney(payload.TransportAllowance), compensationMoney(payload.MealAllowance), compensationMoney(payload.CommunicationAllowance)
|
||||
row.SocialInsuranceBase, row.HousingFundBase, row.SocialInsuranceRate, row.HousingFundRate = compensationMoney(payload.SocialInsuranceBase), compensationMoney(payload.HousingFundBase), payload.SocialInsuranceRate, payload.HousingFundRate
|
||||
row.Status, row.Remark, row.UpdateTime = payload.Status, strings.TrimSpace(payload.Remark), &now
|
||||
@@ -411,7 +511,7 @@ func (c *BackendOaCompensationController) UpdateScheme() {
|
||||
c.compensationError(500, "更新薪酬方案失败")
|
||||
return
|
||||
}
|
||||
c.compensationOK(row)
|
||||
c.compensationOK(compensationSchemeView(uint64(claims.TenantId), row))
|
||||
}
|
||||
|
||||
// DeleteScheme DELETE /backend/oa/compensation/schemes/:id
|
||||
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
// 前端传入的 tid / tenant_id 一律忽略,避免越权读取其它租户的数据。
|
||||
type BackendOrganizationController struct {
|
||||
beego.Controller
|
||||
|
||||
// jobRanks 职级映射缓存:beego 每请求新建一个控制器实例,
|
||||
// 因此这里的缓存只在单次请求内有效,正好避免职位列表逐条查职级表(N+1)。
|
||||
jobRanks map[uint64]models.BackendJobRank
|
||||
jobRanksTid uint64
|
||||
}
|
||||
|
||||
const orgSettingsCodePrefix = "backend_org_settings"
|
||||
@@ -112,6 +117,9 @@ type positionDTO struct {
|
||||
PositionCode string `json:"position_code"`
|
||||
PositionName string `json:"position_name"`
|
||||
PositionType int8 `json:"position_type"`
|
||||
RankID uint64 `json:"rank_id"`
|
||||
RankName string `json:"rank_name"`
|
||||
RankLevelNo int `json:"rank_level_no"`
|
||||
Status int8 `json:"status"`
|
||||
Sort uint `json:"sort"`
|
||||
Remark string `json:"remark"`
|
||||
@@ -1298,12 +1306,27 @@ func (c *BackendOrganizationController) CreatePosition() {
|
||||
sortVal, _ := c.getUintValue(body, "sort")
|
||||
remark, _ := c.getStringValue(body, "remark")
|
||||
|
||||
// 职级:必须是本租户的职级,且适用部门要对得上(0=全公司通用始终可用)。
|
||||
rankID, _ := c.getUint64Value(body, "rank_id")
|
||||
if rankID > 0 {
|
||||
rank, ok := c.jobRankMap(tid)[rankID]
|
||||
if !ok {
|
||||
c.jsonError(400, "职级不存在")
|
||||
return
|
||||
}
|
||||
if rank.DepartmentID != 0 && departmentID > 0 && rank.DepartmentID != departmentID {
|
||||
c.jsonError(400, "职级「"+rank.RankName+"」不属于所选部门")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
row := models.BackendPosition{
|
||||
Tid: tid,
|
||||
DepartmentID: departmentID,
|
||||
PositionCode: positionCode,
|
||||
PositionName: positionName,
|
||||
PositionType: int8(positionType),
|
||||
RankID: rankID,
|
||||
Status: 1,
|
||||
Sort: sortVal,
|
||||
Remark: strPtrIfNotEmpty(remark),
|
||||
@@ -1379,6 +1402,30 @@ func (c *BackendOrganizationController) EditPosition() {
|
||||
if v, has := c.getIntValue(body, "position_type"); has {
|
||||
update["position_type"] = int8(v)
|
||||
}
|
||||
if v, has := c.getUint64Value(body, "rank_id"); has {
|
||||
// 职级必须是本租户的职级;适用部门与职位所属部门不一致时拒绝。
|
||||
if v > 0 {
|
||||
rank, ok := c.jobRankMap(tid)[v]
|
||||
if !ok {
|
||||
c.jsonError(400, "职级不存在")
|
||||
return
|
||||
}
|
||||
departmentID := uint64(0)
|
||||
if cur, has := c.getUint64Value(body, "department_id"); has {
|
||||
departmentID = cur
|
||||
} else {
|
||||
var cur models.BackendPosition
|
||||
if err := c.positionQuery(tid).Filter("id", id).One(&cur); err == nil {
|
||||
departmentID = cur.DepartmentID
|
||||
}
|
||||
}
|
||||
if rank.DepartmentID != 0 && departmentID > 0 && rank.DepartmentID != departmentID {
|
||||
c.jsonError(400, "职级「"+rank.RankName+"」不属于所选部门")
|
||||
return
|
||||
}
|
||||
}
|
||||
update["rank_id"] = v
|
||||
}
|
||||
if v, has := c.getIntValue(body, "status"); has {
|
||||
update["status"] = int8(v)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func (c *BackendOrganizationController) assembleEmployeeDTO(
|
||||
|
||||
func (c *BackendOrganizationController) positionDTO(tid uint64, row models.BackendPosition) positionDTO {
|
||||
nameByID := c.orgNameMap(tid)
|
||||
return positionDTO{
|
||||
dto := positionDTO{
|
||||
ID: row.ID,
|
||||
Tid: row.Tid,
|
||||
TenantID: row.Tid,
|
||||
@@ -141,11 +141,29 @@ func (c *BackendOrganizationController) positionDTO(tid uint64, row models.Backe
|
||||
PositionCode: row.PositionCode,
|
||||
PositionName: row.PositionName,
|
||||
PositionType: row.PositionType,
|
||||
RankID: row.RankID,
|
||||
Status: row.Status,
|
||||
Sort: row.Sort,
|
||||
Remark: derefString(row.Remark),
|
||||
CreateTime: formatDateTime(&row.CreateTime),
|
||||
}
|
||||
// 职级名称:职位挂了职级才显示,未设置时留空由前端显示「-」。
|
||||
if row.RankID > 0 {
|
||||
if rank, ok := c.jobRankMap(tid)[row.RankID]; ok {
|
||||
dto.RankName = rank.RankName
|
||||
dto.RankLevelNo = rank.LevelNo
|
||||
}
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// jobRankMap 取租户内的职级映射,单次请求内只查一次。
|
||||
func (c *BackendOrganizationController) jobRankMap(tid uint64) map[uint64]models.BackendJobRank {
|
||||
if c.jobRanks == nil || c.jobRanksTid != tid {
|
||||
c.jobRanks = JobRankMap(tid)
|
||||
c.jobRanksTid = tid
|
||||
}
|
||||
return c.jobRanks
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- =============================================================
|
||||
-- OA 职级管理 —— 表结构
|
||||
--
|
||||
-- 对应代码:
|
||||
-- models/backend_job_rank.go 职级模型
|
||||
-- controllers/backend_job_rank.go 职级 CRUD 接口
|
||||
--
|
||||
-- 职级(Job Rank)用于定义「职位等级」:
|
||||
-- · 每个部门可以各设一套职级阶梯。department_id 指向
|
||||
-- yz_backend_organization.id;department_id = 0 表示
|
||||
-- 全公司通用职级,任何部门都能选用。
|
||||
-- · level_no 是等级序号(数字越大职级越高),用于排序与
|
||||
-- 「取不超过某职级的最高一级」这类计算。
|
||||
-- · salary_min / salary_max 是该职级的标准薪资区间,
|
||||
-- 薪酬方案选定职级后据此带出参考薪资。
|
||||
-- · default_role_id 是为「职级 → 角色权限绑定」预留的字段,
|
||||
-- 本期不启用,后端不做任何自动授权,等权限方案确定后单独做。
|
||||
--
|
||||
-- 使用链路:
|
||||
-- 员工(yz_backend_employee) → 职位(yz_backend_position.rank_id)
|
||||
-- → 职级(yz_backend_job_rank)
|
||||
-- 薪酬方案(yz_backend_oa_compensation_schemes.rank_id) → 职级
|
||||
--
|
||||
-- 执行顺序:
|
||||
-- 1) 本脚本:建表 + 给职位表 / 薪酬方案表补 rank_id 列
|
||||
-- 2) oa_job_rank_menus.sql:注册「OA → 基础设置 → 职级管理」菜单并授权角色
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------- 职级表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_job_rank` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`rank_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职级编码,如 P5 / M2',
|
||||
`rank_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '职级名称,如 高级工程师',
|
||||
`level_no` INT NOT NULL DEFAULT 0 COMMENT '等级序号,数字越大职级越高',
|
||||
`department_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '适用部门ID(yz_backend_organization.id),0=全公司通用',
|
||||
`salary_min` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '标准薪资下限',
|
||||
`salary_max` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '标准薪资上限,0=不限',
|
||||
`default_role_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '预留:默认角色ID(职级→角色权限绑定,本期不启用)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用 1=启用',
|
||||
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`remark` VARCHAR(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`delete_time` DATETIME DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_tid_deleted` (`tid`, `delete_time`) USING BTREE,
|
||||
KEY `idx_tid_department` (`tid`, `department_id`) USING BTREE,
|
||||
KEY `idx_tid_code` (`tid`, `rank_code`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='职级表';
|
||||
|
||||
-- =============================================================
|
||||
-- 给既有表补 rank_id 列
|
||||
-- MySQL 不支持 ADD COLUMN IF NOT EXISTS,先查 information_schema 再动态执行,
|
||||
-- 保证脚本可重复执行。
|
||||
-- =============================================================
|
||||
|
||||
-- ---------- yz_backend_position(职位挂职级) ----------
|
||||
SET @tbl := 'yz_backend_position';
|
||||
SET @col := 'rank_id';
|
||||
SET @exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
|
||||
);
|
||||
SET @sql := IF(@exists = 0,
|
||||
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
|
||||
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置'' AFTER `position_type`'),
|
||||
CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
|
||||
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置''')
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------- yz_backend_oa_compensation_schemes(薪酬方案带职级) ----------
|
||||
SET @tbl := 'yz_backend_oa_compensation_schemes';
|
||||
SET @col := 'rank_id';
|
||||
SET @exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
|
||||
);
|
||||
SET @sql := IF(@exists = 0,
|
||||
CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
|
||||
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置'' AFTER `employee_id`'),
|
||||
CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
|
||||
'` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''职级ID(yz_backend_job_rank.id),0=未设置''')
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- ---------------- 校验 ----------------
|
||||
SHOW CREATE TABLE `yz_backend_job_rank`;
|
||||
|
||||
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'rank_id'
|
||||
AND TABLE_NAME IN ('yz_backend_position', 'yz_backend_oa_compensation_schemes');
|
||||
@@ -0,0 +1,67 @@
|
||||
-- =============================================================
|
||||
-- 修复:模块「展示端(platforms)」误配置导致菜单整块消失
|
||||
--
|
||||
-- 背景:
|
||||
-- yz_system_modules.platforms 是与 status/is_show、菜单 views、角色 rights
|
||||
-- 并列的第四个维度,决定模块/菜单出现在 Web 端还是移动端:
|
||||
-- ["web"] 仅 Web 端(租户管理后台 PC)
|
||||
-- ["app"] 仅移动端(APP / 微信小程序)
|
||||
-- ["web","app"] 两端都显示
|
||||
-- '' / NULL 全端显示(默认)
|
||||
--
|
||||
-- 菜单自身未配置 platforms 时,按 path 前缀继承所属模块的 platforms
|
||||
-- (services.MenuVisibleOn)。所以模块一旦配错,其下整棵菜单都会被
|
||||
-- 某一端整体剔除——而后台的「模块管理 / 菜单管理 / 角色管理」里
|
||||
-- 所有开关都是正常的,界面上完全看不出原因。
|
||||
--
|
||||
-- 事故原因:
|
||||
-- add_module_platforms.sql 里的「使用示例」第 1、2 条是演示语句,
|
||||
-- 却被原样执行到了正式库:
|
||||
-- 例1 UPDATE yz_system_modules SET platforms = JSON_ARRAY('web') WHERE code = 'crm';
|
||||
-- 例2 UPDATE yz_system_modules SET platforms = JSON_ARRAY('app') WHERE code = 'bianjiegongju';
|
||||
--
|
||||
-- 后果:
|
||||
-- 例2 → 租户后台(Web)左侧菜单里 /tools(便捷工具)整块消失:
|
||||
-- 记事本、日程提醒、账号管理全部不显示。
|
||||
-- 例1 → 移动端工作台没有「客户关系」模块卡片,/apps/crm 下 29 条菜单全部不显示。
|
||||
--
|
||||
-- 修复:两个模块都恢复为两端可见。
|
||||
-- 依据:
|
||||
-- · 便捷工具:backend/src/views/tools/ 有记事本/日程/密码库完整页面;
|
||||
-- uniapp/pages/toolbox/ 有 home/functions/mine/todo;
|
||||
-- 菜单 views 含 2;全部租户套餐都含 bianjiegongju。
|
||||
-- · 客户关系:backend/src/views/apps/crm/ 与 uniapp/pages/crm/ 均已完整实现;
|
||||
-- SUBSYSTEMS 已注册 crm;菜单 views 全为 2(租户端两端共用)。
|
||||
--
|
||||
-- 回滚:
|
||||
-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('app')
|
||||
-- WHERE code = 'bianjiegongju';
|
||||
-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web')
|
||||
-- WHERE code = 'crm';
|
||||
-- =============================================================
|
||||
|
||||
-- ---------- 修复前先看一眼现状 ----------
|
||||
SELECT id, name, code, path, type, status, is_show, platforms
|
||||
FROM yz_system_modules
|
||||
WHERE code IN ('bianjiegongju', 'crm');
|
||||
|
||||
-- ---------- 恢复为两端可见 ----------
|
||||
UPDATE yz_system_modules
|
||||
SET platforms = JSON_ARRAY('web', 'app'),
|
||||
update_time = NOW()
|
||||
WHERE code IN ('bianjiegongju', 'crm')
|
||||
AND delete_time IS NULL;
|
||||
|
||||
-- ---------- 校验 ----------
|
||||
SELECT id, name, code, path, type, status, is_show, platforms
|
||||
FROM yz_system_modules
|
||||
WHERE code IN ('bianjiegongju', 'crm');
|
||||
|
||||
-- ---------- 顺带体检:其余模块不应残留单端配置 ----------
|
||||
-- 除本次修复的两个模块外,其余模块 platforms 应为 NULL(全端)。
|
||||
SELECT id, name, code, path, platforms
|
||||
FROM yz_system_modules
|
||||
WHERE delete_time IS NULL
|
||||
AND platforms IS NOT NULL
|
||||
AND platforms <> ''
|
||||
AND code NOT IN ('bianjiegongju', 'crm');
|
||||
@@ -0,0 +1,68 @@
|
||||
-- ============================================================
|
||||
-- OA 职级管理 —— 菜单配置(yz_system_menu)
|
||||
--
|
||||
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_job_rank_menus.sql
|
||||
--
|
||||
-- 挂载位置:OA → 基础设置(目录,path = '/apps/oa/normalsetting')
|
||||
-- OA
|
||||
-- └─ 基础设置
|
||||
-- ├─ 组织架构 /basic-settings/organization
|
||||
-- ├─ 人员管理 /basic-settings/employee
|
||||
-- ├─ 职位管理 /basic-settings/position
|
||||
-- └─ 职级管理 /basic-settings/jobrank ← 本脚本新增
|
||||
--
|
||||
-- 对应页面:backend/src/views/basicSettings/jobRank/index.vue
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. views = [2](租户端),与同级的组织架构/人员管理/职位管理保持一致;
|
||||
-- platforms 留空(= 全端),移动端可通过 webview 容器打开同一页面。
|
||||
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
|
||||
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
|
||||
-- 因此本脚本同时把菜单 ID 追加到角色 rights。
|
||||
-- ============================================================
|
||||
|
||||
SET @basic_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/apps/oa/normalsetting' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 职级管理(页面) ----------
|
||||
INSERT INTO yz_system_menu
|
||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||
SELECT @basic_id, '职级管理', '/basic-settings/jobrank', '/basicSettings/jobRank/index.vue',
|
||||
'Sort', 4, 1, 1, '[2]', NULL, 2, 'system',
|
||||
'按部门设置职位等级,供薪酬管理与权限分级使用'
|
||||
FROM DUAL
|
||||
WHERE @basic_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basic-settings/jobrank' AND delete_time IS NULL) x);
|
||||
|
||||
SET @jobrank_id := (
|
||||
SELECT id FROM yz_system_menu
|
||||
WHERE path = '/basic-settings/jobrank' AND delete_time IS NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||
|
||||
UPDATE yz_system_admin_role
|
||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @jobrank_id) AS CHAR)
|
||||
WHERE cid = 2
|
||||
AND rights IS NOT NULL
|
||||
AND TRIM(rights) <> ''
|
||||
AND JSON_VALID(rights)
|
||||
AND @jobrank_id IS NOT NULL
|
||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@jobrank_id AS JSON));
|
||||
|
||||
-- ---------- 结果校验 ----------
|
||||
SELECT id, pid, title, path, component_path, views, platforms, type, sort
|
||||
FROM yz_system_menu
|
||||
WHERE delete_time IS NULL AND path = '/basic-settings/jobrank';
|
||||
|
||||
SELECT id, name, tenant_id,
|
||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@jobrank_id AS JSON)) AS has_jobrank
|
||||
FROM yz_system_admin_role
|
||||
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||
ORDER BY tenant_id, id;
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// BackendJobRank 职级表 yz_backend_job_rank
|
||||
//
|
||||
// 职级用于定义「职位等级」:每个部门可以各设一套职级阶梯,
|
||||
// DepartmentID = 0 表示全公司通用职级,任何部门都能选用。
|
||||
//
|
||||
// 与既有模块的关系:
|
||||
// - 职位通过 rank_id 挂到职级上(yz_backend_position.rank_id);
|
||||
// - 薪酬方案通过 rank_id 挂到职级上(yz_backend_oa_compensation_schemes.rank_id),
|
||||
// 并按职级的标准薪资区间(SalaryMin / SalaryMax)给出参考薪资;
|
||||
// - DefaultRoleID 是为「职级 → 角色权限绑定」预留的字段,本期不启用,
|
||||
// 后端不做任何自动授权,等权限方案确定后单独做。
|
||||
type BackendJobRank struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
RankCode string `orm:"column(rank_code);size(50)" json:"rank_code"`
|
||||
RankName string `orm:"column(rank_name);size(100)" json:"rank_name"`
|
||||
LevelNo int `orm:"column(level_no);default(0)" json:"level_no"` // 等级序号,数字越大职级越高
|
||||
DepartmentID uint64 `orm:"column(department_id);default(0)" json:"department_id"` // 适用部门ID,0=全公司通用
|
||||
SalaryMin float64 `orm:"column(salary_min);type(decimal);digits(12);decimals(2);default(0)" json:"salary_min"`
|
||||
SalaryMax float64 `orm:"column(salary_max);type(decimal);digits(12);decimals(2);default(0)" json:"salary_max"`
|
||||
DefaultRoleID uint64 `orm:"column(default_role_id);default(0)" json:"default_role_id"` // 预留:职级→角色权限绑定,本期不启用
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:0-禁用 1-启用
|
||||
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
// TableName 自定义表名
|
||||
func (m *BackendJobRank) TableName() string {
|
||||
return "yz_backend_job_rank"
|
||||
}
|
||||
@@ -8,6 +8,9 @@ type BackendOaCompensationScheme struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
EmployeeID uint64 `orm:"column(employee_id)" json:"employee_id"`
|
||||
// RankID 职级ID(yz_backend_job_rank.id),0=未设置职级。
|
||||
// 方案选定职级后,按职级的标准薪资区间(salary_min/salary_max)给出参考薪资。
|
||||
RankID uint64 `orm:"column(rank_id);default(0)" json:"rank_id"`
|
||||
SchemeName string `orm:"column(scheme_name);size(100)" json:"scheme_name"`
|
||||
EffectiveDate time.Time `orm:"column(effective_date);type(date)" json:"effective_date"`
|
||||
ExpiryDate *time.Time `orm:"column(expiry_date);null;type(date)" json:"expiry_date"`
|
||||
|
||||
@@ -61,6 +61,9 @@ type BackendPosition struct {
|
||||
PositionCode string `orm:"column(position_code);size(50)" json:"position_code"`
|
||||
PositionName string `orm:"column(position_name);size(100)" json:"position_name"`
|
||||
PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"`
|
||||
// RankID 职级ID(yz_backend_job_rank.id),0=未设置职级。
|
||||
// 职级定义了「职位等级」,薪酬与权限分级都沿 员工→职位→职级 取用。
|
||||
RankID uint64 `orm:"column(rank_id);default(0)" json:"rank_id"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||
Sort uint `orm:"column(sort);default(0)" json:"sort"`
|
||||
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
||||
|
||||
@@ -47,6 +47,8 @@ func Init(_ string) {
|
||||
new(BackendOrganization),
|
||||
new(BackendEmployee),
|
||||
new(BackendPosition),
|
||||
// 职级(职位等级):yz_backend_job_rank,按部门分设职级阶梯
|
||||
new(BackendJobRank),
|
||||
new(BackendErpContact),
|
||||
new(BackendApprovalFlow),
|
||||
new(BackendApprovalRecord),
|
||||
|
||||
@@ -334,6 +334,14 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/oa/compensation/payrolls/:id", &controllers.BackendOaCompensationController{}, "get:Detail;post:Update;delete:Delete")
|
||||
beego.Router("/backend/oa/compensation/payrolls/:id/status", &controllers.BackendOaCompensationController{}, "post:UpdateStatus")
|
||||
|
||||
// OA 职级管理(职位等级):租户级基础数据,供职位与薪酬模块引用
|
||||
beego.Router("/backend/jobrank/list", &controllers.BackendJobRankController{}, "get:List")
|
||||
beego.Router("/backend/jobrank/detail/:id", &controllers.BackendJobRankController{}, "get:Detail")
|
||||
beego.Router("/backend/jobrank/create", &controllers.BackendJobRankController{}, "post:Create")
|
||||
beego.Router("/backend/jobrank/update/:id", &controllers.BackendJobRankController{}, "post:Update")
|
||||
beego.Router("/backend/jobrank/delete/:id", &controllers.BackendJobRankController{}, "delete:Delete")
|
||||
beego.Router("/backend/jobrank/checkCodeUnique", &controllers.BackendJobRankController{}, "get:CheckCodeUnique")
|
||||
|
||||
// OA工作排班(班次 / 排班方案 / 排班看板 / 临时调配)
|
||||
beego.Router("/backend/oa/scheduling/shifts", &controllers.BackendOaSchedulingController{}, "get:ShiftList;post:CreateShift")
|
||||
beego.Router("/backend/oa/scheduling/shifts/:id", &controllers.BackendOaSchedulingController{}, "post:UpdateShift;delete:DeleteShift")
|
||||
|
||||
Reference in new issue
Block a user