diff --git a/docs b/docs index a4aee85..1b6ac26 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit a4aee8582df89dad53044d293577e88e1bfd7b49 +Subproject commit 1b6ac267acee3ac517be13461cee23d4f424630f diff --git a/internal/builders/agent_team_schedule_builder.go b/internal/builders/agent_team_schedule_builder.go new file mode 100644 index 0000000..1bb55b6 --- /dev/null +++ b/internal/builders/agent_team_schedule_builder.go @@ -0,0 +1,39 @@ +package builders + +import ( + "cs-agent/internal/pkg/dto/response" + "cs-agent/internal/services" + "time" +) + +func BuildAgentTeamScheduleBatchPreviewResponse(result *services.AgentTeamScheduleBatchPreviewResult) *response.AgentTeamScheduleBatchPreviewResponse { + if result == nil { + return nil + } + items := make([]response.AgentTeamScheduleBatchPreviewItem, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, response.AgentTeamScheduleBatchPreviewItem{ + TeamID: item.TeamID, + TeamName: item.TeamName, + Date: item.Date.Format(time.DateOnly), + Weekday: item.Weekday, + StartAt: item.StartAt.Format(time.DateTime), + EndAt: item.EndAt.Format(time.DateTime), + Remark: item.Remark, + Conflict: item.Conflict, + ConflictReason: item.ConflictReason, + }) + } + return &response.AgentTeamScheduleBatchPreviewResponse{ + Total: result.Total, + Conflict: result.Conflict, + Items: items, + } +} + +func BuildAgentTeamScheduleBatchGenerateResponse(result *services.AgentTeamScheduleBatchGenerateResult) *response.AgentTeamScheduleBatchGenerateResponse { + if result == nil { + return nil + } + return &response.AgentTeamScheduleBatchGenerateResponse{Created: result.Created} +} diff --git a/internal/controllers/dashboard/agent_team_schedule_controller.go b/internal/controllers/dashboard/agent_team_schedule_controller.go index 58b66bb..4c6744c 100644 --- a/internal/controllers/dashboard/agent_team_schedule_controller.go +++ b/internal/controllers/dashboard/agent_team_schedule_controller.go @@ -1,6 +1,7 @@ package dashboard import ( + "cs-agent/internal/builders" "cs-agent/internal/models" "cs-agent/internal/pkg/constants" "cs-agent/internal/pkg/dto/request" @@ -31,6 +32,60 @@ func (c *AgentTeamScheduleController) AnyList() *web.JsonResult { return web.JsonData(&web.PageResult{Results: results, Page: paging}) } +func (c *AgentTeamScheduleController) AnyCalendar() *web.JsonResult { + if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleView); err != nil { + return web.JsonError(err) + } + startAt, _ := params.Get(c.Ctx, "startAt") + endAt, _ := params.Get(c.Ctx, "endAt") + teamID, _ := params.GetInt64(c.Ctx, "teamId") + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: startAt, + EndAt: endAt, + TeamID: teamID, + }) + if err != nil { + return web.JsonError(err) + } + results := make([]response.AgentTeamScheduleResponse, 0, len(list)) + for _, item := range list { + results = append(results, buildAgentTeamScheduleResponse(&item)) + } + return web.JsonData(results) +} + +func (c *AgentTeamScheduleController) PostBatch_preview() *web.JsonResult { + operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleBatchGenerate) + if err != nil { + return web.JsonError(err) + } + req := request.AgentTeamScheduleBatchRequest{} + if err := params.ReadJSON(c.Ctx, &req); err != nil { + return web.JsonError(err) + } + ret, err := services.AgentTeamScheduleService.BatchPreview(req, operator) + if err != nil { + return web.JsonError(err) + } + return web.JsonData(builders.BuildAgentTeamScheduleBatchPreviewResponse(ret)) +} + +func (c *AgentTeamScheduleController) PostBatch_generate() *web.JsonResult { + operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleBatchGenerate) + if err != nil { + return web.JsonError(err) + } + req := request.AgentTeamScheduleBatchRequest{} + if err := params.ReadJSON(c.Ctx, &req); err != nil { + return web.JsonError(err) + } + ret, err := services.AgentTeamScheduleService.BatchGenerate(req, operator) + if err != nil { + return web.JsonError(err) + } + return web.JsonData(builders.BuildAgentTeamScheduleBatchGenerateResponse(ret)) +} + func (c *AgentTeamScheduleController) GetBy(id int64) *web.JsonResult { if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionAgentTeamScheduleView); err != nil { return web.JsonError(err) @@ -89,12 +144,11 @@ func (c *AgentTeamScheduleController) PostDelete() *web.JsonResult { func buildAgentTeamScheduleResponse(item *models.AgentTeamSchedule) response.AgentTeamScheduleResponse { ret := response.AgentTeamScheduleResponse{ - ID: item.ID, - TeamID: item.TeamID, - StartAt: item.StartAt.Format("2006-01-02 15:04:05"), - EndAt: item.EndAt.Format("2006-01-02 15:04:05"), - SourceType: item.SourceType, - Remark: item.Remark, + ID: item.ID, + TeamID: item.TeamID, + StartAt: item.StartAt.Format("2006-01-02 15:04:05"), + EndAt: item.EndAt.Format("2006-01-02 15:04:05"), + Remark: item.Remark, } if team := services.AgentTeamService.Get(item.TeamID); team != nil { ret.TeamName = team.Name diff --git a/internal/models/models.go b/internal/models/models.go index 0dab0f2..fe95898 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -742,13 +742,12 @@ type AgentTeam struct { // AgentTeamSchedule 客服组排班。 type AgentTeamSchedule struct { - ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为组排班主键。 - TeamID int64 `gorm:"type:bigint;not null;index"` // TeamID 为被排班的客服组ID。 - StartAt time.Time `gorm:"type:datetime;not null;index"` // StartAt 为班次开始时间。 - EndAt time.Time `gorm:"type:datetime;not null;index"` // EndAt 为班次结束时间。 - SourceType string `gorm:"type:varchar(30);not null;default:'';index"` // SourceType 表示排班来源,如 manual、batch_import、template_generate。 - Remark string `gorm:"type:varchar(255);not null;default:''"` // Remark 记录排班备注。 - Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 表示组排班记录状态。 + ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为组排班主键。 + TeamID int64 `gorm:"type:bigint;not null;index"` // TeamID 为被排班的客服组ID。 + StartAt time.Time `gorm:"type:datetime;not null;index"` // StartAt 为班次开始时间。 + EndAt time.Time `gorm:"type:datetime;not null;index"` // EndAt 为班次结束时间。 + Remark string `gorm:"type:varchar(255);not null;default:''"` // Remark 记录排班备注。 + Status enums.Status `gorm:"type:int;not null;default:0;index"` // Status 表示组排班记录状态。 AuditFields } diff --git a/internal/pkg/dto/request/agent_request.go b/internal/pkg/dto/request/agent_request.go index 7dc118a..75d8b05 100644 --- a/internal/pkg/dto/request/agent_request.go +++ b/internal/pkg/dto/request/agent_request.go @@ -47,11 +47,10 @@ type DeleteAgentTeamRequest struct { } type CreateAgentTeamScheduleRequest struct { - TeamID int64 `json:"teamId"` - StartAt string `json:"startAt"` - EndAt string `json:"endAt"` - SourceType string `json:"sourceType"` - Remark string `json:"remark"` + TeamID int64 `json:"teamId"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Remark string `json:"remark"` } type UpdateAgentTeamScheduleRequest struct { @@ -62,3 +61,19 @@ type UpdateAgentTeamScheduleRequest struct { type DeleteAgentTeamScheduleRequest struct { ID int64 `json:"id"` } + +type AgentTeamScheduleCalendarRequest struct { + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + TeamID int64 `json:"teamId"` +} + +type AgentTeamScheduleBatchRequest struct { + TeamIDs []int64 `json:"teamIds"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + Weekdays []int `json:"weekdays"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + Remark string `json:"remark"` +} diff --git a/internal/pkg/dto/response/agent_response.go b/internal/pkg/dto/response/agent_response.go index d13fc7e..8bc1b55 100644 --- a/internal/pkg/dto/response/agent_response.go +++ b/internal/pkg/dto/response/agent_response.go @@ -34,11 +34,32 @@ type AgentTeamResponse struct { } type AgentTeamScheduleResponse struct { - ID int64 `json:"id"` - TeamID int64 `json:"teamId"` - TeamName string `json:"teamName,omitempty"` - StartAt string `json:"startAt"` - EndAt string `json:"endAt"` - SourceType string `json:"sourceType"` - Remark string `json:"remark"` + ID int64 `json:"id"` + TeamID int64 `json:"teamId"` + TeamName string `json:"teamName,omitempty"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Remark string `json:"remark"` +} + +type AgentTeamScheduleBatchPreviewResponse struct { + Total int `json:"total"` + Conflict bool `json:"conflict"` + Items []AgentTeamScheduleBatchPreviewItem `json:"items"` +} + +type AgentTeamScheduleBatchPreviewItem struct { + TeamID int64 `json:"teamId"` + TeamName string `json:"teamName"` + Date string `json:"date"` + Weekday int `json:"weekday"` + StartAt string `json:"startAt"` + EndAt string `json:"endAt"` + Remark string `json:"remark"` + Conflict bool `json:"conflict"` + ConflictReason string `json:"conflictReason"` +} + +type AgentTeamScheduleBatchGenerateResponse struct { + Created int `json:"created"` } diff --git a/internal/pkg/dto/response/agent_response_test.go b/internal/pkg/dto/response/agent_response_test.go new file mode 100644 index 0000000..39bb8dc --- /dev/null +++ b/internal/pkg/dto/response/agent_response_test.go @@ -0,0 +1,27 @@ +package response + +import ( + "encoding/json" + "testing" +) + +func TestAgentTeamScheduleResponseOmitsSourceType(t *testing.T) { + payload, err := json.Marshal(AgentTeamScheduleResponse{ + ID: 1, + TeamID: 2, + StartAt: "2026-04-29 09:00:00", + EndAt: "2026-04-29 18:00:00", + Remark: "test", + }) + if err != nil { + t.Fatalf("marshal response error = %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal response error = %v", err) + } + if _, ok := decoded["sourceType"]; ok { + t.Fatalf("sourceType should not be exposed: %s", payload) + } +} diff --git a/internal/repositories/agent_team_schedule_repository.go b/internal/repositories/agent_team_schedule_repository.go index 52685d9..605bb0b 100644 --- a/internal/repositories/agent_team_schedule_repository.go +++ b/internal/repositories/agent_team_schedule_repository.go @@ -2,6 +2,8 @@ package repositories import ( "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" + "time" "github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/web/params" @@ -38,6 +40,36 @@ func (r *agentTeamScheduleRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []m return } +func (r *agentTeamScheduleRepository) FindByTimeRange(db *gorm.DB, startAt, endAt time.Time, teamID int64) (list []models.AgentTeamSchedule) { + query := db.Model(&models.AgentTeamSchedule{}). + Where("start_at < ? AND end_at > ?", endAt, startAt) + if teamID > 0 { + query = query.Where("team_id = ?", teamID) + } + query.Order("team_id ASC").Order("start_at ASC").Order("id ASC").Find(&list) + return +} + +func (r *agentTeamScheduleRepository) FindOverlappingByTeamIDsAndTimeRange(db *gorm.DB, teamIDs []int64, startAt, endAt time.Time) (list []models.AgentTeamSchedule) { + if len(teamIDs) == 0 { + return + } + db.Model(&models.AgentTeamSchedule{}). + 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"). + Find(&list) + return +} + +func (r *agentTeamScheduleRepository) CreateBatch(db *gorm.DB, list []models.AgentTeamSchedule) error { + if len(list) == 0 { + return nil + } + return db.Create(&list).Error +} + func (r *agentTeamScheduleRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.AgentTeamSchedule { ret := &models.AgentTeamSchedule{} if err := cnd.FindOne(db, &ret); err != nil { @@ -62,12 +94,12 @@ func (r *agentTeamScheduleRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) return } -func (r *agentTeamScheduleRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.AgentTeamSchedule) { +func (r *agentTeamScheduleRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.AgentTeamSchedule) { db.Raw(sqlStr, paramArr...).Scan(&list) return } -func (r *agentTeamScheduleRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) { +func (r *agentTeamScheduleRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { db.Raw(sqlStr, paramArr...).Count(&count) return } @@ -99,4 +131,3 @@ func (r *agentTeamScheduleRepository) UpdateColumn(db *gorm.DB, id int64, name s func (r *agentTeamScheduleRepository) Delete(db *gorm.DB, id int64) { db.Delete(&models.AgentTeamSchedule{}, "id = ?", id) } - diff --git a/internal/services/agent_team_schedule_service.go b/internal/services/agent_team_schedule_service.go index 416067f..b121be0 100644 --- a/internal/services/agent_team_schedule_service.go +++ b/internal/services/agent_team_schedule_service.go @@ -8,12 +8,15 @@ import ( "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 +26,40 @@ func newAgentTeamScheduleService() *agentTeamScheduleService { } type agentTeamScheduleService struct { + writeMu sync.Mutex +} + +const maxAgentTeamScheduleBatchItems = 500 + +type AgentTeamScheduleBatchPreviewResult struct { + Total int + Conflict bool + Items []AgentTeamScheduleBatchPreviewItem +} + +type AgentTeamScheduleBatchPreviewItem struct { + TeamID int64 + TeamName string + Date time.Time + Weekday int + StartAt time.Time + EndAt time.Time + Remark string + Conflict bool + ConflictReason string +} + +type AgentTeamScheduleBatchGenerateResult struct { + Created int +} + +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 { @@ -53,6 +90,21 @@ func (s *agentTeamScheduleService) Count(cnd *sqls.Cnd) int64 { return repositories.AgentTeamScheduleRepository.Count(sqls.DB(), cnd) } +func (s *agentTeamScheduleService) FindCalendarSchedules(req request.AgentTeamScheduleCalendarRequest) ([]models.AgentTeamSchedule, error) { + startAtValue, err := parseRequiredDateTime(req.StartAt, "开始时间格式错误") + if err != nil { + return nil, err + } + endAtValue, err := parseRequiredDateTime(req.EndAt, "结束时间格式错误") + if err != nil { + return nil, err + } + if !endAtValue.After(startAtValue) { + return nil, errorsx.InvalidParam("结束时间必须晚于开始时间") + } + return repositories.AgentTeamScheduleRepository.FindByTimeRange(sqls.DB(), startAtValue, endAtValue, req.TeamID), nil +} + func (s *agentTeamScheduleService) Create(t *models.AgentTeamSchedule) error { return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), t) } @@ -77,14 +129,18 @@ func (s *agentTeamScheduleService) CreateAgentTeamSchedule(req request.CreateAge if operator == nil { return nil, errorsx.Unauthorized("未登录或登录已过期") } - item, err := s.buildScheduleModel(0, req.TeamID, req.StartAt, req.EndAt, req.SourceType, req.Remark) + 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 } @@ -93,25 +149,29 @@ 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.SourceType, req.Remark) + 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{ "team_id": item.TeamID, "start_at": item.StartAt, "end_at": item.EndAt, - "source_type": item.SourceType, "remark": item.Remark, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), }); err != nil { + s.writeMu.Unlock() return err } + s.writeMu.Unlock() s.dispatchPendingConversationsIfActive(item) return nil } @@ -124,7 +184,67 @@ func (s *agentTeamScheduleService) DeleteAgentTeamSchedule(id int64) error { return nil } -func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, endAt, sourceType, remark string) (*models.AgentTeamSchedule, error) { +func (s *agentTeamScheduleService) BatchPreview(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchPreviewResult, error) { + if operator == nil { + return nil, errorsx.Unauthorized("未登录或登录已过期") + } + candidates, err := s.buildBatchScheduleCandidates(req) + if err != nil { + return nil, err + } + conflicts := s.findBatchConflict(candidates) + return buildBatchPreviewResult(candidates, conflicts), nil +} + +func (s *agentTeamScheduleService) BatchGenerate(req request.AgentTeamScheduleBatchRequest, operator *dto.AuthPrincipal) (*AgentTeamScheduleBatchGenerateResult, 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 &AgentTeamScheduleBatchGenerateResult{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("请选择客服组") } @@ -135,10 +255,6 @@ func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, if !slices.Contains(enums.StatusValues, team.Status) { return nil, errorsx.InvalidParam("客服组状态不合法") } - sourceType = strings.TrimSpace(sourceType) - if sourceType == "" { - return nil, errorsx.InvalidParam("排班来源不能为空") - } startAtValue, err := parseRequiredDateTime(startAt, "开始时间格式错误") if err != nil { return nil, err @@ -150,22 +266,247 @@ func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, if !endAtValue.After(startAtValue) { 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("该客服组在所选时间段已存在排班") + if !sameLocalDay(startAtValue, endAtValue) { + return nil, errorsx.InvalidParam("单条排班记录不能跨天") + } + if startAtValue.Before(startOfLocalDay(time.Now())) { + 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, - StartAt: startAtValue, - EndAt: endAtValue, - SourceType: sourceType, - Remark: strings.TrimSpace(remark), + TeamID: teamID, + StartAt: startAtValue, + EndAt: endAtValue, + Remark: strings.TrimSpace(remark), }, 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 buildBatchPreviewResult(candidates []batchScheduleCandidate, conflicts map[int]string) *AgentTeamScheduleBatchPreviewResult { + items := make([]AgentTeamScheduleBatchPreviewItem, 0, len(candidates)) + hasConflict := false + for i, candidate := range candidates { + conflictReason := conflicts[i] + conflict := conflictReason != "" + if conflict { + hasConflict = true + } + items = append(items, AgentTeamScheduleBatchPreviewItem{ + TeamID: candidate.TeamID, + TeamName: candidate.TeamName, + Date: candidate.Date, + Weekday: weekdayForBatchRequest(candidate.Date), + StartAt: candidate.StartAt, + EndAt: candidate.EndAt, + Remark: candidate.Remark, + Conflict: conflict, + ConflictReason: conflictReason, + }) + } + return &AgentTeamScheduleBatchPreviewResult{ + 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 == "" { @@ -193,6 +534,17 @@ func parseDateTimeValue(value string) (time.Time, error) { return time.Time{}, errorsx.InvalidParam("时间格式错误") } +func startOfLocalDay(value time.Time) time.Time { + year, month, day := value.In(time.Local).Date() + return time.Date(year, month, day, 0, 0, 0, 0, time.Local) +} + +func sameLocalDay(a, b time.Time) bool { + aYear, aMonth, aDay := a.In(time.Local).Date() + bYear, bMonth, bDay := b.In(time.Local).Date() + return aYear == bYear && aMonth == bMonth && aDay == bDay +} + func (s *agentTeamScheduleService) dispatchPendingConversationsIfActive(item *models.AgentTeamSchedule) { if item == nil { return diff --git a/internal/services/agent_team_schedule_service_test.go b/internal/services/agent_team_schedule_service_test.go new file mode 100644 index 0000000..47727e1 --- /dev/null +++ b/internal/services/agent_team_schedule_service_test.go @@ -0,0 +1,591 @@ +package services_test + +import ( + "strings" + "testing" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/dto" + "cs-agent/internal/pkg/dto/request" + "cs-agent/internal/pkg/enums" + "cs-agent/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAgentTeamScheduleServiceFindCalendarSchedulesReturnsIntersectingSchedules(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestData(t, db) + + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-04-27 00:00:00", + EndAt: "2026-05-04 00:00:00", + }) + if err != nil { + t.Fatalf("FindCalendarSchedules() error = %v", err) + } + + if len(list) != 3 { + t.Fatalf("expected 3 intersecting schedules, got %d: %+v", len(list), list) + } + gotIDs := make([]int64, 0, len(list)) + for _, item := range list { + gotIDs = append(gotIDs, item.ID) + } + wantIDs := []int64{1, 2, 3} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("expected ids %v, got %v", wantIDs, gotIDs) + } + } +} + +func TestAgentTeamScheduleServiceFindCalendarSchedulesFiltersTeamID(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestData(t, db) + + list, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-04-27 00:00:00", + EndAt: "2026-05-04 00:00:00", + TeamID: 2, + }) + if err != nil { + t.Fatalf("FindCalendarSchedules() error = %v", err) + } + + if len(list) != 1 { + t.Fatalf("expected 1 schedule for team 2, got %d: %+v", len(list), list) + } + if list[0].ID != 3 || list[0].TeamID != 2 { + t.Fatalf("unexpected schedule: %+v", list[0]) + } +} + +func TestAgentTeamScheduleServiceFindCalendarSchedulesValidatesTimeRange(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + + _, err := services.AgentTeamScheduleService.FindCalendarSchedules(request.AgentTeamScheduleCalendarRequest{ + StartAt: "2026-05-04 00:00:00", + EndAt: "2026-04-27 00:00:00", + }) + if err == nil { + t.Fatalf("expected invalid time range to fail") + } +} + +func TestAgentTeamScheduleServiceCreateRejectsCrossDaySchedule(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + + tomorrow := time.Now().AddDate(0, 0, 1) + _, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{ + TeamID: 1, + StartAt: formatTestDateTime(tomorrow, "22:00:00"), + EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"), + }, testOperator()) + if err == nil { + t.Fatalf("expected cross-day schedule to fail") + } + if !strings.Contains(err.Error(), "不能跨天") { + t.Fatalf("expected cross-day error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceCreateRejectsHistoricalScheduleByDay(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + + yesterday := time.Now().AddDate(0, 0, -1) + _, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{ + TeamID: 1, + StartAt: formatTestDateTime(yesterday, "09:00:00"), + EndAt: formatTestDateTime(yesterday, "18:00:00"), + }, testOperator()) + if err == nil { + t.Fatalf("expected historical schedule to fail") + } + if !strings.Contains(err.Error(), "历史日期") { + t.Fatalf("expected historical date error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceCreateAllowsTodayEarlierThanCurrentTime(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + + today := time.Now() + item, err := services.AgentTeamScheduleService.CreateAgentTeamSchedule(request.CreateAgentTeamScheduleRequest{ + TeamID: 1, + StartAt: formatTestDateTime(today, "00:00:00"), + EndAt: formatTestDateTime(today, "01:00:00"), + }, testOperator()) + if err != nil { + t.Fatalf("expected today's schedule to pass, got %v", err) + } + if item == nil || item.ID == 0 { + t.Fatalf("expected created schedule, got %+v", item) + } +} + +func TestAgentTeamScheduleServiceUpdateRejectsCrossDaySchedule(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, db) + existingID := createFutureAgentTeamSchedule(t, db) + tomorrow := time.Now().AddDate(0, 0, 1) + + err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{ + ID: existingID, + CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{ + TeamID: 1, + StartAt: formatTestDateTime(tomorrow, "22:00:00"), + EndAt: formatTestDateTime(tomorrow.AddDate(0, 0, 1), "08:00:00"), + }, + }, testOperator()) + if err == nil { + t.Fatalf("expected cross-day update to fail") + } + if !strings.Contains(err.Error(), "不能跨天") { + t.Fatalf("expected cross-day error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceUpdateRejectsHistoricalScheduleByDay(t *testing.T) { + db := setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, db) + existingID := createFutureAgentTeamSchedule(t, db) + yesterday := time.Now().AddDate(0, 0, -1) + + err := services.AgentTeamScheduleService.UpdateAgentTeamSchedule(request.UpdateAgentTeamScheduleRequest{ + ID: existingID, + CreateAgentTeamScheduleRequest: request.CreateAgentTeamScheduleRequest{ + TeamID: 1, + StartAt: formatTestDateTime(yesterday, "09:00:00"), + EndAt: formatTestDateTime(yesterday, "18:00:00"), + }, + }, testOperator()) + if err == nil { + t.Fatalf("expected historical update to fail") + } + if !strings.Contains(err.Error(), "历史日期") { + t.Fatalf("expected historical date error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceBatchPreviewExpandsSharedRule(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + nextMonday := nextTestWeekday(time.Monday) + nextWednesday := nextMonday.AddDate(0, 0, 2) + + preview, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1, 2}, + StartDate: nextMonday.Format(time.DateOnly), + EndDate: nextMonday.AddDate(0, 0, 6).Format(time.DateOnly), + Weekdays: []int{1, 3}, + StartTime: "09:00", + EndTime: "18:00", + Remark: "工作日白班", + }, testOperator()) + if err != nil { + t.Fatalf("BatchPreview() error = %v", err) + } + if preview.Total != 4 || len(preview.Items) != 4 { + t.Fatalf("expected 4 preview items, got total=%d len=%d", preview.Total, len(preview.Items)) + } + if preview.Conflict { + t.Fatalf("expected no conflict, got %+v", preview.Items) + } + teamNames := make(map[int64]string) + for _, item := range preview.Items { + if item.TeamName == "" { + t.Fatalf("expected all preview items to have team names: %+v", preview.Items) + } + teamNames[item.TeamID] = item.TeamName + } + if teamNames[1] == "" || teamNames[2] == "" { + t.Fatalf("expected team ids 1 and 2 with names, got %v", teamNames) + } + type previewKey struct { + teamID int64 + date string + } + itemsByKey := make(map[previewKey]services.AgentTeamScheduleBatchPreviewItem) + for _, item := range preview.Items { + itemsByKey[previewKey{teamID: item.TeamID, date: item.Date.Format(time.DateOnly)}] = item + } + expected := []struct { + teamID int64 + date time.Time + weekday int + }{ + {teamID: 1, date: nextMonday, weekday: 1}, + {teamID: 1, date: nextWednesday, weekday: 3}, + {teamID: 2, date: nextMonday, weekday: 1}, + {teamID: 2, date: nextWednesday, weekday: 3}, + } + for _, want := range expected { + wantDate := want.date.Format(time.DateOnly) + item, ok := itemsByKey[previewKey{teamID: want.teamID, date: wantDate}] + if !ok { + t.Fatalf("expected preview item for teamID=%d date=%s, got %+v", want.teamID, wantDate, preview.Items) + } + if item.Weekday != want.weekday || + item.StartAt.Format(time.DateTime) != formatTestDateTime(want.date, "09:00:00") || + item.EndAt.Format(time.DateTime) != formatTestDateTime(want.date, "18:00:00") || + item.Remark != "工作日白班" { + t.Fatalf("unexpected preview item for teamID=%d date=%s: %+v", want.teamID, wantDate, item) + } + } +} + +func TestAgentTeamScheduleServiceBatchPreviewRejectsHistoricalDate(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + yesterday := time.Now().AddDate(0, 0, -1) + + _, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1}, + StartDate: yesterday.Format(time.DateOnly), + EndDate: yesterday.Format(time.DateOnly), + Weekdays: []int{weekdayForRequest(yesterday)}, + StartTime: "09:00", + EndTime: "18:00", + }, testOperator()) + if err == nil { + t.Fatalf("expected historical batch preview to fail") + } + if !strings.Contains(err.Error(), "历史日期") { + t.Fatalf("expected historical date error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceBatchPreviewRejectsInvalidTimeRange(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + tomorrow := time.Now().AddDate(0, 0, 1) + + _, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1}, + StartDate: tomorrow.Format(time.DateOnly), + EndDate: tomorrow.Format(time.DateOnly), + Weekdays: []int{weekdayForRequest(tomorrow)}, + StartTime: "18:00", + EndTime: "09:00", + }, testOperator()) + if err == nil { + t.Fatalf("expected invalid time range to fail") + } + if !strings.Contains(err.Error(), "结束时间必须晚于开始时间") { + t.Fatalf("expected invalid time range error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceBatchPreviewRejectsOverLimit(t *testing.T) { + setupAgentTeamScheduleTestDB(t) + createAgentTeamScheduleTestTeams(t, sqls.DB()) + today := time.Now() + + _, err := services.AgentTeamScheduleService.BatchPreview(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1, 2}, + StartDate: today.Format(time.DateOnly), + EndDate: today.AddDate(0, 0, 260).Format(time.DateOnly), + Weekdays: []int{1, 2, 3, 4, 5, 6, 7}, + StartTime: "09:00", + EndTime: "18:00", + }, testOperator()) + if err == nil { + t.Fatalf("expected over-limit preview to fail") + } + if !strings.Contains(err.Error(), "500") { + t.Fatalf("expected 500 limit error, got %v", err) + } +} + +func TestAgentTeamScheduleServiceBatchPreviewMarksConflicts(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.StatusOk, + } + 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, 2}, + 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 preview conflict, got %+v", preview) + } + itemsByTeamID := make(map[int64]services.AgentTeamScheduleBatchPreviewItem) + for _, item := range preview.Items { + itemsByTeamID[item.TeamID] = item + } + team1Item, ok := itemsByTeamID[1] + if !ok { + t.Fatalf("expected team 1 preview item, got %+v", preview.Items) + } + team2Item, ok := itemsByTeamID[2] + if !ok { + t.Fatalf("expected team 2 preview item, got %+v", preview.Items) + } + if !team1Item.Conflict || team1Item.ConflictReason == "" { + t.Fatalf("expected team 1 preview item to be marked as conflict: %+v", team1Item) + } + if team2Item.Conflict { + t.Fatalf("expected team 2 preview item to have no conflict: %+v", team2Item) + } +} + +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) + nextMonday := nextTestWeekday(time.Monday) + + result, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1, 2}, + StartDate: nextMonday.Format(time.DateOnly), + EndDate: nextMonday.AddDate(0, 0, 2).Format(time.DateOnly), + Weekdays: []int{1, 3}, + StartTime: "09:00", + EndTime: "18:00", + Remark: "批量生成", + }, testOperator()) + if err != nil { + t.Fatalf("BatchGenerate() error = %v", err) + } + if result.Created != 4 { + t.Fatalf("expected 4 created schedules, got %d", result.Created) + } + var schedules []models.AgentTeamSchedule + if err := db.Where("remark = ?", "批量生成"). + Order("team_id ASC, start_at ASC"). + Find(&schedules).Error; err != nil { + t.Fatalf("query generated schedules error = %v", err) + } + if len(schedules) != 4 { + t.Fatalf("expected 4 stored schedules, got %d: %+v", len(schedules), schedules) + } + expected := []struct { + teamID int64 + startAt string + endAt string + }{ + {teamID: 1, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")}, + {teamID: 1, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")}, + {teamID: 2, startAt: formatTestDateTime(nextMonday, "09:00:00"), endAt: formatTestDateTime(nextMonday, "18:00:00")}, + {teamID: 2, startAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "09:00:00"), endAt: formatTestDateTime(nextMonday.AddDate(0, 0, 2), "18:00:00")}, + } + for i, want := range expected { + got := schedules[i] + if got.TeamID != want.teamID || + got.StartAt.Format(time.DateTime) != want.startAt || + got.EndAt.Format(time.DateTime) != want.endAt { + t.Fatalf("unexpected schedule at index %d: got teamID=%d startAt=%s endAt=%s, want teamID=%d startAt=%s endAt=%s", + i, + got.TeamID, + got.StartAt.Format(time.DateTime), + got.EndAt.Format(time.DateTime), + want.teamID, + want.startAt, + want.endAt, + ) + } + } +} + +func TestAgentTeamScheduleServiceBatchGenerateRejectsConflictsWithoutPartialCreate(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.StatusOk, + } + if err := db.Create(&existing).Error; err != nil { + t.Fatalf("create existing schedule error = %v", err) + } + + _, err := services.AgentTeamScheduleService.BatchGenerate(request.AgentTeamScheduleBatchRequest{ + TeamIDs: []int64{1, 2}, + StartDate: targetDay.Format(time.DateOnly), + EndDate: targetDay.Format(time.DateOnly), + Weekdays: []int{weekdayForRequest(targetDay)}, + StartTime: "09:00", + EndTime: "18:00", + Remark: "不应创建", + }, testOperator()) + if err == nil { + t.Fatalf("expected conflict batch generate to fail") + } + var count int64 + db.Model(&models.AgentTeamSchedule{}).Where("remark = ?", "不应创建").Count(&count) + if count != 0 { + t.Fatalf("expected no partial creates, got %d", count) + } +} + +func setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB { + t.Helper() + + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "t_", + SingularTable: true, + }, + }) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AgentTeam{}, &models.AgentTeamSchedule{}); err != nil { + t.Fatalf("auto migrate error = %v", err) + } + sqls.SetDB(db) + return db +} + +func createAgentTeamScheduleTestData(t *testing.T, db *gorm.DB) { + t.Helper() + + createAgentTeamScheduleTestTeams(t, db) + + parse := func(value string) time.Time { + t.Helper() + ret, err := time.ParseInLocation(time.DateTime, value, time.Local) + if err != nil { + t.Fatalf("parse time %q error = %v", value, err) + } + return ret + } + schedules := []models.AgentTeamSchedule{ + {ID: 1, TeamID: 1, StartAt: parse("2026-04-26 20:00:00"), EndAt: parse("2026-04-27 10:00:00"), Status: enums.StatusOk}, + {ID: 2, TeamID: 1, StartAt: parse("2026-04-28 09:00:00"), EndAt: parse("2026-04-28 18:00:00"), Status: enums.StatusOk}, + {ID: 3, TeamID: 2, StartAt: parse("2026-05-03 20:00:00"), EndAt: parse("2026-05-04 08:00:00"), Status: enums.StatusOk}, + {ID: 4, TeamID: 1, StartAt: parse("2026-04-20 09:00:00"), EndAt: parse("2026-04-20 18:00:00"), Status: enums.StatusOk}, + {ID: 5, TeamID: 2, StartAt: parse("2026-05-04 09:00:00"), EndAt: parse("2026-05-04 18:00:00"), Status: enums.StatusOk}, + } + if err := db.Create(&schedules).Error; err != nil { + t.Fatalf("create schedules error = %v", err) + } +} + +func createAgentTeamScheduleTestTeams(t *testing.T, db *gorm.DB) { + t.Helper() + teams := []models.AgentTeam{ + {ID: 1, Name: "售前组", Status: enums.StatusOk}, + {ID: 2, Name: "售后组", Status: enums.StatusOk}, + } + if err := db.Create(&teams).Error; err != nil { + t.Fatalf("create teams error = %v", err) + } +} + +func formatTestDateTime(date time.Time, clock string) string { + return date.Format(time.DateOnly) + " " + clock +} + +func nextTestWeekday(target time.Weekday) time.Time { + ret := startOfTestDay(time.Now()).AddDate(0, 0, 1) + for ret.Weekday() != target { + ret = ret.AddDate(0, 0, 1) + } + return ret +} + +func startOfTestDay(value time.Time) time.Time { + year, month, day := value.In(time.Local).Date() + return time.Date(year, month, day, 0, 0, 0, 0, time.Local) +} + +func weekdayForRequest(value time.Time) int { + if value.Weekday() == time.Sunday { + return 7 + } + return int(value.Weekday()) +} + +func createFutureAgentTeamSchedule(t *testing.T, db *gorm.DB) int64 { + t.Helper() + tomorrow := time.Now().AddDate(0, 0, 1) + item := models.AgentTeamSchedule{ + TeamID: 1, + StartAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "09:00:00")), + EndAt: parseTestDateTime(t, formatTestDateTime(tomorrow, "18:00:00")), + Status: enums.StatusOk, + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create future schedule error = %v", err) + } + return item.ID +} + +func parseTestDateTime(t *testing.T, value string) time.Time { + t.Helper() + ret, err := time.ParseInLocation(time.DateTime, value, time.Local) + if err != nil { + t.Fatalf("parse time %q error = %v", value, err) + } + return ret +} + +func testOperator() *dto.AuthPrincipal { + return &dto.AuthPrincipal{UserID: 1, Username: "tester", Status: enums.StatusOk} +} diff --git a/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx new file mode 100644 index 0000000..a1da59a --- /dev/null +++ b/web/app/dashboard/agent-team-schedules/_components/batch-schedule-dialog.tsx @@ -0,0 +1,533 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { ArrowLeftIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react" +import { toast } from "sonner" + +import { OptionCombobox } from "@/components/option-combobox" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" +import { + fetchAgentTeamsAll, + generateAgentTeamScheduleBatch, + previewAgentTeamScheduleBatch, + type AdminAgentTeam, + type AdminAgentTeamScheduleBatchPreview, + type BatchAdminAgentTeamSchedulePayload, +} from "@/lib/api/admin" +import { cn } from "@/lib/utils" + +type BatchScheduleDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: (created: number) => void | Promise +} + +const weekdayOptions = [ + { value: 1, label: "周一" }, + { value: 2, label: "周二" }, + { value: 3, label: "周三" }, + { value: 4, label: "周四" }, + { value: 5, label: "周五" }, + { value: 6, label: "周六" }, + { value: 7, label: "周日" }, +] + +function todayDateValue() { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, "0") + const day = String(today.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} + +function defaultFormState() { + const today = todayDateValue() + return { + selectedTeamIds: [] as number[], + startDate: today, + endDate: today, + weekdays: [1, 2, 3, 4, 5], + startTime: "09:00", + endTime: "18:00", + remark: "", + } +} + +type BatchFormState = ReturnType +type DialogStep = "form" | "preview" + +function buildPayload(form: BatchFormState): BatchAdminAgentTeamSchedulePayload { + return { + teamIds: [...form.selectedTeamIds], + startDate: form.startDate, + endDate: form.endDate, + weekdays: [...form.weekdays], + startTime: form.startTime, + endTime: form.endTime, + remark: form.remark.trim(), + } +} + +function getWeekdayLabel(value: number) { + return weekdayOptions.find((option) => option.value === value)?.label ?? `周${value}` +} + +function validateForm(form: BatchFormState) { + const today = todayDateValue() + if (form.selectedTeamIds.length === 0) { + return "请选择至少一个客服组" + } + if (!form.startDate || !form.endDate) { + return "请选择日期范围" + } + if (form.startDate < today) { + return "开始日期不能早于今天" + } + if (form.endDate < form.startDate) { + return "结束日期不能早于开始日期" + } + if (form.weekdays.length === 0) { + return "请选择至少一个星期" + } + if (!form.startTime || !form.endTime) { + return "请选择开始和结束时间" + } + if (form.endTime <= form.startTime) { + return "结束时间必须晚于开始时间" + } + return "" +} + +export function BatchScheduleDialog({ + open, + onOpenChange, + onSuccess, +}: BatchScheduleDialogProps) { + const [teams, setTeams] = useState([]) + const [form, setForm] = useState(defaultFormState) + const [step, setStep] = useState("form") + const [preview, setPreview] = useState(null) + const [previewPayload, setPreviewPayload] = useState(null) + const [loadingTeams, setLoadingTeams] = useState(false) + const [previewing, setPreviewing] = useState(false) + const [submitting, setSubmitting] = useState(false) + const openRef = useRef(open) + const previewRequestIdRef = useRef(0) + const busy = loadingTeams || previewing || submitting + + const teamOptions = useMemo( + () => + teams + .filter((team) => !form.selectedTeamIds.includes(team.id)) + .map((team) => ({ value: String(team.id), label: team.name })), + [form.selectedTeamIds, teams] + ) + + const selectedTeams = useMemo(() => { + const teamMap = new Map(teams.map((team) => [team.id, team])) + return form.selectedTeamIds.map((teamId) => teamMap.get(teamId)).filter(Boolean) as AdminAgentTeam[] + }, [form.selectedTeamIds, teams]) + + const selectedWeekdays = useMemo( + () => new Set(form.weekdays), + [form.weekdays] + ) + + const hasConflict = preview?.conflict === true + + useEffect(() => { + openRef.current = open + }, [open]) + + useEffect(() => { + if (!open) { + previewRequestIdRef.current += 1 + setForm(defaultFormState()) + setStep("form") + setPreview(null) + setPreviewPayload(null) + setPreviewing(false) + setSubmitting(false) + return + } + + let ignore = false + async function loadTeams() { + setLoadingTeams(true) + try { + const data = await fetchAgentTeamsAll() + if (!ignore) { + setTeams(data) + } + } catch (error) { + if (!ignore) { + toast.error(error instanceof Error ? error.message : "加载客服组选项失败") + } + } finally { + if (!ignore) { + setLoadingTeams(false) + } + } + } + + void loadTeams() + return () => { + ignore = true + } + }, [open]) + + function updateForm(values: Partial) { + previewRequestIdRef.current += 1 + setForm((current) => ({ ...current, ...values })) + setPreview(null) + setPreviewPayload(null) + setPreviewing(false) + setStep("form") + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen && busy) { + return + } + onOpenChange(nextOpen) + } + + function handleTeamSelect(value: string) { + const teamId = Number(value) + if (!Number.isFinite(teamId) || form.selectedTeamIds.includes(teamId)) { + return + } + updateForm({ selectedTeamIds: [...form.selectedTeamIds, teamId] }) + } + + function removeTeam(teamId: number) { + updateForm({ + selectedTeamIds: form.selectedTeamIds.filter((selectedTeamId) => selectedTeamId !== teamId), + }) + } + + function toggleWeekday(weekday: number) { + const nextWeekdays = selectedWeekdays.has(weekday) + ? form.weekdays.filter((value) => value !== weekday) + : [...form.weekdays, weekday].sort((a, b) => a - b) + updateForm({ weekdays: nextWeekdays }) + } + + async function handlePreview() { + const validationMessage = validateForm(form) + if (validationMessage) { + toast.error(validationMessage) + return + } + + const payload = buildPayload(form) + const requestId = previewRequestIdRef.current + 1 + previewRequestIdRef.current = requestId + setPreviewing(true) + try { + const data = await previewAgentTeamScheduleBatch(payload) + if (!openRef.current || previewRequestIdRef.current !== requestId) { + return + } + setPreview(data) + setPreviewPayload(payload) + setStep("preview") + } catch (error) { + if (openRef.current && previewRequestIdRef.current === requestId) { + toast.error(error instanceof Error ? error.message : "预览批量排班失败") + } + } finally { + if (openRef.current && previewRequestIdRef.current === requestId) { + setPreviewing(false) + } + } + } + + async function handleSubmit() { + if (!preview || !previewPayload) { + toast.error("请先预览批量排班") + return + } + if (preview.conflict) { + toast.error("存在冲突排班,不能提交") + return + } + + const payload = previewPayload + setSubmitting(true) + try { + const data = await generateAgentTeamScheduleBatch(payload) + toast.success(`已创建 ${data.created} 条客服组排班`) + setPreview(null) + setPreviewPayload(null) + onOpenChange(false) + try { + await onSuccess(data.created) + } catch (error) { + toast.error(error instanceof Error ? `排班已生成,但刷新列表失败:${error.message}` : "排班已生成,但刷新列表失败") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成批量排班失败") + } finally { + setSubmitting(false) + } + } + + return ( + + + + 批量排班 + + 选择客服组、日期范围和工作时间,先预览后生成排班。 + + + +
+ {step === "form" ? ( +
+
+ +
+
+ +
+
+ {selectedTeams.length > 0 ? ( +
+ {selectedTeams.map((team) => ( + + {team.name} + + + ))} +
+ ) : ( +
尚未选择客服组
+ )} +
+ +
+
+ + updateForm({ startDate: event.target.value })} + /> +
+
+ + updateForm({ endDate: event.target.value })} + /> +
+
+ +
+ +
+ {weekdayOptions.map((option) => { + const selected = selectedWeekdays.has(option.value) + return ( + + ) + })} +
+
+ +
+
+ + updateForm({ startTime: event.target.value })} + /> +
+
+ + updateForm({ endTime: event.target.value })} + /> +
+
+ +
+ +