package controllers import ( "encoding/json" "fmt" "strconv" "strings" "time" "server/models" "server/pkg/identity" "server/pkg/jwtutil" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // PlatformSchedTemplateController 平台端排班模板管理(全局模板,只有平台可维护)。 // // 表:yz_platform_sched_tpl / yz_platform_sched_tpl_day // 建表与预置数据:docs/sql/create_platform_sched_template.sql // // 规则: // - 系统预置模板(is_system=1)由平台锁定:禁止编辑、删除、停用; // - 平台可新建「自定义模板」,并编辑 / 停用 / 删除自定义模板; // - 租户端只读取启用模板(/backend/oa/scheduling/templates), // 调用后把规则快照进自己的排班方案,模板改动不影响已保存方案。 type PlatformSchedTemplateController struct { beego.Controller } func (c *PlatformSchedTemplateController) tplClaims() (*jwtutil.Claims, error) { return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndPlatform) } func (c *PlatformSchedTemplateController) tplErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} _ = c.ServeJSON() } func (c *PlatformSchedTemplateController) tplOK(data interface{}, msg string) { if msg == "" { msg = "success" } resp := map[string]interface{}{"code": 200, "msg": msg} if data != nil { resp["data"] = data } c.Data["json"] = resp _ = c.ServeJSON() } // --------------------------------------------------------------------------- // 参数结构 // --------------------------------------------------------------------------- type schedTplDayPayload struct { DayIndex int `json:"day_index"` DayType int8 `json:"day_type"` ShiftName string `json:"shift_name"` ShiftShort string `json:"shift_short"` 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"` } type schedTplPayload struct { Name string `json:"name"` Code string `json:"code"` TemplateType int8 `json:"template_type"` AlignMode int8 `json:"align_mode"` CycleDays int `json:"cycle_days"` WeekOffset int8 `json:"week_offset"` MonthRestDays int8 `json:"month_rest_days"` MonthRestWeekday int8 `json:"month_rest_weekday"` Summary string `json:"summary"` Status int8 `json:"status"` Sort int `json:"sort"` Days []schedTplDayPayload `json:"days"` } // --------------------------------------------------------------------------- // 列表 / 详情 // --------------------------------------------------------------------------- func (c *PlatformSchedTemplateController) tplQuery() orm.QuerySeter { return models.Orm.QueryTable(new(models.PlatformSchedTpl)).Filter("delete_time__isnull", true) } func (c *PlatformSchedTemplateController) tplByID(id uint64) (*models.PlatformSchedTpl, bool) { var row models.PlatformSchedTpl if err := c.tplQuery().Filter("id", id).One(&row); err != nil { return nil, false } return &row, true } func (c *PlatformSchedTemplateController) tplDays(templateID uint64) []models.PlatformSchedTplDay { rows := []models.PlatformSchedTplDay{} _, err := models.Orm.QueryTable(new(models.PlatformSchedTplDay)). Filter("template_id", templateID). OrderBy("day_index").All(&rows) if err != nil && err != orm.ErrNoRows { return []models.PlatformSchedTplDay{} } return rows } func (c *PlatformSchedTemplateController) tplView(row *models.PlatformSchedTpl, withDays bool) map[string]interface{} { view := map[string]interface{}{ "id": row.ID, "code": row.Code, "name": row.Name, "template_type": row.TemplateType, "template_type_text": schedTplTypeText(row.TemplateType), "align_mode": row.AlignMode, "cycle_days": row.CycleDays, "week_offset": row.WeekOffset, "month_rest_days": row.MonthRestDays, "month_rest_weekday": row.MonthRestWeekday, "summary": row.Summary, "is_system": row.IsSystem, "editable": row.IsSystem != 1, "status": row.Status, "sort": row.Sort, "creater": row.Creater, "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), } if row.UpdateTime != nil { view["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05") } if withDays { view["days"] = c.tplDays(row.ID) } return view } func schedTplTypeText(value int8) string { switch value { case models.SchedTplTypeDoubleWeekend: return "双休" case models.SchedTplTypeSingleWeekend: return "单休" case models.SchedTplTypeBigSmallWeek: return "大小周" case models.SchedTplTypeMonthRest: return "月休" case models.SchedTplTypeThreeShift: return "三班倒" case models.SchedTplTypeWorkRestOne: return "做一休一" default: return "自定义" } } // List 模板列表。 // GET /platform/schedTemplates?page=1&pageSize=20&keyword=&status=&template_type= func (c *PlatformSchedTemplateController) List() { if _, err := c.tplClaims(); err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } page, pageSize, offset := dictPageSize(&c.Controller) qs := c.tplQuery() if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" { qs = qs.Filter("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("template_type")); raw != "" { if value, err := strconv.Atoi(raw); err == nil { qs = qs.Filter("template_type", value) } } total, _ := qs.Count() var rows []models.PlatformSchedTpl if _, err := qs.OrderBy("sort", "id").Limit(pageSize).Offset(offset).All(&rows); err != nil && err != orm.ErrNoRows { c.tplErr(500, 500, "查询模板失败: "+err.Error()) return } list := make([]map[string]interface{}, 0, len(rows)) for i := range rows { list = append(list, c.tplView(&rows[i], false)) } c.tplOK(map[string]interface{}{ "list": list, "total": total, "page": page, "page_size": pageSize, }, "") } // Detail 模板详情(含周期每日规则)。 // GET /platform/schedTemplates/:id func (c *PlatformSchedTemplateController) Detail() { if _, err := c.tplClaims(); err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64) if err != nil || id == 0 { c.tplErr(400, 400, "无效的模板ID") return } row, found := c.tplByID(id) if !found { c.tplErr(404, 404, "模板不存在") return } c.tplOK(c.tplView(row, true), "") } // --------------------------------------------------------------------------- // 新建 / 编辑 / 删除 / 启停 // --------------------------------------------------------------------------- func (c *PlatformSchedTemplateController) parsePayload() (*schedTplPayload, bool) { var payload schedTplPayload if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil { c.tplErr(400, 400, "参数错误") return nil, false } return &payload, true } // buildDays 校验并构造周期每日规则;返回非空 msg 表示参数错误。 func buildSchedTplDays(payload *schedTplPayload) ([]models.PlatformSchedTplDay, string) { if payload.AlignMode < models.SchedTplAlignWeek || payload.AlignMode > models.SchedTplAlignMonth { return nil, "排班方式不合法" } if len(payload.Days) == 0 { return nil, "请至少配置一天的规则" } cycle := payload.CycleDays if payload.AlignMode == models.SchedTplAlignMonth { // 月休按自然月计算,周期天数无意义 cycle = 0 if payload.MonthRestDays < 1 || payload.MonthRestDays > 15 { return nil, "月休天数需在 1~15 天之间" } if payload.MonthRestWeekday < 0 || payload.MonthRestWeekday > 7 { return nil, "月休休息周几不合法" } } else { if cycle < 2 || cycle > 31 { return nil, "周期天数需在 2~31 天之间" } if payload.AlignMode == models.SchedTplAlignWeek && cycle%7 != 0 { return nil, "按自然周排班时,周期天数必须是 7 的倍数(7 / 14 / 21 / 28)" } if len(payload.Days) != cycle { return nil, fmt.Sprintf("周期为 %d 天,需要配置 %d 天的规则", cycle, cycle) } } seen := map[int]bool{} workCount := 0 days := make([]models.PlatformSchedTplDay, 0, len(payload.Days)) for _, item := range payload.Days { index := item.DayIndex if payload.AlignMode == models.SchedTplAlignMonth { index = len(days) } if index < 0 || (payload.AlignMode != models.SchedTplAlignMonth && index >= cycle) { return nil, "每日规则的天序号不合法" } if seen[index] { return nil, "每日规则的天序号重复" } seen[index] = true dayType := item.DayType if dayType != models.SchedTplDayWork && dayType != models.SchedTplDayRest { return nil, "每日规则类型不合法(1=上班 2=休息)" } row := models.PlatformSchedTplDay{ DayIndex: index, DayType: dayType, Remark: strings.TrimSpace(item.Remark), } if dayType == models.SchedTplDayWork { workCount++ row.ShiftName = strings.TrimSpace(item.ShiftName) if row.ShiftName == "" { return nil, fmt.Sprintf("第 %d 天选择上班,需填写班次名称", index+1) } if len([]rune(row.ShiftName)) > 50 { return nil, "班次名称不能超过 50 个字符" } row.WorkStart = strings.TrimSpace(item.WorkStart) row.WorkEnd = strings.TrimSpace(item.WorkEnd) 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) } row.RestStart = strings.TrimSpace(item.RestStart) row.RestEnd = strings.TrimSpace(item.RestEnd) 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) } row.ShiftShort = strings.TrimSpace(item.ShiftShort) if row.ShiftShort == "" { row.ShiftShort = row.ShiftName } row.Color = strings.TrimSpace(item.Color) if row.Color == "" { row.Color = "#409EFF" } row.WorkHours = scCalcWorkHours(row.WorkStart, row.WorkEnd, row.RestStart, row.RestEnd) } days = append(days, row) } if workCount == 0 { return nil, "排班模板至少需要一天上班" } // 按天序号排序,避免前端乱序提交 for i := 0; i < len(days); i++ { for j := i + 1; j < len(days); j++ { if days[j].DayIndex < days[i].DayIndex { days[i], days[j] = days[j], days[i] } } } return days, "" } // autoSummary 生成规则摘要(前端未填写时使用)。 func autoSummary(payload *schedTplPayload, days []models.PlatformSchedTplDay) string { work, rest := 0, 0 shiftNames := []string{} seenShift := map[string]bool{} for _, day := range days { if day.DayType == models.SchedTplDayWork { work++ if !seenShift[day.ShiftName] { seenShift[day.ShiftName] = true shiftNames = append(shiftNames, fmt.Sprintf("%s %s-%s", day.ShiftName, day.WorkStart, day.WorkEnd)) } } else { rest++ } } switch payload.AlignMode { case models.SchedTplAlignWeek: weeks := payload.CycleDays / 7 if weeks <= 1 { return fmt.Sprintf("每周上班 %d 天、休息 %d 天;%s", work, rest, strings.Join(shiftNames, "、")) } return fmt.Sprintf("%d 周循环,共上班 %d 天、休息 %d 天;%s", weeks, work, rest, strings.Join(shiftNames, "、")) case models.SchedTplAlignMonth: return fmt.Sprintf("每月固定休 %d 天;上班日 %s", payload.MonthRestDays, strings.Join(shiftNames, "、")) default: return fmt.Sprintf("每 %d 天循环,上班 %d 天、休息 %d 天;%s", payload.CycleDays, work, rest, strings.Join(shiftNames, "、")) } } // Create 新建自定义模板。 // POST /platform/schedTemplates func (c *PlatformSchedTemplateController) Create() { claims, err := c.tplClaims() if err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } payload, ok := c.parsePayload() if !ok { return } payload.Name = strings.TrimSpace(payload.Name) if payload.Name == "" { c.tplErr(400, 400, "模板名称不能为空") return } if len([]rune(payload.Name)) > 50 { c.tplErr(400, 400, "模板名称不能超过 50 个字符") return } if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) { payload.TemplateType = models.SchedTplTypeCustom } days, msg := buildSchedTplDays(payload) if msg != "" { c.tplErr(400, 400, msg) return } if payload.Status != 0 { payload.Status = 1 } code := strings.TrimSpace(payload.Code) if code == "" { code = fmt.Sprintf("custom_%d", time.Now().UnixNano()/1e6) } exist, _ := c.tplQuery().Filter("code", code).Count() if exist > 0 { c.tplErr(400, 400, "模板编码已存在") return } summary := strings.TrimSpace(payload.Summary) if summary == "" { summary = autoSummary(payload, days) } row := &models.PlatformSchedTpl{ Code: code, Name: payload.Name, TemplateType: payload.TemplateType, AlignMode: payload.AlignMode, CycleDays: payload.CycleDays, WeekOffset: payload.WeekOffset, MonthRestDays: payload.MonthRestDays, MonthRestWeekday: payload.MonthRestWeekday, Summary: summary, IsSystem: 0, Status: payload.Status, Sort: payload.Sort, Creater: claims.Username, } if row.AlignMode == models.SchedTplAlignMonth { row.CycleDays = 0 } tx, beginErr := orm.NewOrm().Begin() if beginErr != nil { c.tplErr(500, 500, "保存模板失败: "+beginErr.Error()) return } id, insertErr := tx.Insert(row) if insertErr != nil { _ = tx.Rollback() c.tplErr(500, 500, "保存模板失败: "+insertErr.Error()) return } for i := range days { days[i].TemplateID = row.ID if _, err := tx.Insert(&days[i]); err != nil { _ = tx.Rollback() c.tplErr(500, 500, "保存模板规则失败: "+err.Error()) return } } if err := tx.Commit(); err != nil { c.tplErr(500, 500, "保存模板失败: "+err.Error()) return } _ = id c.tplOK(c.tplView(row, true), "模板已创建") } // Update 编辑自定义模板;系统预置模板只读。 // POST /platform/schedTemplates/:id func (c *PlatformSchedTemplateController) Update() { if _, err := c.tplClaims(); err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64) if err != nil || id == 0 { c.tplErr(400, 400, "无效的模板ID") return } row, found := c.tplByID(id) if !found { c.tplErr(404, 404, "模板不存在") return } if row.IsSystem == 1 { c.tplErr(400, 400, "系统预置模板只读,不能编辑") return } payload, ok := c.parsePayload() if !ok { return } payload.Name = strings.TrimSpace(payload.Name) if payload.Name == "" { c.tplErr(400, 400, "模板名称不能为空") return } if len([]rune(payload.Name)) > 50 { c.tplErr(400, 400, "模板名称不能超过 50 个字符") return } if payload.TemplateType <= 0 || (payload.TemplateType > 6 && payload.TemplateType != models.SchedTplTypeCustom) { payload.TemplateType = models.SchedTplTypeCustom } days, msg := buildSchedTplDays(payload) if msg != "" { c.tplErr(400, 400, msg) return } if payload.Status != 0 { payload.Status = 1 } summary := strings.TrimSpace(payload.Summary) if summary == "" { summary = autoSummary(payload, days) } row.Name = payload.Name row.TemplateType = payload.TemplateType row.AlignMode = payload.AlignMode row.CycleDays = payload.CycleDays if row.AlignMode == models.SchedTplAlignMonth { row.CycleDays = 0 } row.WeekOffset = payload.WeekOffset row.MonthRestDays = payload.MonthRestDays row.MonthRestWeekday = payload.MonthRestWeekday row.Summary = summary row.Status = payload.Status row.Sort = payload.Sort tx, beginErr := orm.NewOrm().Begin() if beginErr != nil { c.tplErr(500, 500, "保存模板失败: "+beginErr.Error()) return } if _, err := tx.Update(row); err != nil { _ = tx.Rollback() c.tplErr(500, 500, "保存模板失败: "+err.Error()) return } // 规则整体替换:先清空旧规则再写入新规则,避免残留天数 if _, err := tx.QueryTable(new(models.PlatformSchedTplDay)).Filter("template_id", row.ID).Delete(); err != nil { _ = tx.Rollback() c.tplErr(500, 500, "保存模板规则失败: "+err.Error()) return } for i := range days { days[i].TemplateID = row.ID if _, err := tx.Insert(&days[i]); err != nil { _ = tx.Rollback() c.tplErr(500, 500, "保存模板规则失败: "+err.Error()) return } } if err := tx.Commit(); err != nil { c.tplErr(500, 500, "保存模板失败: "+err.Error()) return } c.tplOK(c.tplView(row, true), "模板已更新") } // Delete 删除自定义模板(软删除);系统预置模板禁止删除。 // DELETE /platform/schedTemplates/:id func (c *PlatformSchedTemplateController) Delete() { if _, err := c.tplClaims(); err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64) if err != nil || id == 0 { c.tplErr(400, 400, "无效的模板ID") return } row, found := c.tplByID(id) if !found { c.tplErr(404, 404, "模板不存在") return } if row.IsSystem == 1 { c.tplErr(400, 400, "系统预置模板不能删除") return } now := time.Now() row.DeleteTime = &now if _, err := models.Orm.Update(row, "delete_time"); err != nil { c.tplErr(500, 500, "删除模板失败: "+err.Error()) return } c.tplOK(map[string]interface{}{"id": row.ID}, "模板已删除") } // ToggleStatus 启用 / 停用自定义模板;系统预置模板不可停用。 // POST /platform/schedTemplates/:id/status func (c *PlatformSchedTemplateController) ToggleStatus() { if _, err := c.tplClaims(); err != nil { c.tplErr(401, 401, "未登录或登录已过期") return } id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64) if err != nil || id == 0 { c.tplErr(400, 400, "无效的模板ID") return } row, found := c.tplByID(id) if !found { c.tplErr(404, 404, "模板不存在") return } if row.IsSystem == 1 { c.tplErr(400, 400, "系统预置模板固定启用,不能停用") return } status, _ := c.GetInt("status", -1) if status != 0 && status != 1 { var body struct { Status int8 `json:"status"` } _ = json.Unmarshal(c.Ctx.Input.RequestBody, &body) if body.Status != 0 && body.Status != 1 { c.tplErr(400, 400, "状态只能是 0 或 1") return } status = int(body.Status) } row.Status = int8(status) if _, err := models.Orm.Update(row, "status"); err != nil { c.tplErr(500, 500, "更新状态失败: "+err.Error()) return } c.tplOK(map[string]interface{}{"id": row.ID, "status": row.Status}, "状态已更新") }