feat(firstleg): expose smart-loading channel directory

This commit is contained in:
2026-08-31 14:54:00 +08:00
parent ea615b864a
commit 0122ad314b
9 changed files with 152 additions and 1 deletions
@@ -8,6 +8,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; imp
@Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading") @Validated @RestController @RequestMapping("/api/platform/v1/first-leg/smart-loading")
public class PlatformSmartLoadingController { public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service; @Resource private SmartLoadingService service;
@GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<ChannelSummary>> 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')") @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));} public ApiResponse<List<Candidate>> 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')") @GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List; 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 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, public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
@@ -1,6 +1,17 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
@Mapper public interface SmartLoadingCandidateMapper { @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<ChannelContainerCountRow> channelContainerCounts(@Param("warehouseId") long warehouseId);
@Select(""" @Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, 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.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, Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId,
@Param("inboundId") long inboundId,@Param("detailId") long detailId); @Param("inboundId") long inboundId,@Param("detailId") long detailId);
@lombok.Getter @lombok.Setter class ChannelContainerCountRow {
private String channelCode;
private Long availableContainerCount;
}
} }
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper; import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow;
import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -31,6 +32,9 @@ public class SmartLoadingRepository {
@Resource private InboundOrderMapper inbounds; @Resource private InboundOrderMapper inbounds;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); } public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
}
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId); return candidates.candidate(planId, warehouseId, inboundId, detailId);
} }
@@ -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.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; 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 com.baiyue.firstleg.split.service.SplitPreloadService;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
@@ -43,11 +44,35 @@ public class SmartLoadingService {
@Resource private LoadingModelBridgeService loadingModelBridge; @Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService; @Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups; @Resource private ColoadGroupAssignmentPort coloadGroups;
@Resource private PriceBookQueryUseCase priceBooks;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { } public SmartLoadingService() { }
public List<ChannelSummary> channels(long warehouseId) {
if (warehouseId <= 0) throw validation("warehouseId必须大于0");
Long versionId = priceBooks.activeVersionId();
if (versionId == null) return List.of();
Map<String, List<Candidate>> candidatesByChannel = repository.candidates(warehouseId).stream()
.filter(candidate -> StringUtils.hasText(candidate.channelCode()))
.collect(java.util.stream.Collectors.groupingBy(candidate -> candidate.channelCode().trim()));
Map<String, Long> 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<Candidate> 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<Candidate> candidates(long warehouseId) { public List<Candidate> candidates(long warehouseId) {
if (warehouseId <= 0) { if (warehouseId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0"); throw new BizException(ResultCode.BAD_REQUEST, "warehouseId必须大于0");
@@ -382,6 +382,27 @@ paths:
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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]*$'}} - {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}} 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: /api/platform/v1/first-leg/smart-loading/inbound-candidates:
get: get:
summary: 按入仓码查询智能装柜候选 summary: 按入仓码查询智能装柜候选
@@ -1700,6 +1721,16 @@ components:
required: [itemId] required: [itemId]
properties: properties:
itemId: {type: string, pattern: '^[1-9][0-9]*$'} 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: SmartLoadingPlan:
type: object type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items] required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
@@ -12,11 +12,14 @@ class PlatformSmartLoadingControllerContractTest {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class); authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","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("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 { @Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); 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:") 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: bookingId, in: query, required: true")
.contains("name: warehouseId, in: query, required: true"); .contains("name: warehouseId, in: query, required: true");
} }
@@ -69,4 +69,19 @@ class SmartLoadingCandidateMapperContractTest {
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("FOR UPDATE"); .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'");
}
} }
@@ -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.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; 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.baiyue.firstleg.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException; import feign.FeignException;
@@ -73,6 +76,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService; @MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private ColoadGroupAssignmentPort coloadGroups;
@MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service; @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 @Test
void exposesWholeColoadGroupFactsOnEveryMemberCandidate() { void exposesWholeColoadGroupFactsOnEveryMemberCandidate() {
Candidate first = candidate(9L, 10L, 20L, 30L, 4); Candidate first = candidate(9L, 10L, 20L, 30L, 4);
@@ -1297,6 +1335,21 @@ class SmartLoadingServiceTest {
new BigDecimal("0.5"), null, null, null, null, null, null); 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) { private static GroupSnapshot group(String status, Long lockedPlanId, Member... members) {
GroupSnapshot group = mock(GroupSnapshot.class); GroupSnapshot group = mock(GroupSnapshot.class);
when(group.groupId()).thenReturn(700L); when(group.groupId()).thenReturn(700L);