508 lines
16 KiB
Go
508 lines
16 KiB
Go
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()]
|
||
}
|