更新若干事项

This commit is contained in:
hero920103 committed 2026-10-26 08:54:34 +08:00
1 parent 68cdaf38fa
commit 9898962836
30 files changed
+7715

No files matched your search

+540
View File
@@ -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}, "状态已更新")
}