From a24d55f52647456851b2c2bf22640514cfa2a01c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 20:24:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MerchantBatchImportService.java | 22 ++++ .../MerchantBatchDraftServiceTest.java | 23 ++++ .../MerchantBatchImportServiceTest.java | 102 +++++++++++++++++- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java index 53b30e8fb..7c466f551 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java @@ -323,6 +323,7 @@ public class MerchantBatchImportService { if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单"); String rowId = "excel-row-" + (rowNo + 1); try { + validateRequiredColumns(row); var draft = json.createObjectNode(); draft.put("customerName", customerName); draft.put("tradeTerm", "FOB"); @@ -347,6 +348,8 @@ public class MerchantBatchImportService { } validateDestination(draft.path("destinationType").asText(null), fbaSelected, overseasSelected, privateSelected); + validatePickup(draft.path("pickupMethod").asText(null), + draft.hasNonNull("requestedPickupAt"), draft.hasNonNull("senderAddressId")); draft.set("cargoes", json.createArrayNode().add(cargo)); items.add(new MerchantBatchDraftDtos.Item(rowId, json.treeToValue(draft, OrderRequests.SaveRequest.class))); @@ -496,6 +499,15 @@ public class MerchantBatchImportService { return false; } + private static void validateRequiredColumns(Row row) { + for (int col = 0; col < COLUMNS.size(); col++) { + Column column = COLUMNS.get(col); + if (column.required() && !StringUtils.hasText(text(row.getCell(col)))) { + throw new BizException(ResultCode.VALIDATION_FAILED, column.title() + "不能为空"); + } + } + } + private static int excelRowNumber(String clientRowId) { String prefix = "excel-row-"; if (!StringUtils.hasText(clientRowId) || !clientRowId.startsWith(prefix)) { @@ -576,6 +588,16 @@ public class MerchantBatchImportService { }); } + private static void validatePickup(String pickupMethod, boolean requestedPickupAt, boolean senderAddress) { + if (!"PICKUP".equals(pickupMethod)) return; + if (!requestedPickupAt) { + throw new BizException(ResultCode.VALIDATION_FAILED, "平台上门提货时预约提货时间不能为空"); + } + if (!senderAddress) { + throw new BizException(ResultCode.VALIDATION_FAILED, "平台上门提货时取货地址不能为空"); + } + } + private static String label(String name, String code) { if (!StringUtils.hasText(name)) return code; return name.equalsIgnoreCase(code) ? code : name + "|" + code; diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java index d2ef227f1..99829a81e 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java @@ -62,6 +62,29 @@ class MerchantBatchDraftServiceTest { assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空"); } + @Test + void partialModeReturnsA0422WithoutCreatingAParsedInvalidRow() { + var response = service.create(10L, 20L, "batch-key", List.of( + new MerchantBatchDraftDtos.Item("excel-row-9", null, "货物名称不能为空"))); + + assertThat(response.succeeded()).isZero(); + assertThat(response.failed()).isEqualTo(1); + assertThat(response.items().get(0).errorCode()).isEqualTo("A0422"); + assertThat(response.items().get(0).errorMessage()).isEqualTo("货物名称不能为空"); + verify(itemService, never()).create(anyLong(), anyLong(), isNull(), any(), anyString()); + } + + @Test + void atomicModeRejectsWholeBatchBeforeCreatingWhenOneParsedRowIsInvalid() { + assertThatThrownBy(() -> service.createAtomic(10L, 20L, null, "batch-key", List.of( + item("excel-row-3"), + new MerchantBatchDraftDtos.Item("excel-row-9", null, "货物名称不能为空")))) + .isInstanceOf(BizException.class) + .hasMessage("货物名称不能为空"); + + verify(itemService, never()).createJoined(anyLong(), anyLong(), isNull(), any(), anyString()); + } + @Test void rejectsDuplicateRowWithoutCreatingItTwice() { doAnswer(invocation -> order(101L, "FL101", "RK-101")) diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java index 4080c502b..936efafcc 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java @@ -11,6 +11,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.test.util.ReflectionTestUtils; import java.io.ByteArrayInputStream; @@ -20,6 +23,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -132,13 +136,54 @@ class MerchantBatchImportServiceTest { } } + @ParameterizedTest(name = "required column {0} rejects blank cell") + @MethodSource("requiredColumns") + void importRejectsEveryRequiredTemplateColumnWhenItIsBlank(int column, String title) throws Exception { + byte[] content = service.template(88L, "Bearer test"); + try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) { + var sheet = workbook.getSheet("订单导入"); + var row = sheet.getRow(2); + fillValidSelfExpressFbaRow(row); + row.getCell(column).setCellValue(" "); + assertThat(requiredHeaderIndexes(sheet)).containsExactlyElementsOf( + requiredColumns().map(arguments -> (Integer) arguments.get()[0]).toList()); + workbook.write(out); + + var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0); + + assertThat(item.draft()).isNull(); + assertThat(item.parseError()).isEqualTo(title + "不能为空"); + } + } + + @ParameterizedTest(name = "PICKUP rejects missing {0}") + @MethodSource("pickupConditionalColumns") + void importRejectsMissingPickupConditionalFields(String title, int column, String message) throws Exception { + byte[] content = service.template(88L, "Bearer test"); + try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) { + var row = workbook.getSheet("订单导入").getRow(2); + fillValidSelfExpressFbaRow(row); + row.getCell(2).setCellValue("平台上门提货"); + row.getCell(9).setCellValue(LocalDateTime.of(2026, 9, 10, 14, 30)); + row.getCell(29).setCellValue("工厂|张三|深圳|科技园1号"); + row.getCell(column).setCellValue(" "); + workbook.write(out); + + var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0); + + assertThat(item.draft()).isNull(); + assertThat(item.parseError()).isEqualTo(message); + } + } + @Test void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() throws Exception { byte[] content = service.template(88L, "Bearer test"); try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) { var row = workbook.getSheet("订单导入").getRow(2); - row.createCell(5).setCellValue("亚马逊FBA"); - row.createCell(7).setCellValue("洛杉矶海外仓|US-LAX"); + fillValidSelfExpressFbaRow(row); + row.getCell(6).setBlank(); + row.getCell(7).setCellValue("洛杉矶海外仓|US-LAX"); workbook.write(out); var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0); assertThat(item.draft()).isNull(); @@ -205,6 +250,59 @@ class MerchantBatchImportServiceTest { return false; } + private static void fillValidSelfExpressFbaRow(org.apache.poi.ss.usermodel.Row row) { + row.getCell(0).setCellValue("海运"); + row.getCell(2).setCellValue("自行快递"); + row.getCell(3).setCellValue("义乌收货仓|CN-YW"); + row.getCell(4).setCellValue("美森海运|US-SEA"); + row.getCell(5).setCellValue("亚马逊FBA"); + row.getCell(6).setCellValue("ATL6"); + row.getCell(10).setCellValue("蓝牙音箱"); + row.getCell(11).setCellValue("纸箱"); + row.getCell(12).setCellValue("1"); + row.getCell(13).setCellValue("1.2"); + row.getCell(14).setCellValue("0.1"); + row.getCell(16).setCellValue("否"); + row.getCell(17).setCellValue("否"); + row.getCell(22).setCellValue("买单报关"); + row.getCell(23).setCellValue("不包税"); + } + + private static List requiredHeaderIndexes(org.apache.poi.ss.usermodel.Sheet sheet) { + List indexes = new ArrayList<>(); + var header = sheet.getRow(1); + for (int column = 0; column < header.getLastCellNum(); column++) { + if (header.getCell(column).getStringCellValue().endsWith(" *")) indexes.add(column); + } + return indexes; + } + + private static Stream requiredColumns() { + return Stream.of( + Arguments.of(0, "运输方式"), + Arguments.of(2, "提货方式"), + Arguments.of(3, "国内收货仓"), + Arguments.of(4, "运输渠道"), + Arguments.of(5, "目的类型"), + Arguments.of(10, "货物名称"), + Arguments.of(11, "包装类型"), + Arguments.of(12, "包装数量"), + Arguments.of(13, "毛重(千克)"), + Arguments.of(14, "体积(立方米)"), + Arguments.of(16, "是否木制品"), + Arguments.of(17, "是否危险品"), + Arguments.of(22, "报关方式"), + Arguments.of(23, "计价税模式") + ); + } + + private static Stream pickupConditionalColumns() { + return Stream.of( + Arguments.of("预约提货时间", 9, "平台上门提货时预约提货时间不能为空"), + Arguments.of("取货地址", 29, "平台上门提货时取货地址不能为空") + ); + } + private static List rowValues(org.apache.poi.ss.usermodel.Row row) { List values = new ArrayList<>(); for (var cell : row) values.add(cell.getStringCellValue()); From 171f450e5903e295c7a7242288626858197637a7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 20:38:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/dto/SmartLoadingDtos.java | 12 +++- .../mapper/SmartLoadingCandidateMapper.java | 25 ++++++-- .../repository/SmartLoadingRepository.java | 4 ++ .../firstleg/service/SmartLoadingService.java | 20 +++++- .../main/resources/openapi/firstleg-v1.yaml | 21 ++++++- ...ormSmartLoadingControllerContractTest.java | 8 +++ ...artLoadingCandidateMapperContractTest.java | 61 ++++++++++++++++++- .../service/ContainerServiceTest.java | 2 +- .../service/SmartLoadingServiceTest.java | 31 +++++++++- 9 files changed, 173 insertions(+), 11 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index dcada340d..2ba8638dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -67,7 +67,17 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @NotBlank @Size(max=128) String operationId) {} + public record PlanCoLoadGroup(Long groupId,String groupNo,Long lockedPlanId,Integer memberOrderCount, + Integer inboundFragmentCount,Integer fullPackageCount,List memberInboundIds) { + public PlanCoLoadGroup { memberInboundIds=memberInboundIds==null?List.of():List.copyOf(memberInboundIds); } + } public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus, - Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List items) {} + Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List coLoadGroups, + List items) { + public Plan { + coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups); + items=items==null?List.of():List.copyOf(items); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 37a09c9af..5a14f5a69 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -12,6 +12,22 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba """) List channelContainerCounts(@Param("warehouseId") long warehouseId); + @Select(""" + SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo, + group_row.locked_plan_id lockedPlanId,member.order_id orderId, + member.inbound_id inboundId,member.full_package_count fullPackageCount + FROM fl_loading_plan_item plan_item + JOIN fl_coload_group_member member + ON member.tenant_id=plan_item.account_id AND member.inbound_id=plan_item.inbound_id + AND member.active_flag=1 + JOIN fl_coload_group group_row ON group_row.id=member.group_id + WHERE plan_item.loading_plan_id=#{planId} AND plan_item.deleted=0 + AND group_row.locked_plan_id=#{planId} + AND group_row.group_status IN ('LOCKED','CONSUMED') + ORDER BY group_row.id,member.order_id,member.inbound_id + """) + List planColoadMembers(@Param("planId") long planId); + @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, @@ -121,10 +137,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)>0 - AND SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL - AND DATE(eligible.expectedArrivalAt)>=#{query.expectedArrivalStart} - AND DATE(eligible.expectedArrivalAt)<=#{query.expectedArrivalEnd} - THEN 1 ELSE 0 END)>0 + AND MIN(eligible.expectedArrivalAt) IS NOT NULL + AND DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart} + AND DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd} ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId @@ -286,4 +301,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba } record CandidateUnit(Long groupId,Long accountId,Long inboundId, LocalDateTime sortArrivalAt,Long sortInboundId) {} + record PlanColoadMemberRow(Long groupId,String groupNo,Long lockedPlanId, + Long orderId,Long inboundId,Integer fullPackageCount) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 64d34929f..c54ef51e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -19,6 +19,7 @@ import com.baiyue.firstleg.mapper.LoadingPlanMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.mapper.InboundOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @@ -56,6 +57,9 @@ public class SmartLoadingRepository { public List channelContainerCounts(long warehouseId) { return candidates.channelContainerCounts(warehouseId); } + public List planColoadMembers(long planId) { + return candidates.planColoadMembers(planId); + } public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { return candidates.candidate(planId, warehouseId, inboundId, detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 02c45b14d..97bdfe3cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.split.service.SplitPreloadService; import org.springframework.dao.DuplicateKeyException; @@ -235,12 +236,14 @@ public class SmartLoadingService { return actual; } + @Transactional(readOnly = true) public Plan draftByBooking(long bookingId) { ContainerBookingEntity booking = requireBooking(bookingId); LoadingPlanEntity plan = repository.planByBooking(bookingId); return draftView(plan, booking); } + @Transactional(readOnly = true) public Plan detail(long planId) { LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在"); @@ -964,7 +967,22 @@ public class SmartLoadingService { : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, - task.taskId(),task.taskStatus(),task.taskVersion(),items); + task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items); + } + private List planCoLoadGroups(long planId) { + Map> grouped=repository.planColoadMembers(planId).stream() + .collect(java.util.stream.Collectors.groupingBy(PlanColoadMemberRow::groupId, + LinkedHashMap::new,java.util.stream.Collectors.toList())); + return grouped.values().stream().map(rows->{ + PlanColoadMemberRow first=rows.get(0); + LinkedHashMap packagesByInbound=new LinkedHashMap<>(); + rows.forEach(row->packagesByInbound.putIfAbsent(row.inboundId(),row.fullPackageCount())); + int orderCount=(int)rows.stream().map(PlanColoadMemberRow::orderId).distinct().count(); + int packages=packagesByInbound.values().stream().filter(Objects::nonNull) + .mapToInt(Integer::intValue).sum(); + return new PlanCoLoadGroup(first.groupId(),first.groupNo(),first.lockedPlanId(),orderCount, + packagesByInbound.size(),packages,List.copyOf(packagesByInbound.keySet())); + }).toList(); } private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 0dca695be..9edcebd47 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -2035,7 +2035,7 @@ components: version: {type: integer, minimum: 0, description: 柜次版本} SmartLoadingPlan: type: object - required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] + required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items] properties: id: {type: string} planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} @@ -2051,9 +2051,28 @@ components: pdaTaskId: {type: string, nullable: true} pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量} pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值} + coLoadGroups: + type: array + description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建 + items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'} items: type: array items: {$ref: '#/components/schemas/SmartLoadingPlanItem'} + SmartLoadingPlanCoLoadGroup: + type: object + required: [groupId, groupNo, lockedPlanId, memberOrderCount, inboundFragmentCount, fullPackageCount, memberInboundIds] + properties: + groupId: {type: string, pattern: '^[1-9][0-9]*$'} + groupNo: {type: string, minLength: 1} + lockedPlanId: {type: string, pattern: '^[1-9][0-9]*$'} + memberOrderCount: {type: integer, minimum: 1, description: 组内去重订单数} + inboundFragmentCount: {type: integer, minimum: 1, description: 组内去重当前有效入仓片段数} + fullPackageCount: {type: integer, minimum: 1, description: 全部成员冻结满箱数之和} + memberInboundIds: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, pattern: '^[1-9][0-9]*$'} SmartLoadingPlanItem: type: object required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 0e625c0fe..62efc97da 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -78,5 +78,13 @@ class PlatformSmartLoadingControllerContractTest { .contains("回到 IN_PROGRESS") .contains("COMPLETED 永久拒绝增量"); } + @Test void planContractEmbedsColoadGroupsWithoutPerInboundFanout() throws Exception { + String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + assertThat(yaml) + .contains("required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]") + .contains("SmartLoadingPlanCoLoadGroup:") + .contains("required: [groupId, groupNo, lockedPlanId, memberOrderCount, inboundFragmentCount, fullPackageCount, memberInboundIds]") + .contains("调用方不得按 inboundId 扇出重建"); + } private void authority(String method,String permission,Class...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 95d4fb90a..145c7aaff 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -14,6 +14,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; import java.util.List; import java.util.Map; +import java.time.LocalDate; +import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; @@ -83,6 +85,39 @@ class SmartLoadingCandidateMapperContractTest { .doesNotContain("fl_container_booking"); } + @Test + void globalCandidateDateFilterUsesTheSameRepresentativeArrivalAsTheReturnedUnit() throws Exception { + Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits", + Page.class, InboundCandidateQuery.class, Set.class); + String sql = String.join(" ", units.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("MIN(eligible.expectedArrivalAt) sortArrivalAt") + .contains("MIN(eligible.expectedArrivalAt) IS NOT NULL") + .contains("DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}") + .contains("DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}") + .doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL"); + } + + @Test + void representativeArrivalControlsWhetherTheWholeGroupMatchesTheClosedDateRange() { + List groupArrivals = List.of( + LocalDateTime.parse("2026-09-01T09:00:00"), + LocalDateTime.parse("2026-09-10T09:00:00")); + + LocalDate representative = groupArrivals.stream().min(LocalDateTime::compareTo) + .orElseThrow().toLocalDate(); + + assertThat(matchesClosedRange(representative, + LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10"))) + .as("another member matching must not admit a group whose MIN arrival is outside") + .isFalse(); + assertThat(matchesClosedRange(representative, + LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01"))) + .as("a matching MIN arrival admits the whole group") + .isTrue(); + } + @Test void globalCandidateDynamicSqlRendersForStandaloneAndGroupedUnits() throws Exception { Configuration configuration = new Configuration(); @@ -95,7 +130,10 @@ class SmartLoadingCandidateMapperContractTest { null, 1, 20); String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class) .getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql(); - assertThat(renderedUnits).contains("o.channel_code IN", "DATE(eligible.expectedArrivalAt)"); + assertThat(renderedUnits) + .contains("o.channel_code IN", "MIN(eligible.expectedArrivalAt) IS NOT NULL") + .contains("DATE(MIN(eligible.expectedArrivalAt))>=", "DATE(MIN(eligible.expectedArrivalAt))<=") + .doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL"); Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( "globalCandidatesByUnits", List.class); @@ -172,4 +210,25 @@ class SmartLoadingCandidateMapperContractTest { .contains("GROUP BY b.channel_code") .doesNotContain("'COMPLETED'"); } + + @Test + void planColoadSnapshotUsesOnePlanScopedBatchQuery() throws Exception { + Method method=SmartLoadingCandidateMapper.class.getDeclaredMethod("planColoadMembers",long.class); + String sql=String.join(" ",method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("plan_item.loading_plan_id=#{planId}") + .contains("plan_item.deleted=0") + .contains("member.tenant_id=plan_item.account_id") + .contains("member.inbound_id=plan_item.inbound_id") + .contains("member.active_flag=1") + .contains("group_row.locked_plan_id=#{planId}") + .contains("group_row.group_status IN ('LOCKED','CONSUMED')") + .contains("SELECT DISTINCT") + .doesNotContain("WHERE member.inbound_id=#{inboundId}"); + } + + private static boolean matchesClosedRange(LocalDate arrival, LocalDate start, LocalDate end) { + return arrival != null && !arrival.isBefore(start) && !arrival.isAfter(end); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 63adffc8a..8187f6db7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -55,7 +55,7 @@ class ContainerServiceTest { when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation -> new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING", invocation.getArgument(0, Long.class), null, null, 0, true, true, null, - "NOT_STARTED", null, null, null, List.of())); + "NOT_STARTED", null, null, null, List.of(), List.of())); when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); when(suppliers.trucking(202L, "拖车公司A")) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 6146e46a7..540ceb163 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -31,6 +31,7 @@ import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.C import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.split.service.SplitPreloadService; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; @@ -116,6 +117,32 @@ class SmartLoadingServiceTest { assertThat(service.detail(1L).bookingId()).isEqualTo(99L); } + @Test + void returnsPlanColoadGroupsWithOneBatchLookup() { + LoadingPlanEntity plan = plan("EDITING", 2); + when(repository.plan(1L)).thenReturn(plan); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.planColoadMembers(1L)).thenReturn(List.of( + new PlanColoadMemberRow(700L,"CLG-700",1L,30L,10L,4), + new PlanColoadMemberRow(700L,"CLG-700",1L,30L,11L,6), + new PlanColoadMemberRow(700L,"CLG-700",1L,31L,12L,5))); + + Plan result=service.detail(1L); + + assertThat(result.coLoadGroups()).singleElement().satisfies(group->{ + assertThat(group.groupId()).isEqualTo(700L); + assertThat(group.groupNo()).isEqualTo("CLG-700"); + assertThat(group.lockedPlanId()).isEqualTo(1L); + assertThat(group.memberOrderCount()).isEqualTo(2); + assertThat(group.inboundFragmentCount()).isEqualTo(3); + assertThat(group.fullPackageCount()).isEqualTo(15); + assertThat(group.memberInboundIds()).containsExactly(10L,11L,12L); + }); + verify(repository).planColoadMembers(1L); + verify(coloadGroups,never()).findByInbound(anyLong(),anyLong()); + } + @Test void restoresLoadingPlanForContinuedAssignment() { LoadingPlanEntity plan = plan("LOADING", 3); @@ -208,8 +235,8 @@ class SmartLoadingServiceTest { Candidate second = withGroup(withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6), "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10); InboundCandidateQuery query = new InboundCandidateQuery( - List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"), - LocalDate.parse("2026-09-10"), null, 1, 1); + List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"), + LocalDate.parse("2026-09-01"), null, 1, 1); CandidateUnit unit = new CandidateUnit(700L, null, null, LocalDateTime.parse("2026-09-01T09:00:00"), 10L); when(repository.globalCandidateUnits(eq(query), anySet()))