This commit is contained in:
2026-08-21 17:59:21 +08:00
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 = "执行智能装柜推荐")