更新若干事项
This commit is contained in:
1 parent
68cdaf38fa
commit
9898962836
30 files changed
+7715
No files matched your search
@@ -0,0 +1,540 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// BackendOaSchedulingController OA 工作排班(班次 / 排班方案 / 排班看板 / 临时调配)。
|
||||
//
|
||||
// 排班的作用范围直接复用租户的组织架构(yz_backend_organization):
|
||||
// 公司、部门、小组是同一棵树上的不同层级,选中某个节点并勾选“含下级”
|
||||
// 即可把排班下发给该节点下的全部员工;也可以跳过组织,直接挑选
|
||||
// 一个或多个员工做临时安排。所有数据按 JWT 中的 tid 做租户隔离。
|
||||
type BackendOaSchedulingController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证与响应
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *BackendOaSchedulingController) scClaims() (*jwtutil.Claims, bool) {
|
||||
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
|
||||
if err != nil || claims.TenantId <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// scOperator 返回当前操作人信息:tid、uid、用户名。
|
||||
func (c *BackendOaSchedulingController) scOperator() (uint64, uint64, string, bool) {
|
||||
claims, ok := c.scClaims()
|
||||
if !ok {
|
||||
c.scFail(401, 401, "未登录或登录已过期")
|
||||
return 0, 0, "", false
|
||||
}
|
||||
return uint64(claims.TenantId), uint64(claims.UserID), claims.Username, true
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scOK(data interface{}) {
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scFail(httpStatus, bizCode int, msg string) {
|
||||
if httpStatus > 0 {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scBody() map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if len(c.Ctx.Input.RequestBody) == 0 {
|
||||
return body
|
||||
}
|
||||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scPathID(param string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(param)), 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 参数解析辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func scStringValue(body map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case float64:
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scIntValue(body map[string]interface{}, keys ...string) (int, bool) {
|
||||
raw := scStringValue(body, keys...)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func scUintValue(body map[string]interface{}, keys ...string) uint64 {
|
||||
raw := scStringValue(body, keys...)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// scUintSlice 解析 [] 形式的 ID 列表,兼容数字与字符串两种元素。
|
||||
func scUintSlice(body map[string]interface{}, key string) []uint64 {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint64, 0, len(list))
|
||||
seen := map[uint64]bool{}
|
||||
for _, item := range list {
|
||||
var value uint64
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
value = uint64(v)
|
||||
case string:
|
||||
value, _ = strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||
}
|
||||
if value > 0 && !seen[value] {
|
||||
seen[value] = true
|
||||
ids = append(ids, value)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func scBoolValue(body map[string]interface{}, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
raw, ok := body[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case float64:
|
||||
return value != 0
|
||||
case string:
|
||||
return value == "1" || strings.EqualFold(value, "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scWeekdaySet 解析星期筛选,1=周一 ... 7=周日。
|
||||
// 支持 [1,3,5] 数组与 "1,3,5" 字符串;空值表示每天。
|
||||
func scWeekdaySet(body map[string]interface{}) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
raw, ok := body["weekdays"]
|
||||
if !ok || raw == nil {
|
||||
return result
|
||||
}
|
||||
add := func(value int) {
|
||||
if value >= 1 && value <= 7 {
|
||||
result[value] = true
|
||||
}
|
||||
}
|
||||
switch list := raw.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range list {
|
||||
switch v := item.(type) {
|
||||
case float64:
|
||||
add(int(v))
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
add(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(part))
|
||||
add(n)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// scParseDate 解析 "YYYY-MM-DD" 并返回时间对象。
|
||||
func scParseDate(value string) (time.Time, bool) {
|
||||
if !isValidOaDate(value) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// scDateSpan 展开 [start, end] 之间的全部日期字符串;区间上限 92 天,
|
||||
// 避免一次请求把看板拖垮。
|
||||
func scDateSpan(start, end string) ([]string, string) {
|
||||
startDate, ok := scParseDate(start)
|
||||
if !ok {
|
||||
return nil, "开始日期格式错误"
|
||||
}
|
||||
endDate, ok := scParseDate(end)
|
||||
if !ok {
|
||||
return nil, "结束日期格式错误"
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, "结束日期不能早于开始日期"
|
||||
}
|
||||
if endDate.Sub(startDate) > 92*24*time.Hour {
|
||||
return nil, "日期区间不能超过 92 天"
|
||||
}
|
||||
dates := make([]string, 0, 32)
|
||||
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
|
||||
dates = append(dates, cursor.Format("2006-01-02"))
|
||||
}
|
||||
return dates, ""
|
||||
}
|
||||
|
||||
// scMinutes 把 "HH:mm" 转为当日分钟数。
|
||||
func scMinutes(value string) (int, bool) {
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := time.ParseInLocation("15:04", value, time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed.Hour()*60 + parsed.Minute(), true
|
||||
}
|
||||
|
||||
// scCalcWorkHours 计算班次工时(小时):上下班跨度扣除休息时段。
|
||||
// 下班时间早于上班时间视为跨天班次,自动加 24 小时。
|
||||
func scCalcWorkHours(workStart, workEnd, restStart, restEnd string) float64 {
|
||||
start, okStart := scMinutes(workStart)
|
||||
end, okEnd := scMinutes(workEnd)
|
||||
if !okStart || !okEnd {
|
||||
return 0
|
||||
}
|
||||
if end <= start {
|
||||
end += 24 * 60
|
||||
}
|
||||
span := end - start
|
||||
if restStart != "" && restEnd != "" {
|
||||
restFrom, okFrom := scMinutes(restStart)
|
||||
restTo, okTo := scMinutes(restEnd)
|
||||
if okFrom && okTo {
|
||||
if restTo <= restFrom {
|
||||
restTo += 24 * 60
|
||||
}
|
||||
if restTo > start && restFrom < end {
|
||||
overlap := restTo - restFrom
|
||||
if restFrom < start {
|
||||
overlap -= start - restFrom
|
||||
}
|
||||
if restTo > end {
|
||||
overlap -= restTo - end
|
||||
}
|
||||
if overlap > 0 {
|
||||
span -= overlap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if span < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(span) / 60
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 班次管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *BackendOaSchedulingController) shiftQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaShift)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) shiftByID(tid, id uint64) (*models.OaShift, bool) {
|
||||
var row models.OaShift
|
||||
if err := c.shiftQuery(tid).Filter("id", id).One(&row); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &row, true
|
||||
}
|
||||
|
||||
// ShiftList 班次列表。
|
||||
// GET /backend/oa/scheduling/shifts
|
||||
func (c *BackendOaSchedulingController) ShiftList() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var rows []models.OaShift
|
||||
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&rows); err != nil {
|
||||
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.OaShift{}
|
||||
}
|
||||
|
||||
var defaultID uint64
|
||||
for _, row := range rows {
|
||||
if row.IsDefault == 1 {
|
||||
defaultID = row.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"list": rows,
|
||||
"default_id": defaultID,
|
||||
"total": len(rows),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateShift 新增班次。
|
||||
// POST /backend/oa/scheduling/shifts
|
||||
func (c *BackendOaSchedulingController) CreateShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
|
||||
row, msg := c.buildShiftFromBody(body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
row.Tid = int(tid)
|
||||
|
||||
if row.IsDefault == 1 {
|
||||
c.clearDefaultShift(tid, 0)
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
c.scFail(500, 500, "新增班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(row)
|
||||
}
|
||||
|
||||
// UpdateShift 更新班次。
|
||||
// POST /backend/oa/scheduling/shifts/:id
|
||||
func (c *BackendOaSchedulingController) UpdateShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的班次ID")
|
||||
return
|
||||
}
|
||||
existing, found := c.shiftByID(tid, id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "班次不存在")
|
||||
return
|
||||
}
|
||||
|
||||
body := c.scBody()
|
||||
row, msg := c.buildShiftFromBody(body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
existing.Name = row.Name
|
||||
existing.ShortName = row.ShortName
|
||||
existing.Color = row.Color
|
||||
existing.WorkStart = row.WorkStart
|
||||
existing.WorkEnd = row.WorkEnd
|
||||
existing.RestStart = row.RestStart
|
||||
existing.RestEnd = row.RestEnd
|
||||
existing.WorkHours = row.WorkHours
|
||||
existing.IsDefault = row.IsDefault
|
||||
existing.Status = row.Status
|
||||
existing.Sort = row.Sort
|
||||
existing.Remark = row.Remark
|
||||
|
||||
if existing.IsDefault == 1 {
|
||||
c.clearDefaultShift(tid, existing.ID)
|
||||
}
|
||||
if _, err := models.Orm.Update(existing); err != nil {
|
||||
c.scFail(500, 500, "更新班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 班次名称/时间变化时同步已排班的快照,避免看板显示旧时间。
|
||||
c.syncShiftSnapshot(tid, existing)
|
||||
|
||||
c.scOK(existing)
|
||||
}
|
||||
|
||||
// DeleteShift 删除班次(软删除)。已被排班使用时拒绝删除,建议改为停用。
|
||||
// DELETE /backend/oa/scheduling/shifts/:id
|
||||
func (c *BackendOaSchedulingController) DeleteShift() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的班次ID")
|
||||
return
|
||||
}
|
||||
row, found := c.shiftByID(tid, id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "班次不存在")
|
||||
return
|
||||
}
|
||||
|
||||
used, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||
Filter("tid", tid).
|
||||
Filter("shift_id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Count()
|
||||
if used > 0 {
|
||||
c.scFail(400, 400, "该班次已被排班使用,无法删除,请改为停用")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
row.IsDeleted = 1
|
||||
row.DeleteTime = &now
|
||||
if _, err := models.Orm.Update(row, "is_deleted", "delete_time"); err != nil {
|
||||
c.scFail(500, 500, "删除班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scOK(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// buildShiftFromBody 校验并构造班次对象;返回非空 msg 表示参数错误。
|
||||
func (c *BackendOaSchedulingController) buildShiftFromBody(body map[string]interface{}) (*models.OaShift, string) {
|
||||
name := scStringValue(body, "name")
|
||||
if name == "" {
|
||||
return nil, "班次名称不能为空"
|
||||
}
|
||||
if len([]rune(name)) > 50 {
|
||||
return nil, "班次名称不能超过 50 个字符"
|
||||
}
|
||||
|
||||
workStart := scStringValue(body, "work_start")
|
||||
workEnd := scStringValue(body, "work_end")
|
||||
if !isValidOaTime(workStart) || !isValidOaTime(workEnd) {
|
||||
return nil, "上下班时间格式应为 HH:mm"
|
||||
}
|
||||
if workStart == workEnd {
|
||||
return nil, "上下班时间不能相同"
|
||||
}
|
||||
|
||||
restStart := scStringValue(body, "rest_start")
|
||||
restEnd := scStringValue(body, "rest_end")
|
||||
if (restStart == "") != (restEnd == "") {
|
||||
return nil, "休息开始与结束时间需要同时填写"
|
||||
}
|
||||
if restStart != "" && (!isValidOaTime(restStart) || !isValidOaTime(restEnd)) {
|
||||
return nil, "休息时间格式应为 HH:mm"
|
||||
}
|
||||
|
||||
shortName := scStringValue(body, "short_name")
|
||||
if shortName == "" {
|
||||
shortName = name
|
||||
}
|
||||
color := scStringValue(body, "color")
|
||||
if color == "" {
|
||||
color = "#409EFF"
|
||||
}
|
||||
status := int8(1)
|
||||
if value, has := scIntValue(body, "status"); has {
|
||||
status = int8(value)
|
||||
}
|
||||
sortValue, _ := scIntValue(body, "sort")
|
||||
isDefault := int8(0)
|
||||
if scBoolValue(body, "is_default") {
|
||||
isDefault = 1
|
||||
}
|
||||
remark := scStringValue(body, "remark")
|
||||
row := &models.OaShift{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
Color: color,
|
||||
WorkStart: workStart,
|
||||
WorkEnd: workEnd,
|
||||
RestStart: restStart,
|
||||
RestEnd: restEnd,
|
||||
WorkHours: scCalcWorkHours(workStart, workEnd, restStart, restEnd),
|
||||
IsDefault: isDefault,
|
||||
Status: status,
|
||||
Sort: sortValue,
|
||||
}
|
||||
if remark != "" {
|
||||
row.Remark = &remark
|
||||
}
|
||||
return row, ""
|
||||
}
|
||||
|
||||
// clearDefaultShift 把同租户下其它班次的默认标记清掉,保证只有一个默认班次。
|
||||
func (c *BackendOaSchedulingController) clearDefaultShift(tid, excludeID uint64) {
|
||||
qs := c.shiftQuery(tid).Filter("is_default", 1)
|
||||
if excludeID > 0 {
|
||||
qs = qs.Exclude("id", excludeID)
|
||||
}
|
||||
var rows []models.OaShift
|
||||
if _, err := qs.All(&rows); err != nil {
|
||||
return
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].IsDefault = 0
|
||||
_, _ = models.Orm.Update(&rows[i], "is_default")
|
||||
}
|
||||
}
|
||||
|
||||
// syncShiftSnapshot 班次变更后,把新快照写回已生成的排班明细。
|
||||
func (c *BackendOaSchedulingController) syncShiftSnapshot(tid uint64, shift *models.OaShift) {
|
||||
_, _ = models.Orm.Raw(
|
||||
"UPDATE yz_backend_oa_schedule_entry SET shift_name=?, short_name=?, color=?, work_start=?, work_end=? "+
|
||||
"WHERE tid=? AND shift_id=? AND is_deleted=0",
|
||||
shift.Name, shift.ShortName, shift.Color, shift.WorkStart, shift.WorkEnd, tid, shift.ID,
|
||||
).Exec()
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
const scBoardMaxDays = 62
|
||||
|
||||
// scEntryView 看板单元格里的排班信息。
|
||||
type scEntryView struct {
|
||||
ID uint64 `json:"id"`
|
||||
PlanID uint64 `json:"plan_id"`
|
||||
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"`
|
||||
Source int8 `json:"source"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type scBoardRow struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Account string `json:"account"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
Position string `json:"position"`
|
||||
Days map[string]scEntryView `json:"days"`
|
||||
}
|
||||
|
||||
type scDateCell struct {
|
||||
Date string `json:"date"`
|
||||
Weekday int `json:"weekday"`
|
||||
IsToday bool `json:"is_today"`
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scEntryQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||
Filter("tid", tid).
|
||||
Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
// Board 排班看板:按员工 × 日期返回区间内的排班矩阵。
|
||||
// GET /backend/oa/scheduling/board?start_date=&end_date=&org_id=&include_child=1&keyword=
|
||||
func (c *BackendOaSchedulingController) Board() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||
if startDate == "" || endDate == "" {
|
||||
// 默认展示本周(周一至周日)
|
||||
now := time.Now()
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
monday := now.AddDate(0, 0, -(weekday - 1))
|
||||
startDate = monday.Format("2006-01-02")
|
||||
endDate = monday.AddDate(0, 0, 6).Format("2006-01-02")
|
||||
}
|
||||
dates, msg := scDateSpan(startDate, endDate)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
if len(dates) > scBoardMaxDays {
|
||||
c.scFail(400, 400, fmt.Sprintf("看板一次最多展示 %d 天", scBoardMaxDays))
|
||||
return
|
||||
}
|
||||
|
||||
orgID := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
orgID = parsed
|
||||
}
|
||||
}
|
||||
includeChild := c.GetString("include_child") != "0"
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
onlyScheduled := c.GetString("only_scheduled") == "1"
|
||||
shiftFilter := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("shift_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
shiftFilter = parsed
|
||||
}
|
||||
}
|
||||
|
||||
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "查询员工失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
rows := make([]scBoardRow, 0, len(employees))
|
||||
employeeIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
if keyword != "" && !strings.Contains(employee.Name, keyword) && !strings.Contains(employee.Account, keyword) {
|
||||
continue
|
||||
}
|
||||
orgID := scEmployeeDepartmentID(employee)
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = *employee.Position
|
||||
}
|
||||
rows = append(rows, scBoardRow{
|
||||
ID: employee.ID,
|
||||
Name: employee.Name,
|
||||
Account: employee.Account,
|
||||
OrgID: orgID,
|
||||
OrgName: index.name(orgID),
|
||||
Position: position,
|
||||
Days: map[string]scEntryView{},
|
||||
})
|
||||
employeeIDs = append(employeeIDs, uint64(employee.ID))
|
||||
}
|
||||
|
||||
truncated := false
|
||||
const boardMaxEmployees = 500
|
||||
if len(rows) > boardMaxEmployees {
|
||||
rows = rows[:boardMaxEmployees]
|
||||
employeeIDs = employeeIDs[:boardMaxEmployees]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if len(employeeIDs) > 0 {
|
||||
var entries []models.OaScheduleEntry
|
||||
_, err = c.scEntryQuery(tid).
|
||||
Filter("employee_id__in", employeeIDs).
|
||||
Filter("schedule_date__gte", dates[0]).
|
||||
Filter("schedule_date__lte", dates[len(dates)-1]).
|
||||
All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rowIndex := map[uint]*scBoardRow{}
|
||||
for i := range rows {
|
||||
rowIndex[rows[i].ID] = &rows[i]
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if shiftFilter > 0 && entry.ShiftID != shiftFilter {
|
||||
continue
|
||||
}
|
||||
row, hit := rowIndex[entry.EmployeeID]
|
||||
if !hit {
|
||||
continue
|
||||
}
|
||||
row.Days[normalizeOaDate(entry.ScheduleDate)] = scEntryView{
|
||||
ID: entry.ID,
|
||||
PlanID: entry.PlanID,
|
||||
ShiftID: entry.ShiftID,
|
||||
ShiftName: entry.ShiftName,
|
||||
ShortName: entry.ShortName,
|
||||
Color: entry.Color,
|
||||
WorkStart: entry.WorkStart,
|
||||
WorkEnd: entry.WorkEnd,
|
||||
Source: entry.Source,
|
||||
Note: entry.Note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if onlyScheduled {
|
||||
filtered := rows[:0]
|
||||
for _, row := range rows {
|
||||
if len(row.Days) > 0 {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
dateCells := make([]scDateCell, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
parsed, _ := scParseDate(date)
|
||||
weekday := int(parsed.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
dateCells = append(dateCells, scDateCell{Date: date, Weekday: weekday, IsToday: date == today})
|
||||
}
|
||||
|
||||
// 汇总:排班人次、临时调配数、未排班人数、今日在岗、班次分布
|
||||
entryCount, tempCount, unassigned := 0, 0, 0
|
||||
todayOnDuty := 0
|
||||
shiftCounter := map[string]int{}
|
||||
shiftColor := map[string]string{}
|
||||
for _, row := range rows {
|
||||
if len(row.Days) == 0 {
|
||||
unassigned++
|
||||
}
|
||||
for date, entry := range row.Days {
|
||||
entryCount++
|
||||
if entry.Source == models.OaEntrySourceTemp {
|
||||
tempCount++
|
||||
}
|
||||
if date == today {
|
||||
todayOnDuty++
|
||||
}
|
||||
name := entry.ShiftName
|
||||
if name == "" {
|
||||
name = "未命名班次"
|
||||
}
|
||||
shiftCounter[name]++
|
||||
shiftColor[name] = entry.Color
|
||||
}
|
||||
}
|
||||
shiftDist := make([]map[string]interface{}, 0, len(shiftCounter))
|
||||
for name, count := range shiftCounter {
|
||||
shiftDist = append(shiftDist, map[string]interface{}{
|
||||
"name": name,
|
||||
"count": count,
|
||||
"color": shiftColor[name],
|
||||
})
|
||||
}
|
||||
sort.Slice(shiftDist, func(i, j int) bool {
|
||||
return shiftDist[i]["count"].(int) > shiftDist[j]["count"].(int)
|
||||
})
|
||||
|
||||
var shifts []models.OaShift
|
||||
_, _ = c.shiftQuery(tid).Filter("status", 1).OrderBy("sort", "id").All(&shifts)
|
||||
if shifts == nil {
|
||||
shifts = []models.OaShift{}
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"start_date": startDate,
|
||||
"end_date": endDate,
|
||||
"dates": dateCells,
|
||||
"employees": rows,
|
||||
"shifts": shifts,
|
||||
"truncated": truncated,
|
||||
"summary": map[string]interface{}{
|
||||
"employee_count": len(rows),
|
||||
"entry_count": entryCount,
|
||||
"temp_count": tempCount,
|
||||
"unassigned": unassigned,
|
||||
"today_on_duty": todayOnDuty,
|
||||
"shift_dist": shiftDist,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func strconvParseUint(value string) (uint64, error) {
|
||||
return strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
}
|
||||
|
||||
// scEntryTargets 解析临时排班/批量清除的目标员工与日期。
|
||||
func (c *BackendOaSchedulingController) scEntryTargets(tid uint64, body map[string]interface{}) ([]models.BackendEmployee, []string, string) {
|
||||
employeeIDs := scUintSlice(body, "employee_ids")
|
||||
if len(employeeIDs) == 0 {
|
||||
return nil, nil, "请选择员工"
|
||||
}
|
||||
if len(employeeIDs) > scPlanMaxEmployees {
|
||||
return nil, nil, fmt.Sprintf("单次最多操作 %d 名员工", scPlanMaxEmployees)
|
||||
}
|
||||
|
||||
startDate := scStringValue(body, "start_date", "schedule_date", "date")
|
||||
endDate := scStringValue(body, "end_date")
|
||||
if endDate == "" {
|
||||
endDate = startDate
|
||||
}
|
||||
dates, msg := scDateSpan(startDate, endDate)
|
||||
if msg != "" {
|
||||
return nil, nil, msg
|
||||
}
|
||||
workdays := scWorkdays(dates, scWeekdaySet(body))
|
||||
if len(workdays) == 0 {
|
||||
return nil, nil, "所选日期区间内没有符合条件的日期"
|
||||
}
|
||||
|
||||
var employees []models.BackendEmployee
|
||||
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", employeeIDs).All(&employees)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, nil, "查询员工失败: " + err.Error()
|
||||
}
|
||||
if len(employees) == 0 {
|
||||
return nil, nil, "员工不存在或已离职"
|
||||
}
|
||||
if len(employees)*len(workdays) > scPlanMaxEntries {
|
||||
return nil, nil, fmt.Sprintf("本次将产生 %d 条排班,超过单次 %d 条上限", len(employees)*len(workdays), scPlanMaxEntries)
|
||||
}
|
||||
return employees, workdays, ""
|
||||
}
|
||||
|
||||
// SaveEntries 临时排班:给一个或多个员工在指定日期/星期安排班次。
|
||||
// shift_id=0 时表示清除这些日期的排班。
|
||||
// POST /backend/oa/scheduling/entries/save
|
||||
func (c *BackendOaSchedulingController) SaveEntries() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
employees, workdays, msg := c.scEntryTargets(tid, body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
shiftID := scUintValue(body, "shift_id")
|
||||
var shift *models.OaShift
|
||||
if shiftID > 0 {
|
||||
loaded, found := c.scLoadShift(tid, shiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
shift = loaded
|
||||
}
|
||||
|
||||
note := scStringValue(body, "note")
|
||||
overwrite := true
|
||||
if _, has := body["overwrite"]; has {
|
||||
overwrite = scBoolValue(body, "overwrite")
|
||||
}
|
||||
|
||||
empIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
empIDs = append(empIDs, uint64(employee.ID))
|
||||
}
|
||||
var existing []models.OaScheduleEntry
|
||||
_, err := c.scEntryQuery(tid).
|
||||
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 {
|
||||
c.scFail(500, 500, "查询已有排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
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]
|
||||
}
|
||||
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
stats := map[string]int{"created": 0, "updated": 0, "cleared": 0, "skipped": 0}
|
||||
now := time.Now()
|
||||
|
||||
for _, employee := range employees {
|
||||
orgID := scEmployeeDepartmentID(employee)
|
||||
position := ""
|
||||
if employee.Position != nil {
|
||||
position = *employee.Position
|
||||
}
|
||||
for _, date := range workdays {
|
||||
key := fmt.Sprintf("%d|%s", employee.ID, date)
|
||||
row := existingMap[key]
|
||||
|
||||
// shift_id=0:清除排班
|
||||
if shift == nil {
|
||||
if row == nil {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||
row.IsDeleted = row.ID
|
||||
row.DeleteTime = &now
|
||||
if _, updateErr := models.Orm.Update(row, "is_deleted", "delete_time"); updateErr != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+updateErr.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
||||
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", note, uid, username)
|
||||
delete(existingMap, key)
|
||||
stats["cleared"]++
|
||||
continue
|
||||
}
|
||||
|
||||
if row != nil {
|
||||
if !overwrite {
|
||||
stats["skipped"]++
|
||||
continue
|
||||
}
|
||||
oldShiftID, oldShiftName := row.ShiftID, row.ShiftName
|
||||
row.ShiftID = shift.ID
|
||||
row.ShiftName = shift.Name
|
||||
row.ShortName = shift.ShortName
|
||||
row.Color = shift.Color
|
||||
row.WorkStart = shift.WorkStart
|
||||
row.WorkEnd = shift.WorkEnd
|
||||
row.Source = models.OaEntrySourceTemp
|
||||
row.Note = note
|
||||
row.CreatedBy = uid
|
||||
row.CreatedByName = username
|
||||
if _, updateErr := models.Orm.Update(row); updateErr != nil {
|
||||
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, date,
|
||||
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, note, uid, username)
|
||||
stats["updated"]++
|
||||
continue
|
||||
}
|
||||
|
||||
entry := &models.OaScheduleEntry{
|
||||
Tid: int(tid),
|
||||
EmployeeID: employee.ID,
|
||||
EmployeeName: employee.Name,
|
||||
EmployeeAccount: employee.Account,
|
||||
OrgID: orgID,
|
||||
OrgName: index.name(orgID),
|
||||
Position: position,
|
||||
ScheduleDate: date,
|
||||
ShiftID: shift.ID,
|
||||
ShiftName: shift.Name,
|
||||
ShortName: shift.ShortName,
|
||||
Color: shift.Color,
|
||||
WorkStart: shift.WorkStart,
|
||||
WorkEnd: shift.WorkEnd,
|
||||
Source: models.OaEntrySourceTemp,
|
||||
Note: note,
|
||||
CreatedBy: uid,
|
||||
CreatedByName: username,
|
||||
}
|
||||
if _, insertErr := models.Orm.Insert(entry); insertErr != nil {
|
||||
c.scFail(500, 500, "新增排班失败: "+insertErr.Error())
|
||||
return
|
||||
}
|
||||
existingMap[key] = entry
|
||||
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, date,
|
||||
models.OaChangeManual, 0, "", shift.ID, shift.Name, note, uid, username)
|
||||
stats["created"]++
|
||||
}
|
||||
}
|
||||
|
||||
stats["entry_count"] = stats["created"] + stats["updated"] + stats["cleared"]
|
||||
c.scOK(stats)
|
||||
}
|
||||
|
||||
// SwapEntry 单条调班:把某个员工某天的班次换成另一个班次。
|
||||
// POST /backend/oa/scheduling/entries/swap/:id
|
||||
func (c *BackendOaSchedulingController) SwapEntry() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, valid := c.scPathID(":id")
|
||||
if !valid {
|
||||
c.scFail(400, 400, "无效的排班ID")
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
shiftID := scUintValue(body, "shift_id")
|
||||
if shiftID == 0 {
|
||||
c.scFail(400, 400, "请选择调换后的班次")
|
||||
return
|
||||
}
|
||||
shift, found := c.scLoadShift(tid, shiftID)
|
||||
if !found {
|
||||
c.scFail(400, 400, "班次不存在或已停用")
|
||||
return
|
||||
}
|
||||
|
||||
var entry models.OaScheduleEntry
|
||||
if err := c.scEntryQuery(tid).Filter("id", id).One(&entry); err != nil {
|
||||
c.scFail(404, 404, "排班记录不存在")
|
||||
return
|
||||
}
|
||||
if entry.ShiftID == shift.ID {
|
||||
c.scFail(400, 400, "该排班已经是目标班次")
|
||||
return
|
||||
}
|
||||
|
||||
reason := scStringValue(body, "reason")
|
||||
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
|
||||
entry.ShiftID = shift.ID
|
||||
entry.ShiftName = shift.Name
|
||||
entry.ShortName = shift.ShortName
|
||||
entry.Color = shift.Color
|
||||
entry.WorkStart = shift.WorkStart
|
||||
entry.WorkEnd = shift.WorkEnd
|
||||
entry.Source = models.OaEntrySourceTemp
|
||||
if reason != "" {
|
||||
entry.Note = reason
|
||||
}
|
||||
if _, err := models.Orm.Update(&entry); err != nil {
|
||||
c.scFail(500, 500, "调班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(models.Orm, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
|
||||
models.OaChangeShift, oldShiftID, oldShiftName, shift.ID, shift.Name, reason, uid, username)
|
||||
|
||||
c.scOK(map[string]interface{}{"entry": entry, "old_shift_name": oldShiftName})
|
||||
}
|
||||
|
||||
// ClearEntries 批量清除排班:按明细 ID,或按员工 + 日期区间。
|
||||
// POST /backend/oa/scheduling/entries/clear
|
||||
func (c *BackendOaSchedulingController) ClearEntries() {
|
||||
tid, uid, username, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body := c.scBody()
|
||||
entryIDs := scUintSlice(body, "entry_ids")
|
||||
|
||||
var entries []models.OaScheduleEntry
|
||||
if len(entryIDs) > 0 {
|
||||
_, err := c.scEntryQuery(tid).Filter("id__in", entryIDs).All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
employees, workdays, msg := c.scEntryTargets(tid, body)
|
||||
if msg != "" {
|
||||
c.scFail(400, 400, msg)
|
||||
return
|
||||
}
|
||||
empIDs := make([]uint64, 0, len(employees))
|
||||
for _, employee := range employees {
|
||||
empIDs = append(empIDs, uint64(employee.ID))
|
||||
}
|
||||
_, err := c.scEntryQuery(tid).
|
||||
Filter("employee_id__in", empIDs).
|
||||
Filter("schedule_date__in", workdays).
|
||||
All(&entries)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
c.scOK(map[string]interface{}{"cleared": 0})
|
||||
return
|
||||
}
|
||||
if len(entries) > scPlanMaxEntries {
|
||||
c.scFail(400, 400, fmt.Sprintf("单次最多清除 %d 条排班", scPlanMaxEntries))
|
||||
return
|
||||
}
|
||||
|
||||
reason := scStringValue(body, "reason")
|
||||
now := time.Now()
|
||||
tx, err := orm.NewOrm().Begin()
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
oldShiftID, oldShiftName := entry.ShiftID, entry.ShiftName
|
||||
entry.IsDeleted = entry.ID
|
||||
entry.DeleteTime = &now
|
||||
if _, err := tx.Update(entry, "is_deleted", "delete_time"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.scWriteChange(tx, tid, entry.ID, entry.EmployeeID, entry.EmployeeName, normalizeOaDate(entry.ScheduleDate),
|
||||
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", reason, uid, username)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.scFail(500, 500, "清除排班失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{"cleared": len(entries)})
|
||||
}
|
||||
|
||||
// ChangeList 排班变更记录(最近 100 条),用于追溯临时调配。
|
||||
// GET /backend/oa/scheduling/changes?start_date=&end_date=&employee_id=
|
||||
func (c *BackendOaSchedulingController) ChangeList() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.OaScheduleChange)).Filter("tid", tid)
|
||||
if start := strings.TrimSpace(c.GetString("start_date")); isValidOaDate(start) {
|
||||
qs = qs.Filter("schedule_date__gte", start)
|
||||
}
|
||||
if end := strings.TrimSpace(c.GetString("end_date")); isValidOaDate(end) {
|
||||
qs = qs.Filter("schedule_date__lte", end)
|
||||
}
|
||||
if raw := strings.TrimSpace(c.GetString("employee_id")); raw != "" {
|
||||
if employeeID, err := strconvParseUint(raw); err == nil {
|
||||
qs = qs.Filter("employee_id", employeeID)
|
||||
}
|
||||
}
|
||||
var rows []models.OaScheduleChange
|
||||
_, err := qs.OrderBy("-id").Limit(100).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询变更记录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.OaScheduleChange{}
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].ScheduleDate = normalizeOaDate(rows[i].ScheduleDate)
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": rows})
|
||||
}
|
||||
|
||||
// ScheduleEmployeeOptions 排班可选员工(供“指定员工”范围选择)。
|
||||
// GET /backend/oa/scheduling/employees?org_id=&include_child=1&keyword=
|
||||
func (c *BackendOaSchedulingController) ScheduleEmployeeOptions() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orgID := uint64(0)
|
||||
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||
if parsed, err := strconvParseUint(raw); err == nil {
|
||||
orgID = parsed
|
||||
}
|
||||
}
|
||||
includeChild := c.GetString("include_child") != "0"
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
|
||||
employees, err := c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
if err != nil {
|
||||
c.scFail(500, 500, "查询员工失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list := c.scEmployeeDTOs(tid, employees)
|
||||
if keyword != "" {
|
||||
filtered := make([]schedulingEmployee, 0, len(list))
|
||||
for _, item := range list {
|
||||
if strings.Contains(item.Name, keyword) || strings.Contains(item.Account, keyword) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
if list == nil {
|
||||
list = []schedulingEmployee{}
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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()]
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
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 _, 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),
|
||||
"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 := scDateSpan(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,
|
||||
"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 := scDateSpan(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})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// schedulingEmployee 排班看板/方案预览里的员工视图。
|
||||
// Department 保留组织 ID 字符串,DepartmentName 供前端直接展示。
|
||||
type schedulingEmployee struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Account string `json:"account"`
|
||||
Department string `json:"department"`
|
||||
DepartmentName string `json:"department_name"`
|
||||
Position string `json:"position"`
|
||||
Phone string `json:"phone"`
|
||||
AccountStatus int8 `json:"account_status"`
|
||||
}
|
||||
|
||||
// scOrgIndex 租户组织架构的内存索引,用于把“部门/小组”展开成部门子树,
|
||||
// 以及给员工补齐部门名称。一次请求只加载一次,避免 N+1 查询。
|
||||
type scOrgIndex struct {
|
||||
rows map[uint64]models.BackendOrganization
|
||||
children map[uint64][]uint64
|
||||
names map[uint64]string
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scLoadOrgIndex(tid uint64) *scOrgIndex {
|
||||
index := &scOrgIndex{
|
||||
rows: map[uint64]models.BackendOrganization{},
|
||||
children: map[uint64][]uint64{},
|
||||
names: map[uint64]string{},
|
||||
}
|
||||
var rows []models.BackendOrganization
|
||||
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
return index
|
||||
}
|
||||
for _, row := range rows {
|
||||
index.rows[row.ID] = row
|
||||
index.names[row.ID] = row.OrgName
|
||||
index.children[row.ParentID] = append(index.children[row.ParentID], row.ID)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// subtreeIDs 返回节点自身及其全部下级组织 ID。
|
||||
func (index *scOrgIndex) subtreeIDs(root uint64) map[uint64]bool {
|
||||
result := map[uint64]bool{}
|
||||
if root == 0 {
|
||||
return result
|
||||
}
|
||||
queue := []uint64{root}
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
if result[current] {
|
||||
continue
|
||||
}
|
||||
result[current] = true
|
||||
queue = append(queue, index.children[current]...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (index *scOrgIndex) name(id uint64) string {
|
||||
if name, ok := index.names[id]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
|
||||
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
Exclude("account_status", 2)
|
||||
}
|
||||
|
||||
func scEmployeeDepartmentID(row models.BackendEmployee) uint64 {
|
||||
if row.Department == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(*row.Department), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) scEmployeeDTOs(tid uint64, rows []models.BackendEmployee) []schedulingEmployee {
|
||||
index := c.scLoadOrgIndex(tid)
|
||||
result := make([]schedulingEmployee, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
orgID := scEmployeeDepartmentID(row)
|
||||
position := ""
|
||||
if row.Position != nil {
|
||||
position = *row.Position
|
||||
}
|
||||
phone := ""
|
||||
if row.Phone != nil {
|
||||
phone = *row.Phone
|
||||
}
|
||||
result = append(result, schedulingEmployee{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Account: row.Account,
|
||||
Department: strconv.FormatUint(orgID, 10),
|
||||
DepartmentName: index.name(orgID),
|
||||
Position: position,
|
||||
Phone: phone,
|
||||
AccountStatus: row.AccountStatus,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// scEmployeesByOrg 按组织节点取员工;includeChild=true 时包含全部下级组织,
|
||||
// 这样选中“公司/部门/小组”任意层级都能覆盖到该范围内的所有人。
|
||||
func (c *BackendOaSchedulingController) scEmployeesByOrg(tid, orgID uint64, includeChild bool) ([]models.BackendEmployee, error) {
|
||||
qs := c.scBaseEmployeeQuery(tid)
|
||||
if orgID > 0 {
|
||||
allowed := map[uint64]bool{orgID: true}
|
||||
if includeChild {
|
||||
allowed = c.scLoadOrgIndex(tid).subtreeIDs(orgID)
|
||||
}
|
||||
values := make([]string, 0, len(allowed))
|
||||
for id := range allowed {
|
||||
values = append(values, strconv.FormatUint(id, 10))
|
||||
}
|
||||
qs = qs.Filter("department__in", values)
|
||||
}
|
||||
var rows []models.BackendEmployee
|
||||
_, err := qs.OrderBy("department", "id").All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// scEmployeesByIDs 按员工 ID 取员工,保持传入顺序由前端自行处理。
|
||||
func (c *BackendOaSchedulingController) scEmployeesByIDs(tid uint64, ids []uint64) ([]models.BackendEmployee, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []models.BackendEmployee
|
||||
_, err := c.scBaseEmployeeQuery(tid).Filter("id__in", ids).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// scResolveEmployees 把方案范围(公司 / 部门小组 / 指定员工)展开成员工列表。
|
||||
func (c *BackendOaSchedulingController) scResolveEmployees(tid uint64, scopeType int8, orgID uint64, includeChild bool, employeeIDs []uint64) ([]models.BackendEmployee, error) {
|
||||
switch scopeType {
|
||||
case models.OaScopeEmployees:
|
||||
return c.scEmployeesByIDs(tid, employeeIDs)
|
||||
case models.OaScopeCompany:
|
||||
return c.scEmployeesByOrg(tid, 0, false)
|
||||
default:
|
||||
return c.scEmployeesByOrg(tid, orgID, includeChild)
|
||||
}
|
||||
}
|
||||
|
||||
// scWorkdays 从日期区间中筛出命中的星期(1=周一 ... 7=周日)。
|
||||
// 未选择任何星期表示每天。
|
||||
func scWorkdays(dates []string, weekdays map[int]bool) []string {
|
||||
if len(weekdays) == 0 {
|
||||
return dates
|
||||
}
|
||||
result := make([]string, 0, len(dates))
|
||||
for _, date := range dates {
|
||||
parsed, ok := scParseDate(date)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weekday := int(parsed.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
if weekdays[weekday] {
|
||||
result = append(result, date)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 租户端排班模板:只读调用,不允许修改平台模板。
|
||||
//
|
||||
// 调用模板时会把模板的每日规则转成方案周期的 pattern 快照,并把模板里的
|
||||
// 班次按「名称 + 上下班时间」匹配到租户自己的班次;匹配不到则自动创建,
|
||||
// 这样租户拿到方案后就能像普通方案一样自由修改。
|
||||
|
||||
func schedTplTypeLabel(value int8) string {
|
||||
return schedTplTypeText(value)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) schedTplQuery() orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.PlatformSchedTpl)).
|
||||
Filter("delete_time__isnull", true)
|
||||
}
|
||||
|
||||
func (c *BackendOaSchedulingController) schedTplDays(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 *BackendOaSchedulingController) schedTplByID(id uint64) (*models.PlatformSchedTpl, bool) {
|
||||
var row models.PlatformSchedTpl
|
||||
if err := c.schedTplQuery().Filter("id", id).One(&row); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &row, true
|
||||
}
|
||||
|
||||
func schedTplView(row *models.PlatformSchedTpl, days []models.PlatformSchedTplDay) map[string]interface{} {
|
||||
view := map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"code": row.Code,
|
||||
"name": row.Name,
|
||||
"template_type": row.TemplateType,
|
||||
"template_type_text": schedTplTypeLabel(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,
|
||||
}
|
||||
if days != nil {
|
||||
view["days"] = days
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// TemplateList 租户端可调用的排班模板(只返回启用的模板)。
|
||||
// GET /backend/oa/scheduling/templates?keyword=
|
||||
func (c *BackendOaSchedulingController) TemplateList() {
|
||||
if _, _, _, ok := c.scOperator(); !ok {
|
||||
return
|
||||
}
|
||||
qs := c.schedTplQuery().Filter("status", 1)
|
||||
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
|
||||
qs = qs.Filter("name__icontains", keyword)
|
||||
}
|
||||
|
||||
var rows []models.PlatformSchedTpl
|
||||
if _, err := qs.OrderBy("sort", "id").All(&rows); 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, schedTplView(&rows[i], c.schedTplDays(rows[i].ID)))
|
||||
}
|
||||
c.scOK(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// TemplateApply 调用模板:返回可直接用于方案的周期规则,并把模板班次
|
||||
// 匹配到租户班次(不存在时自动创建)。
|
||||
// POST /backend/oa/scheduling/templates/apply body: {"template_id": 1}
|
||||
func (c *BackendOaSchedulingController) TemplateApply() {
|
||||
tid, _, _, ok := c.scOperator()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id := scUintValue(c.scBody(), "template_id")
|
||||
if id == 0 {
|
||||
if raw := strings.TrimSpace(c.GetString("template_id")); raw != "" {
|
||||
id, _ = strconv.ParseUint(raw, 10, 64)
|
||||
}
|
||||
}
|
||||
if id == 0 {
|
||||
c.scFail(400, 400, "请选择排班模板")
|
||||
return
|
||||
}
|
||||
tpl, found := c.schedTplByID(id)
|
||||
if !found {
|
||||
c.scFail(404, 404, "排班模板不存在")
|
||||
return
|
||||
}
|
||||
if tpl.Status != 1 {
|
||||
c.scFail(400, 400, "该模板已停用")
|
||||
return
|
||||
}
|
||||
|
||||
var shifts []models.OaShift
|
||||
if _, err := c.shiftQuery(tid).OrderBy("sort", "id").All(&shifts); err != nil && err != orm.ErrNoRows {
|
||||
c.scFail(500, 500, "查询班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
shiftMap := make(map[string]*models.OaShift, len(shifts))
|
||||
maxSort := 0
|
||||
for i := range shifts {
|
||||
shift := &shifts[i]
|
||||
shiftMap[schedShiftKey(shift.Name, shift.WorkStart, shift.WorkEnd)] = shift
|
||||
if shift.Sort > maxSort {
|
||||
maxSort = shift.Sort
|
||||
}
|
||||
}
|
||||
|
||||
patterns := []scPatternDay{}
|
||||
createdShifts := 0
|
||||
matchedShifts := 0
|
||||
for _, day := range c.schedTplDays(tpl.ID) {
|
||||
if day.DayType != models.SchedTplDayWork {
|
||||
patterns = append(patterns, scPatternDay{DayIndex: day.DayIndex, DayType: models.SchedTplDayRest})
|
||||
continue
|
||||
}
|
||||
key := schedShiftKey(day.ShiftName, day.WorkStart, day.WorkEnd)
|
||||
shift, hit := shiftMap[key]
|
||||
if hit {
|
||||
matchedShifts++
|
||||
} else {
|
||||
maxSort += 10
|
||||
shift = &models.OaShift{
|
||||
Tid: int(tid),
|
||||
Name: day.ShiftName,
|
||||
ShortName: day.ShiftShort,
|
||||
Color: day.Color,
|
||||
WorkStart: day.WorkStart,
|
||||
WorkEnd: day.WorkEnd,
|
||||
RestStart: day.RestStart,
|
||||
RestEnd: day.RestEnd,
|
||||
WorkHours: day.WorkHours,
|
||||
IsDefault: 0,
|
||||
Status: 1,
|
||||
Sort: maxSort,
|
||||
IsDeleted: 0,
|
||||
}
|
||||
if shift.ShortName == "" {
|
||||
shift.ShortName = shift.Name
|
||||
}
|
||||
if shift.Color == "" {
|
||||
shift.Color = "#409EFF"
|
||||
}
|
||||
if _, err := models.Orm.Insert(shift); err != nil {
|
||||
c.scFail(500, 500, "创建班次失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
shiftMap[key] = shift
|
||||
createdShifts++
|
||||
}
|
||||
patterns = append(patterns, scPatternDay{
|
||||
DayIndex: day.DayIndex,
|
||||
DayType: models.SchedTplDayWork,
|
||||
ShiftID: shift.ID,
|
||||
ShiftName: shift.Name,
|
||||
ShortName: shift.ShortName,
|
||||
Color: shift.Color,
|
||||
WorkStart: shift.WorkStart,
|
||||
WorkEnd: shift.WorkEnd,
|
||||
RestStart: shift.RestStart,
|
||||
RestEnd: shift.RestEnd,
|
||||
WorkHours: shift.WorkHours,
|
||||
})
|
||||
}
|
||||
if len(patterns) == 0 {
|
||||
c.scFail(400, 400, "该模板没有配置排班规则")
|
||||
return
|
||||
}
|
||||
|
||||
c.scOK(map[string]interface{}{
|
||||
"template": schedTplView(tpl, nil),
|
||||
"pattern": patterns,
|
||||
"days": patterns,
|
||||
"created_shift": createdShifts,
|
||||
"matched_shift": matchedShifts,
|
||||
})
|
||||
}
|
||||
|
||||
func schedShiftKey(name, workStart, workEnd string) string {
|
||||
return strings.TrimSpace(name) + "|" + strings.TrimSpace(workStart) + "|" + strings.TrimSpace(workEnd)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
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}, "状态已更新")
|
||||
}
|
||||
Reference in new issue
Block a user