268 lines
8.1 KiB
Go
268 lines
8.1 KiB
Go
package controllers
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
|
||
"server/models"
|
||
"server/pkg/identity"
|
||
"server/pkg/jwtutil"
|
||
"server/services"
|
||
|
||
"github.com/beego/beego/v2/client/orm"
|
||
)
|
||
|
||
// 移动端员工档案「全公司视角」接口(HR 使用)。
|
||
//
|
||
// 与 Web 端(/backend/oa/employeefile/*)的关系:
|
||
// - 数据口径完全一致:一人一档(yz_backend_employee_file)+ 教育/工作/合同/证照子记录,
|
||
// DTO 组装直接复用 Web 端 efAssembleDTOList,保证两端展示字段完全一致;
|
||
// - 只读:建档、改档、离职等写操作仍只在 Web 端提供;
|
||
// - 数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。
|
||
//
|
||
// 权限口径:租户角色的 rights 里勾选了「员工档案」菜单(或其祖先节点)即视为 HR,可见全公司档案;
|
||
// 租户管理员 / 未分配角色 / 角色未配置权限等既有「全权限」情形同样放行。
|
||
// 未放行的用户仍可查看本人档案(/api/oa/employee-file),前端据此展示不同视图。
|
||
|
||
// employeeFileMenuIDs 命中「员工档案」菜单的菜单ID集合(含其祖先节点)。
|
||
// 菜单表由租户自行维护,标题/路径可能被改写,因此按 路径关键字 + 标题关键字 双重匹配,
|
||
// 与移动端菜单跳转规则(uniapp/utils/nav.js 中的 employeefile 规则)保持同一识别口径。
|
||
func employeeFileMenuIDs() map[uint64]bool {
|
||
ids := map[uint64]bool{}
|
||
if models.Orm == nil {
|
||
return ids
|
||
}
|
||
var rows []models.SystemMenu
|
||
if _, err := models.Orm.QueryTable(new(models.SystemMenu)).
|
||
Filter("status", 1).
|
||
Filter("delete_time__isnull", true).
|
||
All(&rows); err != nil {
|
||
return ids
|
||
}
|
||
|
||
pidOf := make(map[uint64]int64, len(rows))
|
||
hit := map[uint64]bool{}
|
||
for _, m := range rows {
|
||
pidOf[m.ID] = m.Pid
|
||
hay := strings.ToLower(m.Title + " " + derefString(m.Path) + " " + derefString(m.ComponentPath))
|
||
if strings.Contains(hay, "employeefile") || strings.Contains(hay, "employee-file") ||
|
||
strings.Contains(m.Title, "员工档案") || strings.Contains(m.Title, "人事档案") {
|
||
hit[m.ID] = true
|
||
}
|
||
}
|
||
if len(hit) == 0 {
|
||
return ids
|
||
}
|
||
|
||
// 角色权限勾到父目录即代表含全部下级,因此把命中菜单的祖先一并纳入判定。
|
||
for id := range hit {
|
||
cur := id
|
||
for i := 0; i < 32; i++ { // 限制深度,防御脏数据造成的环
|
||
if _, ok := pidOf[cur]; !ok {
|
||
break
|
||
}
|
||
ids[cur] = true
|
||
pid := pidOf[cur]
|
||
if pid <= 0 {
|
||
break
|
||
}
|
||
cur = uint64(pid)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// canViewAllEmployeeFiles 判断当前用户是否有权查看全公司员工档案(HR 视角)。
|
||
func canViewAllEmployeeFiles(claims *jwtutil.Claims) bool {
|
||
if claims == nil || claims.TenantId <= 0 {
|
||
return false
|
||
}
|
||
// 平台管理员
|
||
if claims.UserType == "platform" {
|
||
return true
|
||
}
|
||
// 租户管理员 / 未分配角色 / 角色未配置权限 —— 与 Web 端 isTenantAdmin 同一口径
|
||
if isTenantAdmin(claims.UserID, claims.TenantId) {
|
||
return true
|
||
}
|
||
|
||
var tu models.SystemTenantUser
|
||
if models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||
Filter("uid", tenantUIDOf(claims)).
|
||
Filter("tid", claims.TenantId).
|
||
Filter("delete_time__isnull", true).One(&tu) != nil {
|
||
return false
|
||
}
|
||
if tu.GroupID == 0 {
|
||
return true
|
||
}
|
||
var role models.AdminRole
|
||
if models.Orm.QueryTable(new(models.AdminRole)).
|
||
Filter("id", tu.GroupID).
|
||
Filter("cid", 2).One(&role) != nil {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(role.Name) == "租户管理员" {
|
||
return true
|
||
}
|
||
rights := ""
|
||
if role.Rights != nil {
|
||
rights = strings.TrimSpace(*role.Rights)
|
||
}
|
||
if rights == "" || rights == "[]" || rights == "null" {
|
||
return true
|
||
}
|
||
|
||
allowed := services.ParseMenuRights(rights)
|
||
if len(allowed) == 0 {
|
||
return true
|
||
}
|
||
for id := range employeeFileMenuIDs() {
|
||
if allowed[id] {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// tenantUIDOf 令牌 user_id 是认证中心 identity_id,换算成本租户的老 uid。
|
||
func tenantUIDOf(claims *jwtutil.Claims) uint64 {
|
||
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||
}
|
||
|
||
// EmployeeFileAccess GET /api/oa/employee-file/access
|
||
// 返回当前用户是否具备 HR 视角(可见全公司档案),前端据此决定展示列表还是本人档案。
|
||
func (c *AppOaController) EmployeeFileAccess() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
code := identity.Status(err)
|
||
c.oaErr(code, code, err.Error())
|
||
return
|
||
}
|
||
c.oaOk(map[string]interface{}{"can_view_all": canViewAllEmployeeFiles(claims)})
|
||
}
|
||
|
||
// EmployeeFileList GET /api/oa/employee-file/list 全公司档案分页列表(需 HR 权限)。
|
||
// 支持 keyword(档案编号/身份证/员工姓名/账号/手机)、status(在职状态)、org_id(部门,含下级)。
|
||
func (c *AppOaController) EmployeeFileList() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
code := identity.Status(err)
|
||
c.oaErr(code, code, err.Error())
|
||
return
|
||
}
|
||
if !canViewAllEmployeeFiles(claims) {
|
||
c.oaErr(403, 403, "您没有查看全部员工档案的权限")
|
||
return
|
||
}
|
||
tid := claims.TenantId
|
||
|
||
page, _ := c.GetInt("page", 1)
|
||
pageSize, _ := c.GetInt("pageSize", 20)
|
||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||
statusRaw := strings.TrimSpace(c.GetString("status"))
|
||
orgID, _ := c.GetUint64("org_id")
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 20
|
||
}
|
||
|
||
web := &BackendEmployeeFileController{}
|
||
qs := web.efQuery(tid)
|
||
|
||
if orgID > 0 {
|
||
empIDs := web.efEmployeeIDsInOrgs(tid, web.efSubtreeOrgIDs(tid, orgID))
|
||
if len(empIDs) == 0 {
|
||
c.oaOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0, "page": page, "pageSize": pageSize})
|
||
return
|
||
}
|
||
qs = qs.Filter("employee_id__in", empIDs)
|
||
}
|
||
if statusRaw != "" {
|
||
status, convErr := strconv.Atoi(statusRaw)
|
||
if convErr != nil || status < 1 || status > 3 {
|
||
c.oaErr(400, 400, "在职状态参数无效")
|
||
return
|
||
}
|
||
qs = qs.Filter("employment_status", int8(status))
|
||
}
|
||
if keyword != "" {
|
||
// 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机)
|
||
empIDs := web.efEmployeeIDsByKeyword(tid, keyword)
|
||
cond := orm.NewCondition().
|
||
Or("file_no__icontains", keyword).
|
||
Or("id_card__icontains", keyword)
|
||
if len(empIDs) > 0 {
|
||
cond = cond.Or("employee_id__in", empIDs)
|
||
}
|
||
qs = qs.SetCond(orm.NewCondition().AndCond(cond))
|
||
}
|
||
|
||
total, _ := qs.Count()
|
||
var files []models.BackendEmployeeFile
|
||
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&files); err != nil && err != orm.ErrNoRows {
|
||
c.oaErr(500, 500, "查询失败")
|
||
return
|
||
}
|
||
|
||
c.oaOk(map[string]interface{}{
|
||
"list": web.efAssembleDTOList(tid, files),
|
||
"total": total,
|
||
"page": page,
|
||
"pageSize": pageSize,
|
||
})
|
||
}
|
||
|
||
// EmployeeFileDetail GET /api/oa/employee-file/detail/:id 员工档案详情(含子记录)。
|
||
//
|
||
// 两种访问路径:
|
||
// - HR 视角:可查看任意员工的档案(租户内隔离);
|
||
// - 普通员工:仅可查看本人档案,看他人档案返回 403。
|
||
func (c *AppOaController) EmployeeFileDetail() {
|
||
claims, err := c.oaClaims()
|
||
if err != nil {
|
||
code := identity.Status(err)
|
||
c.oaErr(code, code, err.Error())
|
||
return
|
||
}
|
||
tid := claims.TenantId
|
||
|
||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||
if id == 0 {
|
||
c.oaErr(400, 400, "无效的档案ID")
|
||
return
|
||
}
|
||
|
||
web := &BackendEmployeeFileController{}
|
||
var file models.BackendEmployeeFile
|
||
if web.efQuery(tid).Filter("id", id).One(&file) != nil {
|
||
c.oaErr(404, 404, "档案不存在")
|
||
return
|
||
}
|
||
|
||
if !canViewAllEmployeeFiles(claims) {
|
||
emp, ok := c.oaMyEmployee(claims)
|
||
if !ok || uint64(emp.ID) != file.EmployeeID {
|
||
c.oaErr(403, 403, "您没有查看他人档案的权限")
|
||
return
|
||
}
|
||
}
|
||
|
||
var records []models.BackendEmployeeFileRecord
|
||
if _, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)).
|
||
Filter("tid", tid).
|
||
Filter("is_deleted", 0).
|
||
Filter("employee_id", file.EmployeeID).
|
||
OrderBy("sort", "id").
|
||
All(&records); err != nil && err != orm.ErrNoRows {
|
||
c.oaErr(500, 500, "查询档案记录失败")
|
||
return
|
||
}
|
||
if records == nil {
|
||
records = []models.BackendEmployeeFileRecord{}
|
||
}
|
||
dtos := web.efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
|
||
c.oaOk(map[string]interface{}{"file": dtos[0], "records": records})
|
||
} |