fix(discard): recover completed PDA confirmation without duplicate writes

This commit is contained in:
2026-10-05 08:10:43 +08:00
parent 5864e62443
commit 113d2b1fec
2 changed files with 148 additions and 4 deletions
@@ -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<Long,Integer> 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<CargoDiscardItem> 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<CargoDiscardCarton> 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<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
@@ -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(); }
}