package controllers import ( "encoding/json" "io" "log" "regexp" "strconv" "strings" "time" "server/models" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // BackendEmployeeFileController 员工档案管理(OA 人事档案)。 // 档案与员工(yz_backend_employee)按 tid + employee_id 一对一, // 子记录表统一存放教育经历 / 工作经历 / 合同信息 / 证照附件。 // 数据按 JWT 中的租户隔离。 type BackendEmployeeFileController struct { beego.Controller } func (c *BackendEmployeeFileController) efClaims() (*jwtutil.Claims, error) { auth := c.Ctx.Request.Header.Get("Authorization") if auth == "" { return nil, orm.ErrNoRows } parts := strings.SplitN(auth, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { return nil, orm.ErrNoRows } claims, err := jwtutil.ParseToken(parts[1]) if err != nil { return nil, err } if claims.UserType != "backend" { return nil, orm.ErrNoRows } return claims, nil } func (c *BackendEmployeeFileController) efOk(data interface{}) { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} _ = c.ServeJSON() } func (c *BackendEmployeeFileController) efErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } var efDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) // efIsValidDate 校验 "YYYY-MM-DD" 日期字符串;允许为空(可空字段)。 func efIsValidDate(s string) bool { s = strings.TrimSpace(s) if s == "" { return true } if !efDatePattern.MatchString(s) { return false } _, err := time.ParseInLocation("2006-01-02", s, time.Local) return err == nil } // efNormalizeDate 将可空 DATE 列统一输出为 "YYYY-MM-DD"。 func efNormalizeDate(value *time.Time) string { if value == nil { return "" } return value.Format("2006-01-02") } // efDatePointer 将前端可选日期转为可空 DATE 值;空字符串明确保存为 SQL NULL。 func efDatePointer(value string) *time.Time { value = strings.TrimSpace(value) if value == "" { return nil } date, err := time.ParseInLocation("2006-01-02", value, time.Local) if err != nil { return nil } return &date } // efQuery 档案查询基座:强制附加租户过滤与未删除条件。 func (c *BackendEmployeeFileController) efQuery(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.BackendEmployeeFile)). Filter("tid", tid). Filter("is_deleted", 0) } // efEmployeeQuery 员工查询基座(与组织架构控制器保持同一过滤口径)。 func efEmployeeQuery(tid int) orm.QuerySeter { return models.Orm.QueryTable(new(models.BackendEmployee)). Filter("tid", tid). Filter("delete_time__isnull", true) } // efOrgNameMap 组织ID -> 名称映射,用于展示员工的隶属单位/部门名称。 func (c *BackendEmployeeFileController) efOrgNameMap(tid int) 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 map[uint64]string{} } names := make(map[uint64]string, len(rows)) for _, row := range rows { names[row.ID] = row.OrgName } return names } // efOrgNameByID 组织ID字符串转名称;非数字时原样返回(手输文本兜底)。 func efOrgNameByID(names map[uint64]string, id string) string { if id == "" { return "" } if v, err := strconv.ParseUint(id, 10, 64); err == nil { return names[v] } return id } // efSubtreeOrgIDs 收集组织节点及其全部下级的ID(BFS,含环保护)。 func (c *BackendEmployeeFileController) efSubtreeOrgIDs(tid int, rootID uint64) []uint64 { var rows []models.BackendOrganization if _, err := models.Orm.QueryTable(new(models.BackendOrganization)). Filter("tid", tid). Filter("delete_time__isnull", true). All(&rows, "ID", "ParentID"); err != nil { return []uint64{rootID} } childrenOf := map[uint64][]uint64{} for _, row := range rows { childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID) } result := []uint64{rootID} queue := []uint64{rootID} visited := map[uint64]bool{rootID: true} for len(queue) > 0 { current := queue[0] queue = queue[1:] for _, child := range childrenOf[current] { if visited[child] { continue } visited[child] = true result = append(result, child) queue = append(queue, child) } } return result } // efEmployeeIDsInOrgs 取部门集合内的员工ID(档案按员工过滤时使用)。 func (c *BackendEmployeeFileController) efEmployeeIDsInOrgs(tid int, orgIDs []uint64) []uint64 { values := make([]string, 0, len(orgIDs)) for _, id := range orgIDs { values = append(values, strconv.FormatUint(id, 10)) } var rows []models.BackendEmployee if _, err := efEmployeeQuery(tid).Filter("department__in", values).All(&rows, "ID"); err != nil { return nil } ids := make([]uint64, 0, len(rows)) for _, row := range rows { ids = append(ids, uint64(row.ID)) } return ids } // efEmployeeIDsByKeyword 取姓名/账号/手机命中的员工ID(档案关键词搜索联动员工字段)。 func (c *BackendEmployeeFileController) efEmployeeIDsByKeyword(tid int, keyword string) []uint64 { cond := orm.NewCondition(). Or("name__icontains", keyword). Or("account__icontains", keyword). Or("phone__icontains", keyword) var rows []models.BackendEmployee if _, err := efEmployeeQuery(tid).SetCond(cond).All(&rows, "ID"); err != nil { return nil } ids := make([]uint64, 0, len(rows)) for _, row := range rows { ids = append(ids, uint64(row.ID)) } return ids } // employeeFileDTO 档案 + 关联员工展示信息(JSON 平铺,前端表格/详情直接用)。 type employeeFileDTO struct { models.BackendEmployeeFile EmployeeName string `json:"employee_name"` EmployeeAccount string `json:"employee_account"` Gender int8 `json:"gender"` Birthday string `json:"birthday"` AffiliateUnit string `json:"affiliate_unit"` AffiliateUnitName string `json:"affiliate_unit_name"` Department string `json:"department"` DepartmentName string `json:"department_name"` Position string `json:"position"` Phone string `json:"phone"` Email string `json:"email"` Education string `json:"education"` Nation string `json:"nation"` HomeAddress string `json:"home_address"` AccountStatus int8 `json:"account_status"` HireDate string `json:"hire_date"` RegularDate string `json:"regular_date"` LeaveDate string `json:"leave_date"` Completeness int `json:"completeness"` } // efFileCompleteness 计算档案资料完整度。仅统计应由档案模块维护的有效字段: // 离职日期仅在离职时要求,转正日期仅在正式员工时要求,备注为可选项不计入。 func efFileCompleteness(file models.BackendEmployeeFile) int { items := []bool{ strings.TrimSpace(file.IDCard) != "", strings.TrimSpace(file.PoliticalStatus) != "", file.MaritalStatus > 0, strings.TrimSpace(file.NativePlace) != "", strings.TrimSpace(file.HouseholdAddress) != "", strings.TrimSpace(file.CurrentAddress) != "", strings.TrimSpace(file.WorkEmail) != "", file.HireDate != nil, strings.TrimSpace(file.EmergencyContact) != "", strings.TrimSpace(file.EmergencyPhone) != "", strings.TrimSpace(file.EmergencyRelation) != "", strings.TrimSpace(file.EducationPhoto) != "", strings.TrimSpace(file.IDCardFront) != "", strings.TrimSpace(file.IDCardBack) != "", } if file.EmploymentStatus == 2 { items = append(items, file.RegularDate != nil) } if file.EmploymentStatus == 3 { items = append(items, file.LeaveDate != nil) } filled := 0 for _, item := range items { if item { filled++ } } if len(items) == 0 { return 0 } return filled * 100 / len(items) } // efAssembleDTOList 档案列表批量组装员工展示信息。 func (c *BackendEmployeeFileController) efAssembleDTOList(tid int, files []models.BackendEmployeeFile) []employeeFileDTO { list := make([]employeeFileDTO, 0, len(files)) if len(files) == 0 { return list } empIDs := make([]uint64, 0, len(files)) for _, f := range files { empIDs = append(empIDs, f.EmployeeID) } var emps []models.BackendEmployee if _, err := efEmployeeQuery(tid).Filter("id__in", empIDs).All(&emps); err != nil { emps = nil } empByID := make(map[uint64]models.BackendEmployee, len(emps)) for _, e := range emps { empByID[uint64(e.ID)] = e } orgNames := c.efOrgNameMap(tid) for _, f := range files { dto := employeeFileDTO{BackendEmployeeFile: f} dto.Completeness = efFileCompleteness(f) dto.HireDate = efNormalizeDate(f.HireDate) dto.RegularDate = efNormalizeDate(f.RegularDate) dto.LeaveDate = efNormalizeDate(f.LeaveDate) if emp, ok := empByID[f.EmployeeID]; ok { birthday := "" if emp.Birthday != nil { birthday = emp.Birthday.Format("2006-01-02") } affiliate := derefString(emp.AffiliateUnit) department := derefString(emp.Department) dto.EmployeeName = emp.Name dto.EmployeeAccount = emp.Account dto.Gender = emp.Gender dto.Birthday = birthday dto.AffiliateUnit = affiliate dto.AffiliateUnitName = efOrgNameByID(orgNames, affiliate) dto.Department = department dto.DepartmentName = efOrgNameByID(orgNames, department) dto.Position = derefString(emp.Position) dto.Phone = derefString(emp.Phone) dto.Email = derefString(emp.Email) dto.Education = derefString(emp.Education) dto.Nation = derefString(emp.Nation) dto.HomeAddress = derefString(emp.HomeAddress) dto.AccountStatus = emp.AccountStatus } list = append(list, dto) } return list } // List GET /backend/oa/employeefile/list // 分页查询档案,支持 keyword(档案编号/身份证/员工姓名/账号/手机)、 // status(在职状态)、org_id(部门,含下级)筛选。 func (c *BackendEmployeeFileController) List() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId page, _ := c.GetInt("page", 1) pageSize, _ := c.GetInt("pageSize", 10) 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 > 200 { pageSize = 10 } qs := c.efQuery(tid) if orgID > 0 { empIDs := c.efEmployeeIDsInOrgs(tid, c.efSubtreeOrgIDs(tid, orgID)) if len(empIDs) == 0 { c.efOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0}) return } qs = qs.Filter("employee_id__in", empIDs) } if statusRaw != "" { status, convErr := strconv.Atoi(statusRaw) if convErr != nil || status < 1 || status > 3 { c.efErr(400, 400, "在职状态参数无效") return } qs = qs.Filter("employment_status", int8(status)) } if keyword != "" { // 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机) empIDs := c.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.efErr(500, 500, "查询失败") return } c.efOk(map[string]interface{}{ "list": c.efAssembleDTOList(tid, files), "total": total, }) } // Stats GET /backend/oa/employeefile/stats // 返回档案总数与在职状态分布,供页面顶部统计卡片展示。 func (c *BackendEmployeeFileController) Stats() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId total, _ := c.efQuery(tid).Count() probation, _ := c.efQuery(tid).Filter("employment_status", 1).Count() active, _ := c.efQuery(tid).Filter("employment_status", 2).Count() resigned, _ := c.efQuery(tid).Filter("employment_status", 3).Count() c.efOk(map[string]interface{}{ "total": total, "probation": probation, "active": active, "resigned": resigned, }) } // Detail GET /backend/oa/employeefile/detail/:id // 返回档案详情(含员工展示信息)与全部子记录(教育/工作/合同/证照)。 func (c *BackendEmployeeFileController) Detail() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } var file models.BackendEmployeeFile if err := c.efQuery(tid).Filter("id", id).One(&file); err != nil { c.efErr(404, 404, "档案不存在") 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.efErr(500, 500, "查询档案记录失败") return } if records == nil { records = []models.BackendEmployeeFileRecord{} } list := c.efAssembleDTOList(tid, []models.BackendEmployeeFile{file}) c.efOk(map[string]interface{}{"file": list[0], "records": records}) } // employeeFilePayload 建档/编辑档案请求体。 type employeeFilePayload struct { EmployeeID uint64 `json:"employee_id"` FileNo string `json:"file_no"` IDCard string `json:"id_card"` IDCardFront string `json:"id_card_front"` IDCardBack string `json:"id_card_back"` EducationPhoto string `json:"education_photo"` PoliticalStatus string `json:"political_status"` MaritalStatus int8 `json:"marital_status"` NativePlace string `json:"native_place"` HouseholdAddress string `json:"household_address"` CurrentAddress string `json:"current_address"` EmergencyContact string `json:"emergency_contact"` EmergencyPhone string `json:"emergency_phone"` EmergencyRelation string `json:"emergency_relation"` WorkEmail string `json:"work_email"` HireDate string `json:"hire_date"` RegularDate string `json:"regular_date"` LeaveDate string `json:"leave_date"` EmploymentStatus int8 `json:"employment_status"` Remark string `json:"remark"` } // efParsePayload 读取并校验建档/编辑档案的请求体;校验失败时直接输出错误响应。 func (c *BackendEmployeeFileController) efParsePayload() (employeeFilePayload, bool) { var payload employeeFilePayload raw, err := io.ReadAll(c.Ctx.Request.Body) if err != nil || json.Unmarshal(raw, &payload) != nil { c.efErr(400, 400, "参数错误") return payload, false } payload.FileNo = strings.TrimSpace(payload.FileNo) payload.IDCard = strings.TrimSpace(payload.IDCard) payload.WorkEmail = strings.TrimSpace(payload.WorkEmail) if payload.IDCard != "" && len(payload.IDCard) > 30 { c.efErr(400, 400, "身份证号过长") return payload, false } // 证照附件地址长度限制(与列宽一致) for label, url := range map[string]string{ "身份证正面": payload.IDCardFront, "身份证反面": payload.IDCardBack, "学历照片": payload.EducationPhoto, } { if len(url) > 500 { c.efErr(400, 400, label+"地址过长") return payload, false } } if payload.WorkEmail != "" && !strings.Contains(payload.WorkEmail, "@") { c.efErr(400, 400, "工作邮箱格式无效") return payload, false } for label, value := range map[string]string{ "入职日期": payload.HireDate, "转正日期": payload.RegularDate, "离职日期": payload.LeaveDate, } { if !efIsValidDate(value) { c.efErr(400, 400, label+"格式无效") return payload, false } } if payload.EmploymentStatus < 1 || payload.EmploymentStatus > 3 { payload.EmploymentStatus = 2 } if payload.MaritalStatus < 0 || payload.MaritalStatus > 4 { payload.MaritalStatus = 0 } return payload, true } // Create POST /backend/oa/employeefile/create // 为员工建档;一人一档,重复建档会被唯一约束与前置校验拦截。 func (c *BackendEmployeeFileController) Create() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId payload, ok := c.efParsePayload() if !ok { return } if payload.EmployeeID == 0 { c.efErr(400, 400, "请选择员工") return } // 员工必须存在 var emp models.BackendEmployee if err := efEmployeeQuery(tid).Filter("id", payload.EmployeeID).One(&emp); err != nil { c.efErr(404, 404, "员工不存在") return } // 一人一档 dup, _ := c.efQuery(tid).Filter("employee_id", payload.EmployeeID).Count() if dup > 0 { c.efErr(400, 400, "该员工已建档,请勿重复创建") return } // 档案编号留空自动生成:EF + 时间戳 fileNo := payload.FileNo if fileNo == "" { fileNo = "EF" + time.Now().Format("20060102150405") } // 证照资料只能在建档完成后,于档案详情中维护;创建接口不接受附件。 payload.IDCardFront = "" payload.IDCardBack = "" payload.EducationPhoto = "" now := time.Now() item := &models.BackendEmployeeFile{ Tid: tid, EmployeeID: payload.EmployeeID, FileNo: fileNo, IDCard: payload.IDCard, IDCardFront: payload.IDCardFront, IDCardBack: payload.IDCardBack, EducationPhoto: payload.EducationPhoto, PoliticalStatus: payload.PoliticalStatus, MaritalStatus: payload.MaritalStatus, NativePlace: payload.NativePlace, HouseholdAddress: payload.HouseholdAddress, CurrentAddress: payload.CurrentAddress, EmergencyContact: payload.EmergencyContact, EmergencyPhone: payload.EmergencyPhone, EmergencyRelation: payload.EmergencyRelation, WorkEmail: payload.WorkEmail, HireDate: efDatePointer(payload.HireDate), RegularDate: efDatePointer(payload.RegularDate), LeaveDate: efDatePointer(payload.LeaveDate), EmploymentStatus: payload.EmploymentStatus, Remark: payload.Remark, IsDeleted: 0, CreateTime: now, UpdateTime: &now, } if _, err := models.Orm.Insert(item); err != nil { log.Printf("员工档案创建失败: tid=%d employee_id=%d err=%v", tid, payload.EmployeeID, err) c.efErr(500, 500, "保存失败,请检查员工档案表字段及日期数据") return } // 建档后同步通讯录(工作邮箱、家庭地址以档案为准) SyncContactOnEmployeeFileChange(uint64(tid), item.EmployeeID) c.efOk(item) } // Update POST /backend/oa/employeefile/update/:id // 更新档案基本资料;员工归属(employee_id)不允许修改。 func (c *BackendEmployeeFileController) Update() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } payload, ok := c.efParsePayload() if !ok { return } var item models.BackendEmployeeFile if err := c.efQuery(tid).Filter("id", id).One(&item); err != nil { c.efErr(404, 404, "档案不存在") return } now := time.Now() item.FileNo = payload.FileNo item.IDCard = payload.IDCard // 证照字段由 UpdateCertificates 独立维护,基础资料保存不得覆盖已有附件。 item.PoliticalStatus = payload.PoliticalStatus item.MaritalStatus = payload.MaritalStatus item.NativePlace = payload.NativePlace item.HouseholdAddress = payload.HouseholdAddress item.CurrentAddress = payload.CurrentAddress item.EmergencyContact = payload.EmergencyContact item.EmergencyPhone = payload.EmergencyPhone item.EmergencyRelation = payload.EmergencyRelation item.WorkEmail = payload.WorkEmail item.HireDate = efDatePointer(payload.HireDate) item.RegularDate = efDatePointer(payload.RegularDate) item.LeaveDate = efDatePointer(payload.LeaveDate) item.EmploymentStatus = payload.EmploymentStatus item.Remark = payload.Remark item.UpdateTime = &now if _, err := models.Orm.Update(&item, "FileNo", "IDCard", "PoliticalStatus", "MaritalStatus", "NativePlace", "HouseholdAddress", "CurrentAddress", "EmergencyContact", "EmergencyPhone", "EmergencyRelation", "WorkEmail", "HireDate", "RegularDate", "LeaveDate", "EmploymentStatus", "Remark", "UpdateTime"); err != nil { log.Printf("员工档案更新失败: tid=%d file_id=%d err=%v", tid, id, err) c.efErr(500, 500, "保存失败,请检查员工档案表字段及日期数据") return } // 档案变更后同步通讯录(工作邮箱、家庭地址以档案为准) SyncContactOnEmployeeFileChange(uint64(tid), item.EmployeeID) c.efOk(item) } // employeeFileCertificatesPayload 证照资料单独更新请求体。 // 该接口仅在档案详情中使用,避免上传附件时覆盖基础资料。 type employeeFileCertificatesPayload struct { IDCardFront string `json:"id_card_front"` IDCardBack string `json:"id_card_back"` EducationPhoto string `json:"education_photo"` } // UpdateCertificates POST /backend/oa/employeefile/certificates/:id // 仅更新学历照片、身份证正反面;档案必须已创建,不能通过建档接口上传。 func (c *BackendEmployeeFileController) UpdateCertificates() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } var payload employeeFileCertificatesPayload raw, err := io.ReadAll(c.Ctx.Request.Body) if err != nil || json.Unmarshal(raw, &payload) != nil { c.efErr(400, 400, "参数错误") return } for label, url := range map[string]string{ "身份证正面": payload.IDCardFront, "身份证反面": payload.IDCardBack, "学历照片": payload.EducationPhoto, } { if len(url) > 500 { c.efErr(400, 400, label+"地址过长") return } } var item models.BackendEmployeeFile if err := c.efQuery(tid).Filter("id", id).One(&item); err != nil { c.efErr(404, 404, "档案不存在") return } now := time.Now() item.IDCardFront = strings.TrimSpace(payload.IDCardFront) item.IDCardBack = strings.TrimSpace(payload.IDCardBack) item.EducationPhoto = strings.TrimSpace(payload.EducationPhoto) item.UpdateTime = &now if _, err := models.Orm.Update(&item, "IDCardFront", "IDCardBack", "EducationPhoto", "UpdateTime"); err != nil { c.efErr(500, 500, "证照保存失败") return } c.efOk(item) } // Delete DELETE /backend/oa/employeefile/delete/:id 档案离职/禁用(档案不做删除)。 // // 与员工同理:人事档案属于企业数据,通讯录(yz_backend_erp_contact)也以 employee_id 关联, // 软删档案会让通讯录里对应的人失去数据来源。因此这里只把在职状态置为"离职"(3), // 不写 is_deleted / delete_time,档案与通讯录记录始终保留可查。 // 在职状态调整(试用/正式/离职)走 update 接口即可,这里只是给"删除"动作一个不毁数据的语义。 func (c *BackendEmployeeFileController) Delete() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } var item models.BackendEmployeeFile if err := c.efQuery(tid).Filter("id", id).One(&item); err != nil { c.efErr(404, 404, "档案不存在") return } now := time.Now() // 离职日期留空时补当天,保证档案资料(离职需填离职日期)完整 if item.LeaveDate == nil { today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) item.LeaveDate = &today } item.EmploymentStatus = 3 item.UpdateTime = &now if _, err := models.Orm.Update(&item, "EmploymentStatus", "LeaveDate", "UpdateTime"); err != nil { log.Printf("员工档案离职失败: tid=%d file_id=%d err=%v", tid, id, err) c.efErr(500, 500, "离职操作失败") return } c.efOk(nil) } // FiledEmployees GET /backend/oa/employeefile/filedEmployees // 返回已建档的员工ID列表,前端用于过滤出可建档(未建档)员工。 func (c *BackendEmployeeFileController) FiledEmployees() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId var files []models.BackendEmployeeFile if _, err := c.efQuery(tid).All(&files, "EmployeeID"); err != nil { c.efErr(500, 500, "查询失败") return } ids := make([]uint64, 0, len(files)) for _, f := range files { ids = append(ids, f.EmployeeID) } c.efOk(ids) } // employeeFileRecordPayload 档案子记录请求体。 // type: 1-教育经历 2-工作经历 3-合同信息 4-证照附件。 type employeeFileRecordPayload struct { EmployeeID uint64 `json:"employee_id"` Type int8 `json:"type"` Title string `json:"title"` SubTitle string `json:"sub_title"` Extra string `json:"extra"` StartDate string `json:"start_date"` EndDate string `json:"end_date"` Description string `json:"description"` AttachmentURL string `json:"attachment_url"` Sort int `json:"sort"` } // efParseRecordPayload 读取并校验子记录请求体;校验失败时直接输出错误响应。 func (c *BackendEmployeeFileController) efParseRecordPayload() (employeeFileRecordPayload, bool) { var payload employeeFileRecordPayload raw, err := io.ReadAll(c.Ctx.Request.Body) if err != nil || json.Unmarshal(raw, &payload) != nil { c.efErr(400, 400, "参数错误") return payload, false } if payload.EmployeeID == 0 { c.efErr(400, 400, "参数缺少员工") return payload, false } if payload.Type < 1 || payload.Type > 4 { c.efErr(400, 400, "记录类型无效") return payload, false } payload.Title = strings.TrimSpace(payload.Title) if payload.Title == "" { c.efErr(400, 400, "请输入名称") return payload, false } if len(payload.Title) > 100 { c.efErr(400, 400, "名称不能超过100字") return payload, false } payload.StartDate = strings.TrimSpace(payload.StartDate) payload.EndDate = strings.TrimSpace(payload.EndDate) if !efIsValidDate(payload.StartDate) || !efIsValidDate(payload.EndDate) { c.efErr(400, 400, "日期格式无效") return payload, false } if payload.StartDate != "" && payload.EndDate != "" && payload.EndDate < payload.StartDate { c.efErr(400, 400, "结束日期不能早于开始日期") return payload, false } if len(payload.AttachmentURL) > 500 { c.efErr(400, 400, "附件地址过长") return payload, false } return payload, true } // CreateRecord POST /backend/oa/employeefile/record/create 新增子记录。 func (c *BackendEmployeeFileController) CreateRecord() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId payload, ok := c.efParseRecordPayload() if !ok { return } // 档案必须存在(记录挂在员工上,但以建档为前提) exists, _ := c.efQuery(tid).Filter("employee_id", payload.EmployeeID).Count() if exists == 0 { c.efErr(404, 404, "档案不存在,请先建档") return } now := time.Now() item := &models.BackendEmployeeFileRecord{ Tid: tid, EmployeeID: payload.EmployeeID, Type: payload.Type, Title: payload.Title, SubTitle: payload.SubTitle, Extra: payload.Extra, StartDate: efDatePointer(payload.StartDate), EndDate: efDatePointer(payload.EndDate), Description: payload.Description, AttachmentURL: payload.AttachmentURL, Sort: payload.Sort, IsDeleted: 0, CreateTime: now, UpdateTime: &now, } if _, err := models.Orm.Insert(item); err != nil { c.efErr(500, 500, "保存失败") return } c.efOk(item) } // UpdateRecord POST /backend/oa/employeefile/record/update/:id 更新子记录。 func (c *BackendEmployeeFileController) UpdateRecord() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } payload, ok := c.efParseRecordPayload() if !ok { return } var item models.BackendEmployeeFileRecord if err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)). Filter("tid", tid). Filter("is_deleted", 0). Filter("id", id). One(&item); err != nil { c.efErr(404, 404, "记录不存在") return } now := time.Now() item.Type = payload.Type item.Title = payload.Title item.SubTitle = payload.SubTitle item.Extra = payload.Extra item.StartDate = efDatePointer(payload.StartDate) item.EndDate = efDatePointer(payload.EndDate) item.Description = payload.Description item.AttachmentURL = payload.AttachmentURL item.Sort = payload.Sort item.UpdateTime = &now if _, err := models.Orm.Update(&item, "Type", "Title", "SubTitle", "Extra", "StartDate", "EndDate", "Description", "AttachmentURL", "Sort", "UpdateTime"); err != nil { c.efErr(500, 500, "保存失败") return } c.efOk(item) } // DeleteRecord DELETE /backend/oa/employeefile/record/delete/:id 软删除子记录。 func (c *BackendEmployeeFileController) DeleteRecord() { claims, err := c.efClaims() if err != nil { c.efErr(401, 401, "未登录或无权限") return } tid := claims.TenantId id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) if err != nil || id == 0 { c.efErr(400, 400, "无效ID") return } now := time.Now() n, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)). Filter("tid", tid). Filter("is_deleted", 0). Filter("id", id). Update(map[string]interface{}{ "IsDeleted": int8(1), "DeleteTime": now, "UpdateTime": now, }) if err != nil || n == 0 { c.efErr(404, 404, "记录不存在或已删除") return } c.efOk(nil) }