Files
yunzerwebsiteallinone/go/controllers/platform_schedule_template.go
T
2026-10-26 08:54:34 +08:00

636 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}, "状态已更新")
}