From c7e6c8a0dbf5cd0432228dd85ffb0361163a3e98 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 19:32:39 +0800 Subject: [PATCH] feat: implement batch schedule service --- .../agent_team_schedule_repository.go | 3 +- .../services/agent_team_schedule_service.go | 317 +++++++++++++++++- .../agent_team_schedule_service_test.go | 33 ++ 3 files changed, 346 insertions(+), 7 deletions(-) diff --git a/internal/repositories/agent_team_schedule_repository.go b/internal/repositories/agent_team_schedule_repository.go index 19e79b3..605bb0b 100644 --- a/internal/repositories/agent_team_schedule_repository.go +++ b/internal/repositories/agent_team_schedule_repository.go @@ -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"). diff --git a/internal/services/agent_team_schedule_service.go b/internal/services/agent_team_schedule_service.go index 3cca4c0..7eb312b 100644 --- a/internal/services/agent_team_schedule_service.go +++ b/internal/services/agent_team_schedule_service.go @@ -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 == "" { diff --git a/internal/services/agent_team_schedule_service_test.go b/internal/services/agent_team_schedule_service_test.go index 35f7989..15e3b5e 100644 --- a/internal/services/agent_team_schedule_service_test.go +++ b/internal/services/agent_team_schedule_service_test.go @@ -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)