From c4db0a4526f61758272c92396aa788d84f098102 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期二, 21 四月 2026 17:04:30 +0800
Subject: [PATCH] 风险规避
---
backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java | 27 +
backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java | 3
backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java | 3
backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java | 86 +++
backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java | 156 +++++
backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java | 156 +++++
backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java | 32 +
backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java | 140 ++++
backend/src/main/java/com/sinata/appointment/controller/ExportController.java | 39 +
backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java | 217 ++-----
backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java | 15
backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java | 81 ++
backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java | 261 +++-----
backend/src/main/resources/db/migration/V28__performance_indexes.sql | 20
backend/src/main/resources/mapper/ApprovalTaskMapper.xml | 51 +
backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java | 42 +
backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java | 18
backend/src/main/resources/mapper/AppointmentOrderMapper.xml | 192 +++++++
backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java | 15
backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java | 10
20 files changed, 1,236 insertions(+), 328 deletions(-)
diff --git a/backend/src/main/java/com/sinata/appointment/controller/ExportController.java b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
index 2cfd592..06793ee 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
@@ -3,13 +3,23 @@
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.sinata.appointment.common.Result;
import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ExportTaskStatusVo;
import com.sinata.appointment.service.ExportApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -35,4 +45,33 @@
res.put("taskId", taskId);
return ResultUtil.data(res);
}
+
+ @Operation(summary = "查询导出任务状态")
+ @GetMapping("/{taskId}")
+ @SaCheckPermission("exports:enqueue")
+ public Result<ExportTaskStatusVo> status(@PathVariable String taskId) {
+ ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+ if (status == null) {
+ return ResultUtil.error(404, "导出任务不存在");
+ }
+ return ResultUtil.data(status);
+ }
+
+ @Operation(summary = "下载导出文件")
+ @GetMapping("/{taskId}/download")
+ @SaCheckPermission("exports:enqueue")
+ public ResponseEntity<byte[]> download(@PathVariable String taskId) throws IOException {
+ Path filePath = exportApplicationService.getExportFilePath(taskId);
+ if (filePath == null) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new byte[0]);
+ }
+ return ResponseEntity.ok()
+ .contentType(
+ MediaType.parseMediaType(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"" + filePath.getFileName() + "\"")
+ .body(Files.readAllBytes(filePath));
+ }
}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java b/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java
new file mode 100644
index 0000000..f985245
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java
@@ -0,0 +1,15 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "导出任务状态")
+public class ExportTaskStatusVo {
+
+ private String taskId;
+ private String type;
+ private String status;
+ private String fileName;
+ private String errorMessage;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java
new file mode 100644
index 0000000..a3d8a4f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序审批历史查询行")
+public class MiniApprovalHistoryRow {
+
+ private Long id;
+ private String uuid;
+ private String orderNo;
+ private String visitedUserName;
+ private Long visitedDeptId;
+ private LocalDate visitStartDate;
+ private LocalDate visitEndDate;
+ private String visitReasonCode;
+ private String visitReasonOther;
+ private String visitTimeMode;
+ private String visitTimeSlot;
+ private String passAccessAreaNames;
+ private String status;
+ private LocalDateTime createTime;
+ private String taskStatus;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
index 2f54b50..2ee7acb 100644
--- a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
+++ b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
@@ -1,8 +1,38 @@
package com.sinata.appointment.mapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.sinata.appointment.entity.AppointmentOrder;
+import java.time.LocalDate;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
@Mapper
-public interface AppointmentOrderMapper extends BaseMapper<AppointmentOrder> {}
+public interface AppointmentOrderMapper extends BaseMapper<AppointmentOrder> {
+
+ IPage<AppointmentOrder> pageAdminOrders(
+ Page<AppointmentOrder> page,
+ @Param("visitedUserName") String visitedUserName,
+ @Param("visitedDeptId") Long visitedDeptId,
+ @Param("accessArea") String accessArea,
+ @Param("localUnit") String localUnit,
+ @Param("visitDateStart") LocalDate visitDateStart,
+ @Param("visitDateEnd") LocalDate visitDateEnd,
+ @Param("displayStatus") String displayStatus,
+ @Param("approvalStatus") String approvalStatus,
+ @Param("visitorKeyword") String visitorKeyword,
+ @Param("visitReasonCode") String visitReasonCode);
+
+ IPage<AppointmentOrder> pageMiniVisitRecords(
+ Page<AppointmentOrder> page,
+ @Param("visitedUserId") Long visitedUserId,
+ @Param("visitorNameKeyword") String visitorNameKeyword,
+ @Param("visitDate") LocalDate visitDate);
+
+ IPage<AppointmentOrder> pageMiniMyOrders(
+ Page<AppointmentOrder> page,
+ @Param("submitterId") Long submitterId,
+ @Param("status") String status,
+ @Param("keyword") String keyword);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
index 88ffc8e..7b6eb7b 100644
--- a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
+++ b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
@@ -1,8 +1,21 @@
package com.sinata.appointment.mapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.dto.MiniApprovalHistoryRow;
import com.sinata.appointment.entity.ApprovalTask;
+import java.time.LocalDate;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
@Mapper
-public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {}
+public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
+
+ IPage<MiniApprovalHistoryRow> pageMiniApprovalHistory(
+ Page<MiniApprovalHistoryRow> page,
+ @Param("assigneeUserId") Long assigneeUserId,
+ @Param("status") String status,
+ @Param("visitedUserNameKeyword") String visitedUserNameKeyword,
+ @Param("visitDate") LocalDate visitDate);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
index e4f9120..3314f48 100644
--- a/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
@@ -1,6 +1,8 @@
package com.sinata.appointment.service;
import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -87,19 +89,21 @@
String approvalStatus,
String visitorKeyword,
String visitReasonCode) {
- List<AppointmentAdminListVo> all =
- listAdminOrders(
- visitedUserName,
+ Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+ IPage<AppointmentOrder> pagedOrders =
+ appointmentOrderMapper.pageAdminOrders(
+ orderPage,
+ trimToNull(visitedUserName),
visitedDeptId,
- accessArea,
- localUnit,
+ trimToNull(accessArea),
+ trimToNull(localUnit),
visitDateStart,
visitDateEnd,
- displayStatus,
- approvalStatus,
- visitorKeyword,
- visitReasonCode);
- return slicePage(page, size, all);
+ trimToNull(displayStatus),
+ trimToNull(approvalStatus),
+ trimToNull(visitorKeyword),
+ trimToNull(visitReasonCode));
+ return buildAdminPage(pagedOrders);
}
public byte[] exportExcel(
@@ -113,53 +117,44 @@
String approvalStatus,
String visitorKeyword,
String visitReasonCode) {
- Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
- if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
- return writeExportRows(List.of());
- }
-
- List<AppointmentOrder> orders =
- appointmentOrderMapper.selectList(
- buildOrderQuery(
- visitedUserName,
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ ExcelWriter writer = EasyExcel.write(bos, AppointmentAdminExportRow.class).build();
+ WriteSheet sheet = EasyExcel.writerSheet("预约记录").build();
+ long pageNo = 1L;
+ long pageSize = 200L;
+ while (true) {
+ Page<AppointmentOrder> orderPage = new Page<>(pageNo, pageSize);
+ IPage<AppointmentOrder> pagedOrders =
+ appointmentOrderMapper.pageAdminOrders(
+ orderPage,
+ trimToNull(visitedUserName),
visitedDeptId,
- accessArea,
- localUnit,
+ trimToNull(accessArea),
+ trimToNull(localUnit),
visitDateStart,
visitDateEnd,
- visitReasonCode,
- visitorOrderIds));
- if (orders.isEmpty()) {
- return writeExportRows(List.of());
- }
-
- List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
- Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
- Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
- Map<Long, String> deptNameMap = loadDeptNameMap(orders);
- Map<String, String> reasonNameMap = loadReasonNameMap(orders);
-
- List<AppointmentOrder> filteredOrders = new ArrayList<>();
- for (AppointmentOrder order : orders) {
- AppointmentAdminListVo vo =
- toAdminListVo(
- order,
- deptNameMap.get(order.getVisitedDeptId()),
- reasonNameMap,
- visitorRowsMap.get(order.getId()),
- approvalInstanceMap.get(order.getId()));
- if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
- filteredOrders.add(order);
+ trimToNull(displayStatus),
+ trimToNull(approvalStatus),
+ trimToNull(visitorKeyword),
+ trimToNull(visitReasonCode));
+ List<AppointmentOrder> orders = pagedOrders.getRecords();
+ if (orders == null || orders.isEmpty()) {
+ break;
+ }
+ AdminPageData pageData = loadAdminPageData(orders);
+ List<AppointmentAdminExportRow> rows =
+ buildExportRows(
+ orders,
+ pageData.visitorRowsMap(),
+ pageData.approvalInstanceMap(),
+ pageData.reasonNameMap());
+ writer.write(rows, sheet);
+ if (pageNo * pageSize >= pagedOrders.getTotal()) {
+ break;
+ }
+ pageNo++;
}
- }
-
- List<AppointmentAdminExportRow> rows = buildExportRows(filteredOrders, visitorRowsMap, approvalInstanceMap, reasonNameMap);
- return writeExportRows(rows);
- }
-
- private byte[] writeExportRows(List<AppointmentAdminExportRow> rows) {
- try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
- EasyExcel.write(bos, AppointmentAdminExportRow.class).sheet("预约记录").doWrite(rows);
+ writer.finish();
return bos.toByteArray();
} catch (Exception e) {
return new byte[0];
@@ -215,99 +210,37 @@
.orderByAsc(AppointmentVisitor::getId));
}
- private List<AppointmentAdminListVo> listAdminOrders(
- String visitedUserName,
- Long visitedDeptId,
- String accessArea,
- String localUnit,
- LocalDate visitDateStart,
- LocalDate visitDateEnd,
- String displayStatus,
- String approvalStatus,
- String visitorKeyword,
- String visitReasonCode) {
- Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
- if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
- return List.of();
+
+ private IPage<AppointmentAdminListVo> buildAdminPage(IPage<AppointmentOrder> pagedOrders) {
+ Page<AppointmentAdminListVo> page =
+ new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+ List<AppointmentOrder> orders = pagedOrders.getRecords();
+ if (orders == null || orders.isEmpty()) {
+ page.setRecords(List.of());
+ return page;
}
-
- List<AppointmentOrder> orders =
- appointmentOrderMapper.selectList(
- buildOrderQuery(
- visitedUserName,
- visitedDeptId,
- accessArea,
- localUnit,
- visitDateStart,
- visitDateEnd,
- visitReasonCode,
- visitorOrderIds));
- if (orders.isEmpty()) {
- return List.of();
- }
-
- List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
- Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
- Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
- Map<Long, String> deptNameMap = loadDeptNameMap(orders);
- Map<String, String> reasonNameMap = loadReasonNameMap(orders);
-
- List<AppointmentAdminListVo> result = new ArrayList<>();
+ AdminPageData pageData = loadAdminPageData(orders);
+ List<AppointmentAdminListVo> records = new ArrayList<>();
for (AppointmentOrder order : orders) {
- AppointmentAdminListVo vo =
+ records.add(
toAdminListVo(
order,
- deptNameMap.get(order.getVisitedDeptId()),
- reasonNameMap,
- visitorRowsMap.get(order.getId()),
- approvalInstanceMap.get(order.getId()));
- if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
- result.add(vo);
- }
+ pageData.deptNameMap().get(order.getVisitedDeptId()),
+ pageData.reasonNameMap(),
+ pageData.visitorRowsMap().get(order.getId()),
+ pageData.approvalInstanceMap().get(order.getId())));
}
- return result;
+ page.setRecords(records);
+ return page;
}
- private LambdaQueryWrapper<AppointmentOrder> buildOrderQuery(
- String visitedUserName,
- Long visitedDeptId,
- String accessArea,
- String localUnit,
- LocalDate visitDateStart,
- LocalDate visitDateEnd,
- String visitReasonCode,
- Set<Long> visitorOrderIds) {
- LambdaQueryWrapper<AppointmentOrder> q = new LambdaQueryWrapper<>();
- q.eq(AppointmentOrder::getDelFlag, 0);
- q.like(StringUtils.hasText(visitedUserName), AppointmentOrder::getVisitedUserName, trim(visitedUserName));
- q.eq(visitedDeptId != null, AppointmentOrder::getVisitedDeptId, visitedDeptId);
- q.like(StringUtils.hasText(accessArea), AppointmentOrder::getPassAccessAreaNames, trim(accessArea));
- q.like(StringUtils.hasText(localUnit), AppointmentOrder::getPassLocalUnitNames, trim(localUnit));
- q.eq(StringUtils.hasText(visitReasonCode), AppointmentOrder::getVisitReasonCode, trim(visitReasonCode));
- q.le(visitDateEnd != null, AppointmentOrder::getVisitStartDate, visitDateEnd);
- q.ge(visitDateStart != null, AppointmentOrder::getVisitEndDate, visitDateStart);
- if (visitorOrderIds != null) {
- q.in(AppointmentOrder::getId, visitorOrderIds);
- }
- q.orderByDesc(AppointmentOrder::getCreateTime).orderByDesc(AppointmentOrder::getId);
- return q;
- }
-
- private Set<Long> findOrderIdsByVisitorKeyword(String visitorKeyword) {
- String keyword = trimToNull(visitorKeyword);
- if (!StringUtils.hasText(keyword)) {
- return null;
- }
- List<AppointmentVisitor> rows =
- appointmentVisitorMapper.selectList(
- new LambdaQueryWrapper<AppointmentVisitor>()
- .eq(AppointmentVisitor::getDelFlag, 0)
- .like(AppointmentVisitor::getName, keyword)
- .select(AppointmentVisitor::getOrderId));
- return rows.stream()
- .map(AppointmentVisitor::getOrderId)
- .filter(Objects::nonNull)
- .collect(Collectors.toCollection(LinkedHashSet::new));
+ private AdminPageData loadAdminPageData(List<AppointmentOrder> orders) {
+ List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+ return new AdminPageData(
+ loadVisitorRows(orderIds),
+ loadApprovalInstanceMap(orderIds),
+ loadDeptNameMap(orders),
+ loadReasonNameMap(orders));
}
private Map<Long, MpVisitor> loadVisitorMap(List<AppointmentVisitor> rows) {
@@ -927,4 +860,10 @@
}
return null;
}
+
+ private record AdminPageData(
+ Map<Long, List<AppointmentVisitor>> visitorRowsMap,
+ Map<Long, ApprovalInstance> approvalInstanceMap,
+ Map<Long, String> deptNameMap,
+ Map<String, String> reasonNameMap) {}
}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
index c788437..520d2bd 100644
--- a/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
@@ -15,10 +15,13 @@
import com.sinata.appointment.mapper.AppointmentOrderMapper;
import com.sinata.appointment.mapper.ApprovalInstanceMapper;
import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
+import com.sinata.appointment.service.event.AppointmentRejectedEvent;
import com.sinata.appointment.util.UuidAssert;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.context.ApplicationEventPublisher;
@Service
@RequiredArgsConstructor
@@ -28,7 +31,7 @@
private final ApprovalInstanceMapper approvalInstanceMapper;
private final AppointmentOrderMapper appointmentOrderMapper;
private final ApprovalEngineService approvalEngineService;
- private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+ private final ApplicationEventPublisher applicationEventPublisher;
public IPage<ApprovalTask> pageMyTodo(long page, long size) {
Long uid = StpUtil.getLoginIdAsLong();
@@ -64,7 +67,7 @@
task.setComment(reason);
approvalTaskMapper.updateById(task);
approvalEngineService.rejectInstance(inst, reason);
- postApprovalOrchestrationService.onOrderRejected(order, reason);
+ applicationEventPublisher.publishEvent(new AppointmentRejectedEvent(order.getId(), reason));
return;
}
@@ -77,8 +80,7 @@
approvalEngineService.advanceOrComplete(inst, order);
ApprovalInstance latest = approvalInstanceMapper.selectById(inst.getId());
if (ApprovalInstanceStatus.APPROVED.equals(latest.getStatus())) {
- AppointmentOrder fresh = appointmentOrderMapper.selectById(order.getId());
- postApprovalOrchestrationService.onOrderFullyApproved(fresh);
+ applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
}
}
}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
index 2d0f612..7297cfa 100644
--- a/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
@@ -1,8 +1,17 @@
package com.sinata.appointment.service;
import com.sinata.appointment.constants.IntegrationConstant;
+import com.sinata.appointment.dto.ExportTaskStatusVo;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
@@ -14,22 +23,137 @@
@RequiredArgsConstructor
public class ExportApplicationService {
+ private static final String STATUS_QUEUED = "QUEUED";
+ private static final String STATUS_RUNNING = "RUNNING";
+ private static final String STATUS_COMPLETED = "COMPLETED";
+ private static final String STATUS_FAILED = "FAILED";
+ private static final String APPOINTMENT_EXPORT_TYPE = "appointment";
+
private final ObjectProvider<RabbitTemplate> rabbitTemplateProvider;
+ private final AppointmentApplicationService appointmentApplicationService;
+ private final LocalExportAsyncService localExportAsyncService;
+
+ private final ConcurrentHashMap<String, ExportTaskSnapshot> taskStore = new ConcurrentHashMap<>();
public String enqueueExport(String exportType, Map<String, Object> criteria) {
+ String normalizedType = normalizeType(exportType);
String taskId = UUID.randomUUID().toString().replace("-", "");
+ Map<String, Object> safeCriteria = criteria == null ? Map.of() : new HashMap<>(criteria);
RabbitTemplate rabbitTemplate = rabbitTemplateProvider.getIfAvailable();
if (rabbitTemplate == null) {
- log.warn(
- "RabbitMQ not configured, export task accepted locally only taskId={} type={}",
- taskId,
- exportType);
+ taskStore.put(taskId, ExportTaskSnapshot.queued(taskId, normalizedType));
+ localExportAsyncService.process(taskId, normalizedType, safeCriteria);
return taskId;
}
- log.info("Enqueue export taskId={} type={}", taskId, exportType);
- rabbitTemplate.convertAndSend(
- IntegrationConstant.EXPORT_TASK_QUEUE,
- Map.of("taskId", taskId, "type", exportType, "criteria", criteria));
+ taskStore.put(taskId, ExportTaskSnapshot.queued(taskId, normalizedType));
+ log.info("Enqueue export taskId={} type={}", taskId, normalizedType);
+ Map<String, Object> payload = new HashMap<>();
+ payload.put("taskId", taskId);
+ payload.put("type", normalizedType);
+ payload.put("criteria", safeCriteria);
+ rabbitTemplate.convertAndSend(IntegrationConstant.EXPORT_TASK_QUEUE, payload);
return taskId;
}
+
+ public ExportTaskStatusVo getTaskStatus(String taskId) {
+ ExportTaskSnapshot snapshot = taskStore.get(taskId);
+ if (snapshot == null) {
+ return null;
+ }
+ ExportTaskStatusVo vo = new ExportTaskStatusVo();
+ vo.setTaskId(snapshot.taskId());
+ vo.setType(snapshot.type());
+ vo.setStatus(snapshot.status());
+ vo.setFileName(snapshot.fileName());
+ vo.setErrorMessage(snapshot.errorMessage());
+ return vo;
+ }
+
+ public Path getExportFilePath(String taskId) {
+ ExportTaskSnapshot snapshot = taskStore.get(taskId);
+ if (snapshot == null || snapshot.filePath() == null || !Files.exists(snapshot.filePath())) {
+ return null;
+ }
+ return snapshot.filePath();
+ }
+
+ void processLocalExport(String taskId, String exportType, Map<String, Object> criteria) {
+ taskStore.computeIfPresent(taskId, (_k, v) -> v.toRunning());
+ try {
+ if (!APPOINTMENT_EXPORT_TYPE.equals(normalizeType(exportType))) {
+ throw new IllegalArgumentException("unsupported export type: " + exportType);
+ }
+ byte[] bytes =
+ appointmentApplicationService.exportExcel(
+ asString(criteria.get("visitedUserName")),
+ asLong(criteria.get("visitedDeptId")),
+ asString(criteria.get("accessArea")),
+ asString(criteria.get("localUnit")),
+ asLocalDate(criteria.get("visitDateStart")),
+ asLocalDate(criteria.get("visitDateEnd")),
+ asString(criteria.get("displayStatus")),
+ asString(criteria.get("approvalStatus")),
+ asString(criteria.get("visitorKeyword")),
+ asString(criteria.get("visitReasonCode")));
+ Path exportDir = Paths.get(System.getProperty("java.io.tmpdir"), "reservation-mini-program-exports");
+ Files.createDirectories(exportDir);
+ Path filePath = exportDir.resolve(taskId + ".xlsx");
+ Files.write(filePath, bytes);
+ taskStore.put(
+ taskId,
+ new ExportTaskSnapshot(taskId, normalizeType(exportType), STATUS_COMPLETED, filePath, filePath.getFileName().toString(), null));
+ } catch (Exception ex) {
+ log.error("Local export failed taskId={} type={}", taskId, exportType, ex);
+ taskStore.put(
+ taskId,
+ new ExportTaskSnapshot(taskId, normalizeType(exportType), STATUS_FAILED, null, null, ex.getMessage()));
+ }
+ }
+
+ private String normalizeType(String exportType) {
+ return exportType == null || exportType.isBlank() ? APPOINTMENT_EXPORT_TYPE : exportType.trim();
+ }
+
+ private String asString(Object value) {
+ return value == null ? null : value.toString().trim();
+ }
+
+ private Long asLong(Object value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Long.parseLong(value.toString().trim());
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private LocalDate asLocalDate(Object value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(value.toString().trim());
+ } catch (DateTimeParseException ex) {
+ return null;
+ }
+ }
+
+ private record ExportTaskSnapshot(
+ String taskId,
+ String type,
+ String status,
+ Path filePath,
+ String fileName,
+ String errorMessage) {
+
+ private static ExportTaskSnapshot queued(String taskId, String type) {
+ return new ExportTaskSnapshot(taskId, type, STATUS_QUEUED, null, null, null);
+ }
+
+ private ExportTaskSnapshot toRunning() {
+ return new ExportTaskSnapshot(taskId, type, STATUS_RUNNING, filePath, fileName, errorMessage);
+ }
+ }
}
diff --git a/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java b/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java
new file mode 100644
index 0000000..c3d5f5e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.service;
+
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class LocalExportAsyncService {
+
+ private final ExportApplicationService exportApplicationService;
+
+ @Async
+ public void process(String taskId, String exportType, Map<String, Object> criteria) {
+ exportApplicationService.processLocalExport(taskId, exportType, criteria);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
index 4cf442b..344c06b 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
@@ -15,6 +15,7 @@
import com.sinata.appointment.constants.SystemSettingKeys;
import com.sinata.appointment.dto.MiniAppointmentCreateRequest;
import com.sinata.appointment.dto.MiniAppointmentListVo;
+import com.sinata.appointment.dto.MiniApprovalHistoryRow;
import com.sinata.appointment.dto.MiniAppointmentVehicleItemRequest;
import com.sinata.appointment.dto.MiniApprovalDetailVisitorVo;
import com.sinata.appointment.dto.MiniMyAppointmentDetailVo;
@@ -67,6 +68,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
+import org.springframework.context.ApplicationEventPublisher;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
@Service
@RequiredArgsConstructor
@@ -96,6 +99,7 @@
private final PostApprovalOrchestrationService postApprovalOrchestrationService;
private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
private final ObjectMapper objectMapper;
+ private final ApplicationEventPublisher applicationEventPublisher;
@Transactional(rollbackFor = Exception.class)
public Long create(MiniAppointmentCreateRequest req) {
@@ -179,11 +183,11 @@
List<String> chain = approvalEngineService.resolveEnabledNodeChain(order);
if (chain.isEmpty()) {
- postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+ applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
} else {
ApprovalInstance inst = approvalEngineService.startFlow(order);
if (inst == null) {
- postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+ applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
}
}
return order.getId();
@@ -222,190 +226,94 @@
public IPage<MiniAppointmentListVo> pageMine(
long page, long size, String status, String visitedUserNameKeyword, LocalDate visitDate) {
MpUser current = miniAuthApplicationService.currentMpUser();
- LambdaQueryWrapper<ApprovalTask> tq =
- new LambdaQueryWrapper<ApprovalTask>()
- .eq(ApprovalTask::getDelFlag, 0)
- .eq(ApprovalTask::getAssigneeUserId, current.getId())
- .in(
- ApprovalTask::getTaskStatus,
- ApprovalTaskStatus.APPROVED,
- ApprovalTaskStatus.REJECTED)
- .orderByDesc(ApprovalTask::getUpdateTime)
- .orderByDesc(ApprovalTask::getId);
- if (StringUtils.hasText(status)) {
- tq.eq(ApprovalTask::getTaskStatus, status.trim());
+ Page<MiniApprovalHistoryRow> rowPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+ IPage<MiniApprovalHistoryRow> pagedRows =
+ approvalTaskMapper.pageMiniApprovalHistory(
+ rowPage,
+ current.getId(),
+ trimToNull(status),
+ trimToNull(visitedUserNameKeyword),
+ visitDate);
+ Page<MiniAppointmentListVo> result =
+ new Page<>(pagedRows.getCurrent(), pagedRows.getSize(), pagedRows.getTotal());
+ List<MiniApprovalHistoryRow> rows = pagedRows.getRecords();
+ if (rows == null || rows.isEmpty()) {
+ result.setRecords(List.of());
+ return result;
}
- List<ApprovalTask> tasks = approvalTaskMapper.selectList(tq);
- if (tasks.isEmpty()) {
- return new Page<>(page, size);
- }
-
- java.util.LinkedHashMap<Long, ApprovalTask> latestTaskByOrder = new java.util.LinkedHashMap<>();
- for (ApprovalTask task : tasks) {
- ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
- if (inst == null || (inst.getDelFlag() != null && inst.getDelFlag() == 1) || inst.getOrderId() == null) {
- continue;
- }
- latestTaskByOrder.putIfAbsent(inst.getOrderId(), task);
- }
- if (latestTaskByOrder.isEmpty()) {
- return new Page<>(page, size);
- }
-
- List<AppointmentOrder> orders =
- appointmentOrderMapper.selectList(
- new LambdaQueryWrapper<AppointmentOrder>()
- .eq(AppointmentOrder::getDelFlag, 0)
- .in(AppointmentOrder::getId, latestTaskByOrder.keySet()));
- java.util.Map<Long, AppointmentOrder> orderMap =
- orders.stream().collect(java.util.stream.Collectors.toMap(AppointmentOrder::getId, x -> x, (a, b) -> a));
-
- List<MiniAppointmentListVo> all = new java.util.ArrayList<>();
- for (var e : latestTaskByOrder.entrySet()) {
- AppointmentOrder order = orderMap.get(e.getKey());
- if (order == null) {
- continue;
- }
- if (StringUtils.hasText(visitedUserNameKeyword)) {
- if (!StringUtils.hasText(order.getVisitedUserName())
- || !order.getVisitedUserName().contains(visitedUserNameKeyword.trim())) {
- continue;
- }
- }
- if (visitDate != null) {
- if (order.getVisitStartDate() == null
- || order.getVisitEndDate() == null
- || visitDate.isBefore(order.getVisitStartDate())
- || visitDate.isAfter(order.getVisitEndDate())) {
- continue;
- }
- }
+ List<AppointmentOrder> orders = rows.stream().map(this::toOrderSnapshot).toList();
+ Map<Long, String> deptNameMap = loadDeptNameMap(orders);
+ Map<String, String> reasonNameMap = loadReasonNameMap(orders);
+ List<MiniAppointmentListVo> records = new ArrayList<>();
+ for (MiniApprovalHistoryRow row : rows) {
+ AppointmentOrder order = toOrderSnapshot(row);
MiniAppointmentListVo vo = toListVo(order);
- vo.setStatus(e.getValue().getTaskStatus());
- all.add(vo);
+ vo.setStatus(row.getTaskStatus());
+ vo.setVisitedDeptName(deptNameMap.get(order.getVisitedDeptId()));
+ vo.setVisitedUserDisplay(buildVisitedUserDisplay(order.getVisitedUserName(), vo.getVisitedDeptName()));
+ vo.setVisitTimeText(buildVisitTimeText(order));
+ vo.setVisitReasonText(buildVisitReasonText(order, reasonNameMap));
+ vo.setAccessAreaText(buildAccessAreaText(order.getPassAccessAreaNames()));
+ records.add(vo);
}
- return slicePage(page, size, all);
+ result.setRecords(records);
+ return result;
}
public IPage<MiniVisitRecordListVo> pageVisitRecords(
long page, long size, String visitorNameKeyword, LocalDate visitDate) {
MpUser current = miniAuthApplicationService.currentMpUser();
- List<AppointmentOrder> orders =
- appointmentOrderMapper.selectList(
- new LambdaQueryWrapper<AppointmentOrder>()
- .eq(AppointmentOrder::getDelFlag, 0)
- .eq(AppointmentOrder::getStatus, AppointmentOrderStatus.APPROVED)
- .eq(AppointmentOrder::getVisitedUserId, current.getId()));
- if (orders.isEmpty()) {
- return new Page<>(page, size);
+ Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+ IPage<AppointmentOrder> pagedOrders =
+ appointmentOrderMapper.pageMiniVisitRecords(
+ orderPage, current.getId(), trimToNull(visitorNameKeyword), visitDate);
+ Page<MiniVisitRecordListVo> result =
+ new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+ List<AppointmentOrder> orders = pagedOrders.getRecords();
+ if (orders == null || orders.isEmpty()) {
+ result.setRecords(List.of());
+ return result;
}
- List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
- List<AppointmentVisitor> visitors =
- appointmentVisitorMapper.selectList(
- new LambdaQueryWrapper<AppointmentVisitor>()
- .eq(AppointmentVisitor::getDelFlag, 0)
- .in(AppointmentVisitor::getOrderId, orderIds)
- .orderByAsc(AppointmentVisitor::getId));
- Map<Long, List<String>> visitorNamesMap = new java.util.LinkedHashMap<>();
- for (AppointmentVisitor row : visitors) {
- visitorNamesMap.computeIfAbsent(row.getOrderId(), k -> new java.util.ArrayList<>());
- if (StringUtils.hasText(row.getName())) {
- visitorNamesMap.get(row.getOrderId()).add(row.getName().trim());
- }
- }
-
- List<MiniVisitRecordListVo> rows = new java.util.ArrayList<>();
+ Map<Long, List<String>> visitorNamesMap = loadVisitorNamesMap(orders.stream().map(AppointmentOrder::getId).toList());
+ List<MiniVisitRecordListVo> records = new ArrayList<>();
for (AppointmentOrder order : orders) {
- List<String> names = visitorNamesMap.getOrDefault(order.getId(), List.of());
- if (StringUtils.hasText(visitorNameKeyword)) {
- String kw = visitorNameKeyword.trim();
- boolean hit = false;
- for (String n : names) {
- if (n.contains(kw)) {
- hit = true;
- break;
- }
- }
- if (!hit) {
- continue;
- }
- }
- if (visitDate != null) {
- if (order.getVisitStartDate() == null
- || order.getVisitEndDate() == null
- || visitDate.isBefore(order.getVisitStartDate())
- || visitDate.isAfter(order.getVisitEndDate())) {
- continue;
- }
- }
MiniVisitRecordListVo vo = new MiniVisitRecordListVo();
vo.setOrderId(order.getId());
vo.setOrderNo(order.getOrderNo());
- vo.setVisitorNames(names);
+ vo.setVisitorNames(visitorNamesMap.getOrDefault(order.getId(), List.of()));
vo.setVisitStartDate(order.getVisitStartDate());
vo.setVisitEndDate(order.getVisitEndDate());
vo.setVisitTimeMode(order.getVisitTimeMode());
vo.setVisitTimeSlot(order.getVisitTimeSlot());
vo.setCreateTime(order.getCreateTime());
- rows.add(vo);
+ records.add(vo);
}
-
- rows.sort(
- java.util.Comparator
- .comparing(
- MiniVisitRecordListVo::getVisitEndDate,
- java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
- .reversed()
- .thenComparing(
- this::singleDaySlotSortWeight,
- java.util.Comparator.reverseOrder())
- .thenComparing(
- MiniVisitRecordListVo::getCreateTime,
- java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())));
- return slicePageVisitRecord(page, size, rows);
+ result.setRecords(records);
+ return result;
}
public IPage<MiniAppointmentListVo> pageMyOrders(
long page, long size, String status, String keyword) {
MpUser current = miniAuthApplicationService.currentMpUser();
- List<AppointmentOrder> orders =
- appointmentOrderMapper.selectList(
- new LambdaQueryWrapper<AppointmentOrder>()
- .eq(AppointmentOrder::getDelFlag, 0)
- .eq(AppointmentOrder::getSubmitterId, current.getId())
- .orderByDesc(AppointmentOrder::getCreateTime)
- .orderByDesc(AppointmentOrder::getId));
- if (orders.isEmpty()) {
- return new Page<>(page, size);
+ Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+ IPage<AppointmentOrder> pagedOrders =
+ appointmentOrderMapper.pageMiniMyOrders(
+ orderPage, current.getId(), trimToNull(status), trimToNull(keyword));
+ Page<MiniAppointmentListVo> result =
+ new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+ List<AppointmentOrder> orders = pagedOrders.getRecords();
+ if (orders == null || orders.isEmpty()) {
+ result.setRecords(List.of());
+ return result;
}
-
Map<Long, String> deptNameMap = loadDeptNameMap(orders);
Map<String, String> reasonNameMap = loadReasonNameMap(orders);
- String normalizedKeyword = trimToNull(keyword);
- String normalizedStatus = trimToNull(status);
- List<MiniAppointmentListVo> rows = new ArrayList<>();
+ List<MiniAppointmentListVo> records = new ArrayList<>();
for (AppointmentOrder order : orders) {
- MiniAppointmentListVo vo = toMyOrderListVo(order, deptNameMap, reasonNameMap);
- if (StringUtils.hasText(normalizedStatus)
- && !Objects.equals(normalizedStatus, vo.getDisplayStatus())) {
- continue;
- }
- if (StringUtils.hasText(normalizedKeyword)
- && !containsIgnoreCase(vo.getVisitedUserDisplay(), normalizedKeyword)
- && !containsIgnoreCase(vo.getVisitReasonText(), normalizedKeyword)) {
- continue;
- }
- rows.add(vo);
+ records.add(toMyOrderListVo(order, deptNameMap, reasonNameMap));
}
-
- rows.sort(
- Comparator.comparing(
- MiniAppointmentListVo::getCreateTime,
- Comparator.nullsLast(Comparator.reverseOrder()))
- .thenComparing(
- MiniAppointmentListVo::getId,
- Comparator.nullsLast(Comparator.reverseOrder())));
- return slicePage(page, size, rows);
+ result.setRecords(records);
+ return result;
}
public List<MiniMyVisitorCodeOrderVo> listMyVisitorCodes() {
@@ -809,6 +717,25 @@
return vo;
}
+ private AppointmentOrder toOrderSnapshot(MiniApprovalHistoryRow row) {
+ AppointmentOrder order = new AppointmentOrder();
+ order.setId(row.getId());
+ order.setUuid(row.getUuid());
+ order.setOrderNo(row.getOrderNo());
+ order.setVisitedUserName(row.getVisitedUserName());
+ order.setVisitedDeptId(row.getVisitedDeptId());
+ order.setVisitStartDate(row.getVisitStartDate());
+ order.setVisitEndDate(row.getVisitEndDate());
+ order.setVisitReasonCode(row.getVisitReasonCode());
+ order.setVisitReasonOther(row.getVisitReasonOther());
+ order.setVisitTimeMode(row.getVisitTimeMode());
+ order.setVisitTimeSlot(row.getVisitTimeSlot());
+ order.setPassAccessAreaNames(row.getPassAccessAreaNames());
+ order.setStatus(row.getStatus());
+ order.setCreateTime(row.getCreateTime());
+ return order;
+ }
+
private Map<Long, String> loadDeptNameMap(List<AppointmentOrder> orders) {
Set<Long> deptIds =
orders.stream()
@@ -852,6 +779,26 @@
LinkedHashMap::new));
}
+ private Map<Long, List<String>> loadVisitorNamesMap(List<Long> orderIds) {
+ if (orderIds == null || orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AppointmentVisitor> visitors =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .in(AppointmentVisitor::getOrderId, orderIds)
+ .orderByAsc(AppointmentVisitor::getId));
+ Map<Long, List<String>> result = new LinkedHashMap<>();
+ for (AppointmentVisitor row : visitors) {
+ result.computeIfAbsent(row.getOrderId(), k -> new ArrayList<>());
+ if (StringUtils.hasText(row.getName())) {
+ result.get(row.getOrderId()).add(row.getName().trim());
+ }
+ }
+ return result;
+ }
+
private String buildVisitedUserDisplay(String userName, String deptName) {
String name = trimToNull(userName);
String dept = trimToNull(deptName);
diff --git a/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
index 2af6630..a4134cb 100644
--- a/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
@@ -24,7 +24,11 @@
import com.sinata.appointment.util.SensitiveDataUtil;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -81,6 +85,8 @@
.eq(AppointmentVisitor::getDelFlag, 0)
.eq(AppointmentVisitor::getOrderId, order.getId())
.orderByAsc(AppointmentVisitor::getId));
+ Map<Long, MpVisitor> visitorProfileMap = loadVisitorProfileMap(visitors);
+ Map<Long, QrcodeTicket> existingTicketMap = loadActiveUnusedTicketMap(order.getId(), visitors);
int validMinutes = getMaxConsecutiveDays() * 24 * 60;
LocalDateTime generateTime = resolveGenerateTime(order);
LocalDateTime expireTime = generateTime.plusMinutes(validMinutes);
@@ -90,17 +96,17 @@
int createdCount = 0;
for (AppointmentVisitor visitor : visitors) {
- QrcodeTicket existed = findActiveUnusedTicket(order.getId(), visitor.getMpVisitorId());
+ QrcodeTicket existed = existingTicketMap.get(visitor.getMpVisitorId());
if (existed != null) {
log.info(
"{} orderId={} mpVisitorId={} reuse ticketId={}",
IntegrationConstant.HIKVISION_QRCODE_EVENT,
- order.getId(),
- visitor.getMpVisitorId(),
- existed.getId());
+ order.getId(),
+ visitor.getMpVisitorId(),
+ existed.getId());
continue;
}
- MpVisitor visitorProfile = loadVisitor(visitor.getMpVisitorId());
+ MpVisitor visitorProfile = visitorProfileMap.get(visitor.getMpVisitorId());
String ticketUuid = UUID.randomUUID().toString().replace("-", "");
String visitorIdCardPlain = resolveVisitorIdCardPlain(visitor, visitorProfile);
String payload =
@@ -125,6 +131,7 @@
ticket.setGenerateTime(generateTime);
ticket.setExpireTime(expireTime);
qrcodeTicketMapper.insert(ticket);
+ existingTicketMap.put(visitor.getMpVisitorId(), ticket);
createdCount++;
log.info(
"{} raw-payload orderId={} mpVisitorId={} payload={}",
@@ -166,20 +173,58 @@
}
}
- private QrcodeTicket findActiveUnusedTicket(Long orderId, Long mpVisitorId) {
- LambdaQueryWrapper<QrcodeTicket> query =
- new LambdaQueryWrapper<QrcodeTicket>()
- .eq(QrcodeTicket::getDelFlag, 0)
- .eq(QrcodeTicket::getOrderId, orderId)
- .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
- .orderByDesc(QrcodeTicket::getId)
- .last("limit 1");
- if (mpVisitorId == null) {
- query.isNull(QrcodeTicket::getMpVisitorId);
- } else {
- query.eq(QrcodeTicket::getMpVisitorId, mpVisitorId);
+ private Map<Long, MpVisitor> loadVisitorProfileMap(List<AppointmentVisitor> visitors) {
+ if (visitors == null || visitors.isEmpty()) {
+ return Map.of();
}
- return qrcodeTicketMapper.selectOne(query);
+ List<Long> visitorIds =
+ visitors.stream()
+ .map(AppointmentVisitor::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ if (visitorIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, visitorIds));
+ Map<Long, MpVisitor> result = new LinkedHashMap<>();
+ for (MpVisitor row : rows) {
+ result.putIfAbsent(row.getId(), row);
+ }
+ return result;
+ }
+
+ private Map<Long, QrcodeTicket> loadActiveUnusedTicketMap(Long orderId, List<AppointmentVisitor> visitors) {
+ if (orderId == null || visitors == null || visitors.isEmpty()) {
+ return new LinkedHashMap<>();
+ }
+ LinkedHashSet<Long> visitorIds =
+ visitors.stream()
+ .map(AppointmentVisitor::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+ if (visitorIds.isEmpty()) {
+ return new LinkedHashMap<>();
+ }
+ List<QrcodeTicket> rows =
+ qrcodeTicketMapper.selectList(
+ new LambdaQueryWrapper<QrcodeTicket>()
+ .eq(QrcodeTicket::getDelFlag, 0)
+ .eq(QrcodeTicket::getOrderId, orderId)
+ .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
+ .in(QrcodeTicket::getMpVisitorId, visitorIds)
+ .orderByDesc(QrcodeTicket::getId));
+ Map<Long, QrcodeTicket> result = new LinkedHashMap<>();
+ for (QrcodeTicket row : rows) {
+ if (row.getMpVisitorId() != null) {
+ result.putIfAbsent(row.getMpVisitorId(), row);
+ }
+ }
+ return result;
}
private LocalDateTime resolveGenerateTime(AppointmentOrder order) {
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java
new file mode 100644
index 0000000..315d505
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java
@@ -0,0 +1,3 @@
+package com.sinata.appointment.service.event;
+
+public record AppointmentApprovedEvent(Long orderId) {}
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java
new file mode 100644
index 0000000..24eb970
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java
@@ -0,0 +1,42 @@
+package com.sinata.appointment.service.event;
+
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.service.PostApprovalOrchestrationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AppointmentLifecycleEventListener {
+
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleApproved(AppointmentApprovedEvent event) {
+ AppointmentOrder order = appointmentOrderMapper.selectById(event.orderId());
+ if (order == null) {
+ log.warn("Approved event skipped, order not found orderId={}", event.orderId());
+ return;
+ }
+ postApprovalOrchestrationService.onOrderFullyApproved(order);
+ }
+
+ @Async
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void handleRejected(AppointmentRejectedEvent event) {
+ AppointmentOrder order = appointmentOrderMapper.selectById(event.orderId());
+ if (order == null) {
+ log.warn("Rejected event skipped, order not found orderId={}", event.orderId());
+ return;
+ }
+ postApprovalOrchestrationService.onOrderRejected(order, event.reason());
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java
new file mode 100644
index 0000000..00dcf46
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java
@@ -0,0 +1,3 @@
+package com.sinata.appointment.service.event;
+
+public record AppointmentRejectedEvent(Long orderId, String reason) {}
diff --git a/backend/src/main/resources/db/migration/V28__performance_indexes.sql b/backend/src/main/resources/db/migration/V28__performance_indexes.sql
new file mode 100644
index 0000000..3f3cdc1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V28__performance_indexes.sql
@@ -0,0 +1,20 @@
+ALTER TABLE appointment_order
+ ADD KEY idx_ao_del_create_id (del_flag, create_time, id),
+ ADD KEY idx_ao_del_visited_status (del_flag, visited_user_id, status),
+ ADD KEY idx_ao_del_submitter_create_id (del_flag, submitter_id, create_time, id),
+ ADD KEY idx_ao_del_visited_visit_range (del_flag, visited_user_id, visit_start_date, visit_end_date);
+
+ALTER TABLE approval_instance
+ ADD KEY idx_ai_del_order_id (del_flag, order_id, id);
+
+ALTER TABLE approval_task
+ ADD KEY idx_at_del_assignee_status_create (del_flag, assignee_user_id, task_status, create_time),
+ ADD KEY idx_at_del_assignee_status_update_id (del_flag, assignee_user_id, task_status, update_time, id),
+ ADD KEY idx_at_instance_node_group_status (instance_id, node_code, group_key, task_status);
+
+ALTER TABLE appointment_visitor
+ ADD KEY idx_av_del_order_id (del_flag, order_id, id),
+ ADD KEY idx_av_del_name (del_flag, name);
+
+ALTER TABLE qrcode_ticket
+ ADD KEY idx_qt_del_order_visitor_status_id (del_flag, order_id, mp_visitor_id, ticket_status, id);
diff --git a/backend/src/main/resources/mapper/AppointmentOrderMapper.xml b/backend/src/main/resources/mapper/AppointmentOrderMapper.xml
new file mode 100644
index 0000000..4cb7f84
--- /dev/null
+++ b/backend/src/main/resources/mapper/AppointmentOrderMapper.xml
@@ -0,0 +1,192 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.AppointmentOrderMapper">
+
+ <sql id="AdminOrderWhere">
+ o.del_flag = 0
+ <if test="visitedUserName != null and visitedUserName != ''">
+ AND o.visited_user_name LIKE CONCAT('%', #{visitedUserName}, '%')
+ </if>
+ <if test="visitedDeptId != null">
+ AND o.visited_dept_id = #{visitedDeptId}
+ </if>
+ <if test="accessArea != null and accessArea != ''">
+ AND o.pass_access_area_names LIKE CONCAT('%', #{accessArea}, '%')
+ </if>
+ <if test="localUnit != null and localUnit != ''">
+ AND o.pass_local_unit_names LIKE CONCAT('%', #{localUnit}, '%')
+ </if>
+ <if test="visitReasonCode != null and visitReasonCode != ''">
+ AND o.visit_reason_code = #{visitReasonCode}
+ </if>
+ <if test="visitDateEnd != null">
+ AND o.visit_start_date <= #{visitDateEnd}
+ </if>
+ <if test="visitDateStart != null">
+ AND o.visit_end_date >= #{visitDateStart}
+ </if>
+ <if test="visitorKeyword != null and visitorKeyword != ''">
+ AND EXISTS (
+ SELECT 1
+ FROM appointment_visitor av
+ WHERE av.del_flag = 0
+ AND av.order_id = o.id
+ AND av.name LIKE CONCAT('%', #{visitorKeyword}, '%')
+ )
+ </if>
+ <if test="displayStatus != null and displayStatus != ''">
+ <choose>
+ <when test="displayStatus == 'PENDING_VISIT'">
+ AND (
+ o.status = 'PENDING_APPROVAL'
+ OR (o.status = 'APPROVED' AND CURDATE() < o.visit_start_date)
+ )
+ </when>
+ <when test="displayStatus == 'EFFECTIVE'">
+ AND o.status = 'APPROVED'
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND CURDATE() >= o.visit_start_date
+ AND CURDATE() <= o.visit_end_date
+ </when>
+ <otherwise>
+ AND NOT (
+ o.status = 'PENDING_APPROVAL'
+ OR (o.status = 'APPROVED' AND CURDATE() < o.visit_start_date)
+ OR (
+ o.status = 'APPROVED'
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND CURDATE() >= o.visit_start_date
+ AND CURDATE() <= o.visit_end_date
+ )
+ )
+ </otherwise>
+ </choose>
+ </if>
+ <if test="approvalStatus != null and approvalStatus != ''">
+ <choose>
+ <when test="approvalStatus == 'APPROVED'">
+ AND o.status IN ('APPROVED', 'VISITED', 'EXPIRED')
+ </when>
+ <when test="approvalStatus == 'REJECTED'">
+ AND o.status = 'REJECTED'
+ </when>
+ <when test="approvalStatus == 'CANCELLED'">
+ AND o.status = 'CANCELLED'
+ </when>
+ <when test="approvalStatus == 'PENDING_APPROVAL'">
+ AND o.status = 'PENDING_APPROVAL'
+ AND (ai.current_node_code IS NULL OR ai.current_node_code = '')
+ </when>
+ <otherwise>
+ AND o.status = 'PENDING_APPROVAL'
+ AND ai.current_node_code = #{approvalStatus}
+ </otherwise>
+ </choose>
+ </if>
+ </sql>
+
+ <select id="pageAdminOrders" resultType="com.sinata.appointment.entity.AppointmentOrder">
+ SELECT o.*
+ FROM appointment_order o
+ LEFT JOIN (
+ SELECT ai1.order_id, ai1.current_node_code
+ FROM approval_instance ai1
+ INNER JOIN (
+ SELECT order_id, MAX(id) AS max_id
+ FROM approval_instance
+ WHERE del_flag = 0
+ GROUP BY order_id
+ ) latest ON latest.max_id = ai1.id
+ WHERE ai1.del_flag = 0
+ ) ai ON ai.order_id = o.id
+ WHERE
+ <include refid="AdminOrderWhere"/>
+ ORDER BY o.create_time DESC, o.id DESC
+ </select>
+
+ <select id="pageMiniVisitRecords" resultType="com.sinata.appointment.entity.AppointmentOrder">
+ SELECT o.*
+ FROM appointment_order o
+ WHERE o.del_flag = 0
+ AND o.status = 'APPROVED'
+ AND o.visited_user_id = #{visitedUserId}
+ <if test="visitDate != null">
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND #{visitDate} >= o.visit_start_date
+ AND #{visitDate} <= o.visit_end_date
+ </if>
+ <if test="visitorNameKeyword != null and visitorNameKeyword != ''">
+ AND EXISTS (
+ SELECT 1
+ FROM appointment_visitor av
+ WHERE av.del_flag = 0
+ AND av.order_id = o.id
+ AND av.name LIKE CONCAT('%', #{visitorNameKeyword}, '%')
+ )
+ </if>
+ ORDER BY o.visit_end_date DESC,
+ CASE
+ WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'ALL_DAY' THEN 3
+ WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'PM' THEN 2
+ WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'AM' THEN 1
+ ELSE 0
+ END DESC,
+ o.create_time DESC,
+ o.id DESC
+ </select>
+
+ <select id="pageMiniMyOrders" resultType="com.sinata.appointment.entity.AppointmentOrder">
+ SELECT o.*
+ FROM appointment_order o
+ LEFT JOIN biz_visit_reason bvr
+ ON bvr.del_flag = 0
+ AND bvr.reason_code = o.visit_reason_code
+ WHERE o.del_flag = 0
+ AND o.submitter_id = #{submitterId}
+ <if test="status != null and status != ''">
+ <choose>
+ <when test="status == 'PENDING_VISIT'">
+ AND (
+ o.status = 'PENDING_APPROVAL'
+ OR (o.status = 'APPROVED' AND CURDATE() < o.visit_start_date)
+ )
+ </when>
+ <when test="status == 'EFFECTIVE'">
+ AND o.status = 'APPROVED'
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND CURDATE() >= o.visit_start_date
+ AND CURDATE() <= o.visit_end_date
+ </when>
+ <otherwise>
+ AND NOT (
+ o.status = 'PENDING_APPROVAL'
+ OR (o.status = 'APPROVED' AND CURDATE() < o.visit_start_date)
+ OR (
+ o.status = 'APPROVED'
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND CURDATE() >= o.visit_start_date
+ AND CURDATE() <= o.visit_end_date
+ )
+ )
+ </otherwise>
+ </choose>
+ </if>
+ <if test="keyword != null and keyword != ''">
+ AND (
+ o.visited_user_name LIKE CONCAT('%', #{keyword}, '%')
+ OR o.visit_reason_code LIKE CONCAT('%', #{keyword}, '%')
+ OR o.visit_reason_other LIKE CONCAT('%', #{keyword}, '%')
+ OR bvr.reason_name LIKE CONCAT('%', #{keyword}, '%')
+ )
+ </if>
+ ORDER BY o.create_time DESC, o.id DESC
+ </select>
+
+</mapper>
diff --git a/backend/src/main/resources/mapper/ApprovalTaskMapper.xml b/backend/src/main/resources/mapper/ApprovalTaskMapper.xml
new file mode 100644
index 0000000..62dc275
--- /dev/null
+++ b/backend/src/main/resources/mapper/ApprovalTaskMapper.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.ApprovalTaskMapper">
+
+ <select id="pageMiniApprovalHistory" resultType="com.sinata.appointment.dto.MiniApprovalHistoryRow">
+ SELECT o.id,
+ o.uuid,
+ o.order_no AS orderNo,
+ o.visited_user_name AS visitedUserName,
+ o.visited_dept_id AS visitedDeptId,
+ o.visit_start_date AS visitStartDate,
+ o.visit_end_date AS visitEndDate,
+ o.visit_reason_code AS visitReasonCode,
+ o.visit_reason_other AS visitReasonOther,
+ o.visit_time_mode AS visitTimeMode,
+ o.visit_time_slot AS visitTimeSlot,
+ o.pass_access_area_names AS passAccessAreaNames,
+ o.status,
+ o.create_time AS createTime,
+ t.task_status AS taskStatus
+ FROM approval_task t
+ INNER JOIN approval_instance ai ON ai.id = t.instance_id AND ai.del_flag = 0
+ INNER JOIN appointment_order o ON o.id = ai.order_id AND o.del_flag = 0
+ INNER JOIN (
+ SELECT ai2.order_id AS orderId, MAX(t2.id) AS latestTaskId
+ FROM approval_task t2
+ INNER JOIN approval_instance ai2 ON ai2.id = t2.instance_id AND ai2.del_flag = 0
+ WHERE t2.del_flag = 0
+ AND t2.assignee_user_id = #{assigneeUserId}
+ AND t2.task_status IN ('APPROVED', 'REJECTED')
+ <if test="status != null and status != ''">
+ AND t2.task_status = #{status}
+ </if>
+ GROUP BY ai2.order_id
+ ) latest ON latest.latestTaskId = t.id
+ WHERE t.del_flag = 0
+ <if test="visitedUserNameKeyword != null and visitedUserNameKeyword != ''">
+ AND o.visited_user_name LIKE CONCAT('%', #{visitedUserNameKeyword}, '%')
+ </if>
+ <if test="visitDate != null">
+ AND o.visit_start_date IS NOT NULL
+ AND o.visit_end_date IS NOT NULL
+ AND #{visitDate} >= o.visit_start_date
+ AND #{visitDate} <= o.visit_end_date
+ </if>
+ ORDER BY t.update_time DESC, t.id DESC
+ </select>
+
+</mapper>
diff --git a/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java
new file mode 100644
index 0000000..404383d
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java
@@ -0,0 +1,156 @@
+package com.sinata.appointment.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.dto.ApprovalActionRequest;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
+import com.sinata.appointment.service.event.AppointmentRejectedEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+@ExtendWith(MockitoExtension.class)
+class ApprovalApplicationServiceTest {
+
+ @Mock
+ private ApprovalTaskMapper approvalTaskMapper;
+ @Mock
+ private ApprovalInstanceMapper approvalInstanceMapper;
+ @Mock
+ private AppointmentOrderMapper appointmentOrderMapper;
+ @Mock
+ private ApprovalEngineService approvalEngineService;
+ @Mock
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ @InjectMocks
+ private ApprovalApplicationService approvalApplicationService;
+
+ @Test
+ void handleActionRejectPublishesRejectedEvent() {
+ ApprovalTask task = buildTask();
+ ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+ AppointmentOrder order = buildOrder(200L);
+ ApprovalActionRequest request = new ApprovalActionRequest();
+ request.setTaskId(100L);
+ request.setTaskUuid("task-uuid");
+ request.setApprove(false);
+ request.setComment("不同意");
+
+ when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+ when(approvalInstanceMapper.selectById(300L)).thenReturn(instance);
+ when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+
+ try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+ mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+ approvalApplicationService.handleAction(request);
+ }
+
+ verify(approvalTaskMapper).updateById(task);
+ verify(approvalEngineService).rejectInstance(instance, "不同意");
+ ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
+ verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
+ AppointmentRejectedEvent event = (AppointmentRejectedEvent) eventCaptor.getValue();
+ assertEquals(200L, event.orderId());
+ assertEquals("不同意", event.reason());
+ }
+
+ @Test
+ void handleActionApprovePublishesApprovedEventWhenFlowCompletes() {
+ ApprovalTask task = buildTask();
+ ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+ ApprovalInstance latest = buildInstance(200L, ApprovalInstanceStatus.APPROVED);
+ AppointmentOrder order = buildOrder(200L);
+ ApprovalActionRequest request = new ApprovalActionRequest();
+ request.setTaskId(100L);
+ request.setTaskUuid("task-uuid");
+ request.setApprove(true);
+ request.setComment("通过");
+
+ when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+ when(approvalInstanceMapper.selectById(300L)).thenReturn(instance, latest);
+ when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+ when(approvalEngineService.isCurrentNodeComplete(instance)).thenReturn(true);
+
+ try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+ mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+ approvalApplicationService.handleAction(request);
+ }
+
+ verify(approvalEngineService).markGroupSkippedExcept(task);
+ verify(approvalEngineService).advanceOrComplete(instance, order);
+ ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
+ verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
+ AppointmentApprovedEvent event = (AppointmentApprovedEvent) eventCaptor.getValue();
+ assertEquals(200L, event.orderId());
+ }
+
+ @Test
+ void handleActionApproveDoesNotPublishEventWhenFlowStillInProgress() {
+ ApprovalTask task = buildTask();
+ ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+ AppointmentOrder order = buildOrder(200L);
+ ApprovalActionRequest request = new ApprovalActionRequest();
+ request.setTaskId(100L);
+ request.setTaskUuid("task-uuid");
+ request.setApprove(true);
+
+ when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+ when(approvalInstanceMapper.selectById(300L)).thenReturn(instance);
+ when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+ when(approvalEngineService.isCurrentNodeComplete(instance)).thenReturn(false);
+
+ try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+ mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+ approvalApplicationService.handleAction(request);
+ }
+
+ verify(applicationEventPublisher, never()).publishEvent(any());
+ }
+
+ private ApprovalTask buildTask() {
+ ApprovalTask task = new ApprovalTask();
+ task.setId(100L);
+ task.setUuid("task-uuid");
+ task.setInstanceId(300L);
+ task.setAssigneeUserId(99L);
+ task.setTaskStatus(ApprovalTaskStatus.PENDING);
+ return task;
+ }
+
+ private ApprovalInstance buildInstance(Long orderId, String status) {
+ ApprovalInstance instance = new ApprovalInstance();
+ instance.setId(300L);
+ instance.setOrderId(orderId);
+ instance.setStatus(status);
+ return instance;
+ }
+
+ private AppointmentOrder buildOrder(Long id) {
+ AppointmentOrder order = new AppointmentOrder();
+ order.setId(id);
+ return order;
+ }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java
new file mode 100644
index 0000000..5b15649
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java
@@ -0,0 +1,86 @@
+package com.sinata.appointment.service;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sinata.appointment.dto.ExportTaskStatusVo;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.ObjectProvider;
+
+@ExtendWith(MockitoExtension.class)
+class ExportApplicationServiceTest {
+
+ @Mock
+ private ObjectProvider<RabbitTemplate> rabbitTemplateProvider;
+ @Mock
+ private AppointmentApplicationService appointmentApplicationService;
+ @Mock
+ private LocalExportAsyncService localExportAsyncService;
+
+ @InjectMocks
+ private ExportApplicationService exportApplicationService;
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void enqueueExportWithoutRabbitStartsLocalAsyncAndQueuesTask() {
+ when(rabbitTemplateProvider.getIfAvailable()).thenReturn(null);
+
+ String taskId = exportApplicationService.enqueueExport("appointment", Map.of("status", "PENDING_APPROVAL"));
+
+ assertNotNull(taskId);
+ ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+ assertEquals("QUEUED", status.getStatus());
+ verify(localExportAsyncService).process(taskId, "appointment", Map.of("status", "PENDING_APPROVAL"));
+ }
+
+ @Test
+ void processLocalExportCreatesFileAndMarksCompleted() throws Exception {
+ String oldTmpDir = System.getProperty("java.io.tmpdir");
+ System.setProperty("java.io.tmpdir", tempDir.toString());
+ try {
+ when(appointmentApplicationService.exportExcel(null, null, null, null, null, null, null, null, null, null))
+ .thenReturn("xlsx-content".getBytes());
+
+ String taskId = exportApplicationService.enqueueExport("appointment", Map.of());
+ exportApplicationService.processLocalExport(taskId, "appointment", Map.of());
+
+ ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+ assertEquals("COMPLETED", status.getStatus());
+ Path filePath = exportApplicationService.getExportFilePath(taskId);
+ assertNotNull(filePath);
+ assertTrue(Files.exists(filePath));
+ assertArrayEquals("xlsx-content".getBytes(), Files.readAllBytes(filePath));
+ } finally {
+ System.setProperty("java.io.tmpdir", oldTmpDir);
+ }
+ }
+
+ @Test
+ void processLocalExportMarksFailedForUnsupportedType() {
+ when(rabbitTemplateProvider.getIfAvailable()).thenReturn(null);
+
+ String taskId = exportApplicationService.enqueueExport("other", Map.of());
+ exportApplicationService.processLocalExport(taskId, "other", Map.of());
+
+ ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+ assertEquals("FAILED", status.getStatus());
+ assertTrue(status.getErrorMessage().contains("unsupported export type"));
+ assertNull(exportApplicationService.getExportFilePath(taskId));
+ }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java
new file mode 100644
index 0000000..8583718
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java
@@ -0,0 +1,156 @@
+package com.sinata.appointment.service;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.QrcodeTicketStatus;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.QrcodeTicket;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.QrcodeTicketMapper;
+import java.time.LocalDate;
+import java.util.List;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class PostApprovalOrchestrationServiceTest {
+
+ @Mock
+ private IntegrationHikvisionService integrationHikvisionService;
+ @Mock
+ private IntegrationWechatService integrationWechatService;
+ @Mock
+ private QrcodeTicketMapper qrcodeTicketMapper;
+ @Mock
+ private AppointmentOrderMapper appointmentOrderMapper;
+ @Mock
+ private ApprovalInstanceMapper approvalInstanceMapper;
+ @Mock
+ private AppointmentVisitorMapper appointmentVisitorMapper;
+ @Mock
+ private MpVisitorMapper mpVisitorMapper;
+ @Mock
+ private MpUserMapper mpUserMapper;
+ @Mock
+ private BizVisitReasonMapper bizVisitReasonMapper;
+ @Mock
+ private SysBizSettingCacheService sysBizSettingCacheService;
+
+ @InjectMocks
+ private PostApprovalOrchestrationService postApprovalOrchestrationService;
+
+ @Test
+ void onOrderFullyApprovedBatchesProfilesAndReusesExistingTickets() {
+ AppointmentOrder order = new AppointmentOrder();
+ order.setId(1L);
+ order.setVisitedUserId(8L);
+ order.setVisitStartDate(LocalDate.now().plusDays(1));
+
+ ApprovalInstance instance = new ApprovalInstance();
+ instance.setStatus(ApprovalInstanceStatus.APPROVED);
+
+ AppointmentVisitor reusedVisitor = new AppointmentVisitor();
+ reusedVisitor.setOrderId(1L);
+ reusedVisitor.setMpVisitorId(11L);
+ reusedVisitor.setName("张三");
+
+ AppointmentVisitor newVisitor = new AppointmentVisitor();
+ newVisitor.setOrderId(1L);
+ newVisitor.setMpVisitorId(12L);
+ newVisitor.setName("李四");
+
+ QrcodeTicket reusedTicket = new QrcodeTicket();
+ reusedTicket.setId(1001L);
+ reusedTicket.setOrderId(1L);
+ reusedTicket.setMpVisitorId(11L);
+ reusedTicket.setTicketStatus(QrcodeTicketStatus.UNUSED);
+
+ MpVisitor profile1 = new MpVisitor();
+ profile1.setId(11L);
+ profile1.setVisitorName("张三");
+
+ MpVisitor profile2 = new MpVisitor();
+ profile2.setId(12L);
+ profile2.setVisitorName("李四");
+
+ MpUser visitedUser = new MpUser();
+ visitedUser.setId(8L);
+ visitedUser.setNickName("员工A");
+
+ when(approvalInstanceMapper.selectOne(any())).thenReturn(instance);
+ when(appointmentVisitorMapper.selectList(any())).thenReturn(List.of(reusedVisitor, newVisitor));
+ when(mpVisitorMapper.selectList(any())).thenReturn(List.of(profile1, profile2));
+ when(qrcodeTicketMapper.selectList(any())).thenReturn(List.of(reusedTicket));
+ when(sysBizSettingCacheService.getValue(any())).thenReturn("7");
+ when(mpUserMapper.selectOne(any())).thenReturn(visitedUser);
+ when(integrationHikvisionService.requestQrcodePayload(
+ eq(order),
+ eq(newVisitor),
+ eq(profile2),
+ eq(visitedUser),
+ any(),
+ any(),
+ any(),
+ any(),
+ eq(7 * 24 * 60),
+ any(),
+ any()))
+ .thenReturn("payload-2");
+
+ postApprovalOrchestrationService.onOrderFullyApproved(order);
+
+ verify(appointmentOrderMapper).updateById(order);
+ verify(mpVisitorMapper, times(1)).selectList(any());
+ verify(qrcodeTicketMapper, times(1)).selectList(any());
+ verify(qrcodeTicketMapper, times(1)).insert(any(QrcodeTicket.class));
+ verify(integrationHikvisionService, times(1))
+ .requestQrcodePayload(
+ eq(order),
+ eq(newVisitor),
+ eq(profile2),
+ eq(visitedUser),
+ any(),
+ any(),
+ any(),
+ any(),
+ eq(7 * 24 * 60),
+ any(),
+ any());
+ verify(integrationWechatService).notifyApprovalFinalResult(order, true);
+ }
+
+ @Test
+ void onOrderFullyApprovedSkipsWhenLatestInstanceNotApproved() {
+ AppointmentOrder order = new AppointmentOrder();
+ order.setId(1L);
+
+ ApprovalInstance instance = new ApprovalInstance();
+ instance.setStatus(ApprovalInstanceStatus.IN_PROGRESS);
+
+ when(approvalInstanceMapper.selectOne(any())).thenReturn(instance);
+
+ postApprovalOrchestrationService.onOrderFullyApproved(order);
+
+ verify(appointmentOrderMapper, never()).updateById(any());
+ verify(appointmentVisitorMapper, never()).selectList(any());
+ verify(qrcodeTicketMapper, never()).insert(any());
+ }
+}
--
Gitblit v1.7.1