diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java index 6b16994f..0ba2fba4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -30,7 +30,19 @@ public class NodeSubmitDTO implements Serializable { @Schema(description = "定位信息") private Location location; - @Schema(description = "凭证照片 URL 列表") + /** + * 带类型的打卡照片,类型由提交方显式给出;非空时优先于 photos。 + */ + @Schema(description = "带类型的照片列表,非空时优先于 photos") + private List photoItems; + + /** + * 旧版小程序提交的裸 URL 列表,类型只能按下标回推过程配置的凭证类型。 + *

+ * 与 photoItems 并存是为了不强制升级小程序:新版提交 photoItems,旧版仍走这里; + * 等旧版本覆盖率达标后由后续改造删除。 + */ + @Schema(description = "凭证照片 URL 列表,类型按下标回推") private List photos; @Schema(description = "重量(吨)") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java new file mode 100644 index 00000000..d7c4e20a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java @@ -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; + +/** + * 带类型的打卡照片 + *

+ * 类型取值域是固定的「打卡照片」加该节点过程配置勾选的凭证类型,域外类型一律拒绝: + * 类型是照片分组的唯一依据,放进来路不明的类型会让回显分组错乱。 + */ +@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; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index a2c9236c..d616bc17 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -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> 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 urls = dto.getPhotos().stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - if (!urls.isEmpty()) { - List types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"); - List> photoItems = new ArrayList<>(); - for (int i = 0; i < urls.size(); i++) { - Map 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 { } /** - * 断言打卡照片已上传。 + * 断言打卡照片已上传(在途链路)。 *

- * 照片是打卡的必填项(每个节点至少 1 张),此处拦的是绕过客户端直接调接口的情况 + * 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况 * ——前端的必填提示不构成约束。 */ private void assertPunchPhoto(List photos) { @@ -931,6 +922,99 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } + /** + * 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。 + *

+ * 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据, + * 放进来路不明的类型会让回显分组错乱。 + */ + private List> typedNodePunchPhotos(List items, List allowedTypes) { + List> 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> legacyNodePunchPhotos(List photos, List voucherTypes) { + List> out = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return out; + } + List 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 nodePunchPhotoItem(String type, String url) { + Map item = new LinkedHashMap<>(); + item.put("type", type); + item.put("url", url); + return item; + } + + /** + * 断言节点打卡照片满足类型口径。 + *

+ * 每个类型至多 9 张,按类型各自计数,不是节点合计。 + * 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」; + * 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」, + * 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。 + */ + private void assertNodePunchPhotos(List> photoItems, Map nodeCfg, + boolean typedPhotoItems) { + Map countByType = countPunchPhotoByType(photoItems); + for (Map.Entry 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 countPunchPhotoByType(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + return countByType; + } + /** * 断言该运单没有未了结的异常。 *

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 02878fae..ce687574 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 @@ -582,7 +582,9 @@ public class WaybillServiceImpl extends BaseServiceImpl punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume())); punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity())); punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark())); - punch.setPhotos(encodePunchPhotos(request.getPhotos())); + List> 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 } /** - * 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。 + * 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。 */ - private String encodePunchPhotos(List photos) { - if (photos == null || photos.isEmpty()) { - return null; - } + private List> punchPhotoItems(List photos) { List> 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 item.put("url", url); items.add(item); } - return items.isEmpty() ? null : JsonUtil.toJson(items); + return items; + } + + /** + * 断言打卡照片每种类型都不超过单类上限。 + *

+ * 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。 + * 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。 + */ + private void assertPunchPhotoTypeLimit(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + for (Map.Entry 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 photos) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 7b69302b..7682948d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -60,6 +60,10 @@ public final class WaybillProcessSupport { private static final String DISPOSAL_REVOKED = "revoked"; /** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */ public static final List 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")); } + /** + * 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。 + *

+ * 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。 + * 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。 + */ + public static List nodePhotoTypes(Map node) { + List 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 nodeStringList(Map node, String field) { Object raw = node.get(field); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java new file mode 100644 index 00000000..d608a8ea --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -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; + +/** + * 节点打卡照片的类型与张数口径。 + *

+ * 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 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> 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> 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 = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + service.submitNode(dto); + } + } + + private ServiceException submitExpectRejected(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return assertThrows(ServiceException.class, () -> service.submitNode(dto)); + } + } + + /** 已入库的打卡流水;断言只在这里取一次插入参数 */ + private WaybillNodePunch savedPunch() { + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private List> savedPhotoItems() { + return (List>) JsonUtil.parse(savedPunch().getPhotos(), List.class); + } + + private List typesOf(List> items) { + return items.stream().map(item -> item.get("type")).toList(); + } + + private List urlsOf(List> items) { + return items.stream().map(item -> item.get("url")).toList(); + } + + private int countByType(List> items, String type) { + return (int) items.stream().filter(item -> type.equals(item.get("type"))).count(); + } + + /** 每个必填类型各 1 张,另有 count 张「磅单」 */ + private List typedPhotos(String type, int count) { + List 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 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; + } + +} 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 index 0a50eee5..6b41a1ef 100644 --- 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 @@ -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; /** * 管理端打卡记录编辑测试。 *

- * 守住三件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 - * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段。 + * 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 + * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段; + * 订正的照片只限「每类各自最多 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 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 sameTypePhotos(String type, int count) { + List 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) { diff --git a/doc/sql/transport/blade_waybill_node_punch_20260911.sql b/doc/sql/transport/blade_waybill_node_punch_20260911.sql index f595a23b..6ba2077c 100644 --- a/doc/sql/transport/blade_waybill_node_punch_20260911.sql +++ b/doc/sql/transport/blade_waybill_node_punch_20260911.sql @@ -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='运单过程节点打卡记录'; diff --git a/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql new file mode 100644 index 00000000..98a1673f --- /dev/null +++ b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql @@ -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 张';