diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index df110692e..b6239d6a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -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> 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java new file mode 100644 index 000000000..49b66e7d4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java @@ -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) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 3cdad5daf..52a94c557 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -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) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java index e8efc3b30..ddb022d7e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -17,10 +17,18 @@ public interface DispatchPalletLockMapper extends BaseMapper List 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 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 80eec715b..63840c822 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -272,8 +272,89 @@ public class DispatchService { draftContainers.deleteById(containerId); } + @Transactional + public List 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(); + Set 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 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(); + Set physicalPallets = new HashSet<>(); + Set dispatchIds = new HashSet<>(); + Set 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 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 actual = new HashSet<>(); + Set customers = new HashSet<>(); + Set 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(); + 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 requestedGroupSources(DispatchRequests.DraftGroup group) { + Set 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 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 { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index cf50a33ac..8d6264a1a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -666,6 +666,17 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } } responses: '200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } } + /api/platform/v1/last-leg/dispatches/drafts/pre-arrange: + post: + summary: 按装车分组原子创建多张预排车单 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;核对当前操作人的完整非空草稿分组、真实来源、唯一客户和海外仓、托位容量。每个装车容器生成独立排车单和PDA任务;任一组失败整批回滚。相同分组再次提交返回对应原单,不能按同一草稿合并不同车辆。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrangeDraft' } } } + responses: + '200': { description: 统一ApiResponse包装的分组结果数组, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchDraftGroupResult' } } } } } + '409': { description: 草稿归属、来源、容量或分组已变化,未部分创建 } /api/platform/v1/last-leg/dispatches/draft-locks/lock: post: summary: 将托盘移入智能排车草稿并立即锁定 @@ -2544,6 +2555,28 @@ components: returnReceiptId: { type: string, nullable: true, description: 退回实收来源ID,不作为计划托盘ID } returnReceiptNo: { type: string, nullable: true, description: 退回实收业务编号 } cartonMarks: { type: string, nullable: true, description: 该托盘箱号,逗号分隔 } + SmartDispatchPreArrangeDraft: + type: object + required: [draftToken, groups] + properties: + draftToken: { type: string, maxLength: 64 } + groups: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [draftContainerId, planPalletIds, returnReceiptIds] + properties: + draftContainerId: { type: integer, format: int64 } + planPalletIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } + returnReceiptIds: { type: array, maxItems: 500, items: { type: integer, format: int64 } } + SmartDispatchDraftGroupResult: + type: object + required: [draftContainerId, dispatch] + properties: + draftContainerId: { type: integer, format: int64 } + dispatch: { $ref: '#/components/schemas/DispatchResponse' } SmartDispatchPreArrange: type: object description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 15a472cba..ff51fffb3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -16,6 +16,10 @@ class SmartDispatchOpenApiContractTest { } assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:")); + assertTrue(yaml.contains("required: [draftToken, groups]")); + assertTrue(yaml.contains("required: [draftContainerId, planPalletIds, returnReceiptIds]")); + assertTrue(yaml.contains("SmartDispatchDraftGroupResult:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/lock:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/unlock:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/release:")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 141d1c508..3aeca9ec9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -75,6 +75,20 @@ class SmartDispatchControllerTest { assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); } + @Test + void groupedDraftUsesAuthenticatedOperatorAndDedicatedWriteAuthority() throws Exception { + var request = new DispatchRequests.PreArrangeDraft("draft-1", List.of( + new DispatchRequests.DraftGroup(1L, List.of(10L), List.of()), + new DispatchRequests.DraftGroup(2L, List.of(11L), List.of()))); + controller.preArrangeDraft(platform(), request); + verify(service).preArrangeDraft("7", request); + assertThat(SmartDispatchController.class.getMethod("preArrangeDraft", AuthenticatedUser.class, + DispatchRequests.PreArrangeDraft.class).getAnnotation(PreAuthorize.class).value()) + .contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); + org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> controller.preArrangeDraft(new AuthenticatedUser(7L, 100L, 0L, 8L, "customer", "MERCHANT", "WEB", Set.of()), request)); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index be1d5e874..2bcef6e42 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -551,6 +551,112 @@ class DispatchServiceTest { verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); } + @Test + void twoLoadingGroupsCreateTwoIndependentDispatchesAndExactReplayDoesNotWriteAgain() throws Exception { + var fixture = twoGroupFixture(); + assertNotNull(DispatchService.class.getMethod("preArrangeDraft", String.class, + DispatchRequests.PreArrangeDraft.class).getAnnotation(Transactional.class)); + var result = service.preArrangeDraft("7", fixture.request()); + assertEquals(2, result.size()); + assertTrue(!result.get(0).dispatch().id().equals(result.get(1).dispatch().id())); + for (var group : result) { + assertEquals(2, group.dispatch().palletCount()); assertEquals(2, group.dispatch().packageCount()); + assertEquals("ABE4,ABE8", group.dispatch().route()); + assertEquals(2, group.dispatch().items().size()); + } + assertEquals(java.util.Set.of(10L, 11L), result.get(0).dispatch().items().stream() + .map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet())); + assertEquals(java.util.Set.of(12L, 13L), result.get(1).dispatch().items().stream() + .map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet())); + org.mockito.Mockito.clearInvocations(orders, items, locks, pdaTasks, drafts); + assertEquals(result, service.preArrangeDraft("7", fixture.request())); + verify(orders, never()).insert(any(DispatchOrder.class)); verify(orders, never()).updateById(any(DispatchOrder.class)); + verify(items, never()).insert(any(DispatchItem.class)); verify(locks, never()).updateById(any(DispatchPalletLock.class)); + verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); verify(drafts, never()).updateById(any(DispatchDraft.class)); + } + + @Test + void flatRequestCannotCollapseTwoOwnedDraftContainers() { + var fixture = twoGroupFixture(); + when(candidates.lockSelected("100", List.of(10L,11L,12L,13L))).thenReturn(fixture.candidates()); + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L,11L,12L,13L),null,null,null,"draft-multi"))); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @Test + void changedMembershipDuplicateSourceExpiredDraftAndCapacityFailBeforeCreatingAnyGroup() { + var fixture = twoGroupFixture(); + var swapped = new DispatchRequests.PreArrangeDraft("draft-multi", List.of( + new DispatchRequests.DraftGroup(21L,List.of(10L,12L),List.of()), + new DispatchRequests.DraftGroup(22L,List.of(11L,13L),List.of()))); + assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", swapped)); + var duplicated = new DispatchRequests.PreArrangeDraft("draft-multi", List.of( + new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()), + new DispatchRequests.DraftGroup(22L,List.of(10L,13L),List.of()))); + assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", duplicated)); + fixture.containers().get(1).setCapacity(1); + assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request())); + fixture.containers().get(1).setCapacity(30); + fixture.draft().setExpiresAt(LocalDateTime.now().minusSeconds(1)); + assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request())); + verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); + } + + @Test + void secondGroupFailureRollsBackFirstGroupInsertInTheSameTransaction() { + var fixture = twoGroupFixture(); + when(candidates.lockSelected("100",List.of(12L,13L))).thenReturn(List.of( + candidate(12L,912L,null,1),candidate(13L,913L,"ABE8",1))); + var ds = new org.h2.jdbcx.JdbcDataSource(); + ds.setURL("jdbc:h2:mem:dispatch-group-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1"); + var jdbc = new org.springframework.jdbc.core.JdbcTemplate(ds); + jdbc.execute("CREATE TABLE dispatch_probe(id BIGINT PRIMARY KEY)"); + org.mockito.Mockito.doAnswer(call -> { var row=(DispatchOrder)call.getArgument(0); + jdbc.update("INSERT INTO dispatch_probe VALUES(?)",row.getId());return 1; }).when(orders).insert(any(DispatchOrder.class)); + var transaction = new org.springframework.transaction.support.TransactionTemplate( + new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds)); + assertThrows(ResponseStatusException.class, () -> transaction.execute(status -> service.preArrangeDraft("7",fixture.request()))); + verify(orders).insert(any(DispatchOrder.class)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM dispatch_probe",Integer.class)); + } + + private GroupFixture twoGroupFixture() { + var draft = new DispatchDraft(); draft.setId(1L);draft.setDraftToken("draft-multi");draft.setOperatorId("7"); + draft.setStatus("ACTIVE");draft.setExpiresAt(LocalDateTime.now().plusMinutes(30)); + lenient().when(drafts.lockActive("draft-multi","7")).thenReturn(draft); + var containerList = new java.util.ArrayList(); + var allCandidates = new java.util.ArrayList(); + var savedOrders = new java.util.HashMap(); + var savedItems = new java.util.HashMap>(); + lenient().doAnswer(call -> {var row=(DispatchOrder)call.getArgument(0);savedOrders.put(row.getId(),row);return 1;}) + .when(orders).insert(any(DispatchOrder.class)); + lenient().when(orders.lockById(eq("100"),any())).thenAnswer(call -> savedOrders.get(call.getArgument(1))); + lenient().doAnswer(call -> {var row=(DispatchItem)call.getArgument(0);savedItems.computeIfAbsent(row.getDispatchOrderId(),k->new java.util.ArrayList<>()).add(row);return 1;}) + .when(items).insert(any(DispatchItem.class)); + lenient().when(items.findByDispatchOrder(eq("100"),any())).thenAnswer(call -> savedItems.getOrDefault(call.getArgument(1),new java.util.ArrayList<>())); + for(int i=0;i<2;i++) { + long containerId=21L+i, first=10L+2*i; + var c=new DispatchDraftContainer();c.setId(containerId);c.setDraftId(1L);c.setDraftToken("draft-multi");c.setCapacity(30);c.setSequenceNo(i+1);containerList.add(c); + var rows=List.of(candidate(first,900L+first,"ABE4",1),candidate(first+1,901L+first,"ABE8",1));allCandidates.addAll(rows); + var bound=new java.util.ArrayList(); + for(var row:rows) {var lock=new DispatchPalletLock();lock.setId(row.planPalletId());lock.setCustomerId("100"); + lock.setDraftToken("draft-multi");lock.setDraftContainerId(containerId);lock.setOperatorId("7"); + lock.setStatus("ACTIVE");lock.setPlanPalletId(row.planPalletId());lock.setSourcePalletKey(row.sourcePalletKey());bound.add(lock); + lenient().when(locks.lockActive("100",row.sourcePalletKey())).thenReturn(lock);} + lenient().when(locks.lockActiveByDraftContainer("draft-multi",containerId)).thenReturn(bound); + lenient().when(locks.findDispatchOrderIdByDraftContainer("draft-multi",containerId)).thenAnswer(call -> bound.get(0).getDispatchOrderId()); + lenient().when(candidates.findCustomerScopesByPlanPalletIds(List.of(first,first+1))).thenReturn(List.of("100")); + lenient().when(candidates.lockSelected("100",List.of(first,first+1))).thenReturn(rows); + } + lenient().when(draftContainers.findByToken("draft-multi")).thenReturn(containerList); + return new GroupFixture(draft,containerList,allCandidates,new DispatchRequests.PreArrangeDraft("draft-multi",List.of( + new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),new DispatchRequests.DraftGroup(22L,List.of(12L,13L),List.of())))); + } + + private record GroupFixture(DispatchDraft draft,List containers, + List candidates,DispatchRequests.PreArrangeDraft request) { } + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { return candidate(planPalletId, warehousePalletId, destination, 12); }