883 lines
27 KiB
Go
883 lines
27 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"server/models"
|
|
|
|
"github.com/beego/beego/v2/client/orm"
|
|
)
|
|
|
|
// scPlanMaxEmployees / scPlanMaxEntries 限制单次方案规模,避免误选整个公司
|
|
// 再叠加长周期导致一次写入数万条明细。
|
|
const (
|
|
scPlanMaxEmployees = 500
|
|
scPlanMaxEntries = 10000
|
|
)
|
|
|
|
// scPlanInput 排班方案的入参(新建 / 编辑 / 预览共用)。
|
|
//
|
|
// 两种排班方式:
|
|
// - 按星期:Weekdays + ShiftID,每天固定一个班次;
|
|
// - 按模板:Pattern + AlignMode,规则来自平台模板(可在租户端微调),
|
|
// 此时 ShiftID 允许为 0。
|
|
type scPlanInput struct {
|
|
PlanName string
|
|
PlanType int8
|
|
ScopeType int8
|
|
OrgID uint64
|
|
IncludeChild bool
|
|
EmployeeIDs []uint64
|
|
StartDate string
|
|
EndDate string
|
|
Weekdays map[int]bool
|
|
ShiftID uint64
|
|
Note string
|
|
TemplateID uint64
|
|
TemplateCode string
|
|
TemplateName string
|
|
AlignMode int8
|
|
CycleDays int
|
|
WeekOffset int8
|
|
MonthRestDays int8
|
|
MonthRestWeekday int8
|
|
Pattern []scPatternDay
|
|
}
|
|
|
|
func (c *BackendOaSchedulingController) scPlanInputFromBody(body map[string]interface{}, requireName bool) (*scPlanInput, string) {
|
|
input := &scPlanInput{
|
|
PlanName: scStringValue(body, "plan_name"),
|
|
ScopeType: models.OaScopeOrg,
|
|
IncludeChild: true,
|
|
StartDate: scStringValue(body, "start_date"),
|
|
EndDate: scStringValue(body, "end_date"),
|
|
Weekdays: scWeekdaySet(body),
|
|
Note: scStringValue(body, "note"),
|
|
ShiftID: scUintValue(body, "shift_id"),
|
|
EmployeeIDs: scUintSlice(body, "employee_ids"),
|
|
TemplateID: scUintValue(body, "template_id"),
|
|
TemplateCode: scStringValue(body, "template_code"),
|
|
TemplateName: scStringValue(body, "template_name"),
|
|
Pattern: scPatternFromBody(body),
|
|
}
|
|
if requireName && input.PlanName == "" {
|
|
return nil, "方案名称不能为空"
|
|
}
|
|
if len([]rune(input.PlanName)) > 100 {
|
|
return nil, "方案名称不能超过 100 个字符"
|
|
}
|
|
|
|
if value, ok := scIntValue(body, "plan_type"); ok {
|
|
input.PlanType = int8(value)
|
|
} else {
|
|
input.PlanType = models.OaPlanTypeRegular
|
|
}
|
|
if input.PlanType != models.OaPlanTypeRegular && input.PlanType != models.OaPlanTypeTemp {
|
|
return nil, "方案类型不合法"
|
|
}
|
|
|
|
if value, ok := scIntValue(body, "scope_type"); ok {
|
|
input.ScopeType = int8(value)
|
|
}
|
|
if input.ScopeType < models.OaScopeCompany || input.ScopeType > models.OaScopeEmployees {
|
|
return nil, "排班范围不合法"
|
|
}
|
|
input.OrgID = scUintValue(body, "org_id")
|
|
if input.ScopeType == models.OaScopeOrg && input.OrgID == 0 {
|
|
return nil, "请选择要排班的部门或小组"
|
|
}
|
|
if input.ScopeType == models.OaScopeEmployees && len(input.EmployeeIDs) == 0 {
|
|
return nil, "请选择需要排班的员工"
|
|
}
|
|
if _, has := body["include_child"]; has {
|
|
input.IncludeChild = scBoolValue(body, "include_child")
|
|
}
|
|
|
|
// 无期限方案:结束日期允许为空,落库时存哨兵值,发布时按滚动窗口分批生成
|
|
if scIsNoEnd(input.EndDate) {
|
|
if _, ok := scParseDate(input.StartDate); !ok {
|
|
return nil, "开始日期格式错误"
|
|
}
|
|
input.EndDate = scPlanNoEndDate
|
|
} else if _, msg := scDateSpan(input.StartDate, input.EndDate); msg != "" {
|
|
return nil, msg
|
|
}
|
|
|
|
if len(input.Pattern) > 0 {
|
|
// 模板模式:校验周期规则,班次信息随规则一起保存
|
|
if value, ok := scIntValue(body, "align_mode"); ok {
|
|
input.AlignMode = int8(value)
|
|
}
|
|
if value, ok := scIntValue(body, "cycle_days"); ok {
|
|
input.CycleDays = value
|
|
}
|
|
if value, ok := scIntValue(body, "week_offset"); ok {
|
|
input.WeekOffset = int8(value)
|
|
}
|
|
if value, ok := scIntValue(body, "month_rest_days"); ok {
|
|
input.MonthRestDays = int8(value)
|
|
}
|
|
if value, ok := scIntValue(body, "month_rest_weekday"); ok {
|
|
input.MonthRestWeekday = int8(value)
|
|
}
|
|
days, msg := scValidatePattern(input.Pattern, input.AlignMode, input.CycleDays)
|
|
if msg != "" {
|
|
return nil, msg
|
|
}
|
|
input.Pattern = days
|
|
if input.TemplateName == "" {
|
|
input.TemplateName = "自定义规则"
|
|
}
|
|
} else {
|
|
if input.ShiftID == 0 {
|
|
return nil, "请选择班次"
|
|
}
|
|
input.AlignMode = 0
|
|
}
|
|
|
|
if len(input.EmployeeIDs) > scPlanMaxEmployees {
|
|
return nil, fmt.Sprintf("单次最多为 %d 名员工排班,请缩小范围", scPlanMaxEmployees)
|
|
}
|
|
return input, ""
|
|
}
|
|
|
|
func (c *BackendOaSchedulingController) scLoadShift(tid, id uint64) (*models.OaShift, bool) {
|
|
shift, ok := c.shiftByID(tid, id)
|
|
if !ok || shift.Status != 1 {
|
|
return nil, false
|
|
}
|
|
return shift, true
|
|
}
|
|
|
|
func scWeekdayList(set map[int]bool) []int {
|
|
list := make([]int, 0, 7)
|
|
for day := 1; day <= 7; day++ {
|
|
if set[day] {
|
|
list = append(list, day)
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
// scPlanView 把方案转成前端友好的结构:employee_ids / weekdays 直接给数组,
|
|
// 避免前端再解析 JSON 字符串。
|
|
func scPlanView(plan *models.OaSchedulePlan) map[string]interface{} {
|
|
employeeIDs := []uint64{}
|
|
if strings.TrimSpace(plan.EmployeeIDs) != "" {
|
|
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
|
|
}
|
|
weekdays := []int{}
|
|
if strings.TrimSpace(plan.Weekdays) != "" {
|
|
_ = json.Unmarshal([]byte(plan.Weekdays), &weekdays)
|
|
}
|
|
return map[string]interface{}{
|
|
"id": plan.ID,
|
|
"plan_name": plan.PlanName,
|
|
"plan_type": plan.PlanType,
|
|
"scope_type": plan.ScopeType,
|
|
"org_id": plan.OrgID,
|
|
"org_name": plan.OrgName,
|
|
"include_child": plan.IncludeChild,
|
|
"employee_ids": employeeIDs,
|
|
"employee_names": plan.EmployeeNames,
|
|
"start_date": normalizeOaDate(plan.StartDate),
|
|
"end_date": normalizeOaDate(plan.EndDate),
|
|
"no_end": scIsNoEnd(plan.EndDate),
|
|
"weekdays": weekdays,
|
|
"shift_id": plan.ShiftID,
|
|
"shift_name": plan.ShiftName,
|
|
"color": plan.Color,
|
|
"template_id": plan.TemplateID,
|
|
"template_code": plan.TemplateCode,
|
|
"template_name": plan.TemplateName,
|
|
"align_mode": plan.AlignMode,
|
|
"cycle_days": plan.CycleDays,
|
|
"week_offset": plan.WeekOffset,
|
|
"month_rest_days": plan.MonthRestDays,
|
|
"month_rest_weekday": plan.MonthRestWeekday,
|
|
"pattern": scPlanPatternDays(plan),
|
|
"note": plan.Note,
|
|
"status": plan.Status,
|
|
"publish_status": plan.PublishStatus,
|
|
"entry_count": plan.EntryCount,
|
|
"created_by": plan.CreatedBy,
|
|
"created_by_name": plan.CreatedByName,
|
|
"create_time": plan.CreateTime.Format("2006-01-02 15:04:05"),
|
|
"update_time": plan.UpdateTime,
|
|
}
|
|
}
|
|
|
|
func (c *BackendOaSchedulingController) scPlanQuery(tid uint64) orm.QuerySeter {
|
|
return models.Orm.QueryTable(new(models.OaSchedulePlan)).
|
|
Filter("tid", tid).
|
|
Filter("is_deleted", 0)
|
|
}
|
|
|
|
// PlanList 排班方案列表。
|
|
// GET /backend/oa/scheduling/plans
|
|
func (c *BackendOaSchedulingController) PlanList() {
|
|
tid, _, _, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
page, _ := c.GetInt("page", 1)
|
|
pageSize, _ := c.GetInt("page_size", 20)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 || pageSize > 200 {
|
|
pageSize = 20
|
|
}
|
|
|
|
qs := c.scPlanQuery(tid)
|
|
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
|
qs = qs.Filter("plan_name__icontains", keyword)
|
|
}
|
|
if raw := strings.TrimSpace(c.GetString("status")); raw != "" {
|
|
if value, err := strconv.Atoi(raw); err == nil {
|
|
qs = qs.Filter("status", value)
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(c.GetString("plan_type")); raw != "" {
|
|
if value, err := strconv.Atoi(raw); err == nil {
|
|
qs = qs.Filter("plan_type", value)
|
|
}
|
|
}
|
|
|
|
total, _ := qs.Count()
|
|
var rows []models.OaSchedulePlan
|
|
_, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
|
if err != nil && err != orm.ErrNoRows {
|
|
c.scFail(500, 500, "查询排班方案失败: "+err.Error())
|
|
return
|
|
}
|
|
|
|
list := make([]map[string]interface{}, 0, len(rows))
|
|
for i := range rows {
|
|
list = append(list, scPlanView(&rows[i]))
|
|
}
|
|
c.scOK(map[string]interface{}{
|
|
"list": list,
|
|
"total": total,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
// PlanDetail 排班方案详情。
|
|
// GET /backend/oa/scheduling/plans/detail/:id
|
|
func (c *BackendOaSchedulingController) PlanDetail() {
|
|
tid, _, _, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
id, valid := c.scPathID(":id")
|
|
if !valid {
|
|
c.scFail(400, 400, "无效的方案ID")
|
|
return
|
|
}
|
|
var plan models.OaSchedulePlan
|
|
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
|
c.scFail(404, 404, "排班方案不存在")
|
|
return
|
|
}
|
|
c.scOK(scPlanView(&plan))
|
|
}
|
|
|
|
// PlanPreview 预览方案覆盖的员工与班次数,不写入任何数据。
|
|
// POST /backend/oa/scheduling/plans/preview
|
|
func (c *BackendOaSchedulingController) PlanPreview() {
|
|
tid, _, _, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
body := c.scBody()
|
|
input, msg := c.scPlanInputFromBody(body, false)
|
|
if msg != "" {
|
|
c.scFail(400, 400, msg)
|
|
return
|
|
}
|
|
|
|
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
|
var shift *models.OaShift
|
|
if len(input.Pattern) == 0 {
|
|
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
|
if !found {
|
|
c.scFail(400, 400, "班次不存在或已停用")
|
|
return
|
|
}
|
|
shift = loaded
|
|
}
|
|
|
|
employees, err := c.scResolveEmployees(tid, input.ScopeType, input.OrgID, input.IncludeChild, input.EmployeeIDs)
|
|
if err != nil {
|
|
c.scFail(500, 500, "展开排班范围失败: "+err.Error())
|
|
return
|
|
}
|
|
if len(employees) == 0 {
|
|
c.scOK(map[string]interface{}{
|
|
"employee_count": 0,
|
|
"employee_list": []schedulingEmployee{},
|
|
"dates": []string{},
|
|
"entry_count": 0,
|
|
"rest_count": 0,
|
|
"shift": shift,
|
|
"scope_name": c.scScopeName(tid, input),
|
|
"template_name": input.TemplateName,
|
|
"pattern": input.Pattern,
|
|
})
|
|
return
|
|
}
|
|
if len(employees) > scPlanMaxEmployees {
|
|
c.scFail(400, 400, fmt.Sprintf("范围覆盖 %d 名员工,超过单次 %d 人上限,请缩小范围", len(employees), scPlanMaxEmployees))
|
|
return
|
|
}
|
|
|
|
dates, msg := scPlanGenWindow(input.StartDate, input.EndDate)
|
|
if msg != "" {
|
|
c.scFail(400, 400, msg)
|
|
return
|
|
}
|
|
previewPlan := &models.OaSchedulePlan{
|
|
StartDate: input.StartDate,
|
|
EndDate: input.EndDate,
|
|
Weekdays: scWeekdaysJSON(input.Weekdays),
|
|
AlignMode: input.AlignMode,
|
|
CycleDays: input.CycleDays,
|
|
WeekOffset: input.WeekOffset,
|
|
MonthRestDays: input.MonthRestDays,
|
|
MonthRestWeekday: input.MonthRestWeekday,
|
|
Pattern: scPatternToJSON(input.Pattern),
|
|
}
|
|
_, workdays, msg := scPlanWorkRules(previewPlan, shift, dates)
|
|
if msg != "" {
|
|
c.scFail(400, 400, msg)
|
|
return
|
|
}
|
|
if len(workdays) == 0 {
|
|
c.scFail(400, 400, "所选日期区间内没有需要上班的日期")
|
|
return
|
|
}
|
|
entryCount := len(employees) * len(workdays)
|
|
if entryCount > scPlanMaxEntries {
|
|
c.scFail(400, 400, fmt.Sprintf("本次将生成 %d 条排班,超过单次 %d 条上限,请缩短日期或缩小范围", entryCount, scPlanMaxEntries))
|
|
return
|
|
}
|
|
|
|
list := c.scEmployeeDTOs(tid, employees)
|
|
c.scOK(map[string]interface{}{
|
|
"employee_count": len(employees),
|
|
"employee_list": list,
|
|
"dates": workdays,
|
|
"workday_count": len(workdays),
|
|
"rest_count": len(dates) - len(workdays),
|
|
"entry_count": entryCount,
|
|
"no_end": scIsNoEnd(input.EndDate),
|
|
"gen_start": dates[0],
|
|
"gen_end": dates[len(dates)-1],
|
|
"shift": shift,
|
|
"scope_name": c.scScopeName(tid, input),
|
|
"template_name": input.TemplateName,
|
|
"pattern": input.Pattern,
|
|
})
|
|
}
|
|
|
|
// scWeekdaysJSON 把星期集合转成与方案存储一致的 JSON 数组。
|
|
func scWeekdaysJSON(set map[int]bool) string {
|
|
list := scWeekdayList(set)
|
|
if len(list) == 0 {
|
|
return ""
|
|
}
|
|
buf, err := json.Marshal(list)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(buf)
|
|
}
|
|
|
|
// scScopeName 给方案生成可读的范围描述。
|
|
func (c *BackendOaSchedulingController) scScopeName(tid uint64, input *scPlanInput) string {
|
|
switch input.ScopeType {
|
|
case models.OaScopeCompany:
|
|
return "全公司"
|
|
case models.OaScopeEmployees:
|
|
employees, err := c.scEmployeesByIDs(tid, input.EmployeeIDs)
|
|
if err != nil || len(employees) == 0 {
|
|
return fmt.Sprintf("指定员工(%d 人)", len(input.EmployeeIDs))
|
|
}
|
|
names := make([]string, 0, len(employees))
|
|
for _, employee := range employees {
|
|
names = append(names, employee.Name)
|
|
}
|
|
if len(names) > 3 {
|
|
return fmt.Sprintf("%s 等 %d 人", strings.Join(names[:3], "、"), len(names))
|
|
}
|
|
return strings.Join(names, "、")
|
|
default:
|
|
name := c.scLoadOrgIndex(tid).name(input.OrgID)
|
|
if name == "" {
|
|
name = "部门"
|
|
}
|
|
if input.IncludeChild {
|
|
return name + "(含下级)"
|
|
}
|
|
return name
|
|
}
|
|
}
|
|
|
|
// PlanCreate 新建排班方案。
|
|
// POST /backend/oa/scheduling/plans/create
|
|
func (c *BackendOaSchedulingController) PlanCreate() {
|
|
tid, uid, username, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
body := c.scBody()
|
|
input, msg := c.scPlanInputFromBody(body, true)
|
|
if msg != "" {
|
|
c.scFail(400, 400, msg)
|
|
return
|
|
}
|
|
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
|
var shift *models.OaShift
|
|
if len(input.Pattern) == 0 {
|
|
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
|
if !found {
|
|
c.scFail(400, 400, "班次不存在或已停用")
|
|
return
|
|
}
|
|
shift = loaded
|
|
}
|
|
|
|
weekdays := scWeekdayList(input.Weekdays)
|
|
weekdayJSON, _ := json.Marshal(weekdays)
|
|
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
|
|
|
|
plan := &models.OaSchedulePlan{
|
|
Tid: int(tid),
|
|
PlanName: input.PlanName,
|
|
PlanType: input.PlanType,
|
|
ScopeType: input.ScopeType,
|
|
OrgID: input.OrgID,
|
|
OrgName: c.scScopeName(tid, input),
|
|
IncludeChild: scBoolToInt8(input.IncludeChild),
|
|
EmployeeIDs: string(employeeJSON),
|
|
EmployeeNames: c.scEmployeeNames(tid, input.EmployeeIDs),
|
|
StartDate: input.StartDate,
|
|
EndDate: input.EndDate,
|
|
Weekdays: string(weekdayJSON),
|
|
Note: input.Note,
|
|
Status: 1,
|
|
PublishStatus: 0,
|
|
CreatedBy: uid,
|
|
CreatedByName: username,
|
|
}
|
|
scApplyPlanRules(plan, input, shift)
|
|
if _, err := models.Orm.Insert(plan); err != nil {
|
|
c.scFail(500, 500, "保存排班方案失败: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// publish_now=true 时创建后立即下发,减少“建完再点发布”的一步操作。
|
|
if scBoolValue(body, "publish_now") {
|
|
stats, publishErr := c.scPublishPlan(tid, uid, username, plan, true)
|
|
if publishErr != nil {
|
|
c.scFail(500, 500, "方案已保存,但发布失败: "+publishErr.Error())
|
|
return
|
|
}
|
|
c.scOK(map[string]interface{}{"plan": scPlanView(plan), "publish": stats})
|
|
return
|
|
}
|
|
c.scOK(map[string]interface{}{"plan": scPlanView(plan)})
|
|
}
|
|
|
|
func scBoolToInt8(value bool) int8 {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// scApplyPlanRules 把入参的排班规则写入方案:
|
|
// - 带 pattern:保存模板快照与周期参数,方案的展示班次取第一个上班日;
|
|
// - 不带 pattern:回到按星期模式,清空模板字段。
|
|
func scApplyPlanRules(plan *models.OaSchedulePlan, input *scPlanInput, shift *models.OaShift) {
|
|
if len(input.Pattern) > 0 {
|
|
plan.TemplateID = input.TemplateID
|
|
plan.TemplateCode = input.TemplateCode
|
|
plan.TemplateName = input.TemplateName
|
|
plan.AlignMode = input.AlignMode
|
|
plan.CycleDays = input.CycleDays
|
|
if plan.AlignMode == models.SchedTplAlignMonth {
|
|
plan.CycleDays = 0
|
|
}
|
|
plan.WeekOffset = input.WeekOffset
|
|
plan.MonthRestDays = input.MonthRestDays
|
|
plan.MonthRestWeekday = input.MonthRestWeekday
|
|
plan.Pattern = scPatternToJSON(input.Pattern)
|
|
if primary, ok := scPrimaryPatternShift(input.Pattern); ok {
|
|
plan.ShiftID = primary.ShiftID
|
|
plan.ShiftName = primary.ShiftName
|
|
plan.Color = primary.Color
|
|
}
|
|
return
|
|
}
|
|
|
|
plan.TemplateID = 0
|
|
plan.TemplateCode = ""
|
|
plan.TemplateName = ""
|
|
plan.AlignMode = 0
|
|
plan.CycleDays = 0
|
|
plan.WeekOffset = 0
|
|
plan.MonthRestDays = 0
|
|
plan.MonthRestWeekday = 0
|
|
plan.Pattern = ""
|
|
if shift != nil {
|
|
plan.ShiftID = shift.ID
|
|
plan.ShiftName = shift.Name
|
|
plan.Color = shift.Color
|
|
}
|
|
}
|
|
|
|
func (c *BackendOaSchedulingController) scEmployeeNames(tid uint64, ids []uint64) string {
|
|
if len(ids) == 0 {
|
|
return ""
|
|
}
|
|
employees, err := c.scEmployeesByIDs(tid, ids)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
names := make([]string, 0, len(employees))
|
|
for _, employee := range employees {
|
|
names = append(names, employee.Name)
|
|
}
|
|
return strings.Join(names, "、")
|
|
}
|
|
|
|
// PlanUpdate 更新排班方案。已发布的方案会回到“未发布”状态,
|
|
// 需要重新发布后新规则才会写入明细(已有明细不会被自动清除)。
|
|
// POST /backend/oa/scheduling/plans/update/:id
|
|
func (c *BackendOaSchedulingController) PlanUpdate() {
|
|
tid, _, _, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
id, valid := c.scPathID(":id")
|
|
if !valid {
|
|
c.scFail(400, 400, "无效的方案ID")
|
|
return
|
|
}
|
|
var plan models.OaSchedulePlan
|
|
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
|
c.scFail(404, 404, "排班方案不存在")
|
|
return
|
|
}
|
|
|
|
body := c.scBody()
|
|
input, msg := c.scPlanInputFromBody(body, true)
|
|
if msg != "" {
|
|
c.scFail(400, 400, msg)
|
|
return
|
|
}
|
|
// 模板模式下由 pattern 携带每天班次,不再要求预先选择班次
|
|
var shift *models.OaShift
|
|
if len(input.Pattern) == 0 {
|
|
loaded, found := c.scLoadShift(tid, input.ShiftID)
|
|
if !found {
|
|
c.scFail(400, 400, "班次不存在或已停用")
|
|
return
|
|
}
|
|
shift = loaded
|
|
}
|
|
|
|
weekdays := scWeekdayList(input.Weekdays)
|
|
weekdayJSON, _ := json.Marshal(weekdays)
|
|
employeeJSON, _ := json.Marshal(input.EmployeeIDs)
|
|
|
|
plan.PlanName = input.PlanName
|
|
plan.PlanType = input.PlanType
|
|
plan.ScopeType = input.ScopeType
|
|
plan.OrgID = input.OrgID
|
|
plan.OrgName = c.scScopeName(tid, input)
|
|
plan.IncludeChild = scBoolToInt8(input.IncludeChild)
|
|
plan.EmployeeIDs = string(employeeJSON)
|
|
plan.EmployeeNames = c.scEmployeeNames(tid, input.EmployeeIDs)
|
|
plan.StartDate = input.StartDate
|
|
plan.EndDate = input.EndDate
|
|
plan.Weekdays = string(weekdayJSON)
|
|
plan.Note = input.Note
|
|
plan.Status = 1
|
|
plan.PublishStatus = 0
|
|
scApplyPlanRules(&plan, input, shift)
|
|
|
|
if _, err := models.Orm.Update(&plan); err != nil {
|
|
c.scFail(500, 500, "更新排班方案失败: "+err.Error())
|
|
return
|
|
}
|
|
c.scOK(map[string]interface{}{"plan": scPlanView(&plan)})
|
|
}
|
|
|
|
// PlanPublish 发布方案:把规则展开成每人每天的排班明细。
|
|
// POST /backend/oa/scheduling/plans/publish/:id
|
|
// body: { overwrite: true } 覆盖同一员工同一天已有排班,默认保留原排班。
|
|
func (c *BackendOaSchedulingController) PlanPublish() {
|
|
tid, uid, username, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
id, valid := c.scPathID(":id")
|
|
if !valid {
|
|
c.scFail(400, 400, "无效的方案ID")
|
|
return
|
|
}
|
|
var plan models.OaSchedulePlan
|
|
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
|
c.scFail(404, 404, "排班方案不存在")
|
|
return
|
|
}
|
|
if plan.Status != 1 {
|
|
c.scFail(400, 400, "方案已停用,请先启用")
|
|
return
|
|
}
|
|
|
|
body := c.scBody()
|
|
stats, err := c.scPublishPlan(tid, uid, username, &plan, scBoolValue(body, "overwrite"))
|
|
if err != nil {
|
|
c.scFail(500, 500, "发布排班失败: "+err.Error())
|
|
return
|
|
}
|
|
c.scOK(map[string]interface{}{"plan": scPlanView(&plan), "publish": stats})
|
|
}
|
|
|
|
// scPublishPlan 方案展开与写入。同一员工同一天只保留一条有效明细:
|
|
// - 不存在 -> 新增
|
|
// - 已存在且 overwrite=false -> 跳过(保留手工调整结果)
|
|
// - 已存在且 overwrite=true -> 覆盖为新班次并记录变更
|
|
func (c *BackendOaSchedulingController) scPublishPlan(tid, uid uint64, username string, plan *models.OaSchedulePlan, overwrite bool) (map[string]int, error) {
|
|
// 模板模式(AlignMode=1/2/3)由 pattern 携带每天班次;按星期模式回退到方案绑定的班次
|
|
var fallback *models.OaShift
|
|
if plan.AlignMode == 0 {
|
|
loaded, found := c.scLoadShift(tid, plan.ShiftID)
|
|
if !found {
|
|
return nil, errors.New("方案绑定的班次不存在或已停用")
|
|
}
|
|
fallback = loaded
|
|
}
|
|
|
|
employeeIDs := []uint64{}
|
|
if strings.TrimSpace(plan.EmployeeIDs) != "" {
|
|
_ = json.Unmarshal([]byte(plan.EmployeeIDs), &employeeIDs)
|
|
}
|
|
employees, err := c.scResolveEmployees(tid, plan.ScopeType, plan.OrgID, plan.IncludeChild == 1, employeeIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(employees) == 0 {
|
|
return nil, errors.New("方案范围内没有可排班的员工")
|
|
}
|
|
if len(employees) > scPlanMaxEmployees {
|
|
return nil, fmt.Errorf("范围覆盖 %d 名员工,超过单次 %d 人上限", len(employees), scPlanMaxEmployees)
|
|
}
|
|
|
|
dates, msg := scPlanGenWindow(normalizeOaDate(plan.StartDate), normalizeOaDate(plan.EndDate))
|
|
if msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
rules, workdays, msg := scPlanWorkRules(plan, fallback, dates)
|
|
if msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
if len(workdays) == 0 {
|
|
return nil, errors.New("方案日期区间内没有需要上班的日期")
|
|
}
|
|
if len(employees)*len(workdays) > scPlanMaxEntries {
|
|
return nil, fmt.Errorf("本次将生成 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
|
|
}
|
|
|
|
index := c.scLoadOrgIndex(tid)
|
|
empIDs := make([]uint64, 0, len(employees))
|
|
for _, employee := range employees {
|
|
empIDs = append(empIDs, uint64(employee.ID))
|
|
}
|
|
|
|
var existing []models.OaScheduleEntry
|
|
_, err = models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
|
Filter("tid", tid).
|
|
Filter("is_deleted", 0).
|
|
Filter("employee_id__in", empIDs).
|
|
Filter("schedule_date__gte", workdays[0]).
|
|
Filter("schedule_date__lte", workdays[len(workdays)-1]).
|
|
All(&existing)
|
|
if err != nil && err != orm.ErrNoRows {
|
|
return nil, err
|
|
}
|
|
existingMap := map[string]*models.OaScheduleEntry{}
|
|
for i := range existing {
|
|
key := fmt.Sprintf("%d|%s", existing[i].EmployeeID, normalizeOaDate(existing[i].ScheduleDate))
|
|
existingMap[key] = &existing[i]
|
|
}
|
|
|
|
stats := map[string]int{"created": 0, "updated": 0, "skipped": 0}
|
|
tx, err := orm.NewOrm().Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, employee := range employees {
|
|
orgID := scEmployeeDepartmentID(employee)
|
|
position := ""
|
|
if employee.Position != nil {
|
|
position = *employee.Position
|
|
}
|
|
for _, date := range workdays {
|
|
rule := rules[date]
|
|
key := fmt.Sprintf("%d|%s", employee.ID, date)
|
|
if row, hit := existingMap[key]; hit {
|
|
if !overwrite {
|
|
stats["skipped"]++
|
|
continue
|
|
}
|
|
if row.ShiftID == rule.ShiftID && row.ShiftName == rule.ShiftName && row.Source == models.OaEntrySourcePlan && row.PlanID == plan.ID {
|
|
stats["skipped"]++
|
|
continue
|
|
}
|
|
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
|
row.PlanID = plan.ID
|
|
row.ShiftID = rule.ShiftID
|
|
row.ShiftName = rule.ShiftName
|
|
row.ShortName = rule.ShortName
|
|
row.Color = rule.Color
|
|
row.WorkStart = rule.WorkStart
|
|
row.WorkEnd = rule.WorkEnd
|
|
row.Source = models.OaEntrySourcePlan
|
|
if _, updateErr := tx.Update(row); updateErr != nil {
|
|
_ = tx.Rollback()
|
|
return nil, updateErr
|
|
}
|
|
c.scWriteChange(tx, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
|
models.OaChangePublish, oldShiftID, oldShiftName, rule.ShiftID, rule.ShiftName, "方案重新发布覆盖", uid, username)
|
|
stats["updated"]++
|
|
continue
|
|
}
|
|
|
|
entry := &models.OaScheduleEntry{
|
|
Tid: int(tid),
|
|
PlanID: plan.ID,
|
|
EmployeeID: employee.ID,
|
|
EmployeeName: employee.Name,
|
|
EmployeeAccount: employee.Account,
|
|
OrgID: orgID,
|
|
OrgName: index.name(orgID),
|
|
Position: position,
|
|
ScheduleDate: date,
|
|
ShiftID: rule.ShiftID,
|
|
ShiftName: rule.ShiftName,
|
|
ShortName: rule.ShortName,
|
|
Color: rule.Color,
|
|
WorkStart: rule.WorkStart,
|
|
WorkEnd: rule.WorkEnd,
|
|
Source: models.OaEntrySourcePlan,
|
|
Note: plan.Note,
|
|
CreatedBy: uid,
|
|
CreatedByName: username,
|
|
}
|
|
if _, insertErr := tx.Insert(entry); insertErr != nil {
|
|
_ = tx.Rollback()
|
|
return nil, insertErr
|
|
}
|
|
existingMap[key] = entry
|
|
stats["created"]++
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
plan.PublishStatus = 1
|
|
plan.EntryCount = stats["created"] + stats["updated"]
|
|
plan.UpdateTime = &now
|
|
if _, err := tx.Update(plan, "publish_status", "entry_count", "update_time"); err != nil {
|
|
_ = tx.Rollback()
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stats["employee_count"] = len(employees)
|
|
stats["workday_count"] = len(workdays)
|
|
return stats, nil
|
|
}
|
|
|
|
// scChangeInserter 抽取 Insert,使普通 Ormer 与事务 TxOrmer 都能写变更记录。
|
|
type scChangeInserter interface {
|
|
Insert(interface{}) (int64, error)
|
|
}
|
|
|
|
// scWriteChange 写一条排班变更记录;失败不阻断主流程。
|
|
func (c *BackendOaSchedulingController) scWriteChange(tx scChangeInserter, tid uint64, entryID uint64, employeeID uint, employeeName, date string,
|
|
changeType int8, oldShiftID uint64, oldShiftName string, newShiftID uint64, newShiftName, reason string, operatorID uint64, operatorName string) {
|
|
if tx == nil || (oldShiftID == newShiftID && oldShiftName == newShiftName) {
|
|
return
|
|
}
|
|
_, _ = tx.Insert(&models.OaScheduleChange{
|
|
Tid: int(tid),
|
|
EntryID: entryID,
|
|
EmployeeID: employeeID,
|
|
EmployeeName: employeeName,
|
|
ScheduleDate: date,
|
|
ChangeType: changeType,
|
|
OldShiftID: oldShiftID,
|
|
OldShiftName: oldShiftName,
|
|
NewShiftID: newShiftID,
|
|
NewShiftName: newShiftName,
|
|
Reason: reason,
|
|
OperatorID: operatorID,
|
|
OperatorName: operatorName,
|
|
})
|
|
}
|
|
|
|
// PlanDelete 删除排班方案;默认同时清除该方案生成且仍未被调整的排班明细
|
|
// (source=1)。被临时调配过的明细 source 已变为 2,会保留下来,
|
|
// 避免把人工调整过的排班一起删掉。
|
|
// DELETE /backend/oa/scheduling/plans/delete/:id?delete_entries=1
|
|
func (c *BackendOaSchedulingController) PlanDelete() {
|
|
tid, _, _, ok := c.scOperator()
|
|
if !ok {
|
|
return
|
|
}
|
|
id, valid := c.scPathID(":id")
|
|
if !valid {
|
|
c.scFail(400, 400, "无效的方案ID")
|
|
return
|
|
}
|
|
var plan models.OaSchedulePlan
|
|
if err := c.scPlanQuery(tid).Filter("id", id).One(&plan); err != nil {
|
|
c.scFail(404, 404, "排班方案不存在")
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
plan.IsDeleted = 1
|
|
plan.DeleteTime = &now
|
|
if _, err := models.Orm.Update(&plan, "is_deleted", "delete_time"); err != nil {
|
|
c.scFail(500, 500, "删除方案失败: "+err.Error())
|
|
return
|
|
}
|
|
|
|
removed := int64(0)
|
|
deleteEntries := c.GetString("delete_entries") != "0"
|
|
if deleteEntries {
|
|
result, err := models.Orm.Raw(
|
|
"UPDATE yz_backend_oa_schedule_entry SET is_deleted=id, delete_time=? "+
|
|
"WHERE tid=? AND plan_id=? AND source=1 AND is_deleted=0", now, tid, plan.ID,
|
|
).Exec()
|
|
if err == nil {
|
|
removed, _ = result.RowsAffected()
|
|
}
|
|
}
|
|
c.scOK(map[string]interface{}{"id": plan.ID, "deleted_entries": removed})
|
|
}
|