feat(firstleg): enforce indivisible coload loading
This commit is contained in:
+4
@@ -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());
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+3
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user