From 0ad8a4d596ff8fd4eb5f377d87aa6e99c418fe7f Mon Sep 17 00:00:00 2001 From: malei <> Date: Sat, 1 Aug 2026 23:47:58 +0800 Subject: [PATCH] feat: add prioritized workbench actions --- .../AdminBusinessEventController.java | 19 ++ .../petstore/mapper/AppointmentMapper.java | 7 + .../com/petstore/mapper/ReportMapper.java | 3 + .../service/AdminWorkbenchService.java | 228 ++++++++++++++++++ .../AdminBusinessEventControllerTest.java | 33 +++ .../service/AdminWorkbenchServiceTest.java | 169 +++++++++++++ 6 files changed, 459 insertions(+) create mode 100644 src/main/java/com/petstore/service/AdminWorkbenchService.java create mode 100644 src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java diff --git a/src/main/java/com/petstore/controller/AdminBusinessEventController.java b/src/main/java/com/petstore/controller/AdminBusinessEventController.java index b7135ee..8917e44 100644 --- a/src/main/java/com/petstore/controller/AdminBusinessEventController.java +++ b/src/main/java/com/petstore/controller/AdminBusinessEventController.java @@ -3,6 +3,7 @@ package com.petstore.controller; import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.service.AdminBusinessEventService; +import com.petstore.service.AdminWorkbenchService; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.CrossOrigin; @@ -22,6 +23,24 @@ import java.util.Map; public class AdminBusinessEventController { private final AdminBusinessEventService adminBusinessEventService; + private final AdminWorkbenchService adminWorkbenchService; + + /** 今日行动聚合:只返回本店准确计数和低敏预览。 */ + @GetMapping("/actions") + public Map actions() { + CurrentUser user = CurrentUserContext.require(); + if (!user.isStoreUser() || user.storeId() == null) { + return Map.of( + "code", 403, + "message", "仅门店员工可查看今日行动", + "bizCode", "FORBIDDEN" + ); + } + return Map.of( + "code", 200, + "data", adminWorkbenchService.today(user.storeId()) + ); + } @GetMapping("/report-funnel") public Map reportFunnel( diff --git a/src/main/java/com/petstore/mapper/AppointmentMapper.java b/src/main/java/com/petstore/mapper/AppointmentMapper.java index 862bade..c591921 100644 --- a/src/main/java/com/petstore/mapper/AppointmentMapper.java +++ b/src/main/java/com/petstore/mapper/AppointmentMapper.java @@ -20,6 +20,7 @@ public interface AppointmentMapper extends JpaRepository { List findByUserIdAndStatusAndDeletedFalse(Long userId, String status); List findByStoreIdAndDeletedFalse(Long storeId); List findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status); + List findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(Long storeId, String status); /** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */ @Deprecated @@ -31,6 +32,12 @@ public interface AppointmentMapper extends JpaRepository { Page findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Optional findByIdAndDeletedFalse(Long id); + long countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan( + Long storeId, LocalDateTime from, LocalDateTime to); + + long countByStoreIdAndStatusAndDeletedFalseAndUpdateTimeGreaterThanEqualAndUpdateTimeLessThan( + Long storeId, String status, LocalDateTime from, LocalDateTime to); + @Query("SELECT a FROM Appointment a WHERE a.deleted = false AND " + "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)) " + "ORDER BY a.appointmentTime DESC") diff --git a/src/main/java/com/petstore/mapper/ReportMapper.java b/src/main/java/com/petstore/mapper/ReportMapper.java index f9a4e09..edec5c8 100644 --- a/src/main/java/com/petstore/mapper/ReportMapper.java +++ b/src/main/java/com/petstore/mapper/ReportMapper.java @@ -20,6 +20,9 @@ public interface ReportMapper extends JpaRepository { List findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId); + List findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( + Long storeId, List highlightVideoStatuses); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ @Deprecated List findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId); diff --git a/src/main/java/com/petstore/service/AdminWorkbenchService.java b/src/main/java/com/petstore/service/AdminWorkbenchService.java new file mode 100644 index 0000000..8e957b2 --- /dev/null +++ b/src/main/java/com/petstore/service/AdminWorkbenchService.java @@ -0,0 +1,228 @@ +package com.petstore.service; + +import com.petstore.entity.Appointment; +import com.petstore.entity.Report; +import com.petstore.entity.ReportLead; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** 单店今日行动读模型:准确计数、低敏预览,不返回手机号、报告 token 或媒体地址。 */ +@Service +@RequiredArgsConstructor +public class AdminWorkbenchService { + static final int PREVIEW_LIMIT = 3; + static final int HIGHLIGHT_TIMEOUT_MINUTES = 30; + + public static final String OVERDUE_APPOINTMENT = "overdue_appointment"; + public static final String IN_SERVICE = "in_service"; + public static final String UPCOMING_TODAY = "upcoming_today"; + public static final String HIGHLIGHT_ANOMALY = "highlight_anomaly"; + public static final String FOLLOW_UP_DUE = "follow_up_due"; + + private final AppointmentMapper appointmentMapper; + private final ReportMapper reportMapper; + private final ReportLeadMapper reportLeadMapper; + private final AdminBusinessEventService adminBusinessEventService; + + public WorkbenchData today(Long storeId) { + return build(storeId, LocalDateTime.now()); + } + + WorkbenchData build(Long storeId, LocalDateTime now) { + if (storeId == null) { + throw new IllegalArgumentException("storeId required"); + } + LocalDate day = now.toLocalDate(); + LocalDateTime dayStart = day.atStartOfDay(); + LocalDateTime dayEnd = day.plusDays(1).atStartOfDay(); + + List newAppointments = sortedAppointments( + appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "new") + ); + List inService = sortedAppointments( + appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "doing") + ); + List overdueAppointments = newAppointments.stream() + .filter(appointment -> appointment.getAppointmentTime() != null + && appointment.getAppointmentTime().isBefore(now)) + .toList(); + List upcomingToday = newAppointments.stream() + .filter(appointment -> appointment.getAppointmentTime() != null + && !appointment.getAppointmentTime().isBefore(now) + && appointment.getAppointmentTime().isBefore(dayEnd)) + .toList(); + + LocalDateTime staleBefore = now.minusMinutes(HIGHLIGHT_TIMEOUT_MINUTES); + List highlightAnomalies = safeReports( + reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( + storeId, List.of("failed", "processing")) + ).stream() + .filter(report -> isHighlightAnomaly(report, staleBefore)) + .sorted(Comparator + .comparing((Report report) -> !"failed".equals(report.getHighlightVideoStatus())) + .thenComparing(AdminWorkbenchService::reportActivityTime, + Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); + + List dueFollowUps = safeLeads( + reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending") + ).stream() + .filter(lead -> lead.getRemindDate() == null || !lead.getRemindDate().isAfter(day)) + .toList(); + + long todayAppointments = appointmentMapper + .countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan( + storeId, dayStart, dayEnd); + long todayDone = appointmentMapper + .countByStoreIdAndStatusAndDeletedFalseAndUpdateTimeGreaterThanEqualAndUpdateTimeLessThan( + storeId, "done", dayStart, dayEnd); + + WorkbenchMetrics metrics = new WorkbenchMetrics( + todayAppointments, + todayDone, + overdueAppointments.size(), + inService.size(), + upcomingToday.size(), + highlightAnomalies.size(), + dueFollowUps.size() + ); + + List actions = new ArrayList<>(); + addAppointmentGroup(actions, OVERDUE_APPOINTMENT, "critical", overdueAppointments, now); + addAppointmentGroup(actions, IN_SERVICE, "high", inService, now); + addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now); + addReportGroup(actions, highlightAnomalies); + addLeadGroup(actions, dueFollowUps); + + Map reportFunnel = adminBusinessEventService.reportFunnel(storeId, day); + return new WorkbenchData(day.toString(), now, metrics, List.copyOf(actions), reportFunnel); + } + + private static List sortedAppointments(List appointments) { + return (appointments == null ? List.of() : appointments).stream() + .sorted(Comparator.comparing( + Appointment::getAppointmentTime, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + } + + private static List safeReports(List reports) { + return reports == null ? List.of() : reports; + } + + private static List safeLeads(List leads) { + return leads == null ? List.of() : leads; + } + + private static boolean isHighlightAnomaly(Report report, LocalDateTime staleBefore) { + if ("failed".equals(report.getHighlightVideoStatus())) { + return true; + } + if (!"processing".equals(report.getHighlightVideoStatus())) { + return false; + } + LocalDateTime activity = reportActivityTime(report); + return activity == null || activity.isBefore(staleBefore); + } + + private static LocalDateTime reportActivityTime(Report report) { + return report.getUpdateTime() != null ? report.getUpdateTime() : report.getCreateTime(); + } + + private static void addAppointmentGroup( + List groups, + String kind, + String urgency, + List appointments, + LocalDateTime now) { + if (appointments.isEmpty()) return; + List items = appointments.stream() + .limit(PREVIEW_LIMIT) + .map(appointment -> new ActionItem( + "appointment", + appointment.getId(), + appointment.getPetName(), + appointment.getServiceType(), + appointment.getAppointmentTime() == null ? null : appointment.getAppointmentTime().toString(), + appointment.getStatus(), + appointment.getAppointmentTime() != null && appointment.getAppointmentTime().isBefore(now) + )) + .toList(); + groups.add(new ActionGroup(kind, urgency, appointments.size(), items)); + } + + private static void addReportGroup(List groups, List reports) { + if (reports.isEmpty()) return; + List items = reports.stream() + .limit(PREVIEW_LIMIT) + .map(report -> new ActionItem( + "report", + report.getId(), + report.getPetName(), + report.getServiceType(), + reportActivityTime(report) == null ? null : reportActivityTime(report).toString(), + report.getHighlightVideoStatus(), + true + )) + .toList(); + groups.add(new ActionGroup(HIGHLIGHT_ANOMALY, "high", reports.size(), items)); + } + + private static void addLeadGroup(List groups, List leads) { + if (leads.isEmpty()) return; + List items = leads.stream() + .limit(PREVIEW_LIMIT) + .map(lead -> new ActionItem( + "lead", + lead.getId(), + lead.getPetName(), + lead.getServiceType(), + lead.getRemindDate() == null ? null : lead.getRemindDate().toString(), + lead.getRemindStatus(), + true + )) + .toList(); + groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", leads.size(), items)); + } + + public record WorkbenchData( + String date, + LocalDateTime generatedAt, + WorkbenchMetrics metrics, + List actions, + Map reportFunnel) { + } + + public record WorkbenchMetrics( + long todayAppointmentCount, + long todayDoneCount, + long overdueAppointmentCount, + long inServiceCount, + long upcomingTodayCount, + long highlightAnomalyCount, + long dueFollowUpCount) { + } + + public record ActionGroup(String kind, String urgency, long count, List items) { + } + + public record ActionItem( + String resourceType, + Long resourceId, + String petName, + String serviceType, + String scheduledAt, + String status, + boolean overdue) { + } +} diff --git a/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java index a25ba6e..8042cee 100644 --- a/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java +++ b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java @@ -3,6 +3,7 @@ package com.petstore.controller; import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.service.AdminBusinessEventService; +import com.petstore.service.AdminWorkbenchService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,6 +12,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -22,6 +25,7 @@ import static org.mockito.Mockito.when; class AdminBusinessEventControllerTest { @Mock private AdminBusinessEventService adminBusinessEventService; + @Mock private AdminWorkbenchService adminWorkbenchService; @InjectMocks private AdminBusinessEventController controller; @AfterEach @@ -53,4 +57,33 @@ class AdminBusinessEventControllerTest { assertEquals(200, result.get("code")); verify(adminBusinessEventService).reportFunnel(10L, date); } + + @Test + void customerCannotReadStoreActions() { + CurrentUserContext.set(new CurrentUser(99L, null, "customer")); + + Map result = controller.actions(); + + assertEquals(403, result.get("code")); + verifyNoInteractions(adminWorkbenchService); + } + + @Test + void staffActionsUseDerivedStoreAndLowSensitivityReadModel() { + CurrentUserContext.set(new CurrentUser(7L, 10L, "staff")); + AdminWorkbenchService.WorkbenchData data = new AdminWorkbenchService.WorkbenchData( + "2026-08-01", + LocalDateTime.of(2026, 8, 1, 10, 0), + new AdminWorkbenchService.WorkbenchMetrics(3, 1, 1, 1, 1, 0, 0), + List.of(), + Map.of() + ); + when(adminWorkbenchService.today(10L)).thenReturn(data); + + Map result = controller.actions(); + + assertEquals(200, result.get("code")); + assertEquals(data, result.get("data")); + verify(adminWorkbenchService).today(10L); + } } diff --git a/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java new file mode 100644 index 0000000..497a6bd --- /dev/null +++ b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java @@ -0,0 +1,169 @@ +package com.petstore.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.entity.Appointment; +import com.petstore.entity.Report; +import com.petstore.entity.ReportLead; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminWorkbenchServiceTest { + + @Mock private AppointmentMapper appointmentMapper; + @Mock private ReportMapper reportMapper; + @Mock private ReportLeadMapper reportLeadMapper; + @Mock private AdminBusinessEventService adminBusinessEventService; + @InjectMocks private AdminWorkbenchService service; + + @Test + void buildsPrioritizedLowSensitivityActionGroups() throws Exception { + LocalDateTime now = LocalDateTime.of(2026, 8, 1, 10, 0); + List newAppointments = List.of( + appointment(1L, "迟到的豆豆", "洗护", "new", now.minusMinutes(30)), + appointment(2L, "即将到店的可乐", "美容", "new", now.plusHours(1)), + appointment(3L, "明天的奶糖", "洗护", "new", now.plusDays(1)) + ); + List doingAppointments = List.of( + appointment(4L, "服务中的团子", "洗护", "doing", now.minusHours(1)) + ); + when(appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(10L, "new")) + .thenReturn(newAppointments); + when(appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(10L, "doing")) + .thenReturn(doingAppointments); + when(appointmentMapper + .countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan( + eq(10L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(5L); + when(appointmentMapper + .countByStoreIdAndStatusAndDeletedFalseAndUpdateTimeGreaterThanEqualAndUpdateTimeLessThan( + eq(10L), eq("done"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(2L); + + Report failed = report(20L, "成片失败", "洗护", "failed", now.minusMinutes(5)); + Report stale = report(21L, "成片超时", "美容", "processing", now.minusMinutes(31)); + Report fresh = report(22L, "正常处理中", "洗护", "processing", now.minusMinutes(10)); + when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( + 10L, List.of("failed", "processing"))) + .thenReturn(List.of(stale, fresh, failed)); + + ReportLead noDate = lead(30L, "无日期线索", null, "pending"); + ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending"); + ReportLead future = lead(32L, "未来回访", LocalDate.of(2026, 8, 2), "pending"); + when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending")) + .thenReturn(List.of(noDate, due, future)); + when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1))) + .thenReturn(Map.of("reportSubmittedCount", 2L)); + + AdminWorkbenchService.WorkbenchData data = service.build(10L, now); + + assertEquals(5L, data.metrics().todayAppointmentCount()); + assertEquals(2L, data.metrics().todayDoneCount()); + assertEquals(1L, data.metrics().overdueAppointmentCount()); + assertEquals(1L, data.metrics().inServiceCount()); + assertEquals(1L, data.metrics().upcomingTodayCount()); + assertEquals(2L, data.metrics().highlightAnomalyCount()); + assertEquals(2L, data.metrics().dueFollowUpCount()); + assertEquals(List.of( + AdminWorkbenchService.OVERDUE_APPOINTMENT, + AdminWorkbenchService.IN_SERVICE, + AdminWorkbenchService.UPCOMING_TODAY, + AdminWorkbenchService.HIGHLIGHT_ANOMALY, + AdminWorkbenchService.FOLLOW_UP_DUE + ), + data.actions().stream().map(AdminWorkbenchService.ActionGroup::kind).toList()); + assertEquals("迟到的豆豆", data.actions().get(0).items().get(0).petName()); + assertEquals("report", data.actions().get(3).items().get(0).resourceType()); + assertEquals(2L, data.reportFunnel().get("reportSubmittedCount")); + + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(data); + assertFalse(json.contains("13800000000")); + assertFalse(json.contains("report-secret-token")); + assertFalse(json.contains("/private/video.mp4")); + } + + @Test + void capsPreviewWithoutLosingAccurateCount() { + LocalDateTime now = LocalDateTime.of(2026, 8, 1, 10, 0); + List overdue = new ArrayList<>(); + for (long id = 1; id <= 5; id++) { + overdue.add(appointment(id, "宠物" + id, "洗护", "new", now.minusMinutes(id))); + } + when(appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(10L, "new")) + .thenReturn(overdue); + when(appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(10L, "doing")) + .thenReturn(List.of()); + when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( + 10L, List.of("failed", "processing"))) + .thenReturn(List.of()); + when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending")) + .thenReturn(List.of()); + when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1))).thenReturn(Map.of()); + + AdminWorkbenchService.WorkbenchData data = service.build(10L, now); + + assertEquals(5L, data.actions().get(0).count()); + assertEquals(AdminWorkbenchService.PREVIEW_LIMIT, data.actions().get(0).items().size()); + } + + @Test + void rejectsMissingStoreScope() { + assertThrows(IllegalArgumentException.class, + () -> service.build(null, LocalDateTime.of(2026, 8, 1, 10, 0))); + } + + private static Appointment appointment( + Long id, String petName, String serviceType, String status, LocalDateTime appointmentTime) { + Appointment appointment = new Appointment(); + appointment.setId(id); + appointment.setPetName(petName); + appointment.setServiceType(serviceType); + appointment.setStatus(status); + appointment.setAppointmentTime(appointmentTime); + appointment.setUpdateTime(appointmentTime); + return appointment; + } + + private static Report report( + Long id, String petName, String serviceType, String status, LocalDateTime updateTime) { + Report report = new Report(); + report.setId(id); + report.setPetName(petName); + report.setServiceType(serviceType); + report.setHighlightVideoStatus(status); + report.setReportToken("report-secret-token"); + report.setHighlightVideoUrl("/private/video.mp4"); + report.setUpdateTime(updateTime); + return report; + } + + private static ReportLead lead(Long id, String petName, LocalDate remindDate, String status) { + ReportLead lead = new ReportLead(); + lead.setId(id); + lead.setPetName(petName); + lead.setServiceType("洗护"); + lead.setPhone("13800000000"); + lead.setRemindDate(remindDate); + lead.setRemindStatus(status); + return lead; + } +}