From 6eff5a4b9b302586381dee5162126426da5af4c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 15:00:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformSmartLoadingController.java | 6 +- .../baiyue/firstleg/dto/SmartLoadingDtos.java | 37 +++- .../mapper/SmartLoadingCandidateMapper.java | 91 ++++++++- .../repository/SmartLoadingRepository.java | 12 ++ .../firstleg/service/SmartLoadingService.java | 151 ++++++++++++++- .../main/resources/openapi/firstleg-v1.yaml | 175 ++++++++++++++++-- ...ormSmartLoadingControllerContractTest.java | 26 ++- ...artLoadingCandidateMapperContractTest.java | 32 ++++ .../service/SmartLoadingServiceTest.java | 99 ++++++++++ 9 files changed, 605 insertions(+), 24 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 1b80a9684..e12b0974f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -1,6 +1,6 @@ package com.baiyue.firstleg.controller; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -13,7 +13,9 @@ public class PlatformSmartLoadingController { @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));} @GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") - public ApiResponse> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));} + public ApiResponse> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid InboundCandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(q));} + @GetMapping("/containers") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse> containers(@AuthenticationPrincipal AuthenticatedUser u,@Valid WorkbenchContainerQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.containers(q));} @GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") public ApiResponse draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));} @GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") 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 506f7c6d6..dcada340d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -1,15 +1,48 @@ package com.baiyue.firstleg.dto; -import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record ChannelSummary(String channelCode, String channelName, long candidateInboundCount, long availablePackageCount, long availableContainerCount) {} + public enum CandidateOrderStatus { PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING } + public record InboundCandidateQuery( + @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, + @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, + LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus, + @Min(1) long page, @Min(1) @Max(200) long size) { + public InboundCandidateQuery { + channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, + String inboundCode, String customerName, Integer availablePackageCount, + String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, - Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} + Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt, + List groupMembers) { + public InboundCandidate { groupMembers = groupMembers == null ? List.of() : List.copyOf(groupMembers); } + public Long customerId() { return accountId; } + } + public record WorkbenchContainerQuery( + @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, + @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, + LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus, + @Min(1) long page, @Min(1) @Max(200) long size) { + public WorkbenchContainerQuery { + channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo, + Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType, + Long planId, String planNo, String planStatus, Integer version) {} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm, 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 70f787c00..6e4ee6f2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -1,5 +1,5 @@ package com.baiyue.firstleg.mapper; -import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import org.apache.ibatis.annotations.*; import java.util.List; import java.util.Set; @Mapper public interface SmartLoadingCandidateMapper { @Select(""" SELECT b.channel_code channelCode,COUNT(DISTINCT b.id) availableContainerCount @@ -62,6 +62,95 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba """) List candidates(@Param("warehouseId") long warehouseId); + @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, + o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId, + o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, + o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, + i.package_count-COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id + AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + -COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item + WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id + AND split_item.parent_inbound_id=o.id + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount, + COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg, + COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, + COALESCE(i.unit_volume_cbm,0) unitVolumeCbm, + NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes, + NULL locked,NULL blockReason,o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt + FROM fl_inbound_order o + JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id + AND m.status='APPROVED' + AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2 + WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED') + JOIN ( + SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count, + COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count)) + / NULLIF(SUM(package_count),0),0) unit_volume_cbm + FROM fl_inbound_measurement_item + WHERE inbound_detail_id IS NOT NULL AND package_count>0 + GROUP BY tenant_id,measurement_id,inbound_detail_id + ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id + WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}) + AND o.process_status='WAIT_SPLIT' + AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL + AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) + AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li + WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id + AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0) + +COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item + WHERE o.inbound_role='MASTER' AND split_item.tenant_id=o.tenant_id + AND split_item.parent_inbound_id=o.id + AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) + ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC + """) + List globalCandidates(@Param("warehouseId") Long warehouseId); + + @Select(""" + + """) + Page workbenchContainers(Page page, + @Param("query") WorkbenchContainerQuery query, + @Param("actualChannels") Set actualChannels, + @Param("orderStatus") String orderStatus); + @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, 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 ec03685cc..569af78e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -2,6 +2,10 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; +import com.baiyue.common.core.api.PageResult; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.LoadingPlanEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; @@ -19,6 +23,7 @@ import jakarta.annotation.Resource; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import java.util.List; +import java.util.Set; /** 智能装柜聚合的真实 MySQL 持久化边界。 */ @Repository @@ -32,6 +37,13 @@ public class SmartLoadingRepository { @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } + public List globalCandidates(Long warehouseId) { return candidates.globalCandidates(warehouseId); } + public PageResult workbenchContainers(WorkbenchContainerQuery query, + Set actualChannels) { + return PageResult.from(candidates.workbenchContainers( + new Page<>(query.page(), query.size()), query, actualChannels, + query.orderStatus() == null ? null : query.orderStatus().name())); + } public List channelContainerCounts(long warehouseId) { return candidates.channelContainerCounts(warehouseId); } 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 399c08cc2..1a6fda028 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; @@ -38,6 +39,17 @@ import java.util.*; @Service public class SmartLoadingService { private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10"); + private static final Map> WORKBENCH_CHANNEL_GROUPS = Map.of( + "MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"), + "MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"), + "EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"), + "HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"), + "OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"), + "OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"), + "OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"), + "OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"), + "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK") + ); @Resource private SmartLoadingRepository repository; @Resource private OrderCargoClient orderCargoClient; @Resource private LoadingPickService loadingPickService; @@ -96,6 +108,57 @@ public class SmartLoadingService { return grouped.values().stream().map(this::inboundCandidate).toList(); } + /** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */ + public PageResult inboundCandidates(InboundCandidateQuery query) { + if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null + && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) { + throw validation("预计到货开始日期不能晚于结束日期"); + } + Set actualChannels = actualChannels(query.channelGroupCodes()); + List all = repository.globalCandidates(query.warehouseId()).stream() + .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, + java.util.stream.Collectors.toList())) + .values().stream().map(this::inboundCandidate).toList(); + Map snapshots = new HashMap<>(); + Map> grouped = new LinkedHashMap<>(); + List standalone = new ArrayList<>(); + for (InboundCandidate candidate : all) { + GroupSnapshot group = snapshots.computeIfAbsent(candidate.accountId() + "|" + candidate.inboundId(), + ignored -> group(candidate.accountId(), candidate.inboundId())); + if (group == null) { + standalone.add(candidate); + } else { + grouped.computeIfAbsent(group.groupId(), ignored -> new ArrayList<>()).add(candidate); + } + } + List units = new ArrayList<>(standalone); + for (List members : grouped.values()) { + GroupSnapshot group = snapshots.get(members.get(0).accountId() + "|" + members.get(0).inboundId()); + InboundCandidate aggregate = globalGroupCandidate(group, members); + if (aggregate != null) units.add(aggregate); + } + units = units.stream().filter(candidate -> matches(query, actualChannels, candidate)) + .sorted(Comparator.comparing(InboundCandidate::expectedArrivalAt, + Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(InboundCandidate::inboundId, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + long total = units.size(); + long offset = query.page() - 1 > Long.MAX_VALUE / query.size() + ? Long.MAX_VALUE : (query.page() - 1) * query.size(); + long from = Math.min(total, offset); + long to = Math.min(total, from + query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(units.subList((int) from, (int) to), query.page(), query.size(), total, pages); + } + + public PageResult containers(WorkbenchContainerQuery query) { + if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null + && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) { + throw validation("预计到货开始日期不能晚于结束日期"); + } + return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); + } + private InboundCandidate inboundCandidate(List details) { Candidate first = details.get(0); int packages = details.stream().map(Candidate::availablePackageCount) @@ -114,7 +177,93 @@ public class SmartLoadingService { first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(), - first.expectedArrivalAt()); + first.expectedArrivalAt(), List.of()); + } + + private InboundCandidate globalGroupCandidate(GroupSnapshot group, List availableMembers) { + if (group == null || !"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) return null; + Map byInbound = availableMembers.stream() + .collect(java.util.stream.Collectors.toMap(InboundCandidate::inboundId, value -> value, (a, b) -> a)); + for (Member member : group.members()) { + InboundCandidate candidate = byInbound.get(member.inboundId()); + if (candidate == null || !Objects.equals(candidate.accountId(), member.tenantId()) + || !Objects.equals(candidate.availablePackageCount(), member.fullPackageCount())) return null; + } + List ordered = group.members().stream().map(member -> byInbound.get(member.inboundId())).toList(); + InboundCandidate first = ordered.get(0); + int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum(); + BigDecimal volume = ordered.stream().map(InboundCandidate::availableVolumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP); + BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages); + BigDecimal net = weightedAverage(ordered, InboundCandidate::unitNetWeightKg, packages); + BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO + : volume.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); + List groupMembers = ordered.stream().map(member -> new GroupMemberCandidate( + member.accountId(), member.inboundId(), member.orderId(), member.orderNo(), member.inboundCode(), + member.customerName(), member.availablePackageCount(), member.orderStatusSnapshot(), + member.expectedArrivalAt())).toList(); + String customers = ordered.stream().map(InboundCandidate::customerName).filter(StringUtils::hasText) + .distinct().collect(java.util.stream.Collectors.joining("、")); + String goods = ordered.stream().map(InboundCandidate::goodsSummary).filter(StringUtils::hasText) + .distinct().collect(java.util.stream.Collectors.joining("、")); + return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), + first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + customers, goods, packages, gross, net, averageVolume, volume, group.groupId(), group.groupNo(), + group.members().size(), group.fullPackageCount(), false, null, first.orderStatusSnapshot(), + ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null), + groupMembers); + } + + private static BigDecimal weightedAverage(List members, + java.util.function.Function getter, + int packages) { + if (packages == 0) return BigDecimal.ZERO; + return members.stream().map(candidate -> unit(getter.apply(candidate)).multiply( + BigDecimal.valueOf(candidate.availablePackageCount()))) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); + } + + private static Set actualChannels(List groups) { + Set actual = new LinkedHashSet<>(); + for (String value : groups) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + Set mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized); + if (mapped == null) throw validation("不支持的渠道分组: " + value); + actual.addAll(mapped); + } + return actual; + } + + private static boolean matches(InboundCandidateQuery query, Set actualChannels, + InboundCandidate candidate) { + if (!actualChannels.contains(candidate.channelCode())) return false; + if (query.customerId() != null && !Objects.equals(candidate.accountId(), query.customerId()) + && candidate.groupMembers().stream().noneMatch( + member -> Objects.equals(member.accountId(), query.customerId()))) return false; + String inboundCode = trim(query.inboundCode()); + if (StringUtils.hasText(inboundCode) && candidate.groupMembers().stream() + .map(GroupMemberCandidate::inboundCode).filter(Objects::nonNull) + .noneMatch(value -> value.toLowerCase(Locale.ROOT).contains(inboundCode.toLowerCase(Locale.ROOT))) + && (candidate.inboundCode() == null || !candidate.inboundCode().toLowerCase(Locale.ROOT) + .contains(inboundCode.toLowerCase(Locale.ROOT)))) return false; + if (query.expectedArrivalStart() != null || query.expectedArrivalEnd() != null) { + boolean arrivalMatches = candidate.groupMembers().isEmpty() + ? inArrivalRange(candidate.expectedArrivalAt(), query) + : candidate.groupMembers().stream().anyMatch(member -> inArrivalRange( + member.expectedArrivalAt(), query)); + if (!arrivalMatches) return false; + } + return query.orderStatus() == null || query.orderStatus() == CandidateOrderStatus.WAIT_SPLIT; + } + + private static boolean inArrivalRange(LocalDateTime arrival, InboundCandidateQuery query) { + if (arrival == null) return false; + return (query.expectedArrivalStart() == null + || !arrival.toLocalDate().isBefore(query.expectedArrivalStart())) + && (query.expectedArrivalEnd() == null + || !arrival.toLocalDate().isAfter(query.expectedArrivalEnd())); } public Plan draftByBooking(long bookingId) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 50b38abed..7cea2e977 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -483,16 +483,72 @@ paths: '403': {description: 无智能装柜查询权限} /api/platform/v1/first-leg/smart-loading/inbound-candidates: get: - summary: 按入仓码查询智能装柜候选 + summary: 全局分页查询智能装柜候选 description: >- - 需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行; + 需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、 + 预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个 + 分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 - 响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码; - 这些数据用于数量级计划,不表示具体箱号已核验归属。 + 只返回当前具备实际装柜候选资格的 WAIT_SPLIT 数据;PENDING_REVIEW、WAIT_MEASURE、LOADING + 是统一筛选枚举,但在候选接口中返回空页。LOADED、COMPLETED 与已锁定或成员不完整的组永不返回。 + 分配时仍由目标 planId 接口复核真实渠道、仓库、容量、组锁和并发版本。 parameters: - - {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - responses: {'200': {description: 成功,data 为入仓码级候选列表}} + - name: channelGroupCodes + in: query + required: true + description: >- + 至少一项,可重复传参。分组到实际渠道:MATSON_CLX=[MATSON_CLX_TRUCK,MATSON_CLX_PARCEL]; + MATSON_MAX=[MATSON_MAX_TRUCK,MATSON_MAX_PARCEL];EXX=[EXX_TRUCK,EXX_RAIL_NY]; + HEDE=[HEDE_TRUCK,HEDE_PARCEL];OA_LA=[OA_LA_TRUCK,OA_LA_FULL_TRUCK,OA_PARCEL]; + OA_CHICAGO=[OA_CHICAGO_RAIL];OA_HOUSTON=[OA_HOUSTON_TRUCK]; + OA_SAVANNAH=[OA_SAVANNAH_TRUCK];OA_NEW_YORK_TRUCK=[OA_NEW_YORK_TRUCK]。 + schema: + type: array + minItems: 1 + maxItems: 9 + uniqueItems: true + items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]} + - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} + - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}} + - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}} + - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 成功,data 为标准分页;total/pages 按普通入库单或完整同柜组分页单元统计 + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundCandidatePageEnvelope'}}} + /api/platform/v1/first-leg/smart-loading/containers: + get: + summary: 分页查询智能装柜工作台柜次 + description: >- + 需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、 + 预计到货日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。 + 只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。 + parameters: + - name: channelGroupCodes + in: query + required: true + schema: + type: array + minItems: 1 + maxItems: 9 + uniqueItems: true + items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]} + - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} + - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}} + - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}} + - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}} + - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: 成功,柜次按更新时间、技术ID倒序稳定分页 + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainerPageEnvelope'}}} /api/platform/v1/first-leg/loading-associations/summary: get: summary: 查询入仓片段的装柜与国内PDA关联摘要 @@ -1203,10 +1259,10 @@ components: type: object properties: records: {type: array, items: {$ref: '#/components/schemas/UnifiedInboundSummary'}} - page: {type: integer, format: int64, minimum: 1} - size: {type: integer, format: int64, minimum: 1} - total: {type: integer, format: int64, minimum: 0} - pages: {type: integer, format: int64, minimum: 0} + page: {type: string, pattern: '^[1-9][0-9]*$'} + size: {type: string, pattern: '^[1-9][0-9]*$'} + total: {type: string, pattern: '^[0-9]+$'} + pages: {type: string, pattern: '^[0-9]+$'} UnifiedInboundListEnvelope: type: object properties: @@ -1880,6 +1936,103 @@ components: candidateInboundCount: {type: integer, format: int64, minimum: 0, description: 当前可分配的唯一入仓码数量} availablePackageCount: {type: integer, format: int64, minimum: 0, description: 当前候选规格剩余可分配箱数合计} availableContainerCount: {type: integer, format: int64, minimum: 0, description: 当前 WAIT_LOADING、EDITING 或 LOADING 可编排柜次数量} + SmartLoadingInboundCandidatePageEnvelope: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingInboundCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + SmartLoadingInboundCandidate: + type: object + required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode, + channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg, + unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, groupMembers] + properties: + accountId: {type: string, pattern: '^[1-9][0-9]*$'} + customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID} + inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 普通候选ID;组聚合行中为可用于整组分配的稳定代表成员ID} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + inboundCode: {type: string} + orderStatusSnapshot: {type: string, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string} + channelName: {type: string} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + destinationWarehouseCode: {type: string, nullable: true} + destinationWarehouseName: {type: string, nullable: true} + customerName: {type: string} + goodsSummary: {type: string} + availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计} + unitGrossWeightKg: {type: number, minimum: 0} + unitNetWeightKg: {type: number, minimum: 0} + averageUnitVolumeCbm: {type: number, minimum: 0} + availableVolumeCbm: {type: number, minimum: 0} + coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + groupNo: {type: string, nullable: true} + memberCount: {type: integer, minimum: 2, nullable: true} + groupTotalBoxes: {type: integer, minimum: 1, nullable: true} + locked: {type: boolean} + blockReason: {type: string, nullable: true} + groupMembers: + type: array + description: 同柜组聚合行的完整成员;普通候选为空数组 + items: + type: object + required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount] + properties: + accountId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundId: {type: string, pattern: '^[1-9][0-9]*$'} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + inboundCode: {type: string} + customerName: {type: string} + availablePackageCount: {type: integer, minimum: 1} + orderStatusSnapshot: {type: string, nullable: true} + expectedArrivalAt: {type: string, format: date-time, nullable: true} + SmartLoadingWorkbenchContainerPageEnvelope: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainer'}} + page: {type: string, pattern: '^[1-9][0-9]*$'} + size: {type: string, pattern: '^[1-9][0-9]*$'} + total: {type: string, pattern: '^[0-9]+$'} + pages: {type: string, pattern: '^[0-9]+$'} + SmartLoadingWorkbenchContainer: + type: object + required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode, + channelName, containerType, planStatus, version] + properties: + bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联} + bookingNo: {type: string, description: 货柜登记业务号} + cabinetNo: {type: string, description: 稳定柜次业务号} + containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + warehouseName: {type: string} + channelCode: {type: string} + channelName: {type: string} + containerType: {type: string} + planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + planNo: {type: string, nullable: true} + planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING]} + version: {type: integer, minimum: 0, description: 柜次版本} SmartLoadingPlan: type: object required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] 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 9123c2cd4..0e625c0fe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; @@ -12,16 +13,27 @@ class PlatformSmartLoadingControllerContractTest { authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class); - authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class); + authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,InboundCandidateQuery.class); + authority("containers","platform:firstleg:smart-loading:list",AuthenticatedUser.class,WorkbenchContainerQuery.class); authority("channels","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); } - @Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception { + @Test void openApiFreezesFilteredWorkbenchContainers() throws Exception { String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); - assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/candidates:") - .contains("/api/platform/v1/first-leg/smart-loading/channels:") - .contains("零候选或零可用柜次的有效渠道仍返回") - .contains("name: bookingId, in: query, required: true") - .contains("name: warehouseId, in: query, required: true"); + assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/containers:") + .contains("SmartLoadingWorkbenchContainerPageEnvelope:") + .contains("只返回 WAIT_LOADING、EDITING 或 LOADING"); + } + @Test void openApiFreezesGlobalPagedInboundCandidates() throws Exception { + Method method=PlatformSmartLoadingController.class.getDeclaredMethod( + "inboundCandidates",AuthenticatedUser.class,InboundCandidateQuery.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/inbound-candidates"); + String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); + String contract=yaml.substring(yaml.indexOf("/api/platform/v1/first-leg/smart-loading/inbound-candidates:"), + yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:")); + assertThat(contract).contains("name: channelGroupCodes") + .contains("name: warehouseId") + .contains("SmartLoadingInboundCandidatePageEnvelope") + .doesNotContain("name: bookingId"); } @Test void everyMutationDeclaresDedicatedPermission() throws Exception { authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); 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 3a9e98e36..750f6fdb1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -6,6 +6,9 @@ import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.util.Set; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import static org.assertj.core.api.Assertions.assertThat; @@ -49,6 +52,35 @@ class SmartLoadingCandidateMapperContractTest { .isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot")); } + @Test + void globalCandidateQueryHasOptionalWarehouseAndNoContainerJoin() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidates", Long.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}") + .contains("o.process_status='WAIT_SPLIT'") + .doesNotContain("fl_container_booking") + .doesNotContain("bookingId"); + } + + @Test + void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception { + Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers", + Page.class, WorkbenchContainerQuery.class, Set.class, String.class); + String sql = String.join(" ", list.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("b.channel_code IN") + .contains("fl_loading_plan_item filter_item") + .contains("fl_inbound_order filter_inbound") + .contains("filter_item.inbound_code LIKE") + .contains("filter_inbound.expected_arrival_at") + .contains("filter_inbound.process_status='LOADING'") + .doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')"); + } + @Test void detailUsesSameBatchRule() throws Exception { Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( 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 16b7e7409..1fc68671f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -7,7 +7,12 @@ import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; +import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest; @@ -39,6 +44,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.math.BigDecimal; +import java.time.LocalDate; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.sql.SQLException; @@ -144,6 +150,89 @@ class SmartLoadingServiceTest { }); } + @Test + void pagesGlobalCandidatesWithoutBindingAContainer() { + Candidate clx = withChannel(candidate(9L, 10L, 20L, 30L, 4), + "MATSON_CLX_TRUCK", "美森正班海卡"); + Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6), + "MATSON_MAX_TRUCK", "美森加班海卡"); + when(repository.globalCandidates(null)).thenReturn(List.of(clx, max)); + + var result = service.inboundCandidates(new InboundCandidateQuery( + List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20)); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.pages()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.inboundId()).isEqualTo(10L); + assertThat(candidate.channelCode()).isEqualTo("MATSON_CLX_TRUCK"); + }); + verify(repository).globalCandidates(null); + verify(repository, never()).booking(anyLong()); + } + + @Test + void pagesWorkbenchContainersWithTheSameGlobalFilters() { + WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, + 100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC", + 1L, "LP-1", "EDITING", 2); + WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, + 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), + CandidateOrderStatus.WAIT_SPLIT, 1, 20); + when(repository.workbenchContainers(eq(query), any())).thenReturn( + new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1)); + + var result = service.containers(query); + + assertThat(result.records()).containsExactly(container); + @SuppressWarnings("unchecked") ArgumentCaptor> channels = + ArgumentCaptor.forClass(java.util.Set.class); + verify(repository).workbenchContainers(eq(query), channels.capture()); + assertThat(channels.getValue()).containsExactlyInAnyOrder( + "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"); + } + + @Test + void treatsWholeColoadGroupAsOneStablePagingUnit() { + Candidate first = withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4), + "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00"); + Candidate second = withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6), + "MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6)); + when(repository.globalCandidates(100L)).thenReturn(List.of(first, second)); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group); + + var result = service.inboundCandidates(new InboundCandidateQuery( + List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"), + LocalDate.parse("2026-09-10"), null, 1, 1)); + + assertThat(result.total()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.coLoadGroupId()).isEqualTo(700L); + assertThat(candidate.availablePackageCount()).isEqualTo(10); + assertThat(candidate.groupMembers()).extracting(GroupMemberCandidate::inboundId) + .containsExactly(10L, 11L); + }); + } + + @Test + void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() { + Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4), + "MATSON_CLX_TRUCK", "美森正班海卡"); + GroupSnapshot group = group("ACTIVE", null, + new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6)); + when(repository.globalCandidates(100L)).thenReturn(List.of(first)); + when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group); + + var result = service.inboundCandidates(new InboundCandidateQuery( + List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20)); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + } + @Test void channelDirectoryKeepsActiveZeroStatChannelAndAggregatesCandidateAndContainerFacts() { ChannelRow maxTruck = channel("MATSON_MAX_TRUCK", "美森加班海卡"); @@ -1124,6 +1213,16 @@ class SmartLoadingServiceTest { value.orderStatusSnapshot(), value.expectedArrivalAt()); } + private static Candidate withArrival(Candidate value, String expectedArrivalAt) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(), + value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(), + value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(), + value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(), + value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(), + value.orderStatusSnapshot(), LocalDateTime.parse(expectedArrivalAt)); + } + private static ChannelRow channel(String channelCode, String channelName) { ChannelRow value = new ChannelRow(); value.setChannelCode(channelCode); value.setChannelName(channelName);