diff --git a/internal/services/agent_team_schedule_service.go b/internal/services/agent_team_schedule_service.go index b57f869..f9f8281 100644 --- a/internal/services/agent_team_schedule_service.go +++ b/internal/services/agent_team_schedule_service.go @@ -165,6 +165,12 @@ func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, if !endAtValue.After(startAtValue) { return nil, errorsx.InvalidParam("结束时间必须晚于开始时间") } + if !sameLocalDay(startAtValue, endAtValue) { + return nil, errorsx.InvalidParam("单条排班记录不能跨天") + } + 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). @@ -208,6 +214,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 index 3fdf277..0b6d5e9 100644 --- a/internal/services/agent_team_schedule_service_test.go +++ b/internal/services/agent_team_schedule_service_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -76,6 +77,109 @@ func TestAgentTeamScheduleServiceFindCalendarSchedulesValidatesTimeRange(t *test } } +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"), + SourceType: "manual", + }, 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"), + SourceType: "manual", + }, 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"), + SourceType: "manual", + }, 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"), + SourceType: "manual", + }, + }, 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"), + SourceType: "manual", + }, + }, 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 setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -105,13 +209,7 @@ func setupAgentTeamScheduleTestDB(t *testing.T) *gorm.DB { func createAgentTeamScheduleTestData(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) - } + createAgentTeamScheduleTestTeams(t, db) parse := func(value string) time.Time { t.Helper() @@ -132,3 +230,47 @@ func createAgentTeamScheduleTestData(t *testing.T, db *gorm.DB) { 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 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")), + SourceType: "manual", + 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/calendar.tsx b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx index ab9b3fc..a4a3bf7 100644 --- a/web/app/dashboard/agent-team-schedules/_components/calendar.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/calendar.tsx @@ -126,6 +126,14 @@ function getDropCell(event: PointerEvent) { return element?.closest("[data-schedule-cell]") } +function isHistoricalDay(day: Date) { + return startOfDay(day).getTime() < startOfDay(new Date()).getTime() +} + +function isSameLocalDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() +} + function buildMovePayload(item: AdminAgentTeamSchedule, date: string): UpdateAdminAgentTeamSchedulePayload { const originalStart = parseLocalDateTime(item.startAt) const originalEnd = parseLocalDateTime(item.endAt) @@ -152,6 +160,9 @@ function buildResizePayload( ): UpdateAdminAgentTeamSchedulePayload | null { const startAt = parseLocalDateTime(item.startAt) const endAt = parseLocalDateTime(item.endAt) + if (!isSameLocalDay(startAt, nextTime)) { + return null + } if (edge === "start") { if (endAt.getTime() - nextTime.getTime() < minDurationMs) { return null @@ -254,6 +265,16 @@ export function ScheduleCalendar({ if (!date) { return null } + if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) { + return { + itemId: state.item.id, + date, + label: "不能修改历史日期", + invalid: true, + x: pointerEvent.clientX, + y: pointerEvent.clientY, + } + } const point = { x: pointerEvent.clientX, y: pointerEvent.clientY } if (state.type === "move") { @@ -261,7 +282,7 @@ export function ScheduleCalendar({ } const payload = buildResizePayload(state.item, state.edge, getPointerDateInCell(pointerEvent, cell)) - return buildPreviewFromPayload(state.item.id, date, payload, point, "至少保留 15 分钟") + return buildPreviewFromPayload(state.item.id, date, payload, point, "不能跨天或少于 15 分钟") } function cleanupPointerInteraction( @@ -324,6 +345,9 @@ export function ScheduleCalendar({ if (!date) { return } + if (isHistoricalDay(parseLocalDateTime(`${date} 00:00:00`))) { + return + } await onMove(buildMovePayload(item, date)) return } @@ -363,6 +387,7 @@ export function ScheduleCalendar({ {days.map((day, dayIndex) => { const date = formatDate(day) const inMonth = day.getMonth() === monthStart.getMonth() + const historical = isHistoricalDay(day) const daySchedules = schedules .filter((item) => intersectsDay(item, day)) .sort((a, b) => parseLocalDateTime(a.startAt).getTime() - parseLocalDateTime(b.startAt).getTime()) @@ -378,6 +403,7 @@ export function ScheduleCalendar({ "min-h-36 border-l border-t bg-background p-2 text-left outline-none transition-colors first:border-l-0 hover:bg-muted/20 focus-visible:ring-2 focus-visible:ring-ring", dayIndex % 7 === 0 && "border-l-0", !inMonth && "bg-muted/20 text-muted-foreground", + historical && "cursor-not-allowed bg-muted/30 hover:bg-muted/30", interactionPreview?.date === date && (interactionPreview.invalid ? "bg-destructive/5 ring-2 ring-destructive/30" : "bg-primary/5 ring-2 ring-primary/35") )} @@ -385,9 +411,15 @@ export function ScheduleCalendar({ if ((event.target as HTMLElement).closest("[data-schedule-block]")) { return } + if (historical) { + return + } handleBlankCellClick(day) }} onKeyDown={(event) => { + if (historical) { + return + } if (event.key === "Enter" || event.key === " ") { event.preventDefault() handleBlankCellClick(day) @@ -401,7 +433,7 @@ export function ScheduleCalendar({