更新若干事项
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()
|
||||
}
|
||||
Reference in new issue
Block a user