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}) }