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

541 lines
14 KiB
Go

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