fix(discard): recover completed PDA confirmation without duplicate writes
This commit is contained in:
+26
-4
@@ -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) {
|
||||
|
||||
+122
@@ -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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user