Files
yunzerwebsiteallinone/go/controllers/backend_oa_scheduling_board.go
T
2026-10-26 08:54:34 +08:00

648 lines
19 KiB
Go

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)})
}