package controllers import ( "encoding/json" "fmt" "sort" "strings" "time" "server/models" "server/pkg/holiday" ) // 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 } } } // 周期类方案:构造基准周期之外的顺延上下文(节假日 / 调休修正 + 星期节奏) var rollExt *scRollExt if plan.AlignMode == models.SchedTplAlignRoll || plan.AlignMode == models.SchedTplAlignWeek { rollExt = scBuildRollExt(pattern, start, dates) } 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, rollExt) if !work { continue } result[date] = rule workdays = append(workdays, date) } return result, workdays, "" } // scRollExt 周期滚动方案(AlignMode=2,按月涂排)在基准周期之外的顺延上下文。 // // 涂刷基准月里的节假日安排属于「基准月的特殊规则」,不应该跟着日序号平移到 // 后续月份——否则 10 月的国庆假期会错位落到 11 月的普通工作日上。周期之外的 // 推算规则: // 1. 目标日期是法定节假日 → 休息; // 2. 目标日期是调休上班日 → 上班(取涂刷里的班次); // 3. 该星期在涂刷周期内呈现严格 休/班 交替(如大小周的周六)→ 按周序奇偶交替顺延; // 4. 其余按涂刷月的星期节奏顺延(同星期几在周期内的多数状态)。 type scRollExt struct { weekdayRules [8]scPatternDay // 下标 1-7(周一~周日):该星期的顺延规则 altRules [8]bool // 该星期是否隔周交替(严格 休/班 交替) altEven [8]scPatternDay // 交替星期:周序为偶数时的规则 altOdd [8]scPatternDay // 交替星期:周序为奇数时的规则 defaultWork scPatternDay // 涂刷里的首个上班规则,供调休上班日使用 holidays map[string]int8 // 目标日期 → holiday.Type(0 调休上班 / 1 法定放假) } // scBuildRollExt 从涂刷周期构造顺延上下文。星期节奏取多数状态(work 数 - rest 数 // ≥ 0 视为上班,班次取该星期首个上班规则);若某星期在周期内严格 休/班 交替, // 记录交替规则,顺延按周序奇偶保持交替(周序锚点 = start 所在周的周一)。 // 节假日数据按目标区间涉及的年份拉取,拉取失败只是少了节假日修正,不阻断发布。 func scBuildRollExt(pattern []scPatternDay, start time.Time, dates []string) *scRollExt { ext := &scRollExt{holidays: map[string]int8{}} workCount := [8]int{} shiftBy := [8]scPatternDay{} type weekSeqItem struct { week int work bool item scPatternDay } byWeekday := [8][]weekSeqItem{} startMonday := start.AddDate(0, 0, -scWeekdayOffset(start)) for _, item := range pattern { day := start.AddDate(0, 0, item.DayIndex) w := scWeekdayOffset(day) + 1 if item.DayType == models.SchedTplDayWork { workCount[w]++ if shiftBy[w].ShiftID == 0 { shiftBy[w] = item } if ext.defaultWork.ShiftID == 0 { ext.defaultWork = item } } else { workCount[w]-- } weekSeq := int(day.AddDate(0, 0, -scWeekdayOffset(day)).Sub(startMonday).Hours()/24) / 7 byWeekday[w] = append(byWeekday[w], weekSeqItem{week: weekSeq, work: item.DayType == models.SchedTplDayWork, item: item}) } for w := 1; w <= 7; w++ { if workCount[w] >= 0 && shiftBy[w].ShiftID != 0 { ext.weekdayRules[w] = shiftBy[w] } else { ext.weekdayRules[w] = scPatternDay{DayIndex: w - 1, DayType: models.SchedTplDayRest} } // 隔周交替检测:该星期在涂刷周期内严格 休/班 交替(如大小周的周六, // 一周上班一周休息),顺延时按周序奇偶保持交替,而不是取多数 list := byWeekday[w] if len(list) < 2 { continue } alternating := true for i := 1; i < len(list); i++ { if list[i].work == list[i-1].work { alternating = false break } } if !alternating { continue } ext.altRules[w] = true if list[0].week%2 == 0 { ext.altEven[w] = list[0].item ext.altOdd[w] = list[1].item } else { ext.altOdd[w] = list[0].item ext.altEven[w] = list[1].item } } years := map[int]bool{} for _, date := range dates { if y := holidayYear(date); y > 0 { years[y] = true } } for y := range years { items, _, err := holiday.FetchYear(y) if err != nil { continue } for _, d := range items { ext.holidays[d.Date] = d.Type } } return ext } // scRuleForDate 推算某一天命中的规则;第二个返回值表示当天是否上班。 // ext 是周期滚动方案(AlignMode=2)在基准周期之外的顺延上下文,可为 nil。 func scRuleForDate(plan *models.OaSchedulePlan, rules map[int]scPatternDay, fallback *models.OaShift, start, day time.Time, weekdays map[int]bool, ext *scRollExt) (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 // 首个周期内原样回放;周期之外目标月的节假日 / 调休优先修正 //(大小周等模板按自然周循环,周序永远交替不串位) if ext != nil && diffWeeks >= weeks { if hType, ok := ext.holidays[day.Format("2006-01-02")]; ok { if hType == holiday.TypeHoliday { return scPatternDay{}, false } if hType == holiday.TypeWorkday && ext.defaultWork.ShiftID != 0 { return ext.defaultWork, true } } } 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) // 基准周期内:按涂刷结果原样回放(涂刷月自己的节假日 / 周末已在保存时处理) if diffDays < cycle || ext == nil { return scRuleOfDay(rules, scMod(diffDays, cycle)) } // 周期之外:先看目标日的节假日 / 调休特殊规则 if hType, ok := ext.holidays[day.Format("2006-01-02")]; ok { if hType == holiday.TypeHoliday { return scPatternDay{}, false } if hType == holiday.TypeWorkday && ext.defaultWork.ShiftID != 0 { return ext.defaultWork, true } } // 整月涂刷方案(周期=开始月天数):按星期节奏顺延,规避节假日 / 周末错位; // 其他滚动周期(三班倒 / 做一休一等模板原生周期):保持相位纯滚动 daysInStartMonth := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, start.Location()).Day() if cycle == daysInStartMonth { w := scWeekdayOffset(day) + 1 rule := ext.weekdayRules[w] // 隔周交替的星期(如大小周的周六):按周序奇偶保持交替 if ext.altRules[w] { startMonday := start.AddDate(0, 0, -scWeekdayOffset(start)) curMonday := day.AddDate(0, 0, -scWeekdayOffset(day)) weekSeq := int(curMonday.Sub(startMonday).Hours()/24) / 7 if weekSeq%2 == 0 { rule = ext.altEven[w] } else { rule = ext.altOdd[w] } } if rule.DayType != models.SchedTplDayWork { return scPatternDay{}, false } return rule, true } 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()] }