fix(dispatch): preserve loading groups in atomic draft pre-arrangement

This commit is contained in:
2026-10-05 03:12:44 +08:00
parent e1cb01e6fb
commit 8e46ba6985
9 changed files with 289 additions and 4 deletions
@@ -81,6 +81,17 @@ public class SmartDispatchController {
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
}
@PostMapping("/drafts/pre-arrange")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Atomically create one dispatch per loading container in an owned draft",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)
public ApiResponse<List<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>> preArrangeDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrangeDraft request) {
requirePlatform(user);
return ApiResponse.success(service.preArrangeDraft(operatorId(user), request));
}
@PostMapping("/draft-locks/lock")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft")
@@ -0,0 +1,4 @@
package com.baiyue.lastleg.dispatch.dto;
/** One independent dispatch for one server-owned loading container. */
public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { }
@@ -57,6 +57,18 @@ public final class DispatchRequests {
public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {}
public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken,
@NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {}
public record DraftGroup(@NotNull Long draftContainerId,
@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 500) List<@NotNull Long> returnReceiptIds) {
public DraftGroup {
planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds);
returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds);
}
}
public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) {
}
@@ -17,10 +17,18 @@ public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock>
List<DispatchPalletLock> findActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " +
"AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " +
"AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY id FOR UPDATE")
List<DispatchPalletLock> lockActiveByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " +
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id AND d.status='PENDING_DEPARTURE' " +
"WHERE l.draft_token=#{draftToken} ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraft(@Param("draftToken") String draftToken);
"ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " +
"WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " +
"ORDER BY l.updated_at DESC,l.id DESC LIMIT 1")
Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken,
@Param("draftContainerId") Long draftContainerId);
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
@@ -272,8 +272,89 @@ public class DispatchService {
draftContainers.deleteById(containerId);
}
@Transactional
public List<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult> preArrangeDraft(
String operatorId, DispatchRequests.PreArrangeDraft request) {
String token = trimToNull(request.draftToken());
if (token == null || request.groups() == null || request.groups().isEmpty() || request.groups().size() > 100)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择需要预排的装车分组");
DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId));
if (draft == null || draft.getExpiresAt() != null && !draft.getExpiresAt().isAfter(LocalDateTime.now()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "排车草稿已失效或不属于当前操作人");
var containers = draftContainers.findByToken(token);
var groups = new java.util.LinkedHashMap<Long, DispatchRequests.DraftGroup>();
Set<String> allSources = new HashSet<>();
for (var group : request.groups()) {
if (group == null || group.draftContainerId() == null || groups.putIfAbsent(group.draftContainerId(), group) != null)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车分组不能重复");
Set<String> selected = requestedGroupSources(group);
if (selected.isEmpty() || !selected.stream().allMatch(allSources::add))
throw new ResponseStatusException(HttpStatus.CONFLICT, "每个来源只能加入一个非空装车分组");
}
if (allSources.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源");
var prepared = new java.util.ArrayList<PreparedDispatchGroup>();
Set<String> physicalPallets = new HashSet<>();
Set<Long> dispatchIds = new HashSet<>();
Set<Long> actualGroups = new HashSet<>();
for (var container : containers.stream().sorted(java.util.Comparator.comparing(DispatchDraftContainer::getId)).toList()) {
if (!Objects.equals(container.getDraftId(), draft.getId()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不属于当前草稿");
List<DispatchPalletLock> bound = locks.lockActiveByDraftContainer(token, container.getId());
if (bound.isEmpty()) continue;
actualGroups.add(container.getId());
var group = groups.get(container.getId());
if (group == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿分组已变化,请重新加载");
if (container.getCapacity() == null || bound.size() > container.getCapacity())
throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组超过车型托位容量");
Set<String> actual = new HashSet<>();
Set<String> customers = new HashSet<>();
Set<Long> groupDispatchIds = new HashSet<>();
for (var lock : bound) {
requireDraftOwner(lock, operatorId, token);
actual.add(lock.getReturnReceiptId() == null ? "PLAN:" + lock.getPlanPalletId() : "RETURN:" + lock.getReturnReceiptId());
customers.add(lock.getCustomerId());
if (lock.getSourcePalletKey() == null || !physicalPallets.add(lock.getSourcePalletKey()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复排车");
if (lock.getDispatchOrderId() != null) groupDispatchIds.add(lock.getDispatchOrderId());
}
if (!actual.equals(requestedGroupSources(group)) || actual.size() != bound.size())
throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘所属装车分组已变化,请重新加载");
String customer = resolveCustomerScopeForSources(group.planPalletIds(), group.returnReceiptIds());
if (!customers.equals(Set.of(customer))) throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组客户归属不一致");
if (groupDispatchIds.size() > 1 || !groupDispatchIds.stream().allMatch(dispatchIds::add))
throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿与排车分组不一致,请核对原排车单");
prepared.add(new PreparedDispatchGroup(container.getId(), customer, group));
}
if (!actualGroups.equals(groups.keySet()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不存在、为空或已失效");
var results = new java.util.ArrayList<com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult>();
for (var group : prepared) {
var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null,
token, null, group.request().returnReceiptIds());
var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true);
results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response));
}
return List.copyOf(results);
}
private Set<String> requestedGroupSources(DispatchRequests.DraftGroup group) {
Set<String> result = new HashSet<>();
for (Long id : group.planPalletIds()) if (id == null || !result.add("PLAN:" + id))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不能重复或为空");
for (Long id : group.returnReceiptIds()) if (id == null || !result.add("RETURN:" + id))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "退回来源不能重复或为空");
return result;
}
private record PreparedDispatchGroup(Long containerId, String customerId, DispatchRequests.DraftGroup request) { }
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
return preArrangeContainer(customerId, operatorId, request, null, false);
}
private DispatchResponse preArrangeContainer(String customerId, String operatorId,
DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) {
if (StringUtils.hasText(request.relabelNo())) {
if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用");
return preArrangeRelabel(customerId, operatorId, request);
@@ -315,6 +396,7 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘");
}
String draftToken = trimToNull(request.draftToken());
Set<Long> selectedContainers = new HashSet<>();
for (DispatchPalletCandidate candidate : selected) {
DispatchPalletLock active = activeLock(customerId, candidate);
if (draftToken != null) {
@@ -323,16 +405,27 @@ public class DispatchService {
"Pallet draft lock has expired: " + candidate.palletNo());
}
requireDraftOwner(active, operatorId, draftToken);
selectedContainers.add(active.getDraftContainerId());
} else if (active != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
}
}
if (draftToken != null && (selectedContainers.size() != 1 || selectedContainers.contains(null)
|| expectedContainerId != null && !selectedContainers.contains(expectedContainerId)))
throw new ResponseStatusException(HttpStatus.CONFLICT, "多个装车分组必须按分组批量预排,不能合并提交");
LocalDateTime now = LocalDateTime.now();
Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken);
Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraftContainer(draftToken, selectedContainers.iterator().next());
DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId);
boolean refreshing = dispatch != null;
if (preserveExactReplay && refreshing) {
var existing = items.findByDispatchOrder(customerId, dispatch.getId());
if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet())
.equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet())))
return response(dispatch, existing);
}
if (refreshing) {
requirePalletEditingAllowed(dispatch);
} else {