diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 3bb1f180e..96001b314 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -420,9 +420,11 @@ public class DeliveryTransferService { outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0); outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); - OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); - item.setOutboundOrderId(outbound.getId()); item.setPackageCount(marks.size()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - item.setCartonMarks(String.join(",", marks)); item.setInboundCode(request.returnNo().trim()); item.setCreatedAt(now); outboundPallets.insert(item); + for (ReturnInventoryCarton carton : locked) { + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); + item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); + item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); item.setCreatedAt(now); outboundPallets.insert(item); + } OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); return taskResult(outbound, task); @@ -646,7 +648,12 @@ public class DeliveryTransferService { || !request.packages().equals(replay.getPackages())) { throw conflict("Idempotency key was reused with a different outbound pick request"); } - return taskResult(requireOutbound(task), task); + OutboundOrder replayOutbound = requireOutbound(task); + if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())) { + OutboundPalletItem replayItem = outboundPallets.selectById(replay.getOutboundPalletItemId()); + requireReturnScan(replayOutbound, replayItem, request); + } + return taskResult(replayOutbound, task); } OutboundOrder existingOutbound = requireOutbound(task); OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo()); @@ -660,17 +667,13 @@ public class DeliveryTransferService { || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { - if (request.inventoryPositionId() != null) throw conflict("退货出库无需扫描库存仓位"); - List marks = Arrays.stream(Optional.ofNullable(item.getCartonMarks()).orElse("").split(",")) - .map(String::trim).filter(StringUtils::hasText).toList(); - if (request.packages() != marks.size() || value(item.getPickedPackageCount()) > 0) throw conflict("退货出库必须一次扫描本次箱唛且不可重复"); - for (String mark : marks) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); - if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) || !"AVAILABLE".equals(carton.getStatus())) { - throw conflict("退货箱唛不可出库:" + mark); - } - carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton); - } + requireReturnScan(outbound, item, request); + if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱唛已完成拣货"); + ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks()); + requireReturnSource(outbound, item, carton); + if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks()); + carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); + if (returnCartons.updateById(carton) != 1) throw conflict("退货箱唛状态已变化,请重新核对"); OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); @@ -1037,7 +1040,8 @@ public class DeliveryTransferService { task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(), item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()), value(item.getPickedPackageCount()), item.getStatus(), - pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); + "RETURN_OUTBOUND".equals(outbound.getOutboundType()) ? returnPickPositions(outbound, task, item) + : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList(); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } @@ -1071,6 +1075,34 @@ public class DeliveryTransferService { throw notFound("Outbound or dispatch task not found in the bound warehouse"); } + private void requireReturnScan(OutboundOrder outbound, OutboundPalletItem item, DeliveryRequests.PdaPick request) { + if (item == null || !outbound.getId().equals(item.getOutboundOrderId()) + || !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在"); + if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1) + throw conflict("退货出库须逐箱扫描,无需库存仓位"); + if (request.cartonMarks() == null || request.cartonMarks().size() != 1 || request.cartonMarks().get(0) == null + || !Objects.equals(item.getCartonMarks(), request.cartonMarks().get(0).trim())) + throw conflict("请扫描当前退货出库明细指定的箱唛"); + } + + private void requireReturnSource(OutboundOrder outbound, OutboundPalletItem item, ReturnInventoryCarton carton) { + if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId()) + || !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !Objects.equals(item.getInboundCode(), carton.getSourceReturnNo()) + || !Objects.equals(item.getCartonMarks(), carton.getCartonMark())) + throw conflict("退货箱唛与本次出库的客户、仓库或来源退货单不一致"); + } + + private List returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, + OutboundPalletItem item) { + ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks()); + requireReturnSource(outbound, item, carton); + boolean pending = Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) && value(item.getPickedPackageCount()) == 0; + long available = pending && "AVAILABLE".equals(carton.getStatus()) ? 1 : 0; + return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(), + item.getInboundCode(), available, value(item.getPickedPackageCount()))); + } + private List pdaPickPositions(String customerId, String warehouseId, OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 988750eaa..99cd5ad41 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -865,16 +865,16 @@ components: remark: { type: string, maxLength: 1000, nullable: true } OutboundPick: type: object - required: [outboundPalletItemId, inventoryPositionId, packages, idempotencyKey] + required: [outboundPalletItemId, packages, idempotencyKey] properties: outboundPalletItemId: { type: string, pattern: '^[0-9]+$' } - inventoryPositionId: { type: string, pattern: '^[0-9]+$' } + inventoryPositionId: { type: string, nullable: true, pattern: '^[0-9]+$', description: 普通库存使用真实仓位键;退货箱库存没有该仓位,提交 null,不提交空字符串或退货库存内部ID } packages: { type: integer, format: int64, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } cartonMarks: type: array maxItems: 500 - description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 + description: 本次实际扫描的箱唛。退货出库每条明细对应一个真实退货箱,必须提交该箱的 cartonMark,packages 为 1;相同幂等键改传其他箱唛同样拒绝,执行时重新核对客户、仓库及来源退货单。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 items: { type: string, minLength: 1, maxLength: 255 } PdaOutboundTaskResponse: type: object @@ -901,25 +901,26 @@ components: type: array items: type: object - required: [outboundPalletItemId, palletNo, skuCode, inboundCode, packageCount, pickedPackageCount, status, positions] + required: [outboundPalletItemId, inboundCode, packageCount, pickedPackageCount, status, positions] properties: outboundPalletItemId: { type: string, description: pick请求提交键,不作为用户展示编号 } - palletNo: { type: string, description: Warehouse 权威实物托盘码 } - skuCode: { type: string } + palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱明细不伪造托盘码 } + skuCode: { type: string, nullable: true } inboundCode: { type: string } packageCount: { type: integer } pickedPackageCount: { type: integer } status: { type: string } positions: type: array + description: 普通出库为真实库存仓位;退货出库为已冻结真实箱的扫码行,inventoryPositionId 与 palletNo 为空,cartonMark 为扫码业务码、inboundCode 为退货单号,按箱计数。已拣或已完成时 availablePackages 为 0,详情查询不写库存。 items: type: object - required: [inventoryPositionId, palletNo, cartonMark, skuCode, inboundCode, availablePackages, pickedPackages] + required: [inboundCode, availablePackages, pickedPackages] properties: - inventoryPositionId: { type: string, description: pick请求提交键,不作为用户展示编号 } - palletNo: { type: string, description: Warehouse 权威实物托盘码 } + inventoryPositionId: { type: string, nullable: true, description: 普通库存的pick提交键;退货箱无仓位键,不得以其内部ID替代 } + palletNo: { type: string, nullable: true, description: Warehouse 权威实物托盘码;退货箱不提供伪造托盘号 } cartonMark: { type: string, nullable: true } - skuCode: { type: string } + skuCode: { type: string, nullable: true } inboundCode: { type: string } availablePackages: { type: integer, format: int64, minimum: 0 } pickedPackages: { type: integer, format: int64, minimum: 0 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java new file mode 100644 index 000000000..62f31bc9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.web.server.ResponseStatusException; +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ReturnOutboundPdaPersistenceTest { + private JdbcTemplate jdbc; + private DeliveryTransferService service; + + @BeforeEach void setUp() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:return-pda-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + // Persist production entities through production MyBatis mappers, with real transactions. + for (Class entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class, + OutboundPdaPick.class, ReturnInventoryCarton.class)) { + String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " " + + (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP" + : field.getType() == java.math.BigDecimal.class ? "DECIMAL(20,6)" : "BIGINT") + + (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(",")); + jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); + } + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + for (Class mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, + OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, ReturnInventoryCartonMapper.class)) configuration.addMapper(mapper); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); + var session = new SqlSessionTemplate(factory.getObject()); + var target = new DeliveryTransferService(); + ReflectionTestUtils.setField(target, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(target, "outboundPallets", session.getMapper(OutboundPalletItemMapper.class)); + ReflectionTestUtils.setField(target, "pdaTasks", session.getMapper(OutboundPdaTaskMapper.class)); + ReflectionTestUtils.setField(target, "pdaPicks", session.getMapper(OutboundPdaPickMapper.class)); + ReflectionTestUtils.setField(target, "returnCartons", session.getMapper(ReturnInventoryCartonMapper.class)); + var numbers = mock(LastlegOrderNoGenerator.class); + when(numbers.next(eq("RETURN_OUTBOUND"), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); + ReflectionTestUtils.setField(target, "orderNoGenerator", numbers); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (DeliveryTransferService) proxy.getProxy(); + carton(1, "C1", "WH-1", "RT-1", "BOX-A"); carton(2, "C1", "WH-1", "RT-1", "BOX-B"); + carton(3, "C2", "WH-1", "RT-2", "OTHER-CUSTOMER"); carton(4, "C1", "WH-2", "RT-1", "OTHER-WAREHOUSE"); + } + private void carton(long id, String customer, String warehouse, String returnNo, String mark) { + jdbc.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_return_no,carton_mark,status,total_quantity) VALUES(?,?,?,?,?,'AVAILABLE',5)", id, customer, warehouse, returnNo, mark); + } + private com.baiyue.lastleg.delivery.dto.DeliveryResponses.PdaTask create(String key, String... marks) { + return service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1", List.of(marks), "fixture", key)); + } + @Test void singleExistingShapeHasRealCartonScanRowWithoutInventedPalletOrPosition() { + var task = create("single", "BOX-A"); + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(1, detail.items().size()); + assertEquals(1, detail.items().get(0).positions().size()); + var row = detail.items().get(0).positions().get(0); + assertNull(row.inventoryPositionId()); assertNull(row.palletNo()); + assertEquals("BOX-A", row.cartonMark()); assertEquals("RT-1", row.inboundCode()); + assertEquals(1, row.availablePackages()); assertEquals(0, row.pickedPackages()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + @Test void multiplePhysicalCartonsPersistSeparateIndependentlyScannableItems() { + var task = create("multi", "BOX-A", "BOX-B"); + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(2, detail.items().size()); + assertTrue(detail.items().stream().allMatch(item -> item.packageCount() == 1 && item.positions().size() == 1)); + var a = detail.items().stream().filter(item -> item.positions().get(0).cartonMark().equals("BOX-A")).findFirst().orElseThrow(); + service.pdaPick("WH-1", "operator", task.taskNo(), new DeliveryRequests.PdaPick(a.outboundPalletItemId(), null, 1L, "pick-a", List.of("BOX-A"))); + var after = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum()); + assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum()); + } + @Test void wrongScanAndWrongReplayPayloadLeaveStoredInventoryUnchanged() { + var task = create("scan", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "bad", List.of("BOX-B")))); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + var valid = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-A")); + service.pdaPick("WH-1", "operator", task.taskNo(), valid); + service.pdaPick("WH-1", "operator", task.taskNo(), valid); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "good", List.of("BOX-B")))); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + @Test void customerWarehouseAndSourceReturnMismatchNeverAppearAsPickableRows() { + var task = create("scope", "BOX-A"); + assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-2", task.taskNo())); + for (String mark : List.of("OTHER-CUSTOMER", "OTHER-WAREHOUSE")) { + assertThrows(ResponseStatusException.class, () -> create("bad-" + mark, mark)); + } + for (String column : List.of("customer_id", "overseas_warehouse_id", "source_return_no")) { + String original = jdbc.queryForObject("SELECT " + column + " FROM ll_return_inventory_carton WHERE id=1", String.class); + jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "='FOREIGN' WHERE id=1"); + assertThrows(ResponseStatusException.class, () -> service.pdaTaskDetail("WH-1", task.taskNo())); + jdbc.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=1", original); + } + } + @Test void completedTaskDetailIsReadOnlyAndContainsNoPickableStock() { + var task = create("complete", "BOX-A"); var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + var pick = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick", List.of("BOX-A")); + service.pdaPick("WH-1", "operator", task.taskNo(), pick); + service.completePda("WH-1", task.taskNo(), new DeliveryRequests.PdaComplete("complete-key")); + for (int i = 0; i < 2; i++) { + var detail = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals("COMPLETED", detail.task().status()); + assertTrue(detail.items().stream().flatMap(row -> row.positions().stream()).allMatch(row -> row.availablePackages() == 0)); + } + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "new-key", List.of("BOX-A")))); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } + + @Test void sourceChangedAfterDetailReadCannotBeShippedByTheOldRequest() { + var task = create("source-change", "BOX-A"); + var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + jdbc.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-OTHER' WHERE id=1"); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), + new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "stale-source", List.of("BOX-A")))); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + assertEquals(0, jdbc.queryForObject("SELECT picked_package_count FROM ll_outbound_pallet_item", Integer.class)); + } + + @Test void failedPickJournalRollsBackCartonAndTaskThenAllowsAnExactRetry() { + var task = create("rollback", "BOX-A"); + var item = service.pdaTaskDetail("WH-1", task.taskNo()).items().get(0); + var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "rollback-pick", List.of("BOX-A")); + jdbc.execute("ALTER TABLE ll_outbound_pda_pick ADD CONSTRAINT fail_pick CHECK(idempotency_key <> 'rollback-pick')"); + assertThrows(RuntimeException.class, () -> service.pdaPick("WH-1", "operator", task.taskNo(), request)); + assertEquals("AVAILABLE", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + var unchanged = service.pdaTaskDetail("WH-1", task.taskNo()); + assertEquals("PENDING", unchanged.task().status()); + assertEquals(1, unchanged.items().get(0).positions().get(0).availablePackages()); + jdbc.execute("ALTER TABLE ll_outbound_pda_pick DROP CONSTRAINT fail_pick"); + service.pdaPick("WH-1", "operator", task.taskNo(), request); + assertEquals("OUTBOUNDED", jdbc.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=1", String.class)); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class)); + } +}