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()))