test(firstleg): harden measurement demo package

This commit is contained in:
2026-09-01 12:03:21 +08:00
parent 6eaf484fcd
commit 9e32efdd90
6 changed files with 208 additions and 32 deletions
@@ -123,11 +123,13 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
}
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion());
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus());
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
}
@@ -135,19 +137,15 @@ public class InboundMeasurementService {
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
if (!alreadyAdvanced) {
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
}
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
@@ -72,8 +72,14 @@ public class MeasurementReviewService {
}
if (approved) {
var items = repository.listMeasurementItems(tenantId, measurement.getId());
if (items.isEmpty() || items.stream().anyMatch(item -> item.getPackageCount() == null
|| item.getPackageCount() <= 0 || item.getTotalVolumeCbm() == null
|| item.getTotalVolumeCbm().signum() <= 0 || item.getVolumeWeightKg() == null
|| item.getVolumeWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"测量明细箱数、体积和体积重必须完整且大于零,才能审核通过");
}
long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
@@ -97,13 +103,23 @@ public class MeasurementReviewService {
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volumeWeight = items.stream()
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getVolumeWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null
|| order.getActualGrossWeightKg().compareTo(grossWeight) != 0
|| order.getActualNetWeightKg().compareTo(netWeight) != 0
|| volume.signum() <= 0) {
|| order.getActualVolumeCbm() == null || order.getActualVolumeCbm().compareTo(volume) != 0
|| order.getVolumeWeightKg() == null || order.getVolumeWeightKg().compareTo(volumeWeight) != 0
|| measurement.getTotalPackageCount() == null
|| measurement.getTotalPackageCount() != confirmed
|| measurement.getTotalVolumeCbm() == null
|| measurement.getTotalVolumeCbm().compareTo(volume) != 0
|| measurement.getVolumeWeightKg() == null
|| measurement.getVolumeWeightKg().compareTo(volumeWeight) != 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
"审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致");
}
}
@@ -234,6 +234,38 @@ class InboundMeasurementServiceTest {
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
InboundMeasurementResponses.WeightResult created = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null));
assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL");
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
InboundWeightResultEntity weight = saved.getValue();
weight.setId(20L);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认"));
assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500");
verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class));
}
@Test
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -799,9 +831,13 @@ class InboundMeasurementServiceTest {
}
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
inbound.setActualGrossWeightKg(new BigDecimal("40.000"));
inbound.setActualNetWeightKg(new BigDecimal("30.000"));
inbound.setActualVolumeCbm(new BigDecimal("2.0000"));
inbound.setVolumeWeightKg(new BigDecimal("334.000"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
@@ -815,14 +851,17 @@ class InboundMeasurementServiceTest {
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核完成");
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
verify(repository, never()).updateInbound(any());
}
@Test
@@ -91,6 +91,7 @@ class MeasurementReviewServiceTest {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
@@ -118,6 +119,40 @@ class MeasurementReviewServiceTest {
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100");
InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816");
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku",
new MeasurementReviewRequest(20L, 3, null));
ArgumentCaptor<InboundStatusHistoryEntity> history =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("体积或体积重任一层汇总不一致时审核零写入")
void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() {
Fixture fixture = fixture(false);
fixture.order.setActualVolumeCbm(new BigDecimal("0.7479"));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("体积和体积重");
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@DisplayName("驳回原因必填且状态推进为测量驳回")
void shouldRequireRejectReason() {
@@ -217,13 +252,15 @@ class MeasurementReviewServiceTest {
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
order.setActualNetWeightKg(new BigDecimal("1747.500"));
order.setActualVolumeCbm(new BigDecimal("0.7480"));
order.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
measurement.setTotalPackageCount(5);
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916");
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
@@ -236,6 +273,14 @@ class MeasurementReviewServiceTest {
return new Fixture(service, repository, idempotency, order, measurement);
}
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(packages);
item.setTotalVolumeCbm(new BigDecimal(volume));
item.setVolumeWeightKg(new BigDecimal(volumeWeight));
return item;
}
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setRecognitionStatus("CONFIRMED");