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" ) // BackendOaSchedulingController OA 工作排班(班次 / 排班方案 / 排班看板 / 临时调配)。 // // 排班的作用范围直接复用租户的组织架构(yz_backend_organization): // 公司、部门、小组是同一棵树上的不同层级,选中某个节点并勾选“含下级” // 即可把排班下发给该节点下的全部员工;也可以跳过组织,直接挑选 // 一个或多个员工做临时安排。所有数据按 JWT 中的 tid 做租户隔离。 type BackendOaSchedulingController struct { beego.Controller } // --------------------------------------------------------------------------- // 认证与响应 // --------------------------------------------------------------------------- func (c *BackendOaSchedulingController) scClaims() (*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 } // scOperator 返回当前操作人信息:tid、uid、用户名。 func (c *BackendOaSchedulingController) scOperator() (uint64, uint64, string, bool) { claims, ok := c.scClaims() if !ok { c.scFail(401, 401, "未登录或登录已过期") return 0, 0, "", false } return uint64(claims.TenantId), uint64(claims.UserID), claims.Username, true } func (c *BackendOaSchedulingController) scOK(data interface{}) { c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} _ = c.ServeJSON() } func (c *BackendOaSchedulingController) scFail(httpStatus, bizCode int, msg string) { if httpStatus > 0 { c.Ctx.Output.SetStatus(httpStatus) } c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } func (c *BackendOaSchedulingController) scBody() map[string]interface{} { body := map[string]interface{}{} if len(c.Ctx.Input.RequestBody) == 0 { return body } _ = json.Unmarshal(c.Ctx.Input.RequestBody, &body) return body } func (c *BackendOaSchedulingController) scPathID(param string) (uint64, bool) { id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(param)), 10, 64) return id, err == nil && id > 0 } // --------------------------------------------------------------------------- // 参数解析辅助 // --------------------------------------------------------------------------- func scStringValue(body map[string]interface{}, keys ...string) string { for _, key := range keys { raw, ok := body[key] if !ok || raw == nil { continue } switch value := raw.(type) { case string: return strings.TrimSpace(value) case float64: return strconv.FormatFloat(value, 'f', -1, 64) case bool: return strconv.FormatBool(value) } } return "" } func scIntValue(body map[string]interface{}, keys ...string) (int, bool) { raw := scStringValue(body, keys...) if raw == "" { return 0, false } value, err := strconv.Atoi(raw) return value, err == nil } func scUintValue(body map[string]interface{}, keys ...string) uint64 { raw := scStringValue(body, keys...) if raw == "" { return 0 } value, err := strconv.ParseUint(raw, 10, 64) if err != nil { return 0 } return value } // scUintSlice 解析 [] 形式的 ID 列表,兼容数字与字符串两种元素。 func scUintSlice(body map[string]interface{}, key string) []uint64 { raw, ok := body[key] if !ok || raw == nil { return nil } list, ok := raw.([]interface{}) if !ok { return nil } ids := make([]uint64, 0, len(list)) seen := map[uint64]bool{} for _, item := range list { var value uint64 switch v := item.(type) { case float64: value = uint64(v) case string: value, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64) } if value > 0 && !seen[value] { seen[value] = true ids = append(ids, value) } } return ids } func scBoolValue(body map[string]interface{}, keys ...string) bool { for _, key := range keys { raw, ok := body[key] if !ok || raw == nil { continue } switch value := raw.(type) { case bool: return value case float64: return value != 0 case string: return value == "1" || strings.EqualFold(value, "true") } } return false } // scWeekdaySet 解析星期筛选,1=周一 ... 7=周日。 // 支持 [1,3,5] 数组与 "1,3,5" 字符串;空值表示每天。 func scWeekdaySet(body map[string]interface{}) map[int]bool { result := map[int]bool{} raw, ok := body["weekdays"] if !ok || raw == nil { return result } add := func(value int) { if value >= 1 && value <= 7 { result[value] = true } } switch list := raw.(type) { case []interface{}: for _, item := range list { switch v := item.(type) { case float64: add(int(v)) case string: n, _ := strconv.Atoi(strings.TrimSpace(v)) add(n) } } case string: for _, part := range strings.Split(list, ",") { n, _ := strconv.Atoi(strings.TrimSpace(part)) add(n) } } return result } // scParseDate 解析 "YYYY-MM-DD" 并返回时间对象。 func scParseDate(value string) (time.Time, bool) { if !isValidOaDate(value) { return time.Time{}, false } parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) if err != nil { return time.Time{}, false } return parsed, true } // scDateSpan 展开 [start, end] 之间的全部日期字符串;区间上限 92 天, // 避免一次请求把看板拖垮。 func scDateSpan(start, end string) ([]string, string) { startDate, ok := scParseDate(start) if !ok { return nil, "开始日期格式错误" } endDate, ok := scParseDate(end) if !ok { return nil, "结束日期格式错误" } if endDate.Before(startDate) { return nil, "结束日期不能早于开始日期" } if endDate.Sub(startDate) > 92*24*time.Hour { return nil, "日期区间不能超过 92 天" } dates := make([]string, 0, 32) for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) { dates = append(dates, cursor.Format("2006-01-02")) } return dates, "" } // scPlanNoEndDate 无期限方案的结束日期哨兵:数据库日期列不支持空串, // 统一存 2099-12-31,前端按 no_end 标记展示为「无期限」。 const scPlanNoEndDate = "2099-12-31" // scDateSpanUnbounded 展开任意长度的日期区间,供无期限方案的 // 「当年窗口」使用,不受 scDateSpan 的 92 天上限约束。 func scDateSpanUnbounded(start, end string) ([]string, string) { startDate, ok := scParseDate(start) if !ok { return nil, "开始日期格式错误" } endDate, ok := scParseDate(end) if !ok { return nil, "结束日期格式错误" } if endDate.Before(startDate) { return nil, "结束日期不能早于开始日期" } total := int(endDate.Sub(startDate).Hours()/24) + 1 dates := make([]string, 0, total) for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) { dates = append(dates, cursor.Format("2006-01-02")) } return dates, "" } // scIsNoEnd 判断结束日期是否表示无期限(空串或哨兵值)。 func scIsNoEnd(end string) bool { return end == "" || end >= scPlanNoEndDate } // scPlanGenWindow 计算一次预览 / 发布实际展开的日期窗口。 // 普通方案:[start, end] 原样展开;无期限方案:从 max(start, 今天) 生成到 // 当年 12 月 31 日,跨年不自动延续,新的一年需要重新排班。 func scPlanGenWindow(startText, endText string) ([]string, string) { if !scIsNoEnd(endText) { return scDateSpan(startText, endText) } startDate, ok := scParseDate(startText) if !ok { return nil, "开始日期格式错误" } windowStart := startDate today := time.Now() if today.After(startDate) { windowStart = today } windowEnd := time.Date(today.Year(), 12, 31, 0, 0, 0, 0, today.Location()) if windowEnd.Before(windowStart) { windowEnd = windowStart } return scDateSpanUnbounded(windowStart.Format("2006-01-02"), windowEnd.Format("2006-01-02")) } // scMinutes 把 "HH:mm" 转为当日分钟数。 func scMinutes(value string) (int, bool) { if value == "" { return 0, false } parsed, err := time.ParseInLocation("15:04", value, time.Local) if err != nil { return 0, false } return parsed.Hour()*60 + parsed.Minute(), true } // scCalcWorkHours 计算班次工时(小时):上下班跨度扣除休息时段。 // 下班时间早于上班时间视为跨天班次,自动加 24 小时。 func scCalcWorkHours(workStart, workEnd, restStart, restEnd string) float64 { start, okStart := scMinutes(workStart) end, okEnd := scMinutes(workEnd) if !okStart || !okEnd { return 0 } if end <= start { end += 24 * 60 } span := end - start if restStart != "" && restEnd != "" { restFrom, okFrom := scMinutes(restStart) restTo, okTo := scMinutes(restEnd) if okFrom && okTo { if restTo <= restFrom { restTo += 24 * 60 } if restTo > start && restFrom < end { overlap := restTo - restFrom if restFrom < start { overlap -= start - restFrom } if restTo > end { overlap -= restTo - end } if overlap > 0 { span -= overlap } } } } if span < 0 { return 0 } return float64(span) / 60 } // --------------------------------------------------------------------------- // 班次管理 // --------------------------------------------------------------------------- func (c *BackendOaSchedulingController) shiftQuery(tid uint64) orm.QuerySeter { return models.Orm.QueryTable(new(models.OaShift)). Filter("tid", tid). Filter("is_deleted", 0) } func (c *BackendOaSchedulingController) shiftByID(tid, id uint64) (*models.OaShift, bool) { var row models.OaShift if err := c.shiftQuery(tid).Filter("id", id).One(&row); err != nil { return nil, false } return &row, true } // ShiftList 班次列表。 // GET /backend/oa/scheduling/shifts func (c *BackendOaSchedulingController) ShiftList() { tid, _, _, ok := c.scOperator() if !ok { return } var rows []models.OaShift if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&rows); err != nil { c.scFail(500, 500, "查询班次失败: "+err.Error()) return } if rows == nil { rows = []models.OaShift{} } var defaultID uint64 for _, row := range rows { if row.IsDefault == 1 { defaultID = row.ID break } } c.scOK(map[string]interface{}{ "list": rows, "default_id": defaultID, "total": len(rows), }) } // CreateShift 新增班次。 // POST /backend/oa/scheduling/shifts func (c *BackendOaSchedulingController) CreateShift() { tid, _, _, ok := c.scOperator() if !ok { return } body := c.scBody() row, msg := c.buildShiftFromBody(body) if msg != "" { c.scFail(400, 400, msg) return } row.Tid = int(tid) if row.IsDefault == 1 { c.clearDefaultShift(tid, 0) } if _, err := models.Orm.Insert(row); err != nil { c.scFail(500, 500, "新增班次失败: "+err.Error()) return } c.scOK(row) } // UpdateShift 更新班次。 // POST /backend/oa/scheduling/shifts/:id func (c *BackendOaSchedulingController) UpdateShift() { tid, _, _, ok := c.scOperator() if !ok { return } id, valid := c.scPathID(":id") if !valid { c.scFail(400, 400, "无效的班次ID") return } existing, found := c.shiftByID(tid, id) if !found { c.scFail(404, 404, "班次不存在") return } body := c.scBody() row, msg := c.buildShiftFromBody(body) if msg != "" { c.scFail(400, 400, msg) return } existing.Name = row.Name existing.ShortName = row.ShortName existing.Color = row.Color existing.WorkStart = row.WorkStart existing.WorkEnd = row.WorkEnd existing.RestStart = row.RestStart existing.RestEnd = row.RestEnd existing.WorkHours = row.WorkHours existing.IsDefault = row.IsDefault existing.Status = row.Status existing.Sort = row.Sort existing.Remark = row.Remark if existing.IsDefault == 1 { c.clearDefaultShift(tid, existing.ID) } if _, err := models.Orm.Update(existing); err != nil { c.scFail(500, 500, "更新班次失败: "+err.Error()) return } // 班次名称/时间变化时同步已排班的快照,避免看板显示旧时间。 c.syncShiftSnapshot(tid, existing) c.scOK(existing) } // DeleteShift 删除班次(软删除)。已被排班使用时拒绝删除,建议改为停用。 // DELETE /backend/oa/scheduling/shifts/:id func (c *BackendOaSchedulingController) DeleteShift() { tid, _, _, ok := c.scOperator() if !ok { return } id, valid := c.scPathID(":id") if !valid { c.scFail(400, 400, "无效的班次ID") return } row, found := c.shiftByID(tid, id) if !found { c.scFail(404, 404, "班次不存在") return } used, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)). Filter("tid", tid). Filter("shift_id", id). Filter("is_deleted", 0). Count() if used > 0 { c.scFail(400, 400, "该班次已被排班使用,无法删除,请改为停用") return } now := time.Now() row.IsDeleted = 1 row.DeleteTime = &now if _, err := models.Orm.Update(row, "is_deleted", "delete_time"); err != nil { c.scFail(500, 500, "删除班次失败: "+err.Error()) return } c.scOK(map[string]interface{}{"id": id}) } // buildShiftFromBody 校验并构造班次对象;返回非空 msg 表示参数错误。 func (c *BackendOaSchedulingController) buildShiftFromBody(body map[string]interface{}) (*models.OaShift, string) { name := scStringValue(body, "name") if name == "" { return nil, "班次名称不能为空" } if len([]rune(name)) > 50 { return nil, "班次名称不能超过 50 个字符" } workStart := scStringValue(body, "work_start") workEnd := scStringValue(body, "work_end") if !isValidOaTime(workStart) || !isValidOaTime(workEnd) { return nil, "上下班时间格式应为 HH:mm" } if workStart == workEnd { return nil, "上下班时间不能相同" } restStart := scStringValue(body, "rest_start") restEnd := scStringValue(body, "rest_end") if (restStart == "") != (restEnd == "") { return nil, "休息开始与结束时间需要同时填写" } if restStart != "" && (!isValidOaTime(restStart) || !isValidOaTime(restEnd)) { return nil, "休息时间格式应为 HH:mm" } shortName := scStringValue(body, "short_name") if shortName == "" { shortName = name } color := scStringValue(body, "color") if color == "" { color = "#409EFF" } status := int8(1) if value, has := scIntValue(body, "status"); has { status = int8(value) } sortValue, _ := scIntValue(body, "sort") isDefault := int8(0) if scBoolValue(body, "is_default") { isDefault = 1 } remark := scStringValue(body, "remark") row := &models.OaShift{ Name: name, ShortName: shortName, Color: color, WorkStart: workStart, WorkEnd: workEnd, RestStart: restStart, RestEnd: restEnd, WorkHours: scCalcWorkHours(workStart, workEnd, restStart, restEnd), IsDefault: isDefault, Status: status, Sort: sortValue, } if remark != "" { row.Remark = &remark } return row, "" } // clearDefaultShift 把同租户下其它班次的默认标记清掉,保证只有一个默认班次。 func (c *BackendOaSchedulingController) clearDefaultShift(tid, excludeID uint64) { qs := c.shiftQuery(tid).Filter("is_default", 1) if excludeID > 0 { qs = qs.Exclude("id", excludeID) } var rows []models.OaShift if _, err := qs.All(&rows); err != nil { return } for i := range rows { rows[i].IsDefault = 0 _, _ = models.Orm.Update(&rows[i], "is_default") } } // syncShiftSnapshot 班次变更后,把新快照写回已生成的排班明细。 func (c *BackendOaSchedulingController) syncShiftSnapshot(tid uint64, shift *models.OaShift) { _, _ = models.Orm.Raw( "UPDATE yz_backend_oa_schedule_entry SET shift_name=?, short_name=?, color=?, work_start=?, work_end=? "+ "WHERE tid=? AND shift_id=? AND is_deleted=0", shift.Name, shift.ShortName, shift.Color, shift.WorkStart, shift.WorkEnd, tid, shift.ID, ).Exec() }