✨ 打卡照片按类型最多传 9 张,照片列加宽为 text
- 节点打卡提交新增 photoItems(带显式类型),photos 原样保留供旧版小程序按下标回推 - 打卡照片是每个打卡节点必有的类型,不由过程配置决定,与各凭证类型各自最多 9 张 - 新版提交要求每个类型至少 1 张;旧版仍只要求至少 1 张,避免打断未升级的小程序 - 管理端订正只校验每类上限,不校验必填 - photos 由 varchar(2000) 改 text:36 张约 4000 字符,旧列宽约 17 张即溢出 - 基线建表脚本补 COLLATE=utf8mb4_0900_ai_ci(与本机库实际排序规则一致)
This commit is contained in:
+13
-1
@@ -30,7 +30,19 @@ public class NodeSubmitDTO implements Serializable {
|
||||
@Schema(description = "定位信息")
|
||||
private Location location;
|
||||
|
||||
@Schema(description = "凭证照片 URL 列表")
|
||||
/**
|
||||
* 带类型的打卡照片,类型由提交方显式给出;非空时优先于 photos。
|
||||
*/
|
||||
@Schema(description = "带类型的照片列表,非空时优先于 photos")
|
||||
private List<PunchPhotoDTO> photoItems;
|
||||
|
||||
/**
|
||||
* 旧版小程序提交的裸 URL 列表,类型只能按下标回推过程配置的凭证类型。
|
||||
* <p>
|
||||
* 与 photoItems 并存是为了不强制升级小程序:新版提交 photoItems,旧版仍走这里;
|
||||
* 等旧版本覆盖率达标后由后续改造删除。
|
||||
*/
|
||||
@Schema(description = "凭证照片 URL 列表,类型按下标回推")
|
||||
private List<String> photos;
|
||||
|
||||
@Schema(description = "重量(吨)")
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* 带类型的打卡照片
|
||||
* <p>
|
||||
* 类型取值域是固定的「打卡照片」加该节点过程配置勾选的凭证类型,域外类型一律拒绝:
|
||||
* 类型是照片分组的唯一依据,放进来路不明的类型会让回显分组错乱。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "带类型的打卡照片")
|
||||
public class PunchPhotoDTO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "照片类型")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "图片URL")
|
||||
private String url;
|
||||
|
||||
}
|
||||
+105
-21
@@ -38,6 +38,7 @@ import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
@@ -323,7 +324,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg));
|
||||
assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg);
|
||||
assertPunchPhoto(dto.getPhotos());
|
||||
// photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表,
|
||||
// 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。
|
||||
boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty();
|
||||
List<Map<String, String>> photoItems = typedPhotoItems
|
||||
? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg))
|
||||
: legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"));
|
||||
assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems);
|
||||
|
||||
Date now = new Date();
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
@@ -342,24 +349,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim());
|
||||
}
|
||||
if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) {
|
||||
List<String> urls = dto.getPhotos().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
if (!urls.isEmpty()) {
|
||||
List<String> types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes");
|
||||
List<Map<String, String>> photoItems = new ArrayList<>();
|
||||
for (int i = 0; i < urls.size(); i++) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1));
|
||||
item.put("type", type);
|
||||
item.put("url", urls.get(i));
|
||||
photoItems.add(item);
|
||||
}
|
||||
punch.setPhotos(JsonUtil.toJson(photoItems));
|
||||
}
|
||||
if (!photoItems.isEmpty()) {
|
||||
punch.setPhotos(JsonUtil.toJson(photoItems));
|
||||
}
|
||||
punch.setWeight(trimOrNull(dto.getWeight()));
|
||||
punch.setVolume(trimOrNull(dto.getVolume()));
|
||||
@@ -916,9 +907,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言打卡照片已上传。
|
||||
* 断言打卡照片已上传(在途链路)。
|
||||
* <p>
|
||||
* 照片是打卡的必填项(每个节点至少 1 张),此处拦的是绕过客户端直接调接口的情况
|
||||
* 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况
|
||||
* ——前端的必填提示不构成约束。
|
||||
*/
|
||||
private void assertPunchPhoto(List<String> photos) {
|
||||
@@ -931,6 +922,99 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。
|
||||
* <p>
|
||||
* 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据,
|
||||
* 放进来路不明的类型会让回显分组错乱。
|
||||
*/
|
||||
private List<Map<String, String>> typedNodePunchPhotos(List<PunchPhotoDTO> items, List<String> allowedTypes) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
for (PunchPhotoDTO item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String url = Func.toStr(item.getUrl(), "").trim();
|
||||
if (Func.isEmpty(url)) {
|
||||
continue;
|
||||
}
|
||||
String type = Func.toStr(item.getType(), "").trim();
|
||||
if (Func.isEmpty(type)) {
|
||||
throw new ServiceException("照片类型不能为空");
|
||||
}
|
||||
if (!allowedTypes.contains(type)) {
|
||||
throw new ServiceException("照片类型「" + type + "」不属于该节点");
|
||||
}
|
||||
out.add(nodePunchPhotoItem(type, url));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。
|
||||
*/
|
||||
private List<Map<String, String>> legacyNodePunchPhotos(List<String> photos, List<String> voucherTypes) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<String> urls = photos.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
for (int i = 0; i < urls.size(); i++) {
|
||||
String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1));
|
||||
out.add(nodePunchPhotoItem(type, urls.get(i)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, String> nodePunchPhotoItem(String type, String url) {
|
||||
Map<String, String> item = new LinkedHashMap<>();
|
||||
item.put("type", type);
|
||||
item.put("url", url);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言节点打卡照片满足类型口径。
|
||||
* <p>
|
||||
* 每个类型至多 9 张,按类型各自计数,不是节点合计。
|
||||
* 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」;
|
||||
* 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」,
|
||||
* 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。
|
||||
*/
|
||||
private void assertNodePunchPhotos(List<Map<String, String>> photoItems, Map<String, Object> nodeCfg,
|
||||
boolean typedPhotoItems) {
|
||||
Map<String, Integer> countByType = countPunchPhotoByType(photoItems);
|
||||
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
|
||||
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
|
||||
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
|
||||
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
|
||||
}
|
||||
}
|
||||
if (!typedPhotoItems) {
|
||||
if (countByType.isEmpty()) {
|
||||
throw new ServiceException("请上传打卡照片");
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) {
|
||||
if (countByType.getOrDefault(type, 0) < 1) {
|
||||
throw new ServiceException("「" + type + "」至少上传 1 张照片");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Integer> countPunchPhotoByType(List<Map<String, String>> photoItems) {
|
||||
Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||
for (Map<String, String> item : photoItems) {
|
||||
countByType.merge(item.get("type"), 1, Integer::sum);
|
||||
}
|
||||
return countByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言该运单没有未了结的异常。
|
||||
* <p>
|
||||
|
||||
+28
-7
@@ -582,7 +582,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume()));
|
||||
punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity()));
|
||||
punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark()));
|
||||
punch.setPhotos(encodePunchPhotos(request.getPhotos()));
|
||||
List<Map<String, String>> photoItems = punchPhotoItems(request.getPhotos());
|
||||
assertPunchPhotoTypeLimit(photoItems);
|
||||
punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems));
|
||||
// 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护
|
||||
if (isNew) {
|
||||
waybillNodePunchMapper.insert(punch);
|
||||
@@ -724,13 +726,13 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
|
||||
/**
|
||||
* 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。
|
||||
* 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。
|
||||
*/
|
||||
private String encodePunchPhotos(List<WaybillPunchRecordSaveRequest.Photo> photos) {
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
private List<Map<String, String>> punchPhotoItems(List<WaybillPunchRecordSaveRequest.Photo> photos) {
|
||||
List<Map<String, String>> items = new ArrayList<>();
|
||||
if (photos == null || photos.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
for (WaybillPunchRecordSaveRequest.Photo photo : photos) {
|
||||
String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl());
|
||||
if (Func.isEmpty(url)) {
|
||||
@@ -742,7 +744,26 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
item.put("url", url);
|
||||
items.add(item);
|
||||
}
|
||||
return items.isEmpty() ? null : JsonUtil.toJson(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言打卡照片每种类型都不超过单类上限。
|
||||
* <p>
|
||||
* 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。
|
||||
* 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。
|
||||
*/
|
||||
private void assertPunchPhotoTypeLimit(List<Map<String, String>> photoItems) {
|
||||
Map<String, Integer> countByType = new LinkedHashMap<>();
|
||||
for (Map<String, String> item : photoItems) {
|
||||
countByType.merge(item.get("type"), 1, Integer::sum);
|
||||
}
|
||||
for (Map.Entry<String, Integer> entry : countByType.entrySet()) {
|
||||
if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) {
|
||||
throw new ServiceException("「" + entry.getKey() + "」最多上传 "
|
||||
+ WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String firstPunchPhotoUrl(List<WaybillPunchRecordSaveRequest.Photo> photos) {
|
||||
|
||||
+21
@@ -60,6 +60,10 @@ public final class WaybillProcessSupport {
|
||||
private static final String DISPOSAL_REVOKED = "revoked";
|
||||
/** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */
|
||||
public static final List<String> SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED);
|
||||
/** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */
|
||||
public static final String PUNCH_PHOTO_TYPE = "打卡照片";
|
||||
/** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */
|
||||
public static final int MAX_PHOTOS_PER_TYPE = 9;
|
||||
private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm");
|
||||
private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
@@ -309,6 +313,23 @@ public final class WaybillProcessSupport {
|
||||
return isTruthy(node.get("uploadVoucher"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。
|
||||
* <p>
|
||||
* 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。
|
||||
* 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。
|
||||
*/
|
||||
public static List<String> nodePhotoTypes(Map<String, Object> node) {
|
||||
List<String> types = new ArrayList<>();
|
||||
types.add(PUNCH_PHOTO_TYPE);
|
||||
for (String voucherType : nodeStringList(node, "voucherTypes")) {
|
||||
if (!types.contains(voucherType)) {
|
||||
types.add(voucherType);
|
||||
}
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static List<String> nodeStringList(Map<String, Object> node, String field) {
|
||||
Object raw = node.get(field);
|
||||
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
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.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 节点打卡照片的类型与张数口径。
|
||||
* <p>
|
||||
* 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 URL 列表,类型只能按下标回推凭证类型。
|
||||
* 两者并存期间,新版每个类型至少 1 张(含固定的「打卡照片」)、每类各自至多 9 张;
|
||||
* 旧版必须照旧提交成功——用「每个类型至少 1 张」去拦旧版,等于让未升级的小程序一次性失效,
|
||||
* 而旧版根本表达不出「打卡照片」。
|
||||
*/
|
||||
class DriverNodePunchPhotoTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3255";
|
||||
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
|
||||
private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg";
|
||||
private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg";
|
||||
/** 该节点勾了两类凭证,故照片类型取值域是「打卡照片 + 磅单 + 签收单」 */
|
||||
private static final String PROCESS_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true,
|
||||
"location":false,"uploadVoucher":true,"voucherTypes":["磅单","签收单"]}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.getOne(any())).thenReturn(waybill());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(waybillService.updateById(any())).thenReturn(true);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1);
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 新版:显式类型
|
||||
|
||||
@Test
|
||||
@DisplayName("显式类型按提交原样落库,「打卡照片」不会被改写成凭证类型")
|
||||
void typedPhotoItemsKeepExplicitType() {
|
||||
submit(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO))));
|
||||
|
||||
List<Map<String, String>> items = savedPhotoItems();
|
||||
assertEquals(List.of("打卡照片", "磅单", "签收单"), typesOf(items));
|
||||
assertEquals(List.of(CLOCK_PHOTO, WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("域外类型被拒且不落库")
|
||||
void typeOutsideNodeDomainIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO),
|
||||
photo("卸货照片", "https://oss/unload.jpg"))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("卸货照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺少某个必填类型时被拒:每个类型至少 1 张")
|
||||
void missingVoucherTypeIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("打卡照片", CLOCK_PHOTO),
|
||||
photo("磅单", WAYBILL_PHOTO))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("签收单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("缺少「打卡照片」时被拒:它不由过程配置决定,每个打卡节点都必须有")
|
||||
void missingClockPhotoIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(List.of(
|
||||
photo("磅单", WAYBILL_PHOTO),
|
||||
photo("签收单", RECEIPT_PHOTO))));
|
||||
|
||||
assertTrue(exception.getMessage().contains("打卡照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 9 张可提交")
|
||||
void ninthPhotoOfSameTypeIsAccepted() {
|
||||
submit(nodeSubmit(typedPhotos("磅单", 9)));
|
||||
|
||||
assertEquals(9, countByType(savedPhotoItems(), "磅单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 10 张被拒且不落库:上限按类型各自计数")
|
||||
void tenthPhotoOfSameTypeIsRejected() {
|
||||
ServiceException exception = submitExpectRejected(nodeSubmit(typedPhotos("磅单", 10)));
|
||||
|
||||
assertTrue(exception.getMessage().contains("磅单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 回显
|
||||
|
||||
@Test
|
||||
@DisplayName("回显按类型原样透传:「打卡照片」不会被丢弃,也不会被改写成凭证类型")
|
||||
void punchNodeDisplayKeepsEveryType() {
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(storedPunch()));
|
||||
|
||||
DriverPunchNodeVO node = service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
|
||||
.filter(item -> "load".equals(item.getKey()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertEquals(List.of("打卡照片", "磅单"),
|
||||
node.getPhotos().stream().map(DriverPunchPhotoVO::getType).toList());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 旧版:裸 URL 列表
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版裸 URL 列表仍能提交成功,类型按下标回推凭证类型")
|
||||
void legacyPhotoUrlsStillSubmit() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotos(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO));
|
||||
|
||||
submit(dto);
|
||||
|
||||
List<Map<String, String>> items = savedPhotoItems();
|
||||
assertEquals(List.of("磅单", "签收单"), typesOf(items));
|
||||
assertEquals(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版不带「打卡照片」也放行:裸 URL 列表表达不出这个类型")
|
||||
void legacyPhotoUrlsWithoutClockPhotoStillSubmit() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotos(List.of(WAYBILL_PHOTO));
|
||||
|
||||
submit(dto);
|
||||
|
||||
assertEquals(List.of("磅单"), typesOf(savedPhotoItems()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧版与新版都没带照片时被拒")
|
||||
void withoutAnyPhotoIsRejected() {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
|
||||
ServiceException exception = submitExpectRejected(dto);
|
||||
|
||||
assertTrue(exception.getMessage().contains("照片"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void submit(NodeSubmitDTO dto) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
service.submitNode(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceException submitExpectRejected(NodeSubmitDTO dto) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return assertThrows(ServiceException.class, () -> service.submitNode(dto));
|
||||
}
|
||||
}
|
||||
|
||||
/** 已入库的打卡流水;断言只在这里取一次插入参数 */
|
||||
private WaybillNodePunch savedPunch() {
|
||||
ArgumentCaptor<WaybillNodePunch> captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
|
||||
verify(nodePunchMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, String>> savedPhotoItems() {
|
||||
return (List<Map<String, String>>) JsonUtil.parse(savedPunch().getPhotos(), List.class);
|
||||
}
|
||||
|
||||
private List<String> typesOf(List<Map<String, String>> items) {
|
||||
return items.stream().map(item -> item.get("type")).toList();
|
||||
}
|
||||
|
||||
private List<String> urlsOf(List<Map<String, String>> items) {
|
||||
return items.stream().map(item -> item.get("url")).toList();
|
||||
}
|
||||
|
||||
private int countByType(List<Map<String, String>> items, String type) {
|
||||
return (int) items.stream().filter(item -> type.equals(item.get("type"))).count();
|
||||
}
|
||||
|
||||
/** 每个必填类型各 1 张,另有 count 张「磅单」 */
|
||||
private List<PunchPhotoDTO> typedPhotos(String type, int count) {
|
||||
List<PunchPhotoDTO> items = new ArrayList<>();
|
||||
items.add(photo("打卡照片", CLOCK_PHOTO));
|
||||
items.add(photo("签收单", RECEIPT_PHOTO));
|
||||
for (int i = 0; i < count; i++) {
|
||||
items.add(photo(type, "https://oss/" + type + i + ".jpg"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private NodeSubmitDTO nodeSubmit(List<PunchPhotoDTO> photoItems) {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode("load");
|
||||
dto.setPhotoItems(photoItems);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private PunchPhotoDTO photo(String type, String url) {
|
||||
PunchPhotoDTO photo = new PunchPhotoDTO();
|
||||
photo.setType(type);
|
||||
photo.setUrl(url);
|
||||
return photo;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/** 库里的打卡流水:一条带「打卡照片」与「磅单」的回显数据 */
|
||||
private WaybillNodePunch storedPunch() {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setId(6601L);
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode("load");
|
||||
punch.setNodeName("装货");
|
||||
punch.setPunchTime(new Date());
|
||||
punch.setPhotos(JsonUtil.toJson(List.of(
|
||||
Map.of("type", "打卡照片", "url", CLOCK_PHOTO),
|
||||
Map.of("type", "磅单", "url", WAYBILL_PHOTO))));
|
||||
return punch;
|
||||
}
|
||||
|
||||
}
|
||||
+58
-2
@@ -20,6 +20,7 @@ import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -41,8 +42,9 @@ import static org.mockito.Mockito.when;
|
||||
/**
|
||||
* 管理端打卡记录编辑测试。
|
||||
* <p>
|
||||
* 守住三件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来
|
||||
* 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段。
|
||||
* 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来
|
||||
* 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段;
|
||||
* 订正的照片只限「每类各自最多 9 张」、不校验必填——订正的是已经发生的事实,不是重新走一遍流程。
|
||||
*/
|
||||
class WaybillPunchRecordSaveTest {
|
||||
|
||||
@@ -180,6 +182,52 @@ class WaybillPunchRecordSaveTest {
|
||||
verify(nodePunchMapper, never()).deleteBatchIds(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 打卡照片张数
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 9 张可保存:上限按类型各自计数")
|
||||
void acceptNinthPhotoOfSameType() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(sameTypePhotos("委托单", 9));
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一类型第 10 张被拒且不落库")
|
||||
void rejectTenthPhotoOfSameType() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(sameTypePhotos("委托单", 10));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.savePunchRecord(request));
|
||||
|
||||
assertTrue(exception.getMessage().contains("委托单"), exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("订正不校验照片必填:一张不传也能保存")
|
||||
void missingPhotosDoNotBlockPunchRecordEdit() {
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill());
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPhotos(List.of());
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<WaybillNodePunch> captor = ArgumentCaptor.forClass(WaybillNodePunch.class);
|
||||
verify(nodePunchMapper).insert(captor.capture());
|
||||
assertNull(captor.getValue().getPhotos());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 在途打卡
|
||||
|
||||
@Test
|
||||
@@ -306,6 +354,14 @@ class WaybillPunchRecordSaveTest {
|
||||
return photo;
|
||||
}
|
||||
|
||||
private List<WaybillPunchRecordSaveRequest.Photo> sameTypePhotos(String type, int count) {
|
||||
List<WaybillPunchRecordSaveRequest.Photo> photos = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
photos.add(photo(type, "https://oss/" + type + i + ".jpg"));
|
||||
}
|
||||
return photos;
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
|
||||
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` (
|
||||
`longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度',
|
||||
`latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度',
|
||||
`address` varchar(500) DEFAULT NULL COMMENT '打卡地址',
|
||||
`photos` varchar(2000) DEFAULT NULL COMMENT '凭证照片URL,多张逗号分隔',
|
||||
`photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张',
|
||||
`weight` varchar(32) DEFAULT NULL COMMENT '重量(吨)',
|
||||
`volume` varchar(32) DEFAULT NULL COMMENT '体积(方)',
|
||||
`quantity` varchar(32) DEFAULT NULL COMMENT '数量(件)',
|
||||
@@ -27,4 +27,4 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` (
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_node_punch_waybill` (`waybill_id`, `node_code`, `punch_time`) USING BTREE,
|
||||
KEY `idx_node_punch_driver` (`driver_id`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单过程节点打卡记录';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运单过程节点打卡记录';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ----------------------------
|
||||
-- 节点打卡照片列加宽:varchar(2000) 改为 text
|
||||
-- ----------------------------
|
||||
-- 一个节点可传「打卡照片 9 张 + 每个凭证类型各 9 张」,勾了 4 类凭证时共 45 张;
|
||||
-- 库里实测每张照片含类型的 JSON 串约 111 字符,36 张约 4000 字符,已超 varchar(2000) 一倍。
|
||||
-- 超长时严格模式报 Data too long,非严格模式会静默截断成半个 JSON,后者更难查。
|
||||
-- 内容形状不变,仍是扁平 [{"type","url"}]。可重复执行。
|
||||
|
||||
ALTER TABLE `blade_waybill_node_punch`
|
||||
MODIFY COLUMN `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张';
|
||||
Reference in New Issue
Block a user