做双端审批流

This commit is contained in:
hero920103 committed 2026-10-07 13:46:32 +08:00
1 parent 5b4dbb2b46
commit ffaadc26ae
69 files changed
+6662 -1071

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"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 := parseRightsToSet(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 {
c.oaErr(401, 401, "未登录或无权限")
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 {
c.oaErr(401, 401, "未登录或无权限")
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 {
c.oaErr(401, 401, "未登录或无权限")
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})
}