diff --git a/backend/components.d.ts b/backend/components.d.ts index 9091e5c..ee53407 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -31,6 +31,7 @@ declare module 'vue' { ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] @@ -79,6 +80,7 @@ declare module 'vue' { ElTag: typeof import('element-plus/es')['ElTag'] ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] + ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTree: typeof import('element-plus/es')['ElTree'] diff --git a/backend/src/api/oaScheduling.js b/backend/src/api/oaScheduling.js new file mode 100644 index 0000000..3768458 --- /dev/null +++ b/backend/src/api/oaScheduling.js @@ -0,0 +1,100 @@ +import request from '@/utils/request' + +// OA 工作排班:班次 / 排班方案 / 排班看板 / 临时调配 +// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data + +/* ------------------------------- 班次 ------------------------------- */ + +export function getShiftList() { + return request({ url: '/backend/oa/scheduling/shifts', method: 'get' }) +} + +export function createShift(data) { + return request({ url: '/backend/oa/scheduling/shifts', method: 'post', data }) +} + +export function updateShift(id, data) { + return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'post', data }) +} + +export function deleteShift(id) { + return request({ url: `/backend/oa/scheduling/shifts/${id}`, method: 'delete' }) +} + +/* ------------------------------- 员工 ------------------------------- */ + +// 排班可选员工:org_id 为空表示全部,include_child=1 含下级组织 +export function getSchedulingEmployees(params) { + return request({ url: '/backend/oa/scheduling/employees', method: 'get', params }) +} + +/* ------------------------------- 看板 ------------------------------- */ + +export function getScheduleBoard(params) { + return request({ url: '/backend/oa/scheduling/board', method: 'get', params }) +} + +/* ----------------------------- 排班模板 ----------------------------- */ + +// 平台预置 + 自定义模板(只读);租户调用后规则会复制到自己的方案 +export function getSchedTemplates(params) { + return request({ url: '/backend/oa/scheduling/templates', method: 'get', params }) +} + +// 调用模板:自动匹配 / 创建租户自己的班次,返回可直接用于方案的 pattern +export function applySchedTemplate(templateId) { + return request({ url: '/backend/oa/scheduling/templates/apply', method: 'post', data: { template_id: templateId } }) +} + +/* ------------------------------- 方案 ------------------------------- */ + +export function getSchedulePlans(params) { + return request({ url: '/backend/oa/scheduling/plans', method: 'get', params }) +} + +export function getSchedulePlanDetail(id) { + return request({ url: `/backend/oa/scheduling/plans/detail/${id}`, method: 'get' }) +} + +// 预览方案覆盖人数与生成条数,不写入数据 +export function previewSchedulePlan(data) { + return request({ url: '/backend/oa/scheduling/plans/preview', method: 'post', data }) +} + +export function createSchedulePlan(data) { + return request({ url: '/backend/oa/scheduling/plans/create', method: 'post', data }) +} + +export function updateSchedulePlan(id, data) { + return request({ url: `/backend/oa/scheduling/plans/update/${id}`, method: 'post', data }) +} + +// overwrite=true 覆盖同一员工同一天已有排班,默认保留 +export function publishSchedulePlan(id, data) { + return request({ url: `/backend/oa/scheduling/plans/publish/${id}`, method: 'post', data }) +} + +// delete_entries=0 时仅删除方案,保留已生成的排班 +export function deleteSchedulePlan(id, params) { + return request({ url: `/backend/oa/scheduling/plans/delete/${id}`, method: 'delete', params }) +} + +/* ----------------------------- 明细与调配 ----------------------------- */ + +// 临时排班:employee_ids + start_date/end_date + weekdays + shift_id +// shift_id=0 表示清除这些日期的排班 +export function saveScheduleEntries(data) { + return request({ url: '/backend/oa/scheduling/entries/save', method: 'post', data }) +} + +export function swapScheduleEntry(id, data) { + return request({ url: `/backend/oa/scheduling/entries/swap/${id}`, method: 'post', data }) +} + +export function clearScheduleEntries(data) { + return request({ url: '/backend/oa/scheduling/entries/clear', method: 'post', data }) +} + +export function getScheduleChanges(params) { + return request({ url: '/backend/oa/scheduling/changes', method: 'get', params }) +} diff --git a/backend/src/router/index.js b/backend/src/router/index.js index d6054b0..1b83311 100644 --- a/backend/src/router/index.js +++ b/backend/src/router/index.js @@ -41,6 +41,13 @@ const staticMainChildren = [ component: () => import("@/views/apps/oa/approvalcenter/settings.vue"), meta: { requiresAuth: true, title: "审批设置", modulePath: "/apps/oa" } }, + // OA 工作排班(静态注册兜底:三级菜单在动态路由重建前/菜单缓存未刷新时直链会落到 404) + { + path: "/apps/oa/scheduling/board", + name: "OaSchedulingBoard", + component: () => import("@/views/apps/oa/scheduling/index.vue"), + meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" } + }, // CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。 // 统一结构:index 为列表页,type 为分类页。 { diff --git a/backend/src/views/apps/oa/scheduling/components/entryDialog.vue b/backend/src/views/apps/oa/scheduling/components/entryDialog.vue new file mode 100644 index 0000000..b43202d --- /dev/null +++ b/backend/src/views/apps/oa/scheduling/components/entryDialog.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/backend/src/views/apps/oa/scheduling/components/planDialog.vue b/backend/src/views/apps/oa/scheduling/components/planDialog.vue new file mode 100644 index 0000000..735ae80 --- /dev/null +++ b/backend/src/views/apps/oa/scheduling/components/planDialog.vue @@ -0,0 +1,821 @@ + + + + + diff --git a/backend/src/views/apps/oa/scheduling/components/shiftDialog.vue b/backend/src/views/apps/oa/scheduling/components/shiftDialog.vue new file mode 100644 index 0000000..3cf33bf --- /dev/null +++ b/backend/src/views/apps/oa/scheduling/components/shiftDialog.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/backend/src/views/apps/oa/scheduling/components/swapDialog.vue b/backend/src/views/apps/oa/scheduling/components/swapDialog.vue new file mode 100644 index 0000000..d7495f3 --- /dev/null +++ b/backend/src/views/apps/oa/scheduling/components/swapDialog.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/backend/src/views/apps/oa/scheduling/index.vue b/backend/src/views/apps/oa/scheduling/index.vue new file mode 100644 index 0000000..c442206 --- /dev/null +++ b/backend/src/views/apps/oa/scheduling/index.vue @@ -0,0 +1,900 @@ + + + + + diff --git a/go/controllers/backend_oa_scheduling.go b/go/controllers/backend_oa_scheduling.go new file mode 100644 index 0000000..3d933e2 --- /dev/null +++ b/go/controllers/backend_oa_scheduling.go @@ -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() +} diff --git a/go/controllers/backend_oa_scheduling_board.go b/go/controllers/backend_oa_scheduling_board.go new file mode 100644 index 0000000..1e406ca --- /dev/null +++ b/go/controllers/backend_oa_scheduling_board.go @@ -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)}) +} diff --git a/go/controllers/backend_oa_scheduling_pattern.go b/go/controllers/backend_oa_scheduling_pattern.go new file mode 100644 index 0000000..7ed04e4 --- /dev/null +++ b/go/controllers/backend_oa_scheduling_pattern.go @@ -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()] +} diff --git a/go/controllers/backend_oa_scheduling_plan.go b/go/controllers/backend_oa_scheduling_plan.go new file mode 100644 index 0000000..505fb91 --- /dev/null +++ b/go/controllers/backend_oa_scheduling_plan.go @@ -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}) +} diff --git a/go/controllers/backend_oa_scheduling_scope.go b/go/controllers/backend_oa_scheduling_scope.go new file mode 100644 index 0000000..de42cc2 --- /dev/null +++ b/go/controllers/backend_oa_scheduling_scope.go @@ -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 +} diff --git a/go/controllers/backend_oa_scheduling_template.go b/go/controllers/backend_oa_scheduling_template.go new file mode 100644 index 0000000..440d698 --- /dev/null +++ b/go/controllers/backend_oa_scheduling_template.go @@ -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) +} diff --git a/go/controllers/platform_schedule_template.go b/go/controllers/platform_schedule_template.go new file mode 100644 index 0000000..48a905d --- /dev/null +++ b/go/controllers/platform_schedule_template.go @@ -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}, "状态已更新") +} diff --git a/go/docs/sql/alter_oa_schedule_plan_template.sql b/go/docs/sql/alter_oa_schedule_plan_template.sql new file mode 100644 index 0000000..802c500 --- /dev/null +++ b/go/docs/sql/alter_oa_schedule_plan_template.sql @@ -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; diff --git a/go/docs/sql/create_oa_scheduling.sql b/go/docs/sql/create_oa_scheduling.sql new file mode 100644 index 0000000..98f700f --- /dev/null +++ b/go/docs/sql/create_oa_scheduling.sql @@ -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'; diff --git a/go/docs/sql/create_platform_sched_template.sql b/go/docs/sql/create_platform_sched_template.sql new file mode 100644 index 0000000..a1332ac --- /dev/null +++ b/go/docs/sql/create_platform_sched_template.sql @@ -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; diff --git a/go/docs/sql/oa_scheduling_menus.sql b/go/docs/sql/oa_scheduling_menus.sql new file mode 100644 index 0000000..f5f244c --- /dev/null +++ b/go/docs/sql/oa_scheduling_menus.sql @@ -0,0 +1,94 @@ +-- ============================================================ +-- OA 工作排班 —— 菜单配置(yz_system_menu) +-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单 +-- 执行:mysql -h -P -u -p < 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; diff --git a/go/docs/sql/platform_sched_template_menu.sql b/go/docs/sql/platform_sched_template_menu.sql new file mode 100644 index 0000000..92aaecb --- /dev/null +++ b/go/docs/sql/platform_sched_template_menu.sql @@ -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'; diff --git a/go/models/init.go b/go/models/init.go index ea656fe..24474e4 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -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) diff --git a/go/models/oa_scheduling.go b/go/models/oa_scheduling.go new file mode 100644 index 0000000..9391f8f --- /dev/null +++ b/go/models/oa_scheduling.go @@ -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" +} diff --git a/go/models/sched_template.go b/go/models/sched_template.go new file mode 100644 index 0000000..2f428f0 --- /dev/null +++ b/go/models/sched_template.go @@ -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" +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index cfb8601..3806703 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -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") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 6e6f581..0300e62 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -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") diff --git a/platform/components.d.ts b/platform/components.d.ts index e4a03ad..4c9caee 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -28,6 +28,7 @@ declare module 'vue' { ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] diff --git a/platform/src/api/schedTemplate.js b/platform/src/api/schedTemplate.js new file mode 100644 index 0000000..e63af8a --- /dev/null +++ b/platform/src/api/schedTemplate.js @@ -0,0 +1,51 @@ +import request from "@/utils/request"; + +// 平台端排班模板管理(全局模板,只有平台可维护) +// 系统预置模板只读:后端会拒绝对 is_system=1 的编辑 / 删除 / 停用 +const PREFIX = "/platform/schedTemplates"; + +export function getSchedTemplateList(params) { + return request({ + url: PREFIX, + method: "get", + params, + }); +} + +export function getSchedTemplateDetail(id) { + return request({ + url: `${PREFIX}/${id}`, + method: "get", + }); +} + +export function createSchedTemplate(data) { + return request({ + url: PREFIX, + method: "post", + data, + }); +} + +export function updateSchedTemplate(id, data) { + return request({ + url: `${PREFIX}/${id}`, + method: "post", + data, + }); +} + +export function deleteSchedTemplate(id) { + return request({ + url: `${PREFIX}/${id}`, + method: "delete", + }); +} + +export function toggleSchedTemplateStatus(id, status) { + return request({ + url: `${PREFIX}/${id}/status`, + method: "post", + data: { status }, + }); +} diff --git a/platform/src/router/index.js b/platform/src/router/index.js index 0ba42cc..6fc2077 100644 --- a/platform/src/router/index.js +++ b/platform/src/router/index.js @@ -73,6 +73,13 @@ const staticMainChildren = [ component: () => import("@/views/template/tags.vue"), meta: { requiresAuth: true, title: "标签调用说明" } }, + // 排班模板(全局模板管理,只有平台可维护) + { + path: "/basicSettings/schedTemplate", + name: "PlatformSchedTemplate", + component: () => import("@/views/system/schedTemplate/index.vue"), + meta: { requiresAuth: true, title: "排班模板" } + }, // 兼容拼写错误的路径重定向 { path: "/apps/erp/dashborad", diff --git a/platform/src/views/system/schedTemplate/components/TemplateEdit.vue b/platform/src/views/system/schedTemplate/components/TemplateEdit.vue new file mode 100644 index 0000000..f2efe9b --- /dev/null +++ b/platform/src/views/system/schedTemplate/components/TemplateEdit.vue @@ -0,0 +1,523 @@ + + + + + diff --git a/platform/src/views/system/schedTemplate/index.vue b/platform/src/views/system/schedTemplate/index.vue new file mode 100644 index 0000000..17e8051 --- /dev/null +++ b/platform/src/views/system/schedTemplate/index.vue @@ -0,0 +1,212 @@ + + + + +