diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java
new file mode 100644
index 00000000..763f8f31
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java
@@ -0,0 +1,114 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 管理端打卡记录编辑请求
+ *
+ * 承载时间轴上单条打卡记录的全部可编辑项,字段按过程配置取舍:
+ * 打卡定位(地址/经纬度)、上传货量(重量/体积/数量)、上传凭证(照片)。
+ *
+ * {@code punched=false} 表示取消打卡,服务端会清除该节点的打卡流水
+ * ——时间轴上「未打卡」的判据就是没有流水,故不能只改状态位。
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "管理端打卡记录编辑请求")
+public class WaybillPunchRecordSaveRequest implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "运单ID")
+ private Long waybillId;
+
+ @Schema(description = "打卡类型:node=过程节点打卡,enroute=在途打卡")
+ private String type;
+
+ @Schema(description = "过程节点 key")
+ private String nodeCode;
+
+ @Schema(description = "过程节点名称")
+ private String nodeName;
+
+ @Schema(description = "打卡状态:true 已打卡,false 未打卡")
+ private Boolean punched;
+
+ @Schema(description = "打卡时间 yyyy-MM-dd HH:mm:ss,为空时取当前时间")
+ private String punchTime;
+
+ @Schema(description = "打卡地址")
+ private String address;
+
+ @Schema(description = "经度")
+ private String longitude;
+
+ @Schema(description = "纬度")
+ private String latitude;
+
+ @Schema(description = "重量(吨)")
+ private String weight;
+
+ @Schema(description = "体积(方)")
+ private String volume;
+
+ @Schema(description = "数量(件)")
+ private String quantity;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "打卡凭证图,按上传顺序回写凭证类型")
+ private List photos = new ArrayList<>();
+
+ /**
+ * 打卡凭证图
+ *
+ * 类型随图一起回写:展示层对「逗号分隔的裸 URL」按凭证类型下标回推标签,
+ * 编辑换了顺序就会串标,所以这里保留原类型。
+ */
+ @Data
+ @Schema(description = "打卡凭证图")
+ public static class Photo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "凭证类型")
+ private String type;
+
+ @Schema(description = "图片URL")
+ private String url;
+
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java
index e7bc1194..b6394dcb 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java
@@ -60,6 +60,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
+import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
import org.springblade.transport.service.IContractManageService;
import org.springblade.transport.service.ICustomerArchiveService;
import org.springblade.transport.service.IProjectApplyService;
@@ -113,6 +114,13 @@ public class WaybillController extends BladeController {
return R.data(waybillService.listPunchRecords(waybillId));
}
+ @PostMapping("/punch-record/save")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "编辑打卡记录", description = "按过程配置补录/修正节点或在途打卡;punched=false 表示取消打卡,清除该节点打卡流水")
+ public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) {
+ return R.status(waybillService.savePunchRecord(request));
+ }
+
@PostMapping("/locate")
@ApiOperationSupport(order = 1)
@Operation(summary = "车辆实时定位", description = "按运单绑定车牌调用 LBS_LOCATE")
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
index 7f3af7dc..8a5abe66 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
@@ -33,6 +33,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
+import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
import java.util.List;
@@ -59,6 +60,16 @@ public interface IWaybillService extends BaseService {
*/
WaybillPunchRecordsVO listPunchRecords(Long waybillId);
+ /**
+ * 管理端:编辑时间轴上的单条打卡记录(含打卡状态)
+ *
+ * punched=false 时清除该节点(在途节点为该运单全部在途流水)的打卡流水。
+ *
+ * @param request 打卡记录编辑请求
+ * @return 是否成功
+ */
+ boolean savePunchRecord(WaybillPunchRecordSaveRequest request);
+
/**
* 运单车辆实时定位(按运单绑定车牌调用 LBS)
*
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
index 5da21fac..3b4440c8 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
@@ -28,9 +28,11 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.JsonNode;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
+import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
+import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
@@ -49,6 +51,7 @@ import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
+import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
import org.springblade.transport.pojo.vo.LoadingManageVO;
import org.springblade.transport.pojo.vo.WaybillLocateVO;
@@ -97,6 +100,9 @@ public class WaybillServiceImpl extends BaseServiceImpl
private static final String STATUS_DRAFT = "draft";
+ /** 打卡记录类型:在途打卡(其余一律按过程节点打卡处理) */
+ private static final String ENROUTE_PUNCH_TYPE = "enroute";
+
@jakarta.annotation.Resource
private ILoadingManageService loadingManageService;
@@ -483,6 +489,274 @@ public class WaybillServiceImpl extends BaseServiceImpl
return vo;
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public boolean savePunchRecord(WaybillPunchRecordSaveRequest request) {
+ if (request == null || request.getWaybillId() == null) {
+ throw new ServiceException("运单ID不能为空");
+ }
+ Waybill waybill = getById(request.getWaybillId());
+ if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
+ throw new ServiceException("运单不存在");
+ }
+ // 只有显式传 false 才是取消打卡,未传按已打卡处理
+ boolean punched = !Boolean.FALSE.equals(request.getPunched());
+ if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
+ return saveEnroutePunchRecord(waybill, request, punched);
+ }
+ return saveNodePunchRecord(waybill, request, punched);
+ }
+
+ /**
+ * 过程节点打卡:已打卡补录/修正,未打卡清除该节点流水。
+ */
+ private boolean saveNodePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) {
+ Long waybillId = waybill.getId();
+ String processJson = resolveLiveProcessJson(waybill);
+ Map nodeConfig = findEnabledNodeConfig(processJson, request.getNodeCode(), request.getNodeName());
+ // 节点以过程配置为准:配置改过 key/name 后,前端带上来的可能是旧值
+ String nodeCode = nodeConfig == null
+ ? TransportBusinessSupport.trimToEmpty(request.getNodeCode())
+ : WaybillProcessSupport.nodeKey(nodeConfig);
+ String nodeName = nodeConfig == null
+ ? TransportBusinessSupport.trimToEmpty(request.getNodeName())
+ : WaybillProcessSupport.nodeName(nodeConfig);
+ if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) {
+ throw new ServiceException("过程节点不能为空");
+ }
+ List rows = nodePunchRows(waybillId, nodeCode, nodeName);
+ if (!punched) {
+ if (!rows.isEmpty()) {
+ waybillNodePunchMapper.deleteBatchIds(rows.stream().map(WaybillNodePunch::getId).toList());
+ }
+ return true;
+ }
+ WaybillNodePunch punch = pickLatestNodePunch(rows, nodeCode, nodeName);
+ boolean isNew = punch == null;
+ if (isNew) {
+ punch = new WaybillNodePunch();
+ punch.setWaybillId(waybillId);
+ punch.setWaybillNo(waybill.getWaybillNo());
+ // 管理端补录没有打卡司机,driver_id 留空,不记到任何司机头上
+ punch.setExceptionFlag(0);
+ }
+ if (Func.isNotEmpty(nodeCode)) {
+ punch.setNodeCode(nodeCode);
+ }
+ if (Func.isNotEmpty(nodeName)) {
+ punch.setNodeName(nodeName);
+ }
+ punch.setPunchTime(parsePunchTime(request.getPunchTime()));
+ punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress()));
+ punch.setLongitude(parseCoordinate(request.getLongitude(), "经度"));
+ punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度"));
+ punch.setWeight(TransportBusinessSupport.trimToNull(request.getWeight()));
+ punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume()));
+ punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity()));
+ punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark()));
+ punch.setPhotos(encodePunchPhotos(request.getPhotos()));
+ // 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护
+ if (isNew) {
+ waybillNodePunchMapper.insert(punch);
+ } else {
+ updateNodePunchFields(punch);
+ }
+ return true;
+ }
+
+ /**
+ * 显式按字段更新节点打卡。
+ *
+ * 不能用 updateById:它按「非空才更新」生成 SQL,把地址/货量/照片清空时这些字段为 null,
+ * 会被整条跳过——界面上删掉的内容保存后还在。
+ */
+ private void updateNodePunchFields(WaybillNodePunch punch) {
+ waybillNodePunchMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(WaybillNodePunch::getId, punch.getId())
+ .set(WaybillNodePunch::getNodeCode, punch.getNodeCode())
+ .set(WaybillNodePunch::getNodeName, punch.getNodeName())
+ .set(WaybillNodePunch::getPunchTime, punch.getPunchTime())
+ .set(WaybillNodePunch::getAddress, punch.getAddress())
+ .set(WaybillNodePunch::getLongitude, punch.getLongitude())
+ .set(WaybillNodePunch::getLatitude, punch.getLatitude())
+ .set(WaybillNodePunch::getWeight, punch.getWeight())
+ .set(WaybillNodePunch::getVolume, punch.getVolume())
+ .set(WaybillNodePunch::getQuantity, punch.getQuantity())
+ .set(WaybillNodePunch::getRemark, punch.getRemark())
+ .set(WaybillNodePunch::getPhotos, punch.getPhotos())
+ // entity 传 null,MP 不会自动填充审计字段,这里补上
+ .set(WaybillNodePunch::getUpdateTime, new Date())
+ .set(WaybillNodePunch::getUpdateUser, AuthUtil.getUserId()));
+ }
+
+ /**
+ * 在途打卡:时间轴上的在途节点只展示最新一条流水。
+ */
+ private boolean saveEnroutePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) {
+ Long waybillId = waybill.getId();
+ List rows = waybillEnroutePunchMapper.selectList(Wrappers.lambdaQuery()
+ .eq(WaybillEnroutePunch::getWaybillId, waybillId)
+ .orderByAsc(WaybillEnroutePunch::getPunchTime)
+ .orderByAsc(WaybillEnroutePunch::getId));
+ if (!punched) {
+ // 在途节点没有节点 key 可定位单条,取消打卡即清空该运单全部在途流水,
+ // 否则更早的流水会顶上来继续显示已打卡
+ if (!rows.isEmpty()) {
+ waybillEnroutePunchMapper.deleteBatchIds(rows.stream().map(WaybillEnroutePunch::getId).toList());
+ }
+ return true;
+ }
+ WaybillEnroutePunch punch = rows.isEmpty() ? null : rows.get(rows.size() - 1);
+ boolean isNew = punch == null;
+ if (isNew) {
+ punch = new WaybillEnroutePunch();
+ punch.setWaybillId(waybillId);
+ punch.setWaybillNo(waybill.getWaybillNo());
+ }
+ punch.setPunchTime(parsePunchTime(request.getPunchTime()));
+ punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress()));
+ punch.setLongitude(parseCoordinate(request.getLongitude(), "经度"));
+ punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度"));
+ // 在途打卡表只有一个照片字段(单张),多张只保留第一张
+ punch.setPhoto(firstPunchPhotoUrl(request.getPhotos()));
+ if (isNew) {
+ waybillEnroutePunchMapper.insert(punch);
+ } else {
+ // 同节点打卡:显式 set,否则清空地址/照片保存后不生效
+ waybillEnroutePunchMapper.update(null, Wrappers.lambdaUpdate()
+ .eq(WaybillEnroutePunch::getId, punch.getId())
+ .set(WaybillEnroutePunch::getPunchTime, punch.getPunchTime())
+ .set(WaybillEnroutePunch::getAddress, punch.getAddress())
+ .set(WaybillEnroutePunch::getLongitude, punch.getLongitude())
+ .set(WaybillEnroutePunch::getLatitude, punch.getLatitude())
+ .set(WaybillEnroutePunch::getPhoto, punch.getPhoto())
+ .set(WaybillEnroutePunch::getUpdateTime, new Date())
+ .set(WaybillEnroutePunch::getUpdateUser, AuthUtil.getUserId()));
+ }
+ return true;
+ }
+
+ /**
+ * 该节点名下的全部打卡流水(含历史),按打卡时间升序。
+ *
+ * 取消打卡要清空全部,只软删最新一条会让更早的历史流水顶上来继续显示已打卡。
+ */
+ private List nodePunchRows(Long waybillId, String nodeCode, String nodeName) {
+ return waybillNodePunchMapper.selectList(Wrappers.lambdaQuery()
+ .eq(WaybillNodePunch::getWaybillId, waybillId)
+ .orderByAsc(WaybillNodePunch::getPunchTime)
+ .orderByAsc(WaybillNodePunch::getId))
+ .stream()
+ .filter(punch -> {
+ String rowCode = TransportBusinessSupport.trimToEmpty(punch.getNodeCode());
+ String rowName = TransportBusinessSupport.trimToEmpty(punch.getNodeName());
+ if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(rowCode)) {
+ return true;
+ }
+ return Func.isNotEmpty(nodeName) && nodeName.equals(rowName);
+ })
+ .toList();
+ }
+
+ /**
+ * 取该节点最新一条流水,口径与展示层一致:nodeCode 命中优先,未命中才回退 nodeName。
+ */
+ private WaybillNodePunch pickLatestNodePunch(List rows, String nodeCode, String nodeName) {
+ WaybillNodePunch byCode = null;
+ WaybillNodePunch byName = null;
+ for (WaybillNodePunch row : rows) {
+ if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(row.getNodeCode()))) {
+ byCode = row;
+ } else if (Func.isNotEmpty(nodeName) && nodeName.equals(TransportBusinessSupport.trimToEmpty(row.getNodeName()))) {
+ byName = row;
+ }
+ }
+ return byCode != null ? byCode : byName;
+ }
+
+ /**
+ * 过程配置中启用(未启用时不参与打卡,补录无从校验)的节点配置;找不到返回 null。
+ */
+ private Map findEnabledNodeConfig(String processJson, String nodeCode, String nodeName) {
+ String code = TransportBusinessSupport.trimToEmpty(nodeCode);
+ String name = TransportBusinessSupport.trimToEmpty(nodeName);
+ List> nodes = WaybillProcessSupport.listEnabledProcessNodes(processJson);
+ if (nodes.isEmpty()) {
+ nodes = WaybillProcessSupport.listDriverPunchNodes(processJson);
+ }
+ for (Map node : nodes) {
+ if (Func.isNotEmpty(code) && code.equalsIgnoreCase(WaybillProcessSupport.nodeKey(node))) {
+ return node;
+ }
+ if (Func.isNotEmpty(name) && name.equals(WaybillProcessSupport.nodeName(node))) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。
+ */
+ private String encodePunchPhotos(List photos) {
+ if (photos == null || photos.isEmpty()) {
+ return null;
+ }
+ List> items = new ArrayList<>();
+ for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
+ String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
+ if (Func.isEmpty(url)) {
+ continue;
+ }
+ String type = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getType());
+ Map item = new LinkedHashMap<>();
+ item.put("type", Func.isEmpty(type) ? ("凭证" + (items.size() + 1)) : type);
+ item.put("url", url);
+ items.add(item);
+ }
+ return items.isEmpty() ? null : JsonUtil.toJson(items);
+ }
+
+ private String firstPunchPhotoUrl(List photos) {
+ if (photos == null) {
+ return null;
+ }
+ for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
+ String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
+ if (Func.isNotEmpty(url)) {
+ return url;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 打卡时间为空表示「此刻补录」;格式非法直接报错,不静默改成当前时间。
+ */
+ private Date parsePunchTime(String text) {
+ String value = TransportBusinessSupport.trimToEmpty(text);
+ if (Func.isEmpty(value)) {
+ return new Date();
+ }
+ try {
+ return DateUtil.parse(value, DateUtil.PATTERN_DATETIME);
+ } catch (Exception exception) {
+ throw new ServiceException("打卡时间格式不正确,应为 yyyy-MM-dd HH:mm:ss");
+ }
+ }
+
+ private BigDecimal parseCoordinate(String text, String label) {
+ String value = TransportBusinessSupport.trimToEmpty(text);
+ if (Func.isEmpty(value)) {
+ return null;
+ }
+ try {
+ return new BigDecimal(value);
+ } catch (NumberFormatException exception) {
+ throw new ServiceException(label + "格式不正确");
+ }
+ }
+
private WaybillNodePunch findLatestNodePunch(Map index, String nodeCode, String nodeName) {
if (index == null || index.isEmpty()) {
return null;
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java
new file mode 100644
index 00000000..81bbb9e1
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java
@@ -0,0 +1,136 @@
+package org.springblade.transport.controller;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
+import org.springblade.transport.service.IContractManageService;
+import org.springblade.transport.service.ICustomerArchiveService;
+import org.springblade.transport.service.IProjectApplyService;
+import org.springblade.transport.service.ITransportPlanService;
+import org.springblade.transport.service.IWaybillImportBatchService;
+import org.springblade.transport.service.IWaybillService;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 打卡记录编辑接口的入参绑定测试。
+ *
+ * 用真实的 HTTP 请求走一遍路由与 JSON 反序列化:字段名写错、凭证图数组结构对不上,
+ * 都会在这里失败,而不是等前端点保存时才发现。
+ */
+class WaybillPunchRecordEndpointTest {
+
+ private IWaybillService waybillService;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ waybillService = mock(IWaybillService.class);
+ WaybillController controller = new WaybillController(
+ waybillService,
+ mock(IProjectApplyService.class),
+ mock(IContractManageService.class),
+ mock(ICustomerArchiveService.class),
+ mock(ITransportPlanService.class),
+ mock(IWaybillImportBatchService.class));
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+ }
+
+ @Test
+ @DisplayName("POST /waybill-manage/punch-record/save:绑定打卡明细与凭证图")
+ void bindPunchRecordBody() throws Exception {
+ when(waybillService.savePunchRecord(any())).thenReturn(true);
+ String body = """
+ {
+ "waybillId": 6001,
+ "type": "node",
+ "nodeCode": "load",
+ "nodeName": "装货",
+ "punched": true,
+ "punchTime": "2026-10-06 09:30:00",
+ "address": "上海市青浦区",
+ "longitude": "121.47",
+ "latitude": "31.23",
+ "weight": "12.5",
+ "volume": "",
+ "quantity": "3",
+ "remark": "补录",
+ "photos": [{"type": "委托单", "url": "https://oss/a.jpg"}]
+ }
+ """;
+
+ mockMvc.perform(post("/waybill-manage/punch-record/save")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.success").value(true));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class);
+ verify(waybillService).savePunchRecord(captor.capture());
+ WaybillPunchRecordSaveRequest request = captor.getValue();
+ assertEquals(6001L, request.getWaybillId());
+ assertEquals("node", request.getType());
+ assertEquals("load", request.getNodeCode());
+ assertEquals("装货", request.getNodeName());
+ assertEquals(Boolean.TRUE, request.getPunched());
+ assertEquals("2026-10-06 09:30:00", request.getPunchTime());
+ assertEquals("上海市青浦区", request.getAddress());
+ assertEquals("121.47", request.getLongitude());
+ assertEquals("31.23", request.getLatitude());
+ assertEquals("12.5", request.getWeight());
+ assertEquals("3", request.getQuantity());
+ assertEquals("补录", request.getRemark());
+ assertNotNull(request.getPhotos());
+ assertEquals(1, request.getPhotos().size());
+ assertEquals("委托单", request.getPhotos().get(0).getType());
+ assertEquals("https://oss/a.jpg", request.getPhotos().get(0).getUrl());
+ }
+
+ @Test
+ @DisplayName("取消打卡:punched=false 原样传入服务层")
+ void bindUnpunchedFlag() throws Exception {
+ when(waybillService.savePunchRecord(any())).thenReturn(true);
+ String body = """
+ {"waybillId": 6001, "type": "enroute", "punched": false}
+ """;
+
+ mockMvc.perform(post("/waybill-manage/punch-record/save")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true));
+
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class);
+ verify(waybillService).savePunchRecord(captor.capture());
+ assertEquals(Boolean.FALSE, captor.getValue().getPunched());
+ assertEquals("enroute", captor.getValue().getType());
+ }
+
+ @Test
+ @DisplayName("服务层返回 false:响应标记为失败")
+ void reportFailureWhenServiceRejects() throws Exception {
+ when(waybillService.savePunchRecord(any())).thenReturn(false);
+ mockMvc.perform(post("/waybill-manage/punch-record/save")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"waybillId\": 6001}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(false));
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java
new file mode 100644
index 00000000..e59ee890
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java
@@ -0,0 +1,307 @@
+package org.springblade.transport.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
+import org.springblade.transport.mapper.WaybillMapper;
+import org.springblade.transport.mapper.WaybillNodePunchMapper;
+import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
+import org.springblade.transport.pojo.entity.Waybill;
+import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
+import org.springblade.transport.pojo.entity.WaybillNodePunch;
+
+import java.lang.reflect.Field;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 管理端打卡记录编辑测试。
+ *
+ * 守住三件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来
+ * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段。
+ */
+class WaybillPunchRecordSaveTest {
+
+ private static final Long WAYBILL_ID = 6001L;
+ private static final String WAYBILL_NO = "YD202610060001";
+ private static final String PROCESS_JSON = "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,"
+ + "\"punch\":true,\"location\":true,\"uploadCargo\":true,\"cargoTypes\":[\"重量\",\"数量\"],"
+ + "\"uploadVoucher\":true,\"voucherTypes\":[\"委托单\"]}]";
+
+ private WaybillMapper waybillMapper;
+ private WaybillNodePunchMapper nodePunchMapper;
+ private WaybillEnroutePunchMapper enroutePunchMapper;
+ private WaybillServiceImpl service;
+
+ @BeforeAll
+ static void initTableInfo() {
+ // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+ assistant.setCurrentNamespace("test");
+ TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
+ TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ waybillMapper = mock(WaybillMapper.class);
+ nodePunchMapper = mock(WaybillNodePunchMapper.class);
+ enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
+ service = new WaybillServiceImpl();
+ inject("baseMapper", waybillMapper);
+ inject("waybillNodePunchMapper", nodePunchMapper);
+ inject("waybillEnroutePunchMapper", enroutePunchMapper);
+ }
+
+ // ------------------------------------------------------------ 过程节点打卡
+
+ @Test
+ @DisplayName("补录未打卡节点:新建流水,节点取过程配置,不记司机、异常标记为否")
+ void insertNodePunchWhenNoneExists() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ when(nodePunchMapper.selectList(any())).thenReturn(List.of());
+
+ WaybillPunchRecordSaveRequest request = request("node", "LOAD", "装货");
+ request.setPunched(true);
+ request.setPunchTime("2026-10-06 09:30:00");
+ request.setWeight("12.5");
+ request.setRemark("补录");
+ request.setPhotos(List.of(photo("委托单", "https://oss/a.jpg")));
+
+ assertTrue(service.savePunchRecord(request));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
+ verify(nodePunchMapper).insert(captor.capture());
+ WaybillNodePunch saved = captor.getValue();
+ // 大小写不一致也应按过程配置回写节点 key,否则司机端按 key 查不到这条打卡
+ assertEquals("load", saved.getNodeCode());
+ assertEquals("装货", saved.getNodeName());
+ assertEquals(WAYBILL_ID, saved.getWaybillId());
+ assertEquals(WAYBILL_NO, saved.getWaybillNo());
+ assertNotNull(saved.getPunchTime());
+ assertEquals("12.5", saved.getWeight());
+ assertEquals(0, saved.getExceptionFlag());
+ assertNull(saved.getDriverId(), "管理端补录不能记到司机头上");
+ assertNotNull(saved.getPhotos());
+ assertTrue(saved.getPhotos().contains("https://oss/a.jpg"), "照片URL应写入 photos");
+ assertTrue(saved.getPhotos().contains("委托单"), "凭证类型应随照片一起写入");
+ verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
+ }
+
+ @Test
+ @DisplayName("编辑已打卡节点:改最新一条,不新建,也不改写异常标记")
+ @SuppressWarnings("unchecked")
+ void updateLatestNodePunch() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ WaybillNodePunch existed = nodePunch(7001L, "load", "装货");
+ existed.setExceptionFlag(1);
+ existed.setWeight("10");
+ when(nodePunchMapper.selectList(any())).thenReturn(List.of(existed));
+
+ WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
+ request.setPunched(true);
+ request.setPunchTime("2026-10-06 11:00:00");
+ request.setWeight("20");
+
+ service.savePunchRecord(request);
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(nodePunchMapper).update(isNull(), captor.capture());
+ LambdaUpdateWrapper wrapper =
+ (LambdaUpdateWrapper) captor.getValue();
+ Collection values = wrapper.getParamNameValuePairs().values();
+ assertTrue(values.contains("20"), "重量应写进更新语句");
+ // updateById 会跳过 null 字段:清空地址/货量/照片必须由显式 set 写 null 才生效
+ assertTrue(values.contains(null), "被清空的字段必须显式写 null");
+ assertFalse(wrapper.getSqlSet().contains("exception_flag"),
+ "异常标记与异常处置表绑定,编辑打卡不得动它");
+ verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
+ }
+
+ @Test
+ @DisplayName("取消打卡:清空该节点全部流水,历史流水不得顶上来")
+ @SuppressWarnings("unchecked")
+ void deleteAllNodePunchesWhenUnpunched() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ when(nodePunchMapper.selectList(any()))
+ .thenReturn(List.of(nodePunch(7001L, "load", "装货"), nodePunch(7002L, "load", "装货")));
+
+ WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
+ request.setPunched(false);
+
+ assertTrue(service.savePunchRecord(request));
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class);
+ verify(nodePunchMapper).deleteBatchIds(captor.capture());
+ assertEquals(Set.of(7001L, 7002L), new HashSet<>(captor.getValue()));
+ verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
+ verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
+ }
+
+ // ------------------------------------------------------------ 在途打卡
+
+ @Test
+ @DisplayName("编辑在途打卡:改最新一条,单张照片字段只留第一张")
+ @SuppressWarnings("unchecked")
+ void updateEnroutePunchKeepsFirstPhoto() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ WaybillEnroutePunch existed = new WaybillEnroutePunch();
+ existed.setId(8001L);
+ when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed));
+
+ WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途");
+ request.setPunched(true);
+ request.setPunchTime("2026-10-06 12:00:00");
+ request.setAddress("沪昆高速");
+ request.setPhotos(List.of(
+ photo("货物照片", "https://oss/a.jpg"),
+ photo("货物照片", "https://oss/b.jpg")));
+
+ service.savePunchRecord(request);
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class);
+ verify(enroutePunchMapper).update(isNull(), captor.capture());
+ LambdaUpdateWrapper wrapper =
+ (LambdaUpdateWrapper) captor.getValue();
+ Collection values = wrapper.getParamNameValuePairs().values();
+ assertTrue(values.contains("https://oss/a.jpg"), "只保留第一张照片");
+ assertFalse(values.contains("https://oss/b.jpg"), "第二张不应写入");
+ assertTrue(values.contains("沪昆高速"));
+ assertTrue(wrapper.getSqlSet().contains("photo"), "照片字段必须显式更新");
+ verify(enroutePunchMapper, never()).insert(any(WaybillEnroutePunch.class));
+ }
+
+ @Test
+ @DisplayName("取消在途打卡:清空该运单全部在途流水")
+ @SuppressWarnings("unchecked")
+ void deleteAllEnroutePunchesWhenUnpunched() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ WaybillEnroutePunch first = new WaybillEnroutePunch();
+ first.setId(8001L);
+ WaybillEnroutePunch second = new WaybillEnroutePunch();
+ second.setId(8002L);
+ when(enroutePunchMapper.selectList(any())).thenReturn(List.of(first, second));
+
+ WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途");
+ request.setPunched(false);
+
+ service.savePunchRecord(request);
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class);
+ verify(enroutePunchMapper).deleteBatchIds(captor.capture());
+ assertEquals(Set.of(8001L, 8002L), new HashSet<>(captor.getValue()));
+ }
+
+ // ------------------------------------------------------------ 入参校验
+
+ @Test
+ @DisplayName("运单不存在:拒绝保存")
+ void rejectMissingWaybill() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(null);
+ WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
+ assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
+ }
+
+ @Test
+ @DisplayName("打卡时间格式非法:报错而不是静默改成当前时间")
+ void rejectInvalidPunchTime() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ when(nodePunchMapper.selectList(any())).thenReturn(List.of());
+ WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
+ request.setPunched(true);
+ request.setPunchTime("2026/10/06 09:30");
+
+ assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
+ verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
+ }
+
+ @Test
+ @DisplayName("经纬度非法:报错且不写库")
+ void rejectInvalidCoordinate() {
+ when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
+ when(nodePunchMapper.selectList(any())).thenReturn(List.of());
+ WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
+ request.setPunched(true);
+ request.setLongitude("东经120度");
+
+ assertThrows(ServiceException.class, () -> service.savePunchRecord(request));
+ verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
+ }
+
+ // ------------------------------------------------------------ 夹具
+
+ private Waybill waybill() {
+ Waybill waybill = new Waybill();
+ waybill.setId(WAYBILL_ID);
+ waybill.setWaybillNo(WAYBILL_NO);
+ waybill.setProcessJson(PROCESS_JSON);
+ waybill.setIsDeleted(0);
+ return waybill;
+ }
+
+ private WaybillNodePunch nodePunch(Long id, String nodeCode, String nodeName) {
+ WaybillNodePunch punch = new WaybillNodePunch();
+ punch.setId(id);
+ punch.setWaybillId(WAYBILL_ID);
+ punch.setNodeCode(nodeCode);
+ punch.setNodeName(nodeName);
+ return punch;
+ }
+
+ private WaybillPunchRecordSaveRequest request(String type, String nodeCode, String nodeName) {
+ WaybillPunchRecordSaveRequest request = new WaybillPunchRecordSaveRequest();
+ request.setWaybillId(WAYBILL_ID);
+ request.setType(type);
+ request.setNodeCode(nodeCode);
+ request.setNodeName(nodeName);
+ return request;
+ }
+
+ private WaybillPunchRecordSaveRequest.Photo photo(String type, String url) {
+ WaybillPunchRecordSaveRequest.Photo photo = new WaybillPunchRecordSaveRequest.Photo();
+ photo.setType(type);
+ photo.setUrl(url);
+ return photo;
+ }
+
+ private void inject(String field, Object value) throws Exception {
+ Class> type = service.getClass();
+ while (type != null) {
+ try {
+ Field declared = type.getDeclaredField(field);
+ declared.setAccessible(true);
+ declared.set(service, value);
+ return;
+ } catch (NoSuchFieldException ignored) {
+ type = type.getSuperclass();
+ }
+ }
+ throw new IllegalStateException("未找到字段:" + field);
+ }
+
+}