feat: add prioritized workbench actions

This commit is contained in:
malei 2026-08-01 23:47:58 +08:00
parent 133f61e4ad
commit 0ad8a4d596
6 changed files with 459 additions and 0 deletions

View File

@ -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<String, Object> 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<String, Object> reportFunnel(

View File

@ -20,6 +20,7 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
List<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status);
List<Appointment> findByStoreIdAndDeletedFalse(Long storeId);
List<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status);
List<Appointment> findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(Long storeId, String status);
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
@Deprecated
@ -31,6 +32,12 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable);
Optional<Appointment> 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")

View File

@ -20,6 +20,9 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
List<Report> findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId);
List<Report> findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
Long storeId, List<String> highlightVideoStatuses);
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
@Deprecated
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);

View File

@ -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<Appointment> newAppointments = sortedAppointments(
appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "new")
);
List<Appointment> inService = sortedAppointments(
appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "doing")
);
List<Appointment> overdueAppointments = newAppointments.stream()
.filter(appointment -> appointment.getAppointmentTime() != null
&& appointment.getAppointmentTime().isBefore(now))
.toList();
List<Appointment> 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<Report> 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<ReportLead> 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<ActionGroup> 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<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
return new WorkbenchData(day.toString(), now, metrics, List.copyOf(actions), reportFunnel);
}
private static List<Appointment> sortedAppointments(List<Appointment> appointments) {
return (appointments == null ? List.<Appointment>of() : appointments).stream()
.sorted(Comparator.comparing(
Appointment::getAppointmentTime,
Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
}
private static List<Report> safeReports(List<Report> reports) {
return reports == null ? List.of() : reports;
}
private static List<ReportLead> safeLeads(List<ReportLead> 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<ActionGroup> groups,
String kind,
String urgency,
List<Appointment> appointments,
LocalDateTime now) {
if (appointments.isEmpty()) return;
List<ActionItem> 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<ActionGroup> groups, List<Report> reports) {
if (reports.isEmpty()) return;
List<ActionItem> 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<ActionGroup> groups, List<ReportLead> leads) {
if (leads.isEmpty()) return;
List<ActionItem> 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<ActionGroup> actions,
Map<String, Object> 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<ActionItem> items) {
}
public record ActionItem(
String resourceType,
Long resourceId,
String petName,
String serviceType,
String scheduledAt,
String status,
boolean overdue) {
}
}

View File

@ -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<String, Object> 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<String, Object> result = controller.actions();
assertEquals(200, result.get("code"));
assertEquals(data, result.get("data"));
verify(adminWorkbenchService).today(10L);
}
}

View File

@ -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<Appointment> newAppointments = List.of(
appointment(1L, "迟到的豆豆", "洗护", "new", now.minusMinutes(30)),
appointment(2L, "即将到店的可乐", "美容", "new", now.plusHours(1)),
appointment(3L, "明天的奶糖", "洗护", "new", now.plusDays(1))
);
List<Appointment> 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<Appointment> 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;
}
}