package controllers import ( "encoding/json" "fmt" "sort" "strings" "time" "server/models" ) // scPatternDay 排班方案周期规则的每日快照。 // // AlignMode=1/2/3 时,Plan.Pattern 保存该结构的 JSON 数组:模板的规则在 // 「调用模板」时复制进方案,之后平台模板怎么改都不影响已保存的方案。 // 发布方案时按自然周 / 开始日滚动 / 自然月推算每天命中哪一天规则。 type scPatternDay struct { DayIndex int `json:"day_index"` DayType int8 `json:"day_type"` ShiftID uint64 `json:"shift_id"` ShiftName string `json:"shift_name"` ShortName string `json:"short_name"` Color string `json:"color"` WorkStart string `json:"work_start"` WorkEnd string `json:"work_end"` RestStart string `json:"rest_start"` RestEnd string `json:"rest_end"` WorkHours float64 `json:"work_hours"` Remark string `json:"remark"` } // scPatternFromBody 解析前端提交的 pattern;兼容数组与 JSON 字符串两种形式。 func scPatternFromBody(body map[string]interface{}) []scPatternDay { raw, ok := body["pattern"] if !ok || raw == nil { return nil } list := []scPatternDay{} switch value := raw.(type) { case string: if strings.TrimSpace(value) == "" { return nil } if err := json.Unmarshal([]byte(value), &list); err != nil { return nil } case []interface{}: buf, err := json.Marshal(value) if err != nil { return nil } if err := json.Unmarshal(buf, &list); err != nil { return nil } default: return nil } return list } func scPatternToJSON(days []scPatternDay) string { if len(days) == 0 { return "" } buf, err := json.Marshal(days) if err != nil { return "" } return string(buf) } func scPatternFromJSON(raw string) []scPatternDay { days := []scPatternDay{} if strings.TrimSpace(raw) == "" { return days } if err := json.Unmarshal([]byte(raw), &days); err != nil { return []scPatternDay{} } return days } // scValidatePattern 校验周期规则,返回补齐默认值并按 day_index 排序后的规则。 func scValidatePattern(days []scPatternDay, alignMode int8, cycleDays int) ([]scPatternDay, string) { if alignMode < models.SchedTplAlignWeek || alignMode > models.SchedTplAlignMonth { return nil, "排班方式不合法" } if len(days) == 0 { return nil, "缺少排班规则,请重新选择模板或班次" } if alignMode == models.SchedTplAlignMonth { // 月休按自然月计算,周期天数无意义 cycleDays = 0 } else { if cycleDays < 2 || cycleDays > 31 { return nil, "周期天数需在 2~31 天之间" } if alignMode == models.SchedTplAlignWeek && cycleDays%7 != 0 { return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)" } if len(days) != cycleDays { return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycleDays, cycleDays) } } seen := map[int]bool{} workCount := 0 result := make([]scPatternDay, 0, len(days)) for _, item := range days { index := item.DayIndex if alignMode == models.SchedTplAlignMonth { index = len(result) } if index < 0 || (alignMode != models.SchedTplAlignMonth && index >= cycleDays) { return nil, "规则的天序号不合法" } if seen[index] { return nil, "规则的天序号重复" } seen[index] = true if item.DayType != models.SchedTplDayWork && item.DayType != models.SchedTplDayRest { return nil, "规则类型不合法(1=上班 2=休息)" } row := scPatternDay{DayIndex: index, DayType: item.DayType, Remark: strings.TrimSpace(item.Remark)} if item.DayType == models.SchedTplDayWork { workCount++ row.ShiftID = item.ShiftID row.ShiftName = strings.TrimSpace(item.ShiftName) row.ShortName = strings.TrimSpace(item.ShortName) row.Color = strings.TrimSpace(item.Color) row.WorkStart = strings.TrimSpace(item.WorkStart) row.WorkEnd = strings.TrimSpace(item.WorkEnd) row.RestStart = strings.TrimSpace(item.RestStart) row.RestEnd = strings.TrimSpace(item.RestEnd) row.WorkHours = item.WorkHours if row.ShiftName == "" { return nil, fmt.Sprintf("第 %d 天选择上班,班次不能为空", index+1) } if row.ShortName == "" { row.ShortName = row.ShiftName } if row.Color == "" { row.Color = "#409EFF" } if !isValidOaTime(row.WorkStart) || !isValidOaTime(row.WorkEnd) { return nil, fmt.Sprintf("第 %d 天的上下班时间格式应为 HH:mm", index+1) } if row.WorkStart == row.WorkEnd { return nil, fmt.Sprintf("第 %d 天的上下班时间不能相同", index+1) } if (row.RestStart == "") != (row.RestEnd == "") { return nil, fmt.Sprintf("第 %d 天的休息开始与结束时间需要同时填写", index+1) } if row.RestStart != "" && (!isValidOaTime(row.RestStart) || !isValidOaTime(row.RestEnd)) { return nil, fmt.Sprintf("第 %d 天的休息时间格式应为 HH:mm", index+1) } if row.WorkHours <= 0 { row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd) } } result = append(result, row) } if workCount == 0 { return nil, "排班规则至少需要一天上班" } sort.Slice(result, func(i, j int) bool { return result[i].DayIndex < result[j].DayIndex }) return result, "" } // scPrimaryPatternShift 取规则里第一个上班日,用于方案列表展示班次名 / 颜色。 func scPrimaryPatternShift(days []scPatternDay) (scPatternDay, bool) { for _, item := range days { if item.DayType == models.SchedTplDayWork { return item, true } } return scPatternDay{}, false } // scPlanPatternDays 取方案中保存的周期规则。 func scPlanPatternDays(plan *models.OaSchedulePlan) []scPatternDay { return scPatternFromJSON(plan.Pattern) } // scPlanWorkRules 计算方案在日期区间内「需要上班」的日期与对应的班次规则。 // // fallback 是按星期模式(AlignMode=0)使用的固定班次;模板模式下可为 nil。 // 返回的 map 以日期字符串为键,workdays 为升序的上班日期列表。 func scPlanWorkRules(plan *models.OaSchedulePlan, fallback *models.OaShift, dates []string) (map[string]scPatternDay, []string, string) { pattern := scPlanPatternDays(plan) if len(pattern) == 0 && fallback == nil { return nil, nil, "排班规则为空,请重新选择模板或班次" } start, ok := scParseDate(normalizeOaDate(plan.StartDate)) if !ok { return nil, nil, "开始日期格式错误" } rules := make(map[int]scPatternDay, len(pattern)) for _, item := range pattern { rules[item.DayIndex] = item } weekdays := map[int]bool{} if strings.TrimSpace(plan.Weekdays) != "" { list := []int{} if err := json.Unmarshal([]byte(plan.Weekdays), &list); err == nil { for _, day := range list { weekdays[day] = true } } } result := make(map[string]scPatternDay, len(dates)) workdays := make([]string, 0, len(dates)) for _, date := range dates { day, valid := scParseDate(date) if !valid { continue } rule, work := scRuleForDate(plan, rules, fallback, start, day, weekdays) if !work { continue } result[date] = rule workdays = append(workdays, date) } return result, workdays, "" } // scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。 func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift, start, day time.Time, weekdays map[int]bool) (scPatternDay, bool) { switch plan.AlignMode { case models.SchedTplAlignWeek: weeks := plan.CycleDays / 7 if weeks < 1 { weeks = 1 } startMonday := start.AddDate(0, 0, -scWeekdayOffset(start)) curMonday := day.AddDate(0, 0, -scWeekdayOffset(day)) diffWeeks := int(curMonday.Sub(startMonday).Hours()/24) / 7 weekIndex := scMod(diffWeeks+int(plan.WeekOffset), weeks) return scRuleOfDay(rules, weekIndex*7+scWeekdayOffset(day)) case models.SchedTplAlignRoll: cycle := plan.CycleDays if cycle < 1 { cycle = 1 } diffDays := int(day.Sub(start).Hours() / 24) return scRuleOfDay(rules, scMod(diffDays, cycle)) case models.SchedTplAlignMonth: if scMonthRestDay(plan, day) { return scPatternDay{}, false } index := 0 if rule, ok := rules[0]; ok && rule.DayType != models.SchedTplDayWork { for _, item := range rules { if item.DayType == models.SchedTplDayWork { index = item.DayIndex break } } } return scRuleOfDay(rules, index) } // 按星期模式(AlignMode=0):weekdays 为空表示每天都上班 weekday := int(day.Weekday()) if weekday == 0 { weekday = 7 } if len(weekdays) > 0 && !weekdays[weekday] { return scPatternDay{}, false } if fallback == nil { return scPatternDay{}, false } return scPatternDay{ DayType: models.SchedTplDayWork, ShiftID: fallback.ID, ShiftName: fallback.Name, ShortName: fallback.ShortName, Color: fallback.Color, WorkStart: fallback.WorkStart, WorkEnd: fallback.WorkEnd, RestStart: fallback.RestStart, RestEnd: fallback.RestEnd, WorkHours: fallback.WorkHours, }, true } func scRuleOfDay(rules map[int]scPatternDay, index int) (scPatternDay, bool) { rule, ok := rules[index] if !ok || rule.DayType != models.SchedTplDayWork { return scPatternDay{}, false } return rule, true } // scWeekdayOffset 返回相对本周周一的天数(周一=0 ... 周日=6)。 func scWeekdayOffset(day time.Time) int { return (int(day.Weekday()) + 6) % 7 } func scMod(value, base int) int { if base <= 0 { return 0 } result := value % base if result < 0 { result += base } return result } // scMonthRestDay 判断自然月排班中某天是否休息。 // // 指定休息周几时,先取当月该周几的前 N 天;不足 N 天时从月末向前补齐。 // 未指定周几(0)时直接取月末最后 N 天。 func scMonthRestDay(plan *models.OaSchedulePlan, day time.Time) bool { restDays := int(plan.MonthRestDays) if restDays <= 0 { return false } year, month := day.Year(), day.Month() first := time.Date(year, month, 1, 0, 0, 0, 0, time.Local) daysInMonth := first.AddDate(0, 1, -1).Day() if restDays >= daysInMonth { return true } restSet := map[int]bool{} weekday := int(plan.MonthRestWeekday) if weekday >= 1 && weekday <= 7 { for d := 1; d <= daysInMonth && len(restSet) < restDays; d++ { w := int(time.Date(year, month, d, 0, 0, 0, 0, time.Local).Weekday()) if w == 0 { w = 7 } if w == weekday { restSet[d] = true } } } for d := daysInMonth; d >= 1 && len(restSet) < restDays; d-- { restSet[d] = true } return restSet[day.Day()] }