595 lines
16 KiB
Go
595 lines
16 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, ""
|
|
}
|
|
|
|
// scPlanNoEndDate 无期限方案的结束日期哨兵:数据库日期列不支持空串,
|
|
// 统一存 2099-12-31,前端按 no_end 标记展示为「无期限」。
|
|
const scPlanNoEndDate = "2099-12-31"
|
|
|
|
// scDateSpanUnbounded 展开任意长度的日期区间,供无期限方案的
|
|
// 「当年窗口」使用,不受 scDateSpan 的 92 天上限约束。
|
|
func scDateSpanUnbounded(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, "结束日期不能早于开始日期"
|
|
}
|
|
total := int(endDate.Sub(startDate).Hours()/24) + 1
|
|
dates := make([]string, 0, total)
|
|
for cursor := startDate; !cursor.After(endDate); cursor = cursor.AddDate(0, 0, 1) {
|
|
dates = append(dates, cursor.Format("2006-01-02"))
|
|
}
|
|
return dates, ""
|
|
}
|
|
|
|
// scIsNoEnd 判断结束日期是否表示无期限(空串或哨兵值)。
|
|
func scIsNoEnd(end string) bool {
|
|
return end == "" || end >= scPlanNoEndDate
|
|
}
|
|
|
|
// scPlanGenWindow 计算一次预览 / 发布实际展开的日期窗口。
|
|
// 普通方案:[start, end] 原样展开;无期限方案:从 max(start, 今天) 生成到
|
|
// 当年 12 月 31 日,跨年不自动延续,新的一年需要重新排班。
|
|
func scPlanGenWindow(startText, endText string) ([]string, string) {
|
|
if !scIsNoEnd(endText) {
|
|
return scDateSpan(startText, endText)
|
|
}
|
|
startDate, ok := scParseDate(startText)
|
|
if !ok {
|
|
return nil, "开始日期格式错误"
|
|
}
|
|
windowStart := startDate
|
|
today := time.Now()
|
|
if today.After(startDate) {
|
|
windowStart = today
|
|
}
|
|
windowEnd := time.Date(today.Year(), 12, 31, 0, 0, 0, 0, today.Location())
|
|
if windowEnd.Before(windowStart) {
|
|
windowEnd = windowStart
|
|
}
|
|
return scDateSpanUnbounded(windowStart.Format("2006-01-02"), windowEnd.Format("2006-01-02"))
|
|
}
|
|
|
|
// 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()
|
|
}
|