From c50a86d5c409a8cc20ce0b04a51dc6a952e9ebe5 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 04:40:43 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=89=93=E5=8D=A1=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E7=BC=96=E8=BE=91=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E6=89=93=E5=8D=A1=E7=8A=B6=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /waybill-manage/punch-record/save:按运单当前过程配置补录或修正 节点打卡与在途打卡,打卡状态也在可编辑之列。 - punched=false 即取消打卡:清除该节点全部打卡流水(时间轴上「未打卡」的判据 就是没有流水);在途节点没有节点 key 可定位单条,故清空该运单全部在途流水 - 节点以当前过程配置为准回写 key/name,前端带旧值也不会写歪 - 编辑按字段显式更新:updateById 会跳过 null 字段,清空地址/货量/照片保存后不生效 - 编辑不改 exception_flag(与异常处置表绑定);管理端补录 driver_id 留空 - 凭证图按 [{type,url}] 回写,避免展示层按下标回推类型时换序串标 - 补 8 个服务单测 + 3 个 MockMvc 端点绑定测试 --- .../dto/WaybillPunchRecordSaveRequest.java | 114 +++++++ .../controller/WaybillController.java | 8 + .../transport/service/IWaybillService.java | 11 + .../service/impl/WaybillServiceImpl.java | 274 ++++++++++++++++ .../WaybillPunchRecordEndpointTest.java | 136 ++++++++ .../impl/WaybillPunchRecordSaveTest.java | 307 ++++++++++++++++++ 6 files changed, 850 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java 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); + } + +}