This commit is contained in:
2026-08-07 16:02:42 +08:00
parent 3f597677fe
commit ba5bad4429
6 changed files with 224 additions and 9 deletions
@@ -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<String> 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<OrderCargoClient.CargoLabelBinding> 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, "订单服务暂不可用,无法生成唛头");
@@ -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);
@@ -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");
}
}
@@ -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<CartonLabelBindingResolver> 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<InboundMeasurementItemEntity> 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<CartonMeasurementResult> 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;
}
}
@@ -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();
@@ -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;
}