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,
|
CargoDiscardApplication application = applications.findNoByWarehouse(discardNo,
|
||||||
String.valueOf(identity.overseasWarehouseId()));
|
String.valueOf(identity.overseasWarehouseId()));
|
||||||
if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库");
|
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()))
|
PdaCargoDiscardDtos.PalletTask task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim()))
|
||||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
|
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
|
||||||
Map<Long,Integer> actuals = request.lines().stream().collect(Collectors.toMap(PdaCargoDiscardDtos.ActualLine::itemId,
|
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()))) {
|
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
|
||||||
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
|
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
|
||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
||||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||||
CargoDiscardItem item = items.selectById(line.itemId());
|
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 ("COMPLETED".equals(item.getPdaStatus())) {
|
||||||
if (!Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId()))) throw conflict("已完成托盘的实际数量不能修改");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
List<CargoDiscardCarton> links = cartons.listApplication(application.getCustomerId(), application.getId()).stream()
|
List<CargoDiscardCarton> links = cartons.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||||
@@ -84,7 +105,8 @@ public class PdaCargoDiscardService {
|
|||||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||||
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认");
|
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) {
|
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