This commit is contained in:
2026-08-21 17:07:34 +08:00
parent 34fb91baf6
commit 598749d8e4
7 changed files with 204 additions and 0 deletions
@@ -10,6 +10,8 @@ public class PlatformSmartLoadingController {
@Resource private SmartLoadingService service;
@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));}
@GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<InboundCandidate>> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> 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')")
@@ -18,6 +20,8 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));}
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
@@ -1,6 +1,11 @@
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 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) {}
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) {
@@ -19,6 +24,9 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
@@ -31,6 +31,13 @@ public class SmartLoadingRepository {
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
return candidates.candidate(warehouseId, inboundId, detailId);
}
public List<LoadingPlanItemEntity> activeItemsByInbound(long planId, long accountId, long inboundId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
.eq(LoadingPlanItemEntity::getAccountId, accountId)
.eq(LoadingPlanItemEntity::getInboundId, inboundId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
public LoadingPlanEntity planByBooking(long bookingId) {
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
@@ -58,6 +58,33 @@ public class SmartLoadingService {
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
}
/** Operator-facing candidates are grouped by inbound code; specification rows remain internal snapshots. */
public List<InboundCandidate> inboundCandidates(long bookingId, long warehouseId) {
Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList();
}
private InboundCandidate inboundCandidate(List<Candidate> details) {
Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
BigDecimal volume = details.stream()
.map(detail -> unit(detail.unitVolumeCbm()).multiply(BigDecimal.valueOf(
Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount()))))
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO : volume.divide(
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
.map(String::trim).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(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume);
}
public Plan draftByBooking(long bookingId) {
ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity plan = repository.planByBooking(bookingId);
@@ -144,6 +171,84 @@ public class SmartLoadingService {
return view(repository.plan(planId), booking);
}
/**
* Assigns one inbound code by total carton count. The selected quantity is proportionally
* distributed over the remaining measured specifications only for volume estimation and
* downstream compatibility; it is not presented as verified physical carton identity.
*/
@Transactional
@BusinessAudit(event = "SMART_LOADING_INBOUND_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "按入仓码分配智能装柜箱数")
public Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
+ request.packageCount() + '|' + request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_INBOUND", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList();
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
Candidate first = details.get(0);
if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) {
throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配");
}
List<InboundAllocation> allocations = proportionalAllocations(details, request.packageCount());
int sequence = 0;
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
LoadingPlanEntity current = repository.plan(planId);
String childOperation = "INB-" + sha256(operation + '|' + allocation.candidate().inboundDetailId())
.substring(0, 48);
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName);
sequence++;
}
LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_INBOUND", plan.getPlanStatus(), assigned.getPlanStatus(), operation,
json(values("version", request.expectedVersion(), "inboundId", request.inboundId())),
json(values("requestHash", requestHash, "packageCount", request.packageCount(),
"specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")),
operatorId, operatorName);
return view(assigned, booking);
}
List<InboundAllocation> proportionalAllocations(List<Candidate> details, int selected) {
int available = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
if (selected < 1 || selected > available) throw validation("分配箱数超过入仓码可用箱数");
if (selected == available) {
return details.stream().map(detail -> new InboundAllocation(detail, detail.availablePackageCount(),
BigDecimal.ZERO)).toList();
}
List<InboundAllocation> result = new ArrayList<>();
int allocated = 0;
for (Candidate detail : details) {
int detailAvailable = Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount());
BigDecimal exact = BigDecimal.valueOf(selected).multiply(BigDecimal.valueOf(detailAvailable))
.divide(BigDecimal.valueOf(available), 12, RoundingMode.HALF_UP);
int base = exact.setScale(0, RoundingMode.DOWN).intValueExact();
result.add(new InboundAllocation(detail, base, exact.subtract(BigDecimal.valueOf(base))));
allocated += base;
}
int remaining = selected - allocated;
List<Integer> priority = java.util.stream.IntStream.range(0, result.size()).boxed()
.sorted(Comparator.<Integer, BigDecimal>comparing(index -> result.get(index).remainder()).reversed()
.thenComparingInt(Integer::intValue)).toList();
for (int index = 0; index < remaining; index++) {
int target = priority.get(index);
InboundAllocation current = result.get(target);
result.set(target, new InboundAllocation(current.candidate(), current.packageCount() + 1,
current.remainder()));
}
return result;
}
record InboundAllocation(Candidate candidate, int packageCount, BigDecimal remainder) {}
@Transactional
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "执行智能装柜推荐")
@@ -392,6 +392,17 @@ 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/inbound-candidates:
get:
summary: 按入仓码查询智能装柜候选
description: >-
需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
这些数据用于数量级计划,不表示具体箱号已核验归属。
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 为入仓码级候选列表}}
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
get:
summary: 查询智能装柜草稿详情
@@ -419,6 +430,20 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:
post:
summary: 按入仓码分配计划装柜箱数
description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundAssignRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:
patch:
summary: 调整预装计划明细箱数
@@ -1594,6 +1619,14 @@ components:
packageCount: {type: integer, minimum: 1}
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingInboundAssignRequest:
type: object
required: [inboundId, packageCount, expectedVersion, operationId]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
packageCount: {type: integer, minimum: 1, description: 该入仓码本次计划装柜总箱数}
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingAdjustRequest:
type: object
required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId]
@@ -12,6 +12,7 @@ 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);
}
@Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
@@ -21,6 +22,7 @@ class PlatformSmartLoadingControllerContractTest {
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
@@ -41,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest {
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/plans/{planId}/assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:")
.contains("SmartLoadingInboundAssignRequest:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
@@ -104,6 +104,49 @@ class SmartLoadingServiceTest {
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
}
@Test
void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
var result = service.inboundCandidates(99L, 100L);
assertThat(result).singleElement().satisfies(candidate -> {
assertThat(candidate.inboundCode()).isEqualTo("I");
assertThat(candidate.goodsSummary()).isEqualTo("服装、鞋类");
assertThat(candidate.availablePackageCount()).isEqualTo(10);
assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000");
assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000");
assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2");
});
}
@Test
void proportionallyDistributesInboundQuantityWithoutLosingCartons() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"));
var result = service.proportionalAllocations(List.of(first, second, third), 60);
assertThat(result).extracting(SmartLoadingService.InboundAllocation::packageCount)
.containsExactly(18, 24, 18);
assertThat(result).extracting(SmartLoadingService.InboundAllocation::candidate)
.extracting(Candidate::inboundDetailId).containsExactly(20L, 21L, 22L);
}
@Test
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
LoadingPlanEntity plan = plan("LOADING", 3);