diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index 7ffd2a9b5..b496842bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -50,7 +50,10 @@ public class PdaCargoDiscardService { CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, String.valueOf(identity.overseasWarehouseId())); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); - if (!CargoDiscardService.APPROVED.equals(application.getStatus())) throw conflict("废弃任务当前不可进行 PDA 作业"); + boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus()) + || CargoDiscardService.COMPLETED.equals(application.getStatus()); + if (!CargoDiscardService.APPROVED.equals(application.getStatus()) && !terminal) + throw conflict("废弃任务当前不可进行 PDA 作业"); PdaCargoDiscardDtos.PalletTask task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())) .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); Map actuals = request.lines().stream().collect(Collectors.toMap(PdaCargoDiscardDtos.ActualLine::itemId, @@ -58,11 +61,29 @@ public class PdaCargoDiscardService { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); } - LocalDateTime now = LocalDateTime.now(); + List palletItems = new ArrayList<>(); for (PdaCargoDiscardDtos.Sku line : task.skus()) { CargoDiscardItem item = items.selectById(line.itemId()); + if (item == null || !application.getCustomerId().equals(item.getCustomerId()) + || !Objects.equals(application.getId(), item.getApplicationId()) + || !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId())) + throw conflict("废弃明细不属于当前申请或托盘"); + if ("COMPLETED".equals(item.getPdaStatus()) + && !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) + throw conflict("已完成托盘的实际数量不能修改"); + palletItems.add(item); + } + if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream() + .anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus()) + || !application.getCustomerId().equals(item.getCustomerId()) + || !Objects.equals(application.getId(), item.getApplicationId()))) + throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); + // A completed pallet is an immutable result, including the final pallet after application completion. + if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; + if (terminal) throw conflict("废弃终态不能执行未完成托盘"); + LocalDateTime now = LocalDateTime.now(); + for (CargoDiscardItem item : palletItems) { if ("COMPLETED".equals(item.getPdaStatus())) { - if (!Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改"); continue; } List links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() @@ -84,7 +105,8 @@ public class PdaCargoDiscardService { discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认"); } - return task; + return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); } private List palletTasks(CargoDiscardApplication application) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java new file mode 100644 index 000000000..6bbed447b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -0,0 +1,122 @@ +package com.baiyue.lastleg.discard.pda.service; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PdaCargoDiscardServiceTest { + private final CargoDiscardApplicationMapper applications = mock(CargoDiscardApplicationMapper.class); + private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class); + private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class); + private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class); + private final CargoDiscardInventoryAdjustmentService adjustments = mock(CargoDiscardInventoryAdjustmentService.class); + private final CargoDiscardService discardService = mock(CargoDiscardService.class); + private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class); + private PdaCargoDiscardService service; + private CargoDiscardApplication application; + private CargoDiscardItem item; + private final PdaWarehouseIdentity identity = new PdaWarehouseIdentity("OP", "operator", 99L); + private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual))); } + + @BeforeEach void setup() { + service = new PdaCargoDiscardService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "cartons", cartons); + ReflectionTestUtils.setField(service, "profiles", profiles); + ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "discardService", discardService); + ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1"); + application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99"); + application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00")); + item = new CargoDiscardItem(); item.setId(11L); item.setApplicationId(1L); item.setCustomerId("CUSTOMER"); + item.setProfileId(22L); item.setSkuCode("SKU"); item.setDiscardPackages(1); item.setPdaStatus("PENDING"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(22L); profile.setCustomerId("CUSTOMER"); profile.setPalletNo("PALLET-1"); + when(applications.findNoByWarehouse("CD-1", "99")).thenReturn(application); + when(items.listApplication("CUSTOMER", 1L)).thenAnswer(invocation -> List.of(item)); + when(items.selectById(11L)).thenReturn(item); + when(profiles.selectById(22L)).thenReturn(profile); + when(cartons.listApplication("CUSTOMER", 1L)).thenReturn(List.of()); + doAnswer(invocation -> { application.setStatus(invocation.getArgument(3)); return null; }) + .when(discardService).updateLinkedStatus(anyString(), same(application), anyString(), anyString(), anyString(), anyString()); + } + private void finished(String status) { + application.setStatus(status); item.setPdaStatus("COMPLETED"); item.setActualDiscardPackages(1); + item.setPdaOperatorId("original"); item.setPdaCompletedAt(LocalDateTime.of(2026, 10, 5, 1, 2)); + } + private void noWrites() { + verifyNoInteractions(adjustments, discardService); + verify(items, never()).updateById(any(CargoDiscardItem.class)); + verify(applications, never()).updateById(any(CargoDiscardApplication.class)); + } + @Test void lastPalletActualConfirmationReplaysAfterPdaCompletionWithoutDeductionOrHistoryAgain() { + var first = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", first.status()); + assertEquals(1, first.skus().get(0).actualPackages()); + assertEquals("COMPLETED", first.skus().get(0).status()); + assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus()); + var completedAt = item.getPdaCompletedAt(); var result = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", result.status()); assertEquals(1, result.skus().get(0).actualPackages()); + assertEquals(completedAt, item.getPdaCompletedAt()); + verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(1), eq("OP"), any()); + verify(items, times(1)).updateById(item); + verify(discardService, times(1)).updateLinkedStatus(anyString(), same(application), anyString(), anyString(), anyString(), anyString()); + } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED", "APPROVED"}) + void exactFinishedPalletIsReadOnlyInAllowedStates(String status) { + finished(status); var result = service.confirm(identity, "CD-1", "PALLET-1", request(1)); + assertEquals("COMPLETED", result.status()); assertEquals(status, application.getStatus()); noWrites(); + } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED"}) + void finishedQuantityCannotChange(String status) { finished(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(0))); noWrites(); } + @Test void missingOrForeignLineAndWrongPalletCannotRecover() { + finished("PDA_COMPLETED"); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of()))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(999L, 1))))); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "OTHER-PALLET", request(1))); noWrites(); + } + @Test void foreignWarehouseHasNoApplication() { + finished("PDA_COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(new PdaWarehouseIdentity("OP", "operator", 100L), "CD-1", "PALLET-1", request(1))); noWrites(); + } + @Test void foreignCustomerPhysicalPalletCannotRecover() { + finished("PDA_COMPLETED"); DropshipOrderProfile other = new DropshipOrderProfile(); other.setCustomerId("OTHER"); other.setPalletNo("PALLET-1"); + when(profiles.selectById(22L)).thenReturn(other); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); + } + @ParameterizedTest @ValueSource(strings = {"CANCELLED", "REJECTED", "PENDING_REVIEW"}) + void cancelledRejectedOrUnapprovedCannotPretendSuccess(String status) { finished(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @ParameterizedTest @ValueSource(strings = {"PDA_COMPLETED", "COMPLETED"}) + void unfinishedItemInTerminalApplicationCannotRecover(String status) { application.setStatus(status); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @Test void terminalApplicationWithAnotherUnfinishedPalletCannotRecover() { + finished("PDA_COMPLETED"); CargoDiscardItem pending = new CargoDiscardItem(); pending.setId(12L); pending.setApplicationId(1L); + pending.setCustomerId("CUSTOMER"); pending.setProfileId(23L); pending.setSkuCode("OTHER-SKU"); pending.setDiscardPackages(1); pending.setPdaStatus("PENDING"); + DropshipOrderProfile other = new DropshipOrderProfile(); other.setId(23L); other.setCustomerId("CUSTOMER"); other.setPalletNo("PALLET-2"); + when(items.listApplication("CUSTOMER", 1L)).thenReturn(List.of(item, pending)); when(profiles.selectById(23L)).thenReturn(other); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); + } + @Test void duplicateLineCannotRecover() { finished("COMPLETED"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 1), new PdaCargoDiscardDtos.ActualLine(11L, 1))))); noWrites(); } + @Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } + @Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); } +}