From 2589c1da13aeac51da409922e2c17e23dcdb2fe7 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Wed, 29 Apr 2026 14:16:26 +0800 Subject: [PATCH] feat(schedule): add validation to prevent cross-day and historical schedule entries --- .../services/agent_team_schedule_service.go | 17 ++ .../agent_team_schedule_service_test.go | 156 +++++++++++++++++- .../_components/calendar.tsx | 68 +++++++- .../agent-team-schedules/_components/edit.tsx | 55 +++++- .../dashboard/agent-team-schedules/page.tsx | 23 ++- 5 files changed, 303 insertions(+), 16 deletions(-) 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({
{dayTimeLayout.rangeLabel}
) : null} - + {historical ? null : }
{daySchedules.slice(0, 5).map((item) => { @@ -409,6 +441,7 @@ export function ScheduleCalendar({ const busy = savingId === item.id const active = interactionPreview?.itemId === item.id const timeLayout = dayTimeLayout.items.get(item.id) + const readonly = historical || isHistoricalDay(parseLocalDateTime(item.startAt)) return (
handlePointerDown(event, item, "move")} + onPointerDown={(event) => { + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "move") + }} onKeyDown={(event) => { + if (readonly) { + return + } if (event.key === "Enter" || event.key === " ") { event.preventDefault() onEdit(item) @@ -437,13 +481,27 @@ export function ScheduleCalendar({ >
handlePointerDown(event, item, "resize", "start")} + onPointerDown={(event) => { + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "resize", "start") + }} >
handlePointerDown(event, item, "resize", "end")} + onPointerDown={(event) => { + if (readonly) { + event.preventDefault() + event.stopPropagation() + return + } + handlePointerDown(event, item, "resize", "end") + }} >
diff --git a/web/app/dashboard/agent-team-schedules/_components/edit.tsx b/web/app/dashboard/agent-team-schedules/_components/edit.tsx index dd9b4f7..a3c316f 100644 --- a/web/app/dashboard/agent-team-schedules/_components/edit.tsx +++ b/web/app/dashboard/agent-team-schedules/_components/edit.tsx @@ -61,6 +61,34 @@ const editFormSchema = z.object({ endAt: z.string().trim().min(1, "结束时间不能为空"), sourceType: z.enum(["manual", "batch_import", "template_generate"], { message: "请选择排班来源" }), remark: z.string().trim(), +}).superRefine((value, ctx) => { + const startAt = parseDateTimeLocal(value.startAt) + const endAt = parseDateTimeLocal(value.endAt) + if (!startAt || !endAt) { + return + } + if (!endAt || endAt <= startAt) { + ctx.addIssue({ + code: "custom", + path: ["endAt"], + message: "结束时间必须晚于开始时间", + }) + return + } + if (!isSameLocalDay(startAt, endAt)) { + ctx.addIssue({ + code: "custom", + path: ["endAt"], + message: "单条排班记录不能跨天", + }) + } + if (startAt < startOfLocalDay(new Date())) { + ctx.addIssue({ + code: "custom", + path: ["startAt"], + message: "不能添加或修改历史日期的排班", + }) + } }) type EditForm = z.infer @@ -77,6 +105,28 @@ function toDateTimeLocal(value?: string) { return value.replace(" ", "T").slice(0, 16) } +function parseDateTimeLocal(value: string) { + const ret = new Date(value) + return Number.isNaN(ret.getTime()) ? null : ret +} + +function startOfLocalDay(value: Date) { + const ret = new Date(value) + ret.setHours(0, 0, 0, 0) + return ret +} + +function isSameLocalDay(a: Date, b: Date) { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() +} + +function todayDateTimeLocalMin() { + const today = startOfLocalDay(new Date()) + const month = String(today.getMonth() + 1).padStart(2, "0") + const day = String(today.getDate()).padStart(2, "0") + return `${today.getFullYear()}-${month}-${day}T00:00` +} + function buildForm(item: AdminAgentTeamSchedule | null, defaultValues?: Partial | null): EditForm { if (!item) { return { @@ -167,6 +217,7 @@ function ScheduleEditDialogBody({ register, formState: { errors }, } = form + const minDateTime = todayDateTimeLocalMin() useEffect(() => { async function loadDetail() { @@ -235,14 +286,14 @@ function ScheduleEditDialogBody({ 开始时间 - + 结束时间 - + diff --git a/web/app/dashboard/agent-team-schedules/page.tsx b/web/app/dashboard/agent-team-schedules/page.tsx index 524c323..09bd07f 100644 --- a/web/app/dashboard/agent-team-schedules/page.tsx +++ b/web/app/dashboard/agent-team-schedules/page.tsx @@ -101,6 +101,16 @@ function formatDateTimeValue(date: Date) { return `${date.getFullYear()}-${month}-${day} ${hour}:${minute}:${second}` } +function parseLocalDateTime(value: string) { + const ret = new Date(value.replace(" ", "T")) + return Number.isNaN(ret.getTime()) ? null : ret +} + +function isHistoricalSchedule(item: AdminAgentTeamSchedule) { + const startAt = parseLocalDateTime(item.startAt) + return !!startAt && startAt < startOfDay(new Date()) +} + function addMonths(date: Date, months: number) { const ret = startOfMonth(date) ret.setMonth(ret.getMonth() + months) @@ -220,6 +230,10 @@ export default function DashboardAgentTeamSchedulesPage() { } function openEditDialog(item: AdminAgentTeamSchedule) { + if (isHistoricalSchedule(item)) { + toast.error("不能修改历史日期的排班") + return + } setDialogDefaults(null) setEditingItem(item) setDialogOpen(true) @@ -281,6 +295,11 @@ export default function DashboardAgentTeamSchedulesPage() { } async function handleCalendarUpdate(payload: UpdateAdminAgentTeamSchedulePayload) { + const startAt = parseLocalDateTime(payload.startAt) + if (startAt && startAt < startOfDay(new Date())) { + toast.error("不能修改历史日期的排班") + return + } setActionLoadingId(payload.id) try { await updateAgentTeamSchedule(payload) @@ -398,7 +417,7 @@ export default function DashboardAgentTeamSchedulesPage() { {result.results.map((item) => ( - +
@@ -419,7 +438,7 @@ export default function DashboardAgentTeamSchedulesPage() { -