贴标申请,仓储转运

This commit is contained in:
2026-10-14 11:36:35 +08:00
parent 83dc6dea3f
commit e14bdb4f49
37 changed files with 884 additions and 32 deletions
@@ -12,6 +12,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantCargoDiscardControllerTest {
private final CargoDiscardService service = mock(CargoDiscardService.class);
@@ -36,6 +37,17 @@ class MerchantCargoDiscardControllerTest {
() -> controller.page(user(0L, "PLATFORM"), query));
}
@Test void photoDownloadUsesTokenCustomerAndReturnsPrivateImageBytes() {
byte[] bytes = {1, 2, 3};
when(service.downloadPhoto("100", "DS001", 101L)).thenReturn(
new com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile(bytes, "discard.png", "image/png"));
var response = controller.downloadPhoto(user(100L, "MERCHANT"), "DS001", 101L);
org.junit.jupiter.api.Assertions.assertArrayEquals(bytes, response.getBody());
org.junit.jupiter.api.Assertions.assertEquals("no-store", response.getHeaders().getCacheControl());
verify(service).downloadPhoto("100", "DS001", 101L);
assertThrows(ResponseStatusException.class, () -> controller.downloadPhoto(user(0L, "PLATFORM"), "DS001", 101L));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 9L, "merchant", appCode, "WEB", Set.of());
}
@@ -37,10 +37,12 @@ class PdaCargoDiscardServiceTest {
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))); }
private final com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService = mock(com.baiyue.lastleg.discard.service.CargoDiscardPhotoService.class);
private PdaCargoDiscardDtos.Confirm request(int actual) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of(101L)); }
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
@@ -93,8 +95,8 @@ class PdaCargoDiscardServiceTest {
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", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(999L, 1)), List.of(101L))));
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "OTHER-PALLET", request(1))); noWrites();
}
@Test void foreignWarehouseHasNoApplication() {
@@ -116,7 +118,37 @@ class PdaCargoDiscardServiceTest {
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 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)), List.of(101L)))); 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(); }
@ParameterizedTest @ValueSource(ints = {3, 1, 0})
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
var stored = new java.util.ArrayList<com.baiyue.lastleg.discard.entity.CargoDiscardPhoto>();
ReflectionTestUtils.setField(realPhotos, "photos", photoMapper); ReflectionTestUtils.setField(realPhotos, "files", photoFiles);
ReflectionTestUtils.setField(service, "photoService", realPhotos);
when(photoMapper.listApplication("CUSTOMER", 1L)).thenAnswer(invocation -> stored);
when(photoMapper.insert(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class))).thenAnswer(invocation -> { stored.add(invocation.getArgument(0)); return 1; });
when(photoFiles.upload(eq("CUSTOMER"), eq(1L), eq(9L), any())).thenReturn(new com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.UploadedFile(101L, "discard.png", "image/png", 8L));
when(applications.readNoByWarehouse("CD-1", "99")).thenReturn(application);
item.setDiscardPackages(3);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, actual)), List.of())));
verifyNoInteractions(adjustments);
service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1}));
assertEquals(1, service.photos(identity, "CD-1", "PALLET-1").size());
assertTrue(realPhotos.list(application, null, true).isEmpty());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
assertEquals(actual, application.getTotalPackages());
assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee());
assertEquals(1, realPhotos.list(application, null, true).size());
assertNotNull(realPhotos.list(application, null, true).get(0).submittedAt());
service.confirm(identity, "CD-1", "PALLET-1", request(actual));
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(actual), eq("OP"), any());
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
}
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto;
import com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway;
import com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper;
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.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CargoDiscardPhotoServiceTest {
private final CargoDiscardPhotoMapper photos = mock(CargoDiscardPhotoMapper.class);
private final CargoDiscardPhotoFileGateway files = mock(CargoDiscardPhotoFileGateway.class);
private final CargoDiscardPhotoService service = new CargoDiscardPhotoService();
private final CargoDiscardApplication application = new CargoDiscardApplication();
private final List<CargoDiscardPhoto> stored = new ArrayList<>();
@BeforeEach void setup() {
application.setId(1L); application.setCustomerId("7");
ReflectionTestUtils.setField(service, "photos", photos); ReflectionTestUtils.setField(service, "files", files);
when(photos.listApplication("7", 1L)).thenAnswer(invocation -> stored);
when(photos.insert(any(CargoDiscardPhoto.class))).thenAnswer(invocation -> { stored.add(invocation.getArgument(0)); return 1; });
when(files.upload(eq("7"), eq(1L), eq(9L), any())).thenReturn(new CargoDiscardPhotoFileGateway.UploadedFile(101L, "discard.png", "image/png", 8L));
}
private CargoDiscardPhoto photo(long fileId, String palletNo) {
var photo = new CargoDiscardPhoto(); photo.setFileId(fileId); photo.setPalletNo(palletNo);
photo.setCustomerId("7"); photo.setApplicationId(1L); stored.add(photo); return photo;
}
@Test void uploadsRestoreForPdaButOnlySubmittedPhotosAppearInBothDetails() {
var uploaded = service.upload(application, "PALLET-A", "operator", 9L, new MockMultipartFile("file", new byte[]{1}));
assertEquals(101L, uploaded.fileId()); assertEquals("PALLET-A", uploaded.palletNo());
assertEquals(1, service.list(application, "PALLET-A", false).size());
assertTrue(service.list(application, null, true).isEmpty());
assertThrows(ResponseStatusException.class, () -> service.download(application, null, 101L, true));
service.confirm(application, "PALLET-A", List.of(101L), false);
assertEquals(1, service.list(application, null, true).size());
assertNotNull(service.list(application, null, true).get(0).submittedAt());
service.download(application, null, 101L, true); verify(files).download("7", 1L, 101L);
service.confirm(application, "PALLET-A", List.of(101L), true);
verify(photos, times(1)).updateById(any(CargoDiscardPhoto.class));
}
@Test void noPhotosDuplicatesAndCrossPalletOrApplicationReferencesCannotSubmit() {
photo(101L, "PALLET-A"); photo(102L, "PALLET-B");
for (var ids : List.of(List.<Long>of(), List.of(101L, 101L), List.of(102L), List.of(999L)))
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", ids, false));
assertThrows(ResponseStatusException.class, () -> service.download(application, "PALLET-A", 102L, false));
verify(files, never()).validate(anyString(), anyLong(), anyLong());
verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
}
@Test void ownershipValidationFailureDoesNotBindAnyPhoto() {
photo(101L, "PALLET-A"); photo(102L, "PALLET-A");
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST)).when(files).validate("7", 1L, 102L);
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(101L, 102L), false));
verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
assertTrue(stored.stream().allMatch(p -> p.getSubmittedAt() == null));
}
@Test void completedEvidenceCannotChangeAndReplayDoesNotWrite() {
photo(101L, "PALLET-A").setSubmittedAt(LocalDateTime.now()); photo(102L, "PALLET-A");
service.confirm(application, "PALLET-A", List.of(101L), true);
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(102L), true));
assertThrows(ResponseStatusException.class, () -> service.confirm(application, "PALLET-A", List.of(101L, 102L), true));
verifyNoInteractions(files); verify(photos, never()).updateById(any(CargoDiscardPhoto.class));
}
@Test void uploadLimitIsPerPalletAndStopsBeforeFileStorage() {
for (long id=1; id<=20; id++) photo(id, "PALLET-A");
assertThrows(ResponseStatusException.class, () -> service.upload(application, "PALLET-A", "operator", 9L, new MockMultipartFile("file", new byte[]{1})));
verify(files, never()).upload(anyString(), anyLong(), anyLong(), any());
assertEquals(101L, service.upload(application, "PALLET-B", "operator", 9L, new MockMultipartFile("file", new byte[]{1})).fileId());
}
}
@@ -67,6 +67,7 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
@@ -536,11 +536,15 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockCarton(31L)).thenReturn(List.of(line));
when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line);
when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1");
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2);
assertThat(created.sources().get(0).sourceReturnNo()).isEqualTo("RT-1");
assertThat(created.sources().get(0).participation()).isEqualTo("PARTIAL");
assertThat(created.sources().get(0).originalItems().get(0).quantity()).isEqualTo(5);
assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED");
var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class);
verify(packingPlans).insert(captor.capture());
@@ -548,7 +552,36 @@ class ReturnInventoryServiceTest {
assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED");
assertThat(line.getPackingReservedQuantity()).isEqualTo(0);
assertThat(source.getStatus()).isEqualTo("AVAILABLE");
verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L));
verify(balances).releaseAvailable("10001", "42", "SKU-A", 2);
}
@Test
void completedPlanKeepsResultSnapshotWhenLiveCartonHasBeenConsumed() {
var plan = new com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan();
plan.setPlanNo("PLAN-SNAPSHOT"); plan.setCustomerId("10001"); plan.setOverseasWarehouseId("42");
plan.setStatus("COMPLETED"); plan.setMode("SKU"); plan.setReturnNosJson("[\"RT-1\",\"RT-2\"]");
plan.setSourceItemsJson("[]"); plan.setBoxesJson("[]");
plan.setResultLabelsJson("[{\"cartonMark\":\"RESULT\",\"customerId\":\"10001\",\"overseasWarehouseId\":\"42\",\"totalQuantity\":15,\"items\":[{\"skuCode\":\"SKU-A\",\"quantity\":15}]}]");
when(packingPlans.findNo("PLAN-SNAPSHOT")).thenReturn(plan);
var result = service.packingPlan(new PdaWarehouseIdentity("worker", "验收员", 42), "PLAN-SNAPSHOT");
assertThat(result.resultCartons()).isEmpty();
assertThat(result.completionCartons()).hasSize(1);
assertThat(result.completionCartons().get(0).totalQuantity()).isEqualTo(15);
}
@Test
void boxPlanRejectsPartialSourceDeductionsEvenWhenTargetIsNew() {
var plan = new com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan();
plan.setPlanNo("PLAN-FULL"); plan.setCustomerId("10001"); plan.setOverseasWarehouseId("42");
plan.setStatus("READY"); plan.setMode("BOX");
plan.setSourceItemsJson("[{\"cartonMark\":\"A\",\"skuCode\":\"SKU-A\",\"quantity\":10}]");
when(packingPlans.lockNo("PLAN-FULL")).thenReturn(plan);
var request = new ReturnInventoryRequests.PackingPlanComplete(
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 5)), BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE)),
"full-key", List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU-A", 5)));
var error = assertThrows(ResponseStatusException.class, () -> service.completePackingPlan(new PdaWarehouseIdentity("worker", "worker", 42), "PLAN-FULL", request));
assertThat(error.getReason()).contains("完全一致");
verifyNoInteractions(balances, cartonItems);
}
@Test
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.returnreceipt.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ReturnPackingBalancePersistenceTest {
@Test
void releasingPlanReservationPreservesBalanceConstraintsAndRejectsExcessOrMissingStock() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:packing-balance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_return_inventory_balance (id BIGINT PRIMARY KEY, customer_id VARCHAR(128), " +
"overseas_warehouse_id VARCHAR(128), sku_code VARCHAR(128), product_name_zh VARCHAR(255), " +
"total_quantity BIGINT NOT NULL, available_quantity BIGINT NOT NULL, carton_quantity BIGINT NOT NULL, " +
"created_at TIMESTAMP, updated_at TIMESTAMP, " +
"CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0))");
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(1,'C','W','SKU',4,4,1)");
var config = new MybatisConfiguration();
config.addMapper(ReturnInventoryBalanceMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnInventoryBalanceMapper.class);
assertEquals(1, mapper.reserveAvailable("C", "W", "SKU", 4));
assertEquals(1, mapper.releaseAvailable("C", "W", "SKU", 4));
assertEquals(4L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(4L, jdbc.queryForObject("SELECT total_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(1L, jdbc.queryForObject("SELECT carton_quantity FROM ll_return_inventory_balance", Long.class));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("OTHER", "W", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("C", "OTHER", "SKU", 1));
assertEquals(0, mapper.releaseAvailable("C", "W", "MISSING", 1));
assertEquals(1, mapper.reserveAvailable("C", "W", "SKU", 3));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", 4));
assertEquals(0, mapper.releaseAvailable("C", "W", "SKU", -1));
assertEquals(1, mapper.releaseAvailable("C", "W", "SKU", 3));
assertEquals(1L, jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_balance", Long.class));
}
}