diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index 516fbae3a..a1847d431 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -25,7 +25,7 @@ import java.util.Set; /** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */ @Service -public class InboundCartonLabelService { +public class InboundCartonLabelService implements CartonLabelBindingResolver { private static final int MAX_LABELS = 2000; private static final int MAX_QR_CODE_POINTS = 34; private static final Set ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING"); @@ -82,16 +82,29 @@ public class InboundCartonLabelService { batch.totalCount(), labels); } - public InboundCartonLabels.Resolution resolveByBoxCode(long tenantId, String boxCode) { + @Override + public CartonLabelBindingResolver.Binding resolveByBoxCode(long tenantId, String boxCode) { + InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode); + return new CartonLabelBindingResolver.Binding(label.getInboundId(), label.getInboundDetailId(), + label.getInboundCode(), label.getCartonIndex(), label.getTotalCartonCount(), + label.getBoxSpecification()); + } + + public InboundCartonLabels.Resolution resolveDetailsByBoxCode(long tenantId, String boxCode) { + InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode); + return new InboundCartonLabels.Resolution(label.getInboundId(), label.getOrderId(), + label.getInboundDetailId(), label.getBoxCode(), label.getTotalCartonCount(), + label.getCartonIndex(), label.getCargoItemNo(), label.getCargoName(), + label.getBoxSpecification()); + } + + private InboundCartonLabelEntity requireByBoxCode(long tenantId, String boxCode) { if (tenantId <= 0 || !StringUtils.hasText(boxCode)) { throw new BizException(ResultCode.BAD_REQUEST, "客户ID和箱号不能为空"); } InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim()); if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在"); - return new InboundCartonLabels.Resolution(label.getInboundId(), label.getOrderId(), - label.getInboundDetailId(), label.getBoxCode(), label.getTotalCartonCount(), - label.getCartonIndex(), label.getCargoItemNo(), label.getCargoName(), - label.getBoxSpecification()); + return label; } private List loadCargoes(InboundOrderEntity inbound) { @@ -105,8 +118,10 @@ public class InboundCartonLabelService { throw new BizException(ResultCode.VALIDATION_FAILED, "订单没有可生成唛头的货物明细"); } return response.data().stream() - .sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo) - .thenComparing(OrderCargoClient.CargoLabelBinding::cargoId)) + .sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo, + java.util.Comparator.nullsLast(Integer::compareTo)) + .thenComparing(OrderCargoClient.CargoLabelBinding::cargoId, + java.util.Comparator.nullsLast(Long::compareTo))) .toList(); } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index 78f4c0ada..af8e1c488 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -22,6 +22,9 @@ class PlatformInboundMeasurementControllerContractTest { assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.SubmitRequest.class); + assertAuthority("updateConfirmedPackageCount", "hasAuthority('platform:firstleg:inbound:measure:edit')", + AuthenticatedUser.class, long.class, long.class, long.class, + InboundMeasurementRequests.ConfirmedPackageCountRequest.class); assertAuthority("createWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')", AuthenticatedUser.class, long.class, long.class, InboundMeasurementRequests.CreateWeightResultRequest.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 36a47d521..c5bcf4e41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -38,4 +38,22 @@ class InboundWorkflowSqlContractTest { .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } + + @Test + @DisplayName("单箱当前测量记录包含箱号、设备元数据和唯一约束") + void shouldPersistCurrentCartonMeasurementFields() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String migration = new ClassPathResource("db/phase2-carton-measurement-current-record.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(schema) + .contains("box_code VARCHAR(128)") + .contains("machine_no VARCHAR(64)") + .contains("device_measured_at DATETIME(3)") + .contains("uk_measure_box_code"); + assertThat(migration) + .contains("phase2_add_carton_measurement_column") + .contains("uk_measure_box_code") + .contains("information_schema.STATISTICS"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java new file mode 100644 index 000000000..05ccd9c76 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -0,0 +1,162 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.MeasurementImageClient; +import com.baiyue.firstleg.dto.CartonMeasurementCommand; +import com.baiyue.firstleg.dto.CartonMeasurementResult; +import com.baiyue.firstleg.entity.InboundMeasurementEntity; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.repository.InboundMeasurementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; + +class CartonMeasurementServiceTest { + private final InboundMeasurementRepository repository = mock(InboundMeasurementRepository.class); + @SuppressWarnings("unchecked") + private final ObjectProvider resolverProvider = mock(ObjectProvider.class); + private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class); + private final MeasurementImageClient images = mock(MeasurementImageClient.class); + private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private CartonMeasurementService service; + private final AtomicReference insertedItem = new AtomicReference<>(); + + @BeforeEach + void setUp() { + service = new CartonMeasurementService(repository, resolverProvider, images, transactions); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + when(resolverProvider.getIfAvailable()).thenReturn(resolver); + when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) + .thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm")); + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE")); + when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null))); + when(transactions.execute(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(org.springframework.transaction.TransactionStatus.class)); + }); + doAnswer(invocation -> { + insertedItem.set(invocation.getArgument(0)); + return null; + }).when(repository).insertItem(any()); + } + + @Test + void firstScanCreatesOneRecordWithConfirmedCountOne() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, command()); + + assertThat(result.created()).isTrue(); + assertThat(result.confirmedPackageCount()).isEqualTo(1); + assertThat(result.boxCode()).isEqualTo("RK-001#5-1"); + assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L); + assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.0600"); + } + + @Test + void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() { + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4); + InboundMeasurementItemEntity existing = item(40001L, 30001L, 7, 88001L); + when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(existing); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(existing)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(existing)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.upsert(10001L, 90001L, command()); + + assertThat(result.created()).isFalse(); + assertThat(result.confirmedPackageCount()).isEqualTo(7); + assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100"); + assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); + verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); + } + + @Test + void submittedInboundRejectsDeviceBeforeUploadingImage() { + when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING")); + + assertThatThrownBy(() -> service.upsert(10001L, 90001L, command())) + .isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖"); + + verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()); + } + + @Test + void confirmedPackageCountUsesMeasurementOptimisticVersion() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2); + InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L); + when(repository.findItem(10001L, 40001L)).thenReturn(item); + when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement); + when(repository.updateItem(item)).thenReturn(true); + when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + CartonMeasurementResult result = service.updateConfirmedPackageCount( + 10001L, 70001L, 40001L, 2, 5); + + assertThat(result.confirmedPackageCount()).isEqualTo(5); + assertThat(item.getPackageCount()).isEqualTo(5); + verify(repository).updateMeasurement(measurement); + } + + private CartonMeasurementCommand command() { + return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("60.100"), + new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"), + "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes())); + } + + private InboundOrderEntity inbound(String status) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setProcessStatus(status); + inbound.setTotalPackageCount(5); inbound.setVersion(0); + return inbound; + } + + private InboundMeasurementEntity measurement(long id, String status, int version) { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(id); measurement.setTenantId(10001L); measurement.setInboundId(70001L); + measurement.setStatus(status); measurement.setVersion(version); + return measurement; + } + + private InboundMeasurementItemEntity item(long id, long measurementId, int packageCount, long imageId) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId); + item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount); + item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40")); + item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10")); + item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm"); + return item; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index ef1202039..6393ce4c1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -90,12 +90,26 @@ class InboundCartonLabelServiceTest { var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); - assertThat(resolved.orderId()).isEqualTo(60001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174"); assertThat(resolved.cartonIndex()).isEqualTo(1); + assertThat(resolved.totalCartons()).isEqualTo(1); + assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); } + @Test + void exposesDetailedResolutionForFirstlegConsumers() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + + assertThat(resolved.orderId()).isEqualTo(60001L); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.cargoItemNo()).isEqualTo(1); + } + @Test void refusesFirstAllocationAfterMeasurementSubmission() { InboundOrderEntity inbound = inbound(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index b234b81e7..4bc8ca504 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -116,6 +116,7 @@ class InboundMeasurementServiceTest { measurement.setTotalVolumeCbm(new BigDecimal("1.2345")); measurement.setVolumeWeightKg(new BigDecimal("206.162")); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); item.setSourceImageFileId(500L); InboundWeightResultEntity weight = weight(20L, "20.000", "KG", "CONFIRMED", 1); weight.setFinalWeightKg(new BigDecimal("20.000")); @@ -145,6 +146,7 @@ class InboundMeasurementServiceTest { measurement.setStatus("DRAFT"); measurement.setVersion(0); InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setPackageCount(3); item.setSourceImageFileId(500L); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 30L)).thenReturn(measurement); @@ -195,6 +197,7 @@ class InboundMeasurementServiceTest { inbound.setId(10L); inbound.setTenantId(1L); inbound.setProcessStatus(status); + inbound.setTotalPackageCount(3); inbound.setVersion(0); return inbound; }