feat(firstleg): enforce indivisible coload loading

This commit is contained in:
2026-08-29 10:59:38 +08:00
parent ac6d3289a6
commit 3339ec91f9
11 changed files with 638 additions and 15 deletions
@@ -22,6 +22,10 @@ public class PlatformSmartLoadingController {
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}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> assignGroup(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignGroupRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignGroup(planId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/group-assignments/{groupId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removeGroup(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long groupId,@Valid @RequestBody RemoveGroupRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removeGroup(planId,groupId,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')")
@@ -5,16 +5,25 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
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) {}
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
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) {}
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {}
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 AssignGroupRequest(@NotNull @Positive Long inboundId,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max=128) String operationId) {}
public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@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,
@@ -18,7 +18,9 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
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
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm,
NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes,
NULL locked,NULL blockReason
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'
@@ -65,7 +67,9 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
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
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm,
NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes,
NULL locked,NULL blockReason
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'
@@ -46,6 +46,12 @@ public class SmartLoadingRepository {
.eq(LoadingPlanItemEntity::getInboundId, inboundId)
.eq(LoadingPlanItemEntity::getDeleted, false));
}
public List<LoadingPlanItemEntity> activeItemsByInbound(long accountId, long inboundId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
.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>()
@@ -35,6 +35,9 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
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.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -65,6 +68,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Map;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -92,6 +96,8 @@ public class LoadingPickService {
@Resource
private LoadingDeliveryService loadingDeliveryService;
@Resource
private ColoadGroupAssignmentPort coloadGroups;
@Resource
private TruckAppointmentService truckAppointmentService;
@Resource
private InboundStatusHistoryMapper inboundHistories;
@@ -482,6 +488,7 @@ public class LoadingPickService {
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.toList();
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
requireColoadConservation(task, discoveredOwners);
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(IdWorker.getId());
command.setPickTaskId(taskId);
@@ -512,6 +519,7 @@ public class LoadingPickService {
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList();
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
List<GroupSnapshot> taskGroups = coloadGroups(owners);
LoadingPickItemMapper.InboundOwner evidenceOwner = owners.get(0);
// Phase 1 is read-only: every owner must pass its business gates against the same cabinet evidence owner
@@ -569,6 +577,11 @@ public class LoadingPickService {
command.getOperatorId(), command.getOperatorName()));
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
for (GroupSnapshot group : taskGroups) {
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
}
requireCompletionLease(command.getId(), worker);
LoadingPickTaskEntity current = requiredTask(taskId);
if (!"COMPLETED".equals(current.getTaskStatus())
@@ -581,6 +594,42 @@ public class LoadingPickService {
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
private void requireColoadConservation(LoadingPickTaskEntity task,
List<LoadingPickItemMapper.InboundOwner> owners) {
List<LoadingPickItemEntity> items = repository.items(task.getId());
Map<String, LoadingPickItemMapper.InboundOwner> ownerIndex = owners.stream().collect(Collectors.toMap(
owner -> owner.tenantId() + "|" + owner.inboundId(), owner -> owner, (left, right) -> left));
for (GroupSnapshot group : coloadGroups(owners)) {
if (!"LOCKED".equals(group.status())
|| !Objects.equals(group.lockedPlanId(), task.getLoadingPlanId())) {
throw conflict("同柜不可拆组未锁定到当前PDA装柜计划");
}
for (Member member : group.members()) {
if (!ownerIndex.containsKey(member.tenantId() + "|" + member.inboundId())) {
throw conflict("同柜不可拆组成员未全部进入当前PDA任务");
}
int planned = items.stream().filter(item -> member.inboundId() == item.getInboundId())
.map(LoadingPickItemEntity::getPlannedPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
int picked = items.stream().filter(item -> member.inboundId() == item.getInboundId())
.map(LoadingPickItemEntity::getPickedPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
if (planned != member.fullPackageCount() || picked != member.fullPackageCount()) {
throw conflict("同柜不可拆组完成前必须全部成员满箱且全组箱数守恒");
}
}
}
}
private List<GroupSnapshot> coloadGroups(List<LoadingPickItemMapper.InboundOwner> owners) {
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
for (LoadingPickItemMapper.InboundOwner owner : owners) {
GroupSnapshot group = coloadGroups.findByInbound(owner.tenantId(), owner.inboundId());
if (group != null) groups.putIfAbsent(group.groupId(), group);
}
return List.copyOf(groups.values());
}
private void requireCompletionLease(long commandId, String worker) {
if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) {
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
@@ -9,6 +9,9 @@ import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
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.split.service.SplitPreloadService;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataAccessException;
@@ -39,6 +42,7 @@ public class SmartLoadingService {
@Resource private LoadingPickService loadingPickService;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@@ -54,8 +58,9 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
return candidates(warehouseId).stream()
List<Candidate> values = candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
}
/** Operator-facing candidates are grouped by inbound code; specification rows remain internal snapshots. */
@@ -82,7 +87,8 @@ public class SmartLoadingService {
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume);
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason());
}
public Plan draftByBooking(long bookingId) {
@@ -124,6 +130,11 @@ public class SmartLoadingService {
@BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "分配智能装柜明细")
public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) {
return assign(planId, request, operatorId, operatorName, false);
}
private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName,
boolean wholeGroupAssignment) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN|" + planId + '|'
+ request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount()
@@ -135,6 +146,7 @@ public class SmartLoadingService {
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(),
request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId);
@@ -189,6 +201,11 @@ public class SmartLoadingService {
resourceId = "#planId", description = "按入仓码分配智能装柜箱数")
public Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName) {
return assignInbound(planId, request, operatorId, operatorName, false);
}
private Plan assignInbound(long planId, AssignInboundRequest request,
long operatorId, String operatorName, boolean wholeGroupAssignment) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
+ request.packageCount() + '|' + request.expectedVersion());
@@ -201,6 +218,7 @@ public class SmartLoadingService {
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList();
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
Candidate first = details.get(0);
if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId());
if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) {
throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配");
}
@@ -212,7 +230,8 @@ public class SmartLoadingService {
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);
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName,
wholeGroupAssignment);
sequence++;
}
LoadingPlanEntity assigned = repository.plan(planId);
@@ -257,6 +276,82 @@ public class SmartLoadingService {
record InboundAllocation(Candidate candidate, int packageCount, BigDecimal remainder) {}
@Transactional
@BusinessAudit(event = "SMART_LOADING_GROUP_ASSIGN", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "整组分配同柜不可拆订单")
public Plan assignGroup(long planId, AssignGroupRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("ASSIGN_GROUP|" + planId + '|' + request.inboundId() + '|'
+ request.expectedVersion());
Plan replay = replay(operation, planId, "ADD_COLOAD_GROUP", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate selected = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId()))
.findFirst().orElseThrow(() -> conflict("同柜不可拆组成员当前不可分配"));
GroupSnapshot discovered = requiredGroup(selected.accountId(), selected.inboundId());
Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates(
planId, booking, discovered, repository.items(planId));
GroupSnapshot locked = coloadGroups.lockWholeGroupForAssignment(selected.accountId(),
selected.inboundId(), planId, "LOCK-" + sha256(operation).substring(0, 48),
operatorId, operatorName);
requireSameGroup(discovered, locked, planId);
for (Member member : locked.members()) {
LoadingPlanEntity current = repository.plan(planId);
assignInbound(planId, new AssignInboundRequest(member.inboundId(), member.fullPackageCount(),
current.getVersion(), "GRP-INB-" + sha256(operation + '|' + member.inboundId())
.substring(0, 48)), operatorId, operatorName, true);
}
LoadingPlanEntity assigned = repository.plan(planId);
history(planId, null, "ADD_COLOAD_GROUP", plan.getPlanStatus(), assigned.getPlanStatus(), operation,
json(values("version", request.expectedVersion(), "groupId", locked.groupId())),
json(values("requestHash", requestHash, "groupId", locked.groupId(),
"memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(),
"candidateInboundCount", memberCandidates.size())), operatorId, operatorName);
return view(assigned, booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_GROUP_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "整组移除同柜不可拆订单")
public Plan removeGroup(long planId, long groupId, RemoveGroupRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("REMOVE_GROUP|" + planId + '|' + groupId + '|'
+ request.expectedPlanVersion());
Plan replay = replay(operation, planId, "REMOVE_COLOAD_GROUP", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> planItems = repository.items(planId);
GroupSnapshot group = planItems.stream().map(item -> group(item.getAccountId(), item.getInboundId()))
.filter(Objects::nonNull).filter(value -> value.groupId() == groupId).findFirst()
.orElseThrow(() -> conflict("同柜不可拆组不属于当前装柜计划"));
requireCompleteGroupAllocation(planId, group, planItems);
touch(plan, operatorId, operatorName);
for (LoadingPlanItemEntity item : planItems) {
if (group.members().stream().noneMatch(member -> member.tenantId() == item.getAccountId()
&& member.inboundId() == item.getInboundId())) continue;
loadingPickService.remove(item.getId());
if (repository.remove(planId, item.getId(), item.getVersion(), operatorId, operatorName) != 1) {
throw conflict("同柜不可拆组明细已被并发修改");
}
}
for (Member member : group.members()) {
loadingModelBridge.removeIfUnallocated(planId, member.tenantId(), member.inboundId());
}
coloadGroups.unlockWholeGroupAfterRemoval(groupId, planId,
"UNLOCK-" + sha256(operation).substring(0, 48),
operatorId, operatorName);
history(planId, null, "REMOVE_COLOAD_GROUP", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "groupId", groupId)),
json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)),
operatorId, operatorName);
return view(repository.plan(planId), booking);
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "执行智能装柜推荐")
@@ -267,9 +362,20 @@ public class SmartLoadingService {
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Set<Long> visitedGroups = new HashSet<>();
for (InboundCandidate candidate : inboundCandidates(plan.getContainerBookingId(), booking.getWarehouseId())) {
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
if (candidate.coLoadGroupId() != null) {
if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) {
continue;
}
LoadingPlanEntity current = repository.plan(planId);
assignGroup(planId, new AssignGroupRequest(candidate.inboundId(), current.getVersion(),
"REC-GRP-" + sha256(operation + '|' + candidate.coLoadGroupId()).substring(0, 48)),
operatorId, operatorName);
continue;
}
int count = fittingBoxes(booking, repository.items(planId), candidate.availablePackageCount(),
candidate.averageUnitVolumeCbm(), candidate.unitGrossWeightKg());
if (count > 0) {
@@ -300,6 +406,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
int pickedPackageCount = pickSnapshot == null ? 0
@@ -392,6 +499,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
requireStandalone(item.getAccountId(), item.getInboundId());
loadingPickService.remove(itemId);
touch(plan, operatorId, operatorName);
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
@@ -419,6 +527,7 @@ public class SmartLoadingService {
if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求");
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
List<LoadingPlanItemEntity> items = repository.items(planId);
List<GroupSnapshot> groups = requireCompleteColoadAllocations(planId, items);
ensurePendingPreloads(items, operatorId, operatorName);
checkPlanCapacity(booking, items);
try {
@@ -433,6 +542,12 @@ public class SmartLoadingService {
}
LoadingPlanEntity pushed = repository.plan(planId);
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
for (GroupSnapshot group : groups) {
// The physical group remains LOCKED through PDA work; it is consumed only after the
// full group has been picked and the cabinet completion worker reaches its final stage.
requireSameGroup(group, requiredGroup(group.members().get(0).tenantId(),
group.members().get(0).inboundId()), planId);
}
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
@@ -458,6 +573,155 @@ public class SmartLoadingService {
item.getAccountId(), item.getInboundId(), operatorId, operatorName));
}
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>();
Map<Long, String> blocks = new HashMap<>();
List<LoadingPlanItemEntity> planItems = plan == null ? List.of() : repository.items(plan.getId());
for (Candidate candidate : values) {
GroupSnapshot group = snapshots.computeIfAbsent(candidate.accountId() + "|" + candidate.inboundId(),
ignored -> group(candidate.accountId(), candidate.inboundId()));
if (group != null) {
blocks.computeIfAbsent(group.groupId(), ignored -> groupBlockReason(
booking, plan, group, values, planItems));
}
}
return values.stream().map(candidate -> {
GroupSnapshot group = snapshots.get(candidate.accountId() + "|" + candidate.inboundId());
if (group == null) return candidate;
return new Candidate(candidate.accountId(), candidate.inboundId(), candidate.inboundDetailId(),
candidate.orderId(), candidate.orderNo(), candidate.inboundCode(), candidate.warehouseId(),
candidate.channelCode(), candidate.channelName(), candidate.destinationWarehouseId(),
candidate.destinationWarehouseCode(), candidate.destinationWarehouseName(),
candidate.customerName(), candidate.goodsName(), candidate.availablePackageCount(),
candidate.unitGrossWeightKg(), candidate.unitNetWeightKg(), candidate.unitVolumeCbm(),
group.groupId(), group.groupNo(), group.members().size(), group.fullPackageCount(),
group.lockedPlanId() != null, blocks.get(group.groupId()));
}).toList();
}
private String groupBlockReason(ContainerBookingEntity booking, LoadingPlanEntity plan, GroupSnapshot group,
List<Candidate> values, List<LoadingPlanItemEntity> planItems) {
if ("LOCKED".equals(group.status())) {
return plan != null && Objects.equals(group.lockedPlanId(), plan.getId())
? "同柜不可拆组已在当前装柜计划中" : "同柜不可拆组已锁定到其他装柜计划";
}
if (!"ACTIVE".equals(group.status())) return "同柜不可拆组当前不可分配";
for (Member member : group.members()) {
int available = values.stream().filter(candidate -> candidate.accountId() == member.tenantId()
&& candidate.inboundId() == member.inboundId())
.map(Candidate::availablePackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
if (available != member.fullPackageCount()) return "同柜不可拆组成员未全部具备满箱候选资格";
}
return groupFits(booking, planItems, group, values) ? null : "同柜不可拆组整体超过当前柜剩余容量";
}
private GroupSnapshot requiredGroup(long tenantId, long inboundId) {
GroupSnapshot group = group(tenantId, inboundId);
if (group == null) throw conflict("入库单不属于有效同柜不可拆组");
return group;
}
private GroupSnapshot group(Long tenantId, Long inboundId) {
if (tenantId == null || inboundId == null) return null;
return coloadGroups.findByInbound(tenantId, inboundId);
}
private void requireStandalone(Long tenantId, Long inboundId) {
if (group(tenantId, inboundId) != null) {
throw conflict("同柜不可拆组成员必须使用整组分配或整组移除");
}
}
private Map<Long, List<Candidate>> requireWholeGroupCandidates(long planId, ContainerBookingEntity booking,
GroupSnapshot group,
List<LoadingPlanItemEntity> planItems) {
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
throw conflict("同柜不可拆组已锁定或已消费");
}
List<Candidate> available = repository.candidates(booking.getWarehouseId()).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
for (Member member : group.members()) {
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
throw conflict("同柜不可拆组成员已分配到装柜计划");
}
List<Candidate> details = available.stream()
.filter(candidate -> candidate.accountId() == member.tenantId()
&& candidate.inboundId() == member.inboundId()).toList();
int boxes = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
if (details.isEmpty() || boxes != member.fullPackageCount()) {
throw conflict("同柜不可拆组成员必须以完整可用箱数整组分配");
}
result.put(member.inboundId(), details);
}
if (!groupFits(booking, planItems, group, available)) {
throw validation("同柜不可拆组整体超过货柜容积或重量的110%上限");
}
return result;
}
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
GroupSnapshot group, List<Candidate> available) {
if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) return false;
Set<Long> memberInbounds = group.members().stream().map(Member::inboundId).collect(
java.util.stream.Collectors.toSet());
BigDecimal volume = planItems.stream().map(item -> unit(item.getVolumeCbm()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal weight = planItems.stream().map(item -> unit(item.getGrossWeightKg()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
for (Candidate candidate : available) {
if (!memberInbounds.contains(candidate.inboundId())) continue;
int boxes = Math.max(0, candidate.availablePackageCount() == null ? 0
: candidate.availablePackageCount());
volume = volume.add(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(boxes)));
weight = weight.add(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(boxes)));
}
return volume.compareTo(booking.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO)) <= 0
&& weight.compareTo(booking.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO)) <= 0;
}
private void requireSameGroup(GroupSnapshot expected, GroupSnapshot actual, long planId) {
if (actual == null || expected.groupId() != actual.groupId()
|| !"LOCKED".equals(actual.status()) || !Objects.equals(actual.lockedPlanId(), planId)
|| !Objects.equals(expected.members(), actual.members())) {
throw conflict("同柜不可拆组锁定事实已变化");
}
}
private List<GroupSnapshot> requireCompleteColoadAllocations(long planId,
List<LoadingPlanItemEntity> planItems) {
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
for (LoadingPlanItemEntity item : planItems) {
GroupSnapshot group = group(item.getAccountId(), item.getInboundId());
if (group != null) groups.putIfAbsent(group.groupId(), group);
}
groups.values().forEach(group -> requireCompleteGroupAllocation(planId, group, planItems));
return List.copyOf(groups.values());
}
private void requireCompleteGroupAllocation(long planId, GroupSnapshot group,
List<LoadingPlanItemEntity> planItems) {
if (!"LOCKED".equals(group.status()) || !Objects.equals(group.lockedPlanId(), planId)) {
throw conflict("同柜不可拆组未完整锁定到当前装柜计划");
}
for (Member member : group.members()) {
int boxes = planItems.stream().filter(item -> item.getAccountId() == member.tenantId()
&& item.getInboundId() == member.inboundId())
.map(LoadingPlanItemEntity::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum();
if (boxes != member.fullPackageCount()) {
throw conflict("同柜不可拆组成员必须全部在同一柜且每票满箱");
}
if (repository.activeItemsByInbound(member.tenantId(), member.inboundId()).stream()
.anyMatch(item -> !Objects.equals(item.getLoadingPlanId(), planId))) {
throw conflict("同柜不可拆组成员已落入其他装柜计划");
}
}
}
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
@@ -23,6 +23,8 @@ 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("assignGroup","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignGroupRequest.class);
authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.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);
@@ -13,7 +13,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test
void candidateHasOnlyTheCanonicalMybatisConstructor() {
assertThat(Candidate.class.getDeclaredConstructors()).singleElement()
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(18));
.satisfies(constructor -> assertThat(constructor.getParameterCount()).isEqualTo(24));
}
@Test
@@ -33,6 +33,8 @@ class SmartLoadingCandidateMapperContractTest {
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.contains("NULL coLoadGroupId", "NULL groupNo", "NULL memberCount",
"NULL groupTotalBoxes", "NULL locked", "NULL blockReason")
.contains("o.actual_gross_weight_kg")
.contains("o.actual_net_weight_kg")
.doesNotContain("i.unit_weight_kg")
@@ -25,6 +25,9 @@ import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
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.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
@@ -43,6 +46,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -78,6 +82,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private ColoadGroupAssignmentPort coloadGroups;
@MockBean
private TruckAppointmentService truckAppointmentService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@@ -800,6 +806,40 @@ class LoadingPickServiceTest {
verify(completionCommands, never()).insert(any());
}
@Test
void shortPickedColoadMemberCannotCreateCompletionCommandOrRemainderChild() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity shortPicked = item();
shortPicked.setInboundId(50L);
shortPicked.setPlannedPackageCount(5);
shortPicked.setPickedPackageCount(4);
shortPicked.setItemStatus("PARTIAL");
LoadingPickItemEntity complete = item();
complete.setId(31L);
complete.setInboundId(60L);
complete.setPlannedPackageCount(5);
complete.setPickedPackageCount(5);
complete.setItemStatus("PICKED");
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, 5), new Member(202L, 302L, 60L, 5));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(202L, 60L)));
when(repository.items(20L)).thenReturn(List.of(shortPicked, complete));
when(coloadGroups.findByInbound(101L, 50L)).thenReturn(group);
when(coloadGroups.findByInbound(202L, 60L)).thenReturn(group);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-short-coload", "短拣异常"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("全组箱数守恒");
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
LoadingPickTaskEntity completing = task();
@@ -841,6 +881,10 @@ class LoadingPickServiceTest {
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(903L, "worker-multi")).thenReturn(true);
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, 1), new Member(101L, 302L, 60L, 1),
new Member(202L, 303L, 70L, 1), new Member(202L, 304L, 80L, 1));
when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group);
service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi");
@@ -855,6 +899,8 @@ class LoadingPickServiceTest {
"COMPLETE_LOADING".equals(operation.getActionType())
&& "async-multi".equals(operation.getOperationId())));
verify(loadingListService, times(1)).publishCompletedEvent(10L);
verify(coloadGroups).consumeWholeGroup(eq(700L), eq(10L),
org.mockito.ArgumentMatchers.startsWith("PDA-CONSUME-"), eq(9L), eq("operator"));
verify(completionCommands).complete(903L, "worker-multi");
}
@@ -1200,4 +1246,9 @@ class LoadingPickServiceTest {
return assignment;
}
private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) {
return new GroupSnapshot(700L, "CLG-700", status, 101L, "FBA", "FBA", 201L,
"FBA1", lockedPlanId, 1, List.of(members), LocalDateTime.now(), LocalDateTime.now());
}
}
@@ -19,6 +19,9 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
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.split.service.SplitPreloadService;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
@@ -40,6 +43,9 @@ import java.sql.SQLException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -57,6 +63,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest {
@@ -65,6 +72,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service;
@@ -110,10 +118,12 @@ class SmartLoadingServiceTest {
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"));
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
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"));
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
@@ -129,17 +139,218 @@ class SmartLoadingServiceTest {
});
}
@Test
void exposesWholeColoadGroupFactsOnEveryMemberCandidate() {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(19L, 11L, 21L, 31L, 6);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
var result = service.inboundCandidates(99L, 100L);
assertThat(result).hasSize(2).allSatisfy(candidate -> {
assertThat(candidate.coLoadGroupId()).isEqualTo(700L);
assertThat(candidate.groupNo()).isEqualTo("CLG-700");
assertThat(candidate.memberCount()).isEqualTo(2);
assertThat(candidate.groupTotalBoxes()).isEqualTo(10);
assertThat(candidate.locked()).isFalse();
assertThat(candidate.blockReason()).isNull();
});
}
@Test
void rejectsIndividualAssignmentForColoadMemberWithoutWriting() {
stubAssignment();
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group("ACTIVE", null,
new Member(9L, 30L, 10L, 10), new Member(19L, 31L, 11L, 5)));
assertThatThrownBy(() -> service.assign(1L,
new AssignRequest(10L, 20L, 10, 0, "individual-group-member"), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("整组分配");
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void atomicallyAssignsEveryMemberWithItsFullAvailablePackageCount() {
Candidate first = candidate(9L, 10L, 20L, 30L, 4);
Candidate second = candidate(19L, 11L, 21L, 31L, 6);
GroupSnapshot active = group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
GroupSnapshot locked = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>();
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(first);
when(repository.candidate(1L, 100L, 11L, 21L)).thenReturn(second);
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of());
when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of());
when(repository.activeItem(anyLong(), anyLong(), anyLong())).thenReturn(null);
when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> {
int expected = invocation.getArgument(1);
if (expected != version.get()) return 0;
version.incrementAndGet();
return 1;
});
doAnswer(invocation -> {
LoadingPlanItemEntity item = invocation.getArgument(0);
item.setId(800L + saved.size());
saved.add(item);
return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenAnswer(invocation -> {
long orderId = invocation.getArgument(2);
long detailId = invocation.getArgument(3);
return ApiResponse.success(new OrderCargoClient.CargoBinding(detailId, orderId, 1,
"货物", orderId == 30L ? 4 : 6, BigDecimal.TEN, BigDecimal.ONE));
});
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(active);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(active);
when(coloadGroups.lockWholeGroupForAssignment(eq(9L), eq(10L), eq(1L),
anyString(), eq(7L), eq("u"))).thenReturn(locked);
Plan result = service.assignGroup(1L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.AssignGroupRequest(
10L, 0, "assign-whole-group"), 7L, "u");
assertThat(result.version()).isEqualTo(2);
assertThat(saved).extracting(LoadingPlanItemEntity::getInboundId,
LoadingPlanItemEntity::getPackageCount).containsExactlyInAnyOrder(
org.assertj.core.groups.Tuple.tuple(10L, 4),
org.assertj.core.groups.Tuple.tuple(11L, 6));
verify(coloadGroups).lockWholeGroupForAssignment(eq(9L), eq(10L), eq(1L),
org.mockito.ArgumentMatchers.startsWith("LOCK-"), eq(7L), eq("u"));
verify(loadingModelBridge, times(2)).bindIncrementalAllocation(eq(1L), anyLong(), anyLong(),
eq(7L), eq("u"), anyString());
}
@Test
void recommendSkipsWholeThreeMemberGroupWhenCombinedCapacityDoesNotFit() {
Candidate first = candidate(9L, 10L, 20L, 30L, 5);
Candidate second = candidate(19L, 11L, 21L, 31L, 5);
Candidate third = candidate(29L, 12L, 22L, 32L, 5);
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 30L, 10L, 5), new Member(19L, 31L, 11L, 5),
new Member(29L, 32L, 12L, 5));
ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("5"));
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1L)).thenReturn(plan);
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(small);
when(repository.items(1L)).thenReturn(List.of());
when(repository.candidates(100L)).thenReturn(List.of(first, second, third));
when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group);
Plan result = service.recommend(1L, new RecommendRequest(0, "recommend-group-too-large"), 7L, "u");
assertThat(result.items()).isEmpty();
verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(),
anyString(), anyLong(), anyString());
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void adjustAndIndividualDeleteRejectColoadMembersBeforeAnyWrite() {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity item = item(8L, 9L, 20L, 4);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(item);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6)));
assertThatThrownBy(() -> service.adjust(1L, 8L,
new AdjustRequest(4, 0, 0, "adjust-group-member"), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("整组分配");
assertThatThrownBy(() -> service.delete(1L, 8L,
new DeleteRequest(0, 0, "delete-group-member"), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("整组分配");
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).remove(anyLong(), anyLong(), anyInt(), anyLong(), anyString());
verify(loadingPickService, never()).remove(anyLong());
}
@Test
void pushPreloadRejectsIncompleteGroupBeforeAnyLifecycleOrPlanWrite() {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
assertThatThrownBy(() -> service.pushPreload(1L, "push-incomplete-group",
new PushRequest(0), 7L, "u"))
.isInstanceOf(BizException.class).hasMessageContaining("全部在同一柜");
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
anyLong(), anyLong(), anyLong(), anyString());
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(
anyLong(), anyLong(), anyString(), anyString());
}
@Test
void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() {
LoadingPlanEntity plan = plan("EDITING", 0);
LoadingPlanEntity after = plan("EDITING", 1);
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
LoadingPlanItemEntity second = item(9L, 19L, 21L, 6);
second.setInboundId(11L);
GroupSnapshot group = group("LOCKED", 1L,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6));
when(repository.plan(1L)).thenReturn(plan, after);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(first, second), List.of());
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
when(repository.activeItemsByInbound(19L, 11L)).thenReturn(List.of(second));
when(repository.touch(1L, 0, 7L, "u")).thenReturn(1);
when(repository.remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"))).thenReturn(1);
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
when(coloadGroups.unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
anyString(), eq(7L), eq("u"))).thenReturn(group("ACTIVE", null,
new Member(9L, 30L, 10L, 4), new Member(19L, 31L, 11L, 6)));
Plan result = service.removeGroup(1L, 700L,
new com.baiyue.firstleg.dto.SmartLoadingDtos.RemoveGroupRequest(
0, "remove-whole-group"), 7L, "u");
assertThat(result.items()).isEmpty();
verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u"));
verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L),
org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u"));
}
@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"));
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null);
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"));
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null);
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"));
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"),
null, null, null, null, null, null);
var result = service.proportionalAllocations(List.of(first, second, third), 60);
@@ -987,8 +1198,19 @@ class SmartLoadingServiceTest {
}
private static Candidate candidate(int boxes) {
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L,
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
return candidate(9L, 10L, 20L, 30L, boxes);
}
private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes) {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
100L, "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null);
}
private static GroupSnapshot group(String status, Long lockedPlanId, Member... members) {
return new GroupSnapshot(700L, "CLG-700", status, 100L, "FBA", "FBA", 200L,
"FBA1", lockedPlanId, 0, List.of(members), LocalDateTime.now(), LocalDateTime.now());
}
private static ContainerBookingEntity booking() {
@@ -16,6 +16,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -139,4 +141,12 @@ class SplitPreloadServiceTest {
verify(repository, never()).createPlan(any());
}
@Test
void splitPreloadNeverCreatesInboundChildrenOrFulfillmentSlices() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java"));
assertThat(source).doesNotContain("cloneChild", "OrderFulfillmentSplitClient",
"PdaPickSettlementService", "REMAINDER");
}
}