Files
yunzerwebsiteallinone/go/controllers/backend_oa_scheduling_pattern.go
2026-11-05 18:00:50 +08:00

508 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()]
}