From 0122ad314b3f22746268092eef87b1530ab1b589 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 14:54:00 +0800 Subject: [PATCH] feat(firstleg): expose smart-loading channel directory --- .../PlatformSmartLoadingController.java | 2 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 2 + .../mapper/SmartLoadingCandidateMapper.java | 16 ++++++ .../repository/SmartLoadingRepository.java | 4 ++ .../firstleg/service/SmartLoadingService.java | 25 +++++++++ .../main/resources/openapi/firstleg-v1.yaml | 31 +++++++++++ ...ormSmartLoadingControllerContractTest.java | 5 +- ...artLoadingCandidateMapperContractTest.java | 15 ++++++ .../service/SmartLoadingServiceTest.java | 53 +++++++++++++++++++ 9 files changed, 152 insertions(+), 1 deletion(-) 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 1fe04bcac..abf0ed8e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -8,6 +8,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp @Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading") public class PlatformSmartLoadingController { @Resource private SmartLoadingService service; + @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") + public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.channels(warehouseId));} @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')") 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 df7c18292..ce77047fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.dto; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; public final class SmartLoadingDtos { private SmartLoadingDtos(){} + public record ChannelSummary(String channelCode, String channelName, + long candidateInboundCount, long availablePackageCount, long availableContainerCount) {} 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, 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 b89a2fed3..3d4729b41 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -1,6 +1,17 @@ package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface SmartLoadingCandidateMapper { + @Select(""" + SELECT b.channel_code channelCode,COUNT(DISTINCT b.id) availableContainerCount + FROM fl_container_booking b + LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id + WHERE b.record_status='ACTIVE' AND b.warehouse_id=#{warehouseId} + AND b.channel_code IS NOT NULL AND TRIM(b.channel_code)<>'' + AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING')) + GROUP BY b.channel_code + """) + List channelContainerCounts(@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, @@ -98,4 +109,9 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba """) Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId, @Param("inboundId") long inboundId,@Param("detailId") long detailId); + + @lombok.Getter @lombok.Setter class ChannelContainerCountRow { + private String channelCode; + private Long availableContainerCount; + } } 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 684c8f4eb..89d263e49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; import com.baiyue.firstleg.mapper.LoadingPlanMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.mapper.InboundOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @@ -31,6 +32,9 @@ public class SmartLoadingRepository { @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } + public List channelContainerCounts(long warehouseId) { + return candidates.channelContainerCounts(warehouseId); + } 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 68a5f7c5e..4c8ea0803 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; 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.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.split.service.SplitPreloadService; import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DataAccessException; @@ -43,11 +44,35 @@ public class SmartLoadingService { @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @Resource private ColoadGroupAssignmentPort coloadGroups; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public SmartLoadingService() { } + public List channels(long warehouseId) { + if (warehouseId <= 0) throw validation("warehouseId必须大于0"); + Long versionId = priceBooks.activeVersionId(); + if (versionId == null) return List.of(); + Map> candidatesByChannel = repository.candidates(warehouseId).stream() + .filter(candidate -> StringUtils.hasText(candidate.channelCode())) + .collect(java.util.stream.Collectors.groupingBy(candidate -> candidate.channelCode().trim())); + Map containersByChannel = repository.channelContainerCounts(warehouseId).stream() + .filter(row -> StringUtils.hasText(row.getChannelCode())) + .collect(java.util.stream.Collectors.toMap(row -> row.getChannelCode().trim(), + row -> Objects.requireNonNullElse(row.getAvailableContainerCount(), 0L), Long::sum)); + return priceBooks.channels(versionId, warehouseId).stream().map(channel -> { + String channelCode = channel.getChannelCode().trim(); + List channelCandidates = candidatesByChannel.getOrDefault(channelCode, List.of()); + long candidateInboundCount = channelCandidates.stream().map(Candidate::inboundId) + .filter(Objects::nonNull).distinct().count(); + long availablePackageCount = channelCandidates.stream().map(Candidate::availablePackageCount) + .filter(Objects::nonNull).mapToLong(Integer::longValue).sum(); + return new ChannelSummary(channelCode, channel.getChannelName(), candidateInboundCount, + availablePackageCount, containersByChannel.getOrDefault(channelCode, 0L)); + }).toList(); + } + public List candidates(long warehouseId) { if (warehouseId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c3e059a39..4b8b6c640 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -382,6 +382,27 @@ paths: - {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: Candidates filtered by the server to the booking channel}} + /api/platform/v1/first-leg/smart-loading/channels: + get: + summary: 查询当前仓库智能装柜渠道目录及统计 + description: >- + 需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中 + 对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。 + 零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态;实际分配仍由服务端重新校验仓库、渠道和资格。 + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功;无 ACTIVE 价格版本时 data 为空数组 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {type: array, items: {$ref: '#/components/schemas/SmartLoadingChannelSummary'}} + '403': {description: 无智能装柜查询权限} /api/platform/v1/first-leg/smart-loading/inbound-candidates: get: summary: 按入仓码查询智能装柜候选 @@ -1700,6 +1721,16 @@ components: required: [itemId] properties: itemId: {type: string, pattern: '^[1-9][0-9]*$'} + SmartLoadingChannelSummary: + type: object + additionalProperties: false + required: [channelCode, channelName, candidateInboundCount, availablePackageCount, availableContainerCount] + properties: + channelCode: {type: string, minLength: 1, maxLength: 64} + channelName: {type: string, minLength: 1, maxLength: 128} + 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 可编排柜次数量} 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 ab63d24aa..833d7de2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -12,11 +12,14 @@ 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,long.class,long.class); + authority("channels","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); } @Test void openApiRequiresBookingAndWarehouseForCandidates() 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"); } 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 403c665f4..6cd5052e8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -69,4 +69,19 @@ class SmartLoadingCandidateMapperContractTest { .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("FOR UPDATE"); } + + @Test + void channelContainerStatisticsCountOnlyActiveOrchestratableContainers() throws Exception { + Method method = SmartLoadingCandidateMapper.class.getDeclaredMethod( + "channelContainerCounts", long.class); + String sql = String.join(" ", method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("b.record_status='ACTIVE'") + .contains("b.warehouse_id=#{warehouseId}") + .contains("p.id IS NULL OR p.plan_status IN ('EDITING','LOADING')") + .contains("COUNT(DISTINCT b.id) availableContainerCount") + .contains("GROUP BY b.channel_code") + .doesNotContain("'COMPLETED'"); + } } 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 1f7802e51..c2eb225b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -22,6 +22,9 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; import com.baiyue.firstleg.split.service.SplitPreloadService; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; @@ -73,6 +76,7 @@ class SmartLoadingServiceTest { @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @MockBean private ColoadGroupAssignmentPort coloadGroups; + @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @Resource private SmartLoadingService service; @@ -139,6 +143,40 @@ class SmartLoadingServiceTest { }); } + @Test + void channelDirectoryKeepsActiveZeroStatChannelAndAggregatesCandidateAndContainerFacts() { + ChannelRow maxTruck = channel("MATSON_MAX_TRUCK", "美森加班海卡"); + ChannelRow clxTruck = channel("MATSON_CLX_TRUCK", "美森正班海卡"); + Candidate first = candidate(9L, 10L, 20L, 30L, 4); + Candidate secondDetail = candidate(9L, 10L, 21L, 30L, 6); + Candidate secondInbound = candidate(19L, 11L, 22L, 31L, 5); + first = withChannel(first, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + secondDetail = withChannel(secondDetail, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + secondInbound = withChannel(secondInbound, "MATSON_MAX_TRUCK", "旧渠道快照名称"); + ChannelContainerCountRow count = new ChannelContainerCountRow(); + count.setChannelCode("MATSON_MAX_TRUCK"); count.setAvailableContainerCount(2L); + when(priceBooks.activeVersionId()).thenReturn(88L); + when(priceBooks.channels(88L, 100L)).thenReturn(List.of(maxTruck, clxTruck)); + when(repository.candidates(100L)).thenReturn(List.of(first, secondDetail, secondInbound)); + when(repository.channelContainerCounts(100L)).thenReturn(List.of(count)); + + var result = service.channels(100L); + + assertThat(result).hasSize(2); + assertThat(result.get(0)).satisfies(channel -> { + assertThat(channel.channelName()).isEqualTo("美森加班海卡"); + assertThat(channel.candidateInboundCount()).isEqualTo(2); + assertThat(channel.availablePackageCount()).isEqualTo(15); + assertThat(channel.availableContainerCount()).isEqualTo(2); + }); + assertThat(result.get(1)).satisfies(channel -> { + assertThat(channel.channelCode()).isEqualTo("MATSON_CLX_TRUCK"); + assertThat(channel.candidateInboundCount()).isZero(); + assertThat(channel.availablePackageCount()).isZero(); + assertThat(channel.availableContainerCount()).isZero(); + }); + } + @Test void exposesWholeColoadGroupFactsOnEveryMemberCandidate() { Candidate first = candidate(9L, 10L, 20L, 30L, 4); @@ -1297,6 +1335,21 @@ class SmartLoadingServiceTest { new BigDecimal("0.5"), null, null, null, null, null, null); } + private static Candidate withChannel(Candidate value, String channelCode, String channelName) { + return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(), + value.orderNo(), value.inboundCode(), value.warehouseId(), channelCode, 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()); + } + + private static ChannelRow channel(String channelCode, String channelName) { + ChannelRow value = new ChannelRow(); + value.setChannelCode(channelCode); value.setChannelName(channelName); + return value; + } + private static GroupSnapshot group(String status, Long lockedPlanId, Member... members) { GroupSnapshot group = mock(GroupSnapshot.class); when(group.groupId()).thenReturn(700L);