This commit is contained in:
2026-09-04 10:51:25 +08:00
parent 4ca5a57e6a
commit 7a0e158b51
13 changed files with 561 additions and 30 deletions
@@ -38,4 +38,6 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> removeUnpicked(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RemoveUnpickedRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removeUnpicked(planId,r,u.userId(),u.username()));}
}
@@ -73,6 +73,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {}
@@ -420,7 +420,6 @@ public class SmartLoadingService {
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);
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
@@ -525,7 +524,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream()
@@ -581,7 +579,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<Candidate> details = frozenDetails == null
? candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
? manualCandidates(booking).stream()
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList()
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
@@ -641,7 +639,6 @@ public class SmartLoadingService {
for (InboundAllocation allocation : allocations) {
if (allocation.packageCount() == 0) continue;
Candidate candidate = allocation.candidate();
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo)
@@ -853,7 +850,6 @@ public class SmartLoadingService {
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(),
item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
@@ -945,6 +941,84 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "批量移除智能装柜未拣明细")
public Plan removeUnpicked(long planId, RemoveUnpickedRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String requestHash = sha256("REMOVE_UNPICKED|" + planId + '|' + request.expectedPlanVersion());
Plan replay = replay(operation, planId, "REMOVE_UNPICKED", null, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
List<LoadingPlanItemEntity> items = repository.items(planId);
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
Map<Long, Integer> picked = pickSnapshot == null ? Map.of() : pickSnapshot.pickedByPlanItem();
Map<String, GroupSnapshot> groupByMember = new HashMap<>();
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
for (LoadingPlanItemEntity item : items) {
String memberKey = item.getAccountId() + "|" + item.getInboundId();
if (!groupByMember.containsKey(memberKey)) {
groupByMember.put(memberKey, group(item.getAccountId(), item.getInboundId()));
}
GroupSnapshot group = groupByMember.get(memberKey);
if (group != null) groups.putIfAbsent(group.groupId(), group);
}
Set<Long> groupsWithPickedItems = new HashSet<>();
for (LoadingPlanItemEntity item : items) {
if (picked.getOrDefault(item.getId(), 0) <= 0) continue;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
if (group != null) groupsWithPickedItems.add(group.groupId());
}
List<LoadingPlanItemEntity> removable = items.stream().filter(item -> {
if (picked.getOrDefault(item.getId(), 0) > 0) return false;
GroupSnapshot group = groupByMember.get(item.getAccountId() + "|" + item.getInboundId());
return group == null || !groupsWithPickedItems.contains(group.groupId());
}).toList();
Set<Long> removableIds = removable.stream().map(LoadingPlanItemEntity::getId)
.collect(java.util.stream.Collectors.toSet());
Set<Long> removableGroupIds = groups.values().stream()
.filter(group -> !groupsWithPickedItems.contains(group.groupId()))
.filter(group -> items.stream().anyMatch(item -> removableIds.contains(item.getId())
&& group.members().stream().anyMatch(member -> member.tenantId() == item.getAccountId()
&& member.inboundId() == item.getInboundId())))
.map(GroupSnapshot::groupId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
for (Long groupId : removableGroupIds) {
requireCompleteGroupAllocation(planId, groups.get(groupId), items);
}
if (!removable.isEmpty()) {
touch(plan, operatorId, operatorName);
for (LoadingPlanItemEntity item : removable) {
loadingPickService.remove(item.getId());
if (repository.remove(planId, item.getId(), item.getVersion(), operatorId, operatorName) != 1) {
throw conflict("装柜未拣明细已被并发修改");
}
}
Set<String> releasedInbounds = new HashSet<>();
for (LoadingPlanItemEntity item : removable) {
String key = item.getAccountId() + "|" + item.getInboundId();
if (releasedInbounds.add(key)) {
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
}
}
for (Long groupId : removableGroupIds) {
GroupSnapshot group = groups.get(groupId);
coloadGroups.unlockWholeGroupAfterRemoval(groupId, planId,
"UNLOCK-" + sha256(operation + '|' + groupId).substring(0, 48),
operatorId, operatorName);
}
}
history(planId, null, "REMOVE_UNPICKED", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "itemCount", items.size())),
json(values("requestHash", requestHash, "removedItemCount", removable.size(),
"removedGroupCount", removableGroupIds.size())), operatorId, operatorName);
return view(repository.plan(planId), booking);
}
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>();
@@ -1013,7 +1087,7 @@ public class SmartLoadingService {
if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) {
throw conflict("同柜不可拆组已锁定或已消费");
}
List<Candidate> available = availableCandidates(booking);
List<Candidate> available = manualCandidates(booking);
Map<Long, List<Candidate>> result = new LinkedHashMap<>();
for (Member member : group.members()) {
if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) {
@@ -1040,6 +1114,11 @@ public class SmartLoadingService {
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
}
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
return repository.candidates(booking.getWarehouseId()).stream()
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
}
private boolean groupFits(ContainerBookingEntity booking, List<LoadingPlanItemEntity> planItems,
GroupSnapshot group, List<Candidate> available) {
if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) return false;
@@ -1272,7 +1351,6 @@ public class SmartLoadingService {
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,
int count, boolean requireSameDestination){
requireSameChannel(b, c);
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (requireSameDestination && StringUtils.hasText(b.getDestinationWarehouseCode())
&& !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) {
@@ -1376,12 +1454,6 @@ public class SmartLoadingService {
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
}
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
}
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
}
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
if (!sameCustomerScope(booking, candidate)) throw forbidden();
}