更新若干事项

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}, "状态已更新")
}
@@ -0,0 +1,71 @@
-- ============================================================
-- OA 工作排班:排班方案支持「平台模板 + 周期规则」
--
-- 适用:已执行过 create_oa_scheduling.sql 的环境(表已存在)。
-- 执行:在业务库(go-platform)下整段执行即可。
--
-- 幂等:逐列判断 information_schema,只添加缺失的列;
-- 9 列全部存在时自动跳过一次无害查询,不会报 1060。
--
-- 字段说明:
-- template_id / template_code / template_name
-- 方案来源模板的快照。租户调用模板后,模板规则会复制进本方案,
-- 之后模板调整或停用都不影响已保存方案(platform 模板只读,租户可改自己的)。
-- align_mode
-- 0 = 未使用模板(沿用 weekdays + shift_id 的按星期排班)
-- 1 = 按自然周(双休 / 单休 / 大小周)
-- 2 = 从 start_date 起滚动(三班倒 / 做一休一)
-- 3 = 按自然月(月休)
-- cycle_days 周期天数(align_mode=1/2 时等于 pattern 长度)
-- week_offset 大小周起始周偏移:0=开始日期所在周为周期第 1 周,1=第 2 周
-- month_rest_days 月休:每月休息天数
-- month_rest_weekday 月休固定休息周几(1-7),0 表示不固定
-- pattern 周期规则 JSON 快照,元素形如
-- {"day_index":0,"day_type":1,"shift_id":12,"shift_name":"白班",
-- "short_name":"白","color":"#409EFF","work_start":"09:00","work_end":"18:00"}
-- ============================================================
-- 执行前自查:existing_cols = 9 表示字段已齐全,执行到下面会自动跳过
SELECT COUNT(*) AS existing_cols
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern');
SET @sql := CONCAT('ALTER TABLE `yz_backend_oa_schedule_plan` ',
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_id') = 0,
'ADD COLUMN `template_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT ''来源模板ID(0=未使用模板)'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_code') = 0,
'ADD COLUMN `template_code` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板编码快照'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'template_name') = 0,
'ADD COLUMN `template_name` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''来源模板名称快照'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'align_mode') = 0,
'ADD COLUMN `align_mode` TINYINT NOT NULL DEFAULT 0 COMMENT ''0不使用模板 1按自然周 2按开始日滚动 3按自然月'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'cycle_days') = 0,
'ADD COLUMN `cycle_days` SMALLINT NOT NULL DEFAULT 0 COMMENT ''周期天数'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'week_offset') = 0,
'ADD COLUMN `week_offset` TINYINT NOT NULL DEFAULT 0 COMMENT ''大小周起始周偏移'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_days') = 0,
'ADD COLUMN `month_rest_days` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休天数'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'month_rest_weekday') = 0,
'ADD COLUMN `month_rest_weekday` TINYINT NOT NULL DEFAULT 0 COMMENT ''月休固定周几(1-7,0不固定)'', ', ''),
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_oa_schedule_plan' AND COLUMN_NAME = 'pattern') = 0,
'ADD COLUMN `pattern` TEXT NULL COMMENT ''周期规则JSON快照'', ', '')
);
-- 去掉结尾多余的 ", "(两个字符),否则 ALTER 语句会因尾逗号报 1064
SET @sql := IF(RIGHT(@sql, 2) = ', ', LEFT(@sql, CHAR_LENGTH(@sql) - 2), @sql);
-- 没有任何缺失列时,@sql 只剩表名,替换为一条无害语句
SET @sql := IF(RIGHT(@sql, 6) = 'TABLE ', 'SELECT 1 AS skipped_all_columns_exist', @sql);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 核对结果:应返回 9 行
SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'yz_backend_oa_schedule_plan'
AND COLUMN_NAME IN ('template_id','template_code','template_name','align_mode','cycle_days','week_offset','month_rest_days','month_rest_weekday','pattern')
ORDER BY ORDINAL_POSITION;
+151
View File
@@ -0,0 +1,151 @@
-- =============================================================
-- OA 工作排班 —— 表结构
--
-- 对应代码:
-- models/oa_scheduling.go
-- controllers/backend_oa_scheduling.go (班次管理)
-- controllers/backend_oa_scheduling_scope.go (组织/员工范围展开)
-- controllers/backend_oa_scheduling_plan.go (排班方案与发布)
-- controllers/backend_oa_scheduling_board.go (看板、临时调配、变更记录)
--
-- 执行顺序:
-- 1) 本脚本:创建 4 张排班表
-- 2) oa_scheduling_menus.sql:注册「OA → 排班考勤 → 工作排班」菜单并授权角色
--
-- 设计要点:
-- 1. 排班对象直接复用租户组织架构(yz_backend_organization)与员工表
-- (yz_backend_employee),不另建“小组”表:公司、部门、小组都是组织
-- 树上的节点,方案通过 scope_type + org_id + include_child 决定范围。
-- 2. 方案(plan)只保存规则;发布后展开成明细(entry),一人一天一条。
-- 看板与临时调配只操作明细,方案可重复发布(可选覆盖)。
-- 3. 明细软删除:is_deleted=0 表示有效,删除时写入记录 ID,
-- 使唯一键 (tid, employee_id, schedule_date, is_deleted) 在删除后
-- 同一天重新排班时不会与历史记录冲突。
-- 4. 变更留痕(change)只记录人、日期与新旧班次,方便追溯临时调配。
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 1. 班次定义 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_shift` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称,如早班/晚班',
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '看板色块上的简称',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '标识颜色',
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm(早于上班时间视为跨天)',
`rest_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm,空表示无',
`rest_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
`work_hours` DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '工时(已扣除休息)',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '1=默认班次',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`remark` VARCHAR(255) DEFAULT NULL,
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_status` (`tid`, `status`),
KEY `idx_tid_deleted` (`tid`, `is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班班次';
-- ---------------- 2. 排班方案 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_plan` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`plan_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '方案名称',
`plan_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=常规排班 2=临时方案',
`scope_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1=整个公司 2=部门/小组 3=指定员工',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '范围组织ID(yz_backend_organization.id)',
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '范围名称快照',
`include_child` TINYINT NOT NULL DEFAULT 1 COMMENT '1=包含全部下级组织',
`employee_ids` TEXT COMMENT '指定员工ID JSON 数组:[1,2,3]',
`employee_names` TEXT COMMENT '指定员工姓名快照',
`start_date` DATE NOT NULL COMMENT '开始日期',
`end_date` DATE NOT NULL COMMENT '结束日期',
`weekdays` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生效星期 JSON:[1,2,3,4,5];空=每天',
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
`note` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
`publish_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未发布 1=已发布',
`entry_count` INT NOT NULL DEFAULT 0 COMMENT '最近一次发布生成/覆盖的条数',
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人 uid',
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人名称快照',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid_deleted` (`tid`, `is_deleted`),
KEY `idx_tid_scope` (`tid`, `scope_type`, `org_id`),
KEY `idx_tid_dates` (`tid`, `start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班方案';
-- ---------------- 3. 排班明细 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_entry` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`plan_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '来源方案ID,0=纯手工排班',
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID(yz_backend_employee.id)',
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`employee_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工账号快照',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属组织ID',
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '所属组织名称快照',
`position` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职位快照',
`schedule_date` DATE NOT NULL COMMENT '排班日期',
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID',
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '班次简称快照',
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间快照',
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间快照',
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '1=方案生成 2=临时调配',
`note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注/调配原因',
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
`created_by_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
`is_deleted` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标记:0=有效;删除时写入记录ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_emp_date` (`tid`, `employee_id`, `schedule_date`, `is_deleted`),
KEY `idx_tid_date` (`tid`, `schedule_date`),
KEY `idx_tid_plan` (`tid`, `plan_id`),
KEY `idx_tid_emp` (`tid`, `employee_id`),
KEY `idx_tid_org_date` (`tid`, `org_id`, `schedule_date`),
KEY `idx_tid_shift` (`tid`, `shift_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班明细';
-- ---------------- 4. 排班变更记录 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_oa_schedule_change` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`entry_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排班明细ID',
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
`schedule_date` DATE NOT NULL COMMENT '排班日期',
`change_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=调班 2=清除 3=手工新增 4=方案覆盖',
`old_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '原班次ID',
`old_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '原班次名称',
`new_shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '新班次ID',
`new_shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '新班次名称',
`reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '调整原因',
`operator_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人 uid',
`operator_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tid_date` (`tid`, `schedule_date`),
KEY `idx_tid_emp` (`tid`, `employee_id`),
KEY `idx_tid_entry` (`tid`, `entry_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作排班变更记录';
-- ---------------- 校验 ----------------
SELECT TABLE_NAME, TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'yz_backend_oa_schedule_%'
OR TABLE_NAME = 'yz_backend_oa_shift';
@@ -0,0 +1,219 @@
-- ============================================================
-- 平台端排班模板(全局预制模板 + 自定义模板)
--
-- 执行顺序:
-- 1) 本脚本:创建 2 张模板表并写入 6 个系统预置模板
-- 2) alter_oa_schedule_plan_template.sql:租户排班方案增加模板/周期字段
-- 3) platform_sched_template_menu.sql:注册平台端「排班模板」菜单
-- 4) create_oa_scheduling.sql / oa_scheduling_menus.sql(若尚未执行)
--
-- 说明:
-- 1. 模板是全局数据(不按租户隔离),只有平台端可维护;
-- 系统预置模板(is_system=1)由平台锁定:不可编辑、不可删除、不可停用。
-- 2. 租户端只能「调用」模板:调用时把模板规则快照到自己的排班方案
-- (yz_backend_oa_schedule_plan.pattern),之后在租户端随意修改,
-- 模板本身的后续调整不会再影响已保存的方案。
-- 3. align_mode:1=按自然周(双休/单休/大小周) 2=从开始日滚动(三班倒/做一休一)
-- 3=按自然月(月休,按月计算休息日)
-- 4. day_index 表示周期内第几天,从 0 开始;按自然周时 0=周一、6=周日。
-- 5. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行,重复执行安全。
-- ============================================================
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(50) NOT NULL DEFAULT '' COMMENT '模板编码(系统模板固定)',
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '模板名称',
`template_type` tinyint NOT NULL DEFAULT 9 COMMENT '1双休 2单休 3大小周 4月休 5三班倒 6做一休一 9自定义',
`align_mode` tinyint NOT NULL DEFAULT 1 COMMENT '1按自然周 2按开始日滚动 3按自然月',
`cycle_days` smallint NOT NULL DEFAULT 7 COMMENT '周期天数(按自然月时为0)',
`week_offset` tinyint NOT NULL DEFAULT 0 COMMENT '大小周起始周偏移:0=开始周为第1周 1=第2周',
`month_rest_days` tinyint NOT NULL DEFAULT 0 COMMENT '月休:每月休息天数',
`month_rest_weekday` tinyint NOT NULL DEFAULT 0 COMMENT '月休固定休息周几(1-7,0=不固定)',
`summary` varchar(255) NOT NULL DEFAULT '' COMMENT '规则摘要(列表展示)',
`is_system` tinyint NOT NULL DEFAULT 0 COMMENT '1=系统预置(只读,禁止编辑/删除)',
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`creater` varchar(50) NOT NULL DEFAULT '' COMMENT '创建人',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
PRIMARY KEY (`id`),
KEY `idx_code` (`code`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板(全局)';
CREATE TABLE IF NOT EXISTS `yz_platform_sched_tpl_day` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`template_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '模板ID',
`day_index` smallint NOT NULL DEFAULT 0 COMMENT '周期内第几天,从0开始',
`day_type` tinyint NOT NULL DEFAULT 1 COMMENT '1上班 2休息',
`shift_name` varchar(50) NOT NULL DEFAULT '' COMMENT '班次名称',
`shift_short` varchar(20) NOT NULL DEFAULT '' COMMENT '班次简称',
`color` varchar(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色',
`work_start` varchar(8) NOT NULL DEFAULT '' COMMENT '上班时间 HH:mm',
`work_end` varchar(8) NOT NULL DEFAULT '' COMMENT '下班时间 HH:mm',
`rest_start` varchar(8) NOT NULL DEFAULT '' COMMENT '休息开始 HH:mm',
`rest_end` varchar(8) NOT NULL DEFAULT '' COMMENT '休息结束 HH:mm',
`work_hours` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '工时(小时)',
`remark` varchar(100) NOT NULL DEFAULT '' COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tpl_day` (`template_id`,`day_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台端排班模板-周期每日规则';
-- ============================================================
-- 系统预置模板(6 个)
-- ============================================================
-- 1. 双休:周一至周五上班,周六日休息
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'double_weekend', '双休', 1, 1, 7, 0, 0, 0, '周一至周五上班 09:00-18:00,周六日休息', 1, 1, 10, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'double_weekend' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 2. 单休:周一至周六上班,周日休息
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'single_weekend', '单休', 2, 1, 7, 0, 0, 0, '周一至周六上班 09:00-18:00,周日休息', 1, 1, 20, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'single_weekend' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 3. 大小周:第 1 周双休,第 2 周单休(周六上班),14 天一个周期
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'big_small_week', '大小周', 3, 1, 14, 0, 0, 0, '第 1 周双休、第 2 周单休(周六上班),14 天循环', 1, 1, 30, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 2, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 3, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 4, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 5, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 6, 2, '', '', '', '', '', '', '', 0.00
UNION ALL SELECT 7, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 8, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 9, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 10, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 11, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 12, 1, '白班', '白', '#409EFF', '09:00', '18:00', '12:00', '13:30', 7.50
UNION ALL SELECT 13, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'big_small_week' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 4. 月休 4 天:每月固定休 4 天,优先安排在周日(周末不足时从月末补齐)
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'month_4_rest', '月休 4 天', 4, 3, 0, 0, 4, 7, '每月固定休 4 天,优先安排周日,遇 5 个周日只休前 4 个', 1, 1, 40, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'month_4_rest' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 5. 三班倒:早班 -> 中班 -> 夜班,各 1 天循环
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'three_shift', '三班倒', 5, 2, 3, 0, 0, 0, '早班 08:00-16:00 → 中班 16:00-24:00 → 夜班 00:00-08:00,3 天循环', 1, 1, 50, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '早班' AS shift_name, '早' AS shift_short, '#67C23A' AS color, '08:00' AS work_start, '16:00' AS work_end, '' AS rest_start, '' AS rest_end, 8.00 AS work_hours
UNION ALL SELECT 1, 1, '中班', '中', '#E6A23C', '16:00', '24:00', '', '', 8.00
UNION ALL SELECT 2, 1, '夜班', '夜', '#909399', '00:00', '08:00', '', '', 8.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'three_shift' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- 6. 做一休一:上 1 天休 1 天
INSERT INTO yz_platform_sched_tpl
(code, name, template_type, align_mode, cycle_days, week_offset, month_rest_days, month_rest_weekday, summary, is_system, status, sort, creater)
SELECT 'work_one_rest_one', '做一休一', 6, 2, 2, 0, 0, 0, '上 1 天休 1 天循环(09:00-18:00)', 1, 1, 60, 'system'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL) t);
INSERT INTO yz_platform_sched_tpl_day
(template_id, day_index, day_type, shift_name, shift_short, color, work_start, work_end, rest_start, rest_end, work_hours)
SELECT tpl.id, d.idx, d.day_type, d.shift_name, d.shift_short, d.color, d.work_start, d.work_end, d.rest_start, d.rest_end, d.work_hours
FROM (
SELECT 0 AS idx, 1 AS day_type, '白班' AS shift_name, '白' AS shift_short, '#409EFF' AS color, '09:00' AS work_start, '18:00' AS work_end, '12:00' AS rest_start, '13:30' AS rest_end, 7.50 AS work_hours
UNION ALL SELECT 1, 2, '', '', '', '', '', '', '', 0.00
) d
CROSS JOIN (SELECT id FROM yz_platform_sched_tpl WHERE code = 'work_one_rest_one' AND delete_time IS NULL LIMIT 1) tpl
WHERE NOT EXISTS (
SELECT 1 FROM (SELECT template_id, day_index FROM yz_platform_sched_tpl_day) x
WHERE x.template_id = tpl.id AND x.day_index = d.idx
);
-- ============================================================
-- 结果校验:6 行,day_count 分别为 7 / 7 / 14 / 1 / 3 / 2
-- ============================================================
SELECT t.id, t.code, t.name, t.template_type, t.align_mode, t.cycle_days,
t.month_rest_days, t.month_rest_weekday, t.is_system, t.status, t.sort,
COUNT(d.id) AS day_count
FROM yz_platform_sched_tpl t
LEFT JOIN yz_platform_sched_tpl_day d ON d.template_id = t.id
WHERE t.delete_time IS NULL
GROUP BY t.id
ORDER BY t.sort;
+94
View File
@@ -0,0 +1,94 @@
-- ============================================================
-- OA 工作排班 —— 菜单配置(yz_system_menu)
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
--
-- 挂载位置:OA 应用根(path = '/apps/oa')
-- OA
-- └─ 排班考勤(目录,/apps/oa/scheduling)
-- └─ 工作排班(页面,/apps/oa/scheduling/board)
--
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
-- 周看板、单个或批量员工的临时调配、变更记录查看。
--
-- 说明:
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
-- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
-- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
-- 目录与页面都必须授权,缺一不可。
-- ============================================================
SET @oa_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 排班考勤(目录) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @oa_id, '排班考勤', '/apps/oa/scheduling', NULL,
'Calendar', 88, 1, 1, '[2]', '["web"]', 1, 'system',
'按公司、部门、小组或指定员工制定排班方案,支持临时调配'
FROM DUAL
WHERE @oa_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL) x);
SET @scheduling_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 工作排班(页面) ----------
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
SELECT @scheduling_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
FROM DUAL
WHERE @scheduling_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
SET @board_id := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
LIMIT 1
);
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @scheduling_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @scheduling_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON));
UPDATE yz_system_admin_role
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
WHERE cid = 2
AND rights IS NOT NULL
AND TRIM(rights) <> ''
AND JSON_VALID(rights)
AND @board_id IS NOT NULL
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
-- ---------- 结果校验 ----------
SELECT id, pid, title, path, component_path, platforms, type, sort
FROM yz_system_menu
WHERE delete_time IS NULL AND path LIKE '/apps/oa/scheduling%'
ORDER BY pid, sort;
SELECT id, name, tenant_id,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON)) AS has_dir,
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
ORDER BY tenant_id, id;
@@ -0,0 +1,42 @@
-- ============================================================
-- 排班模板 —— 平台端菜单(yz_system_menu)
--
-- 挂载位置:平台端「基础设置」(path = /basicSettings)下,与「字典管理」并列。
-- 基础设置
-- └─ 排班模板(页面,/basicSettings/schedTemplate)
--
-- 说明:
-- 1. views = [1]:只在平台端出现;租户端通过 /backend/oa/scheduling/templates
-- 读取模板(不在租户菜单里暴露模板管理入口)。
-- 2. 平台端菜单接口(/platform/menu/:id)按 views 过滤,不按角色 rights 过滤,
-- 因此本脚本无需追加角色授权。
-- 3. 幂等:按 path 判重,重复执行不会产生重复菜单。
-- 4. 本脚本不使用会话变量 / PREPARE,可整段执行也可逐条执行。
-- ============================================================
-- 1) 有「基础设置」目录时,挂到该目录下
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT
(SELECT id FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL ORDER BY id LIMIT 1) b),
'排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
'Calendar', 90, 1, 1, '[1]', 2, 'system',
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
FROM DUAL
WHERE EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
-- 2) 未找到「基础设置」目录时,退化为平台端顶级菜单(pid=0)
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT 0, '排班模板', '/basicSettings/schedTemplate', '/system/schedTemplate/index.vue',
'Calendar', 92, 1, 1, '[1]', 2, 'system',
'全局排班模板:预置双休/单休/大小周/月休/三班倒/做一休一,租户调用后存入自己的方案'
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings' AND delete_time IS NULL) b)
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basicSettings/schedTemplate' AND delete_time IS NULL) e);
-- 结果校验:应返回 1 行
SELECT id, pid, title, path, component_path, views, type, is_visible, status
FROM yz_system_menu
WHERE delete_time IS NULL AND path = '/basicSettings/schedTemplate';
+8
View File
@@ -190,6 +190,14 @@ func Init(_ string) {
new(BackendScheduleReminderSendLog),
new(OaSchedule),
// 平台端排班模板(docs/sql/create_platform_sched_template.sql)
new(PlatformSchedTpl),
new(PlatformSchedTplDay),
// 工作排班(docs/sql/create_oa_scheduling.sql)
new(OaShift),
new(OaSchedulePlan),
new(OaScheduleEntry),
new(OaScheduleChange),
new(OaHoliday),
new(OaNotice),
// 工作日报(docs/sql/create_oa_dailyreport.sql)
+176
View File
@@ -0,0 +1,176 @@
package models
import "time"
// OaShift OA 工作排班-班次定义表 yz_backend_oa_shift
//
// 班次是排班的最小单位,描述一段可被复用的工作时间(如早班 09:00-18:00)。
// 字段中的时间均为 "HH:mm" 字符串,休息时段可为空表示不区分午休。
type OaShift struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
Name string `orm:"column(name);size(50)" json:"name"`
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Remark *string `orm:"column(remark);size(255);null" json:"remark"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaShift) TableName() string {
return "yz_backend_oa_shift"
}
// 排班方案类型(plan_type)
const (
OaPlanTypeRegular int8 = 1 // 常规排班:按组织范围周期性下发
OaPlanTypeTemp int8 = 2 // 临时方案:针对单个/多个员工的短期安排
)
// 排班作用范围(scope_type)
const (
OaScopeCompany int8 = 1 // 整个公司
OaScopeOrg int8 = 2 // 部门 / 小组(可含下级组织)
OaScopeEmployees int8 = 3 // 指定员工(一人或多人)
)
// OaSchedulePlan 排班方案 yz_backend_oa_schedule_plan
//
// 方案只描述“谁、在什么时间、上什么班”的规则,发布后由后端展开成
// yz_backend_oa_schedule_entry 明细(每人每天一条),这样看板查询与
// 临时调配都只操作明细表,方案本身可以随时停用而不影响已下发的排班。
//
// 两种排班方式:
// - 按星期(AlignMode=0):Weekdays + ShiftID,一人一天一个固定班次;
// - 按模板(AlignMode=1/2/3):Pattern 存周期的每日规则快照,
// 发布时按自然周 / 开始日滚动 / 自然月推算每天是否上班、上哪个班次。
// 模板来自平台端(yz_platform_sched_tpl),调用后即固化到本方案,
// 之后模板调整与方案无关。
type OaSchedulePlan struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
PlanName string `orm:"column(plan_name);size(100)" json:"plan_name"`
PlanType int8 `orm:"column(plan_type);default(1)" json:"plan_type"`
ScopeType int8 `orm:"column(scope_type);default(2)" json:"scope_type"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
IncludeChild int8 `orm:"column(include_child);default(1)" json:"include_child"`
EmployeeIDs string `orm:"column(employee_ids);type(text);null" json:"employee_ids"`
EmployeeNames string `orm:"column(employee_names);type(text);null" json:"employee_names"`
StartDate string `orm:"column(start_date);type(date)" json:"start_date"`
EndDate string `orm:"column(end_date);type(date)" json:"end_date"`
Weekdays string `orm:"column(weekdays);size(32);default()" json:"weekdays"`
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
// 模板快照与周期规则(平台模板调用后固化到方案,见 models/sched_template.go)
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
TemplateCode string `orm:"column(template_code);size(50);default()" json:"template_code"`
TemplateName string `orm:"column(template_name);size(50);default()" json:"template_name"`
AlignMode int8 `orm:"column(align_mode);default(0)" json:"align_mode"`
CycleDays int `orm:"column(cycle_days);default(0)" json:"cycle_days"`
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
Pattern string `orm:"column(pattern);type(text);null" json:"pattern"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
Note string `orm:"column(note);size(500);default()" json:"note"`
Status int8 `orm:"column(status);default(1)" json:"status"`
PublishStatus int8 `orm:"column(publish_status);default(0)" json:"publish_status"`
EntryCount int `orm:"column(entry_count);default(0)" json:"entry_count"`
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaSchedulePlan) TableName() string {
return "yz_backend_oa_schedule_plan"
}
// 排班明细来源(source)
const (
OaEntrySourcePlan int8 = 1 // 由排班方案发布生成
OaEntrySourceTemp int8 = 2 // 临时调配 / 单人多人手工安排
)
// OaScheduleEntry 排班明细 yz_backend_oa_schedule_entry
//
// 一人一天一条有效记录:唯一键 (tid, employee_id, schedule_date, is_deleted)。
// 删除时把 is_deleted 写成记录 ID,既保留历史痕迹,又避免同一天重新排班
// 与软删记录冲突(与 OA 工作日报的处理方式一致)。
type OaScheduleEntry struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
PlanID uint64 `orm:"column(plan_id);default(0)" json:"plan_id"`
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
EmployeeAccount string `orm:"column(employee_account);size(50);default()" json:"employee_account"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
Position string `orm:"column(position);size(50);default()" json:"position"`
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
Source int8 `orm:"column(source);default(1)" json:"source"`
Note string `orm:"column(note);size(255);default()" json:"note"`
CreatedBy uint64 `orm:"column(created_by);default(0)" json:"created_by"`
CreatedByName string `orm:"column(created_by_name);size(64);default()" json:"created_by_name"`
IsDeleted uint64 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *OaScheduleEntry) TableName() string {
return "yz_backend_oa_schedule_entry"
}
// 排班变更类型(change_type)
const (
OaChangeShift int8 = 1 // 调班:换班次
OaChangeClear int8 = 2 // 清除排班
OaChangeManual int8 = 3 // 手工新增排班
OaChangePublish int8 = 4 // 方案发布覆盖
)
// OaScheduleChange 排班变更留痕 yz_backend_oa_schedule_change
//
// 只记录“人 + 日期 + 原班次 -> 新班次”的关键信息,用于回答
// “这天谁被临时调班了、为什么调”,不做完整审计日志。
type OaScheduleChange struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid int `orm:"column(tid)" json:"tid"`
EntryID uint64 `orm:"column(entry_id);default(0)" json:"entry_id"`
EmployeeID uint `orm:"column(employee_id)" json:"employee_id"`
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
ScheduleDate string `orm:"column(schedule_date);type(date)" json:"schedule_date"`
ChangeType int8 `orm:"column(change_type);default(1)" json:"change_type"`
OldShiftID uint64 `orm:"column(old_shift_id);default(0)" json:"old_shift_id"`
OldShiftName string `orm:"column(old_shift_name);size(50);default()" json:"old_shift_name"`
NewShiftID uint64 `orm:"column(new_shift_id);default(0)" json:"new_shift_id"`
NewShiftName string `orm:"column(new_shift_name);size(50);default()" json:"new_shift_name"`
Reason string `orm:"column(reason);size(255);default()" json:"reason"`
OperatorID uint64 `orm:"column(operator_id);default(0)" json:"operator_id"`
OperatorName string `orm:"column(operator_name);size(64);default()" json:"operator_name"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *OaScheduleChange) TableName() string {
return "yz_backend_oa_schedule_change"
}
+84
View File
@@ -0,0 +1,84 @@
package models
import "time"
// 排班模板对齐方式(align_mode)
//
// 1 按自然周:双休 / 单休 / 大小周,day_index 0=周一 ... 6=周日
// 2 按开始日滚动:三班倒 / 做一休一,从方案开始日期起按周期顺延
// 3 按自然月:月休,按月计算休息日
const (
SchedTplAlignWeek int8 = 1
SchedTplAlignRoll int8 = 2
SchedTplAlignMonth int8 = 3
)
// 排班模板每天类型(day_type)
const (
SchedTplDayWork int8 = 1 // 上班
SchedTplDayRest int8 = 2 // 休息
)
// 排班模板类型(template_type)
const (
SchedTplTypeDoubleWeekend int8 = 1 // 双休
SchedTplTypeSingleWeekend int8 = 2 // 单休
SchedTplTypeBigSmallWeek int8 = 3 // 大小周
SchedTplTypeMonthRest int8 = 4 // 月休
SchedTplTypeThreeShift int8 = 5 // 三班倒
SchedTplTypeWorkRestOne int8 = 6 // 做一休一
SchedTplTypeCustom int8 = 9 // 自定义
)
// PlatformSchedTpl 平台端排班模板 yz_platform_sched_tpl
//
// 模板是全局数据(不按租户隔离),只有平台端可维护;系统预置模板
// (is_system=1)由平台锁定,禁止编辑 / 删除 / 停用。租户端调用模板时
// 会把规则快照进自己的排班方案(OaSchedulePlan.Pattern),之后互不影响。
type PlatformSchedTpl struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Code string `orm:"column(code);size(50);default()" json:"code"`
Name string `orm:"column(name);size(50)" json:"name"`
TemplateType int8 `orm:"column(template_type);default(9)" json:"template_type"`
AlignMode int8 `orm:"column(align_mode);default(1)" json:"align_mode"`
CycleDays int `orm:"column(cycle_days);default(7)" json:"cycle_days"`
WeekOffset int8 `orm:"column(week_offset);default(0)" json:"week_offset"`
MonthRestDays int8 `orm:"column(month_rest_days);default(0)" json:"month_rest_days"`
MonthRestWeekday int8 `orm:"column(month_rest_weekday);default(0)" json:"month_rest_weekday"`
Summary string `orm:"column(summary);size(255);default()" json:"summary"`
IsSystem int8 `orm:"column(is_system);default(0)" json:"is_system"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
Creater string `orm:"column(creater);size(50);default()" json:"creater"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformSchedTpl) TableName() string {
return "yz_platform_sched_tpl"
}
// PlatformSchedTplDay 平台端排班模板的周期每日规则 yz_platform_sched_tpl_day
//
// day_index 为周期内第几天(从 0 开始)。day_type=2 表示休息,
// 此时班次字段为空;day_type=1 时填写班次名称与上下班时间。
type PlatformSchedTplDay struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TemplateID uint64 `orm:"column(template_id);default(0)" json:"template_id"`
DayIndex int `orm:"column(day_index);default(0)" json:"day_index"`
DayType int8 `orm:"column(day_type);default(1)" json:"day_type"`
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
ShiftShort string `orm:"column(shift_short);size(20);default()" json:"shift_short"`
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
RestStart string `orm:"column(rest_start);size(8);default()" json:"rest_start"`
RestEnd string `orm:"column(rest_end);size(8);default()" json:"rest_end"`
WorkHours float64 `orm:"column(work_hours);type(decimal);digits(5);decimals(2);default(0)" json:"work_hours"`
Remark string `orm:"column(remark);size(100);default()" json:"remark"`
}
func (m *PlatformSchedTplDay) TableName() string {
return "yz_platform_sched_tpl_day"
}
+19
View File
@@ -334,6 +334,25 @@ func RegisterAuthRoutes() {
beego.Router("/backend/oa/compensation/payrolls/:id", &controllers.BackendOaCompensationController{}, "get:Detail;post:Update;delete:Delete")
beego.Router("/backend/oa/compensation/payrolls/:id/status", &controllers.BackendOaCompensationController{}, "post:UpdateStatus")
// OA工作排班(班次 / 排班方案 / 排班看板 / 临时调配)
beego.Router("/backend/oa/scheduling/shifts", &controllers.BackendOaSchedulingController{}, "get:ShiftList;post:CreateShift")
beego.Router("/backend/oa/scheduling/shifts/:id", &controllers.BackendOaSchedulingController{}, "post:UpdateShift;delete:DeleteShift")
beego.Router("/backend/oa/scheduling/employees", &controllers.BackendOaSchedulingController{}, "get:ScheduleEmployeeOptions")
beego.Router("/backend/oa/scheduling/board", &controllers.BackendOaSchedulingController{}, "get:Board")
beego.Router("/backend/oa/scheduling/plans", &controllers.BackendOaSchedulingController{}, "get:PlanList")
beego.Router("/backend/oa/scheduling/templates", &controllers.BackendOaSchedulingController{}, "get:TemplateList")
beego.Router("/backend/oa/scheduling/templates/apply", &controllers.BackendOaSchedulingController{}, "post:TemplateApply")
beego.Router("/backend/oa/scheduling/plans/create", &controllers.BackendOaSchedulingController{}, "post:PlanCreate")
beego.Router("/backend/oa/scheduling/plans/preview", &controllers.BackendOaSchedulingController{}, "post:PlanPreview")
beego.Router("/backend/oa/scheduling/plans/detail/:id", &controllers.BackendOaSchedulingController{}, "get:PlanDetail")
beego.Router("/backend/oa/scheduling/plans/update/:id", &controllers.BackendOaSchedulingController{}, "post:PlanUpdate")
beego.Router("/backend/oa/scheduling/plans/publish/:id", &controllers.BackendOaSchedulingController{}, "post:PlanPublish")
beego.Router("/backend/oa/scheduling/plans/delete/:id", &controllers.BackendOaSchedulingController{}, "delete:PlanDelete")
beego.Router("/backend/oa/scheduling/entries/save", &controllers.BackendOaSchedulingController{}, "post:SaveEntries")
beego.Router("/backend/oa/scheduling/entries/swap/:id", &controllers.BackendOaSchedulingController{}, "post:SwapEntry")
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
// OA日程管理(日历待办)
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
+6
View File
@@ -404,6 +404,12 @@ func Register() {
beego.Router("/platform/loginBrand", &controllers.PlatformWebsiteSettingController{}, "get:LoginBrandInfo")
beego.Router("/platform/saveLoginBrand", &controllers.PlatformWebsiteSettingController{}, "post:SaveLoginBrand")
// 平台端排班模板(全局模板;系统预置只读,只有平台可维护)
// 注意:带 /status 的固定路径先于 /:id 注册
beego.Router("/platform/schedTemplates", &controllers.PlatformSchedTemplateController{}, "get:List;post:Create")
beego.Router("/platform/schedTemplates/:id/status", &controllers.PlatformSchedTemplateController{}, "post:ToggleStatus")
beego.Router("/platform/schedTemplates/:id", &controllers.PlatformSchedTemplateController{}, "get:Detail;post:Update;delete:Delete")
// ===== 平台官网管理(docs/sql/create_platform_website.sql)=====
// 文章管理 yz_platform_website_article
beego.Router("/platform/website/article/list", &controllers.PlatformWebsiteArticleController{}, "get:List")