feat: implement batch schedule service
This commit is contained in:
@@ -2,6 +2,7 @@ package repositories
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
@@ -54,7 +55,7 @@ func (r *agentTeamScheduleRepository) FindOverlappingByTeamIDsAndTimeRange(db *g
|
||||
return
|
||||
}
|
||||
db.Model(&models.AgentTeamSchedule{}).
|
||||
Where("team_id IN ? AND start_at < ? AND end_at > ?", teamIDs, endAt, startAt).
|
||||
Where("team_id IN ? AND status = ? AND start_at < ? AND end_at > ?", teamIDs, enums.StatusOk, endAt, startAt).
|
||||
Order("team_id ASC").
|
||||
Order("start_at ASC").
|
||||
Order("id ASC").
|
||||
|
||||
@@ -4,16 +4,20 @@ import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AgentTeamScheduleService = newAgentTeamScheduleService()
|
||||
@@ -23,6 +27,18 @@ func newAgentTeamScheduleService() *agentTeamScheduleService {
|
||||
}
|
||||
|
||||
type agentTeamScheduleService struct {
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
const maxAgentTeamScheduleBatchItems = 500
|
||||
|
||||
type batchScheduleCandidate struct {
|
||||
TeamID int64
|
||||
TeamName string
|
||||
Date time.Time
|
||||
StartAt time.Time
|
||||
EndAt time.Time
|
||||
Remark string
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Get(id int64) *models.AgentTeamSchedule {
|
||||
@@ -92,14 +108,18 @@ func (s *agentTeamScheduleService) CreateAgentTeamSchedule(req request.CreateAge
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
item, err := s.buildScheduleModel(0, req.TeamID, req.StartAt, req.EndAt, req.Remark)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentTeamScheduleRepository.Create(sqls.DB(), item); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return item, nil
|
||||
}
|
||||
@@ -108,11 +128,14 @@ func (s *agentTeamScheduleService) UpdateAgentTeamSchedule(req request.UpdateAge
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
if s.Get(req.ID) == nil {
|
||||
s.writeMu.Unlock()
|
||||
return errorsx.InvalidParam("客服组排班不存在")
|
||||
}
|
||||
item, err := s.buildScheduleModel(req.ID, req.TeamID, req.StartAt, req.EndAt, req.Remark)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
if err := repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
@@ -124,8 +147,10 @@ func (s *agentTeamScheduleService) UpdateAgentTeamSchedule(req request.UpdateAge
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return nil
|
||||
}
|
||||
@@ -138,6 +163,66 @@ func (s *agentTeamScheduleService) DeleteAgentTeamSchedule(id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) BatchPreview(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*response.AgentTeamScheduleBatchPreviewResponse, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
candidates, err := s.buildBatchScheduleCandidates(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conflicts := s.findBatchConflict(candidates)
|
||||
return buildBatchPreviewResponse(candidates, conflicts), nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) BatchGenerate(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*response.AgentTeamScheduleBatchGenerateResponse, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
s.writeMu.Lock()
|
||||
candidates, err := s.buildBatchScheduleCandidates(req)
|
||||
if err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
conflicts := s.findBatchConflict(candidates)
|
||||
for _, conflict := range conflicts {
|
||||
if conflict != "" {
|
||||
s.writeMu.Unlock()
|
||||
return nil, errorsx.InvalidParam("存在冲突排班,请先处理冲突")
|
||||
}
|
||||
}
|
||||
|
||||
schedules := make([]models.AgentTeamSchedule, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
schedules = append(schedules, models.AgentTeamSchedule{
|
||||
TeamID: candidate.TeamID,
|
||||
StartAt: candidate.StartAt,
|
||||
EndAt: candidate.EndAt,
|
||||
Remark: candidate.Remark,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
})
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conflicts := s.findBatchConflictByDB(ctx.Tx, candidates)
|
||||
for _, conflict := range conflicts {
|
||||
if conflict != "" {
|
||||
return errorsx.InvalidParam("存在冲突排班,请先处理冲突")
|
||||
}
|
||||
}
|
||||
return repositories.AgentTeamScheduleRepository.CreateBatch(ctx.Tx, schedules)
|
||||
}); err != nil {
|
||||
s.writeMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.writeMu.Unlock()
|
||||
for i := range schedules {
|
||||
s.dispatchPendingConversationsIfActive(&schedules[i])
|
||||
}
|
||||
return &response.AgentTeamScheduleBatchGenerateResponse{Created: len(schedules)}, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, endAt, remark string) (*models.AgentTeamSchedule, error) {
|
||||
if teamID <= 0 {
|
||||
return nil, errorsx.InvalidParam("请选择客服组")
|
||||
@@ -166,12 +251,11 @@ func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt,
|
||||
if startAtValue.Before(startOfLocalDay(time.Now())) {
|
||||
return nil, errorsx.InvalidParam("不能添加或修改历史日期的排班")
|
||||
}
|
||||
var count int64
|
||||
sqls.DB().Model(&models.AgentTeamSchedule{}).
|
||||
Where("team_id = ? AND id <> ? AND start_at < ? AND end_at > ?", teamID, id, endAtValue, startAtValue).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errorsx.InvalidParam("该客服组在所选时间段已存在排班")
|
||||
overlapping := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(sqls.DB(), []int64{teamID}, startAtValue, endAtValue)
|
||||
for _, item := range overlapping {
|
||||
if item.ID != id {
|
||||
return nil, errorsx.InvalidParam("该客服组在所选时间段已存在排班")
|
||||
}
|
||||
}
|
||||
return &models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
@@ -181,6 +265,227 @@ func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) buildBatchScheduleCandidates(req request.AgentTeamScheduleBatchRequest) ([]batchScheduleCandidate, error) {
|
||||
teamIDs := uniquePositiveInt64s(req.TeamIDs)
|
||||
if len(teamIDs) == 0 {
|
||||
return nil, errorsx.InvalidParam("请选择客服组")
|
||||
}
|
||||
weekdays, err := normalizeBatchWeekdays(req.Weekdays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startDate, err := parseRequiredDate(req.StartDate, "开始日期格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endDate, err := parseRequiredDate(req.EndDate, "结束日期格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return nil, errorsx.InvalidParam("结束日期必须晚于或等于开始日期")
|
||||
}
|
||||
if startDate.Before(startOfLocalDay(time.Now())) {
|
||||
return nil, errorsx.InvalidParam("不能添加或修改历史日期的排班")
|
||||
}
|
||||
startClock, err := parseRequiredClock(req.StartTime, "开始时间格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endClock, err := parseRequiredClock(req.EndTime, "结束时间格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firstStartAt := combineDateAndClock(startDate, startClock)
|
||||
firstEndAt := combineDateAndClock(startDate, endClock)
|
||||
if !firstEndAt.After(firstStartAt) {
|
||||
return nil, errorsx.InvalidParam("结束时间必须晚于开始时间")
|
||||
}
|
||||
|
||||
teams := AgentTeamService.FindByIds(teamIDs)
|
||||
teamsByID := make(map[int64]models.AgentTeam, len(teams))
|
||||
for _, team := range teams {
|
||||
teamsByID[team.ID] = team
|
||||
}
|
||||
for _, teamID := range teamIDs {
|
||||
team, ok := teamsByID[teamID]
|
||||
if !ok || team.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("客服组不存在")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, team.Status) {
|
||||
return nil, errorsx.InvalidParam("客服组状态不合法")
|
||||
}
|
||||
}
|
||||
|
||||
weekdaySet := make(map[int]struct{}, len(weekdays))
|
||||
for _, weekday := range weekdays {
|
||||
weekdaySet[weekday] = struct{}{}
|
||||
}
|
||||
candidates := make([]batchScheduleCandidate, 0)
|
||||
remark := strings.TrimSpace(req.Remark)
|
||||
for _, teamID := range teamIDs {
|
||||
team := teamsByID[teamID]
|
||||
for date := startDate; !date.After(endDate); date = date.AddDate(0, 0, 1) {
|
||||
if _, ok := weekdaySet[weekdayForBatchRequest(date)]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(candidates) >= maxAgentTeamScheduleBatchItems {
|
||||
return nil, errorsx.InvalidParam(fmt.Sprintf("单次最多生成 %d 条排班", maxAgentTeamScheduleBatchItems))
|
||||
}
|
||||
candidates = append(candidates, batchScheduleCandidate{
|
||||
TeamID: teamID,
|
||||
TeamName: team.Name,
|
||||
Date: date,
|
||||
StartAt: combineDateAndClock(date, startClock),
|
||||
EndAt: combineDateAndClock(date, endClock),
|
||||
Remark: remark,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, errorsx.InvalidParam("未生成任何排班")
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func parseRequiredDate(value, message string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParam(message)
|
||||
}
|
||||
ret, err := time.ParseInLocation(time.DateOnly, value, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, errorsx.InvalidParam(message + ",请使用 yyyy-MM-dd")
|
||||
}
|
||||
return startOfLocalDay(ret), nil
|
||||
}
|
||||
|
||||
func parseRequiredClock(value, message string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParam(message)
|
||||
}
|
||||
layouts := []string{"15:04", "15:04:05"}
|
||||
for _, layout := range layouts {
|
||||
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errorsx.InvalidParam(message + ",请使用 HH:mm 或 HH:mm:ss")
|
||||
}
|
||||
|
||||
func combineDateAndClock(date, clock time.Time) time.Time {
|
||||
year, month, day := date.In(time.Local).Date()
|
||||
hour, minute, second := clock.In(time.Local).Clock()
|
||||
return time.Date(year, month, day, hour, minute, second, 0, time.Local)
|
||||
}
|
||||
|
||||
func buildBatchPreviewResponse(candidates []batchScheduleCandidate, conflicts map[int]string) *response.AgentTeamScheduleBatchPreviewResponse {
|
||||
items := make([]response.AgentTeamScheduleBatchPreviewItem, 0, len(candidates))
|
||||
hasConflict := false
|
||||
for i, candidate := range candidates {
|
||||
conflictReason := conflicts[i]
|
||||
conflict := conflictReason != ""
|
||||
if conflict {
|
||||
hasConflict = true
|
||||
}
|
||||
items = append(items, response.AgentTeamScheduleBatchPreviewItem{
|
||||
TeamID: candidate.TeamID,
|
||||
TeamName: candidate.TeamName,
|
||||
Date: candidate.Date.Format(time.DateOnly),
|
||||
Weekday: weekdayForBatchRequest(candidate.Date),
|
||||
StartAt: candidate.StartAt.Format(time.DateTime),
|
||||
EndAt: candidate.EndAt.Format(time.DateTime),
|
||||
Remark: candidate.Remark,
|
||||
Conflict: conflict,
|
||||
ConflictReason: conflictReason,
|
||||
})
|
||||
}
|
||||
return &response.AgentTeamScheduleBatchPreviewResponse{
|
||||
Total: len(items),
|
||||
Conflict: hasConflict,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) findBatchConflict(candidates []batchScheduleCandidate) map[int]string {
|
||||
return s.findBatchConflictByDB(sqls.DB(), candidates)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) findBatchConflictByDB(db *gorm.DB, candidates []batchScheduleCandidate) map[int]string {
|
||||
conflicts := make(map[int]string)
|
||||
if len(candidates) == 0 {
|
||||
return conflicts
|
||||
}
|
||||
teamIDs := make([]int64, 0, len(candidates))
|
||||
startAt := candidates[0].StartAt
|
||||
endAt := candidates[0].EndAt
|
||||
for _, candidate := range candidates {
|
||||
teamIDs = append(teamIDs, candidate.TeamID)
|
||||
if candidate.StartAt.Before(startAt) {
|
||||
startAt = candidate.StartAt
|
||||
}
|
||||
if candidate.EndAt.After(endAt) {
|
||||
endAt = candidate.EndAt
|
||||
}
|
||||
}
|
||||
existing := repositories.AgentTeamScheduleRepository.FindOverlappingByTeamIDsAndTimeRange(db, uniquePositiveInt64s(teamIDs), startAt, endAt)
|
||||
for i, candidate := range candidates {
|
||||
for _, item := range existing {
|
||||
if item.TeamID != candidate.TeamID {
|
||||
continue
|
||||
}
|
||||
if item.StartAt.Before(candidate.EndAt) && item.EndAt.After(candidate.StartAt) {
|
||||
conflicts[i] = fmt.Sprintf("该客服组在 %s 至 %s 已存在排班", item.StartAt.Format(time.DateTime), item.EndAt.Format(time.DateTime))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
func normalizeBatchWeekdays(values []int) ([]int, error) {
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
ret := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value < 1 || value > 7 {
|
||||
return nil, errorsx.InvalidParam("星期必须在 1 到 7 之间")
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
ret = append(ret, value)
|
||||
}
|
||||
if len(ret) == 0 {
|
||||
return nil, errorsx.InvalidParam("请选择星期")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func weekdayForBatchRequest(value time.Time) int {
|
||||
if value.Weekday() == time.Sunday {
|
||||
return 7
|
||||
}
|
||||
return int(value.Weekday())
|
||||
}
|
||||
|
||||
func uniquePositiveInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
ret := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
ret = append(ret, value)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseRequiredDateTime(value, message string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -354,6 +354,39 @@ func TestAgentTeamScheduleServiceBatchPreviewMarksConflicts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchPreviewIgnoresDisabledOverlappingSchedule(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
targetDay := time.Now().AddDate(0, 0, 2)
|
||||
existing := models.AgentTeamSchedule{
|
||||
TeamID: 1,
|
||||
StartAt: parseTestDateTime(t, formatTestDateTime(targetDay, "10:00:00")),
|
||||
EndAt: parseTestDateTime(t, formatTestDateTime(targetDay, "12:00:00")),
|
||||
Status: enums.StatusDisabled,
|
||||
}
|
||||
if err := db.Create(&existing).Error; err != nil {
|
||||
t.Fatalf("create existing schedule error = %v", err)
|
||||
}
|
||||
|
||||
preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{
|
||||
TeamIDs: []int64{1},
|
||||
StartDate: targetDay.Format(time.DateOnly),
|
||||
EndDate: targetDay.Format(time.DateOnly),
|
||||
Weekdays: []int{weekdayForRequest(targetDay)},
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
}, testOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("BatchPreview() error = %v", err)
|
||||
}
|
||||
if preview.Conflict {
|
||||
t.Fatalf("expected disabled overlapping schedule to be ignored, got %+v", preview)
|
||||
}
|
||||
if len(preview.Items) != 1 || preview.Items[0].Conflict {
|
||||
t.Fatalf("expected one non-conflicting preview item, got %+v", preview.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTeamScheduleServiceBatchGenerateCreatesAllSchedules(t *testing.T) {
|
||||
db := setupAgentTeamScheduleTestDB(t)
|
||||
createAgentTeamScheduleTestTeams(t, db)
|
||||
|
||||
Reference in New Issue
Block a user