Files
yunzerwebsiteallinone/go/controllers/backend_oa_scheduling_board.go
2026-11-11 08:53:14 +08:00

968 lines
29 KiB
Go

package controllers
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"server/models"
authSvc "server/services/auth"
"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
// 指定字段更新:DATE 列从库里读回 string 会带上
// "00:00:00 +0800 CST" 后缀,全字段 Update 会写回导致 MySQL 1292
if _, updateErr := models.Orm.Update(row,
"shift_id", "shift_name", "short_name", "color", "work_start", "work_end",
"source", "note", "created_by", "created_by_name"); 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)
}
// SaveEntryCells 批量按格子编辑排班:cells 为任意「员工 × 日期」组合(排班板多选)。
// shift_id>0 时把这些格子设为该班次(已有排班视为调班,无排班则新增临时排班);
// shift_id=0 时清除这些格子的排班(休息 / 请假)。
// POST /backend/oa/scheduling/entries/save-cells
func (c *BackendOaSchedulingController) SaveEntryCells() {
tid, uid, username, ok := c.scOperator()
if !ok {
return
}
body := c.scBody()
type scCellTarget struct {
employeeID uint
date string
}
rawCells, _ := body["cells"].([]interface{})
seen := map[string]bool{}
cells := make([]scCellTarget, 0, len(rawCells))
for _, raw := range rawCells {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
employeeID := uint(scUintValue(item, "employee_id"))
date := normalizeOaDate(scStringValue(item, "schedule_date", "date"))
if employeeID == 0 || date == "" {
continue
}
if _, err := time.Parse("2006-01-02", date); err != nil {
c.scFail(400, 400, "存在无效的日期: "+date)
return
}
key := fmt.Sprintf("%d|%s", employeeID, date)
if seen[key] {
continue
}
seen[key] = true
cells = append(cells, scCellTarget{employeeID: employeeID, date: date})
}
if len(cells) == 0 {
c.scFail(400, 400, "请先选择要编辑的格子")
return
}
if len(cells) > scPlanMaxEntries {
c.scFail(400, 400, fmt.Sprintf("单次最多编辑 %d 个格子", scPlanMaxEntries))
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
}
// 排班来源:normal=正常排班(不显示临时标记),temp=临时调整(默认,显示「临」角标)
entrySource := models.OaEntrySourceTemp
if scStringValue(body, "source") == "normal" {
entrySource = models.OaEntrySourcePlan
}
empIDSet := map[uint]bool{}
empIDs := make([]uint64, 0, len(cells))
dates := make([]string, 0, len(cells))
for _, cell := range cells {
if !empIDSet[cell.employeeID] {
empIDSet[cell.employeeID] = true
empIDs = append(empIDs, uint64(cell.employeeID))
}
dates = append(dates, cell.date)
}
var employees []models.BackendEmployee
if _, err := c.scBaseEmployeeQuery(tid).Filter("id__in", empIDs).All(&employees); err != nil && err != orm.ErrNoRows {
c.scFail(500, 500, "查询员工失败: "+err.Error())
return
}
employeeMap := map[uint]*models.BackendEmployee{}
for i := range employees {
employeeMap[employees[i].ID] = &employees[i]
}
var existing []models.OaScheduleEntry
if _, err := c.scEntryQuery(tid).
Filter("employee_id__in", empIDs).
Filter("schedule_date__in", dates).
All(&existing); 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)
note := scStringValue(body, "note")
stats := map[string]int{"created": 0, "updated": 0, "cleared": 0, "skipped": 0}
now := time.Now()
for _, cell := range cells {
employee, found := employeeMap[cell.employeeID]
if !found {
stats["skipped"]++
continue
}
key := fmt.Sprintf("%d|%s", cell.employeeID, cell.date)
row := existingMap[key]
orgID := scEmployeeDepartmentID(*employee)
position := ""
if employee.Position != nil {
position = *employee.Position
}
// 清除排班(休息 / 请假):只处理已有排班的格子
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, cell.date,
models.OaChangeClear, oldShiftID, oldShiftName, 0, "", note, uid, username)
stats["cleared"]++
continue
}
// 已有排班:调整为目标班次(调班)
if row != nil {
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 = entrySource
row.Note = note
row.CreatedBy = uid
row.CreatedByName = username
// 指定字段更新:DATE 列从库里读回 string 会带上
// "00:00:00 +0800 CST" 后缀,全字段 Update 会写回导致 MySQL 1292
if _, updateErr := models.Orm.Update(row,
"shift_id", "shift_name", "short_name", "color", "work_start", "work_end",
"source", "note", "created_by", "created_by_name"); updateErr != nil {
c.scFail(500, 500, "调整排班失败: "+updateErr.Error())
return
}
c.scWriteChange(models.Orm, tid, row.ID, row.EmployeeID, row.EmployeeName, cell.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: cell.date,
ShiftID: shift.ID,
ShiftName: shift.Name,
ShortName: shift.ShortName,
Color: shift.Color,
WorkStart: shift.WorkStart,
WorkEnd: shift.WorkEnd,
Source: entrySource,
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, cell.date,
models.OaChangeManual, 0, "", shift.ID, shift.Name, note, uid, username)
stats["created"]++
}
stats["cell_count"] = len(cells)
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)})
}
// MyBoard 当前登录用户的排班:按登录身份匹配员工档案,返回区间内每天的排班,
// 供工作台今日卡片与日程日历渲染上班/休息背景。
// GET /backend/oa/scheduling/my?start_date=&end_date=
func (c *BackendOaSchedulingController) MyBoard() {
tid, uid, username, 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()
startDate = now.Format("2006-01-02")
endDate = startDate
}
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
}
// 解析当前登录者对应的员工档案。OIDC 换码签发的令牌里 username 为空,
// 即使有值也是认证中心昵称而非员工工号,因此以「身份+租户」绑定账号为主,
// 再用令牌 username 按 账号 → 姓名 兜底。都匹配不到时返回空列表。
employee, ok := c.scFindSelfEmployee(tid, uid, username)
if !ok {
c.scOK(map[string]interface{}{"employee": nil, "list": []map[string]interface{}{}})
return
}
var entries []models.OaScheduleEntry
_, _ = c.scEntryQuery(tid).
Filter("employee_id", employee.ID).
Filter("schedule_date__gte", startDate).
Filter("schedule_date__lte", endDate).
OrderBy("schedule_date").
All(&entries)
list := make([]map[string]interface{}, 0, len(entries))
for i := range entries {
e := entries[i]
list = append(list, map[string]interface{}{
"date": e.ScheduleDate,
"shift_id": e.ShiftID,
"shift_name": e.ShiftName,
"short_name": e.ShortName,
"color": e.Color,
"work_start": e.WorkStart,
"work_end": e.WorkEnd,
"note": e.Note,
})
}
c.scOK(map[string]interface{}{
"employee": map[string]interface{}{"id": employee.ID, "name": employee.Name},
"list": list,
})
}
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
// 其次用令牌 username 按「账号 → 姓名」匹配。
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
var employee models.BackendEmployee
if identityID > 0 {
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
if v := authBindValue(bind.Account); v != "" {
if emp, found := c.scEmployeeByField(tid, "account", v); found {
return emp, true
}
}
if v := authBindValue(bind.Name); v != "" {
if emp, found := c.scEmployeeByField(tid, "name", v); found {
return emp, true
}
}
}
}
username = strings.TrimSpace(username)
if username != "" {
for _, field := range []string{"account", "name"} {
if emp, found := c.scEmployeeByField(tid, field, username); found {
return emp, true
}
}
}
return employee, false
}
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
var rows []models.BackendEmployee
if _, err := c.scBaseEmployeeQuery(tid).
Filter(field, value).
OrderBy("id").
Limit(1).
All(&rows); err == nil && len(rows) > 0 {
return rows[0], true
}
return models.BackendEmployee{}, false
}
// authBindValue 取认证绑定记录里的可空字符串
func authBindValue(p *string) string {
if p == nil {
return ""
}
return strings.TrimSpace(*p)
}
// 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)})
}