354 lines
10 KiB
Go
354 lines
10 KiB
Go
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()]
|
||
}
|