This commit is contained in:
2026-08-27 11:42:05 +08:00
parent c80307a838
commit 086a0f9c89
9 changed files with 275 additions and 18 deletions
@@ -170,10 +170,7 @@ public class ContainerService {
}
return required(id);
}
ContainerBookingEntity current = repository.findForUpdate(id);
if (current == null || !"ACTIVE".equals(current.getRecordStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
}
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
replay = transportHistories == null ? null
@@ -317,17 +317,27 @@ public class SmartLoadingService {
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
String before = json(adjustSnapshot(item));
LoadingPlanItemEntity desired = new LoadingPlanItemEntity();
desired.setPackageCount(request.packageCount());
desired.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setPalletCount(estimatedPalletCount(candidate, request.packageCount()));
applyCargo(desired, cargo, request.packageCount());
if (sameAdjustSnapshot(item, desired)) {
return view(plan, booking);
}
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount());
item.setPackageCount(desired.getPackageCount());
item.setGrossWeightKg(desired.getGrossWeightKg());
item.setNetWeightKg(desired.getNetWeightKg());
item.setVolumeCbm(desired.getVolumeCbm());
item.setPalletCount(desired.getPalletCount()); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
before, json(values("requestHash", requestHash, "business", adjustSnapshot(item))),
operatorId, operatorName);
loadingPickService.sync(planId, item);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
@@ -483,6 +493,33 @@ public class SmartLoadingService {
}
}
private static boolean sameAdjustSnapshot(LoadingPlanItemEntity current, LoadingPlanItemEntity desired) {
return Objects.equals(current.getPackageCount(), desired.getPackageCount())
&& decimalEquals(current.getGrossWeightKg(), desired.getGrossWeightKg())
&& decimalEquals(current.getNetWeightKg(), desired.getNetWeightKg())
&& decimalEquals(current.getVolumeCbm(), desired.getVolumeCbm())
&& Objects.equals(current.getPalletCount(), desired.getPalletCount())
&& Objects.equals(current.getSkuCode(), desired.getSkuCode())
&& Objects.equals(current.getProductNameEn(), desired.getProductNameEn())
&& Objects.equals(current.getUnitsPerCarton(), desired.getUnitsPerCarton())
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity())
&& Objects.equals(current.getCustomerRemark(), desired.getCustomerRemark())
&& Objects.equals(current.getInternalRemark(), desired.getInternalRemark());
}
private static boolean decimalEquals(BigDecimal left, BigDecimal right) {
return left == null ? right == null : right != null && left.compareTo(right) == 0;
}
private static Map<String, Object> adjustSnapshot(LoadingPlanItemEntity item) {
return values("packageCount", item.getPackageCount(), "grossWeightKg", item.getGrossWeightKg(),
"netWeightKg", item.getNetWeightKg(), "volumeCbm", item.getVolumeCbm(),
"palletCount", item.getPalletCount(), "skuCode", item.getSkuCode(),
"productNameEn", item.getProductNameEn(), "unitsPerCarton", item.getUnitsPerCarton(),
"productQuantity", item.getProductQuantity(), "customerRemark", item.getCustomerRemark(),
"internalRemark", item.getInternalRemark(), "version", item.getVersion());
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(internalToken,
@@ -330,9 +330,10 @@ paths:
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读;相同幂等键精确重放零新增历史,相同业务字段为无效变更且不递增版本、不写历史
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
@@ -154,12 +154,63 @@ class ContainerServiceTest {
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-1",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
verify(transportHistories).insert(history.capture());
assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567");
assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002");
assertThat(history.getValue().getOperationId()).hasSize(64);
assertThat(history.getValue().getRequestHash()).hasSize(64);
}
@Test
@DisplayName("运输字段没有有效变化时不递增版本且不写历史")
void ignoresTransportNoOp() {
ContainerBookingEntity current = booking("LOADING", 2);
current.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current);
ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop",
new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2));
assertThat(result).isSameAs(current);
verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any());
verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class));
verifyNoInteractions(loadingPickService);
}
@Test
@DisplayName("运输字段精确幂等重放零新增")
void replaysTransportWithoutSecondMutationOrHistory() {
ContainerBookingEntity current = booking("LOADING", 2);
ContainerBookingEntity updated = booking("LOADING", 3);
updated.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current, updated, updated);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2);
service.updateTransport(11L, 9L, "admin", "transport-replay", request);
verify(transportHistories).insert(history.capture());
when(transportHistories.selectOperation(eq(11L), eq(9L), anyString()))
.thenReturn(history.getValue());
ContainerBookingEntity replay = service.updateTransport(
11L, 9L, "admin", "transport-replay", request);
assertThat(replay.getVersion()).isEqualTo(3);
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
verify(transportHistories, org.mockito.Mockito.times(1))
.insert(any(ContainerTransportHistoryEntity.class));
}
@Test
@@ -175,7 +226,7 @@ class ContainerServiceTest {
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-2",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
@@ -195,7 +246,7 @@ class ContainerServiceTest {
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-3",
new TransportUpdateRequest("MSCU7654321", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
@@ -211,7 +262,7 @@ class ContainerServiceTest {
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4",
new TransportUpdateRequest(null, "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
@@ -225,7 +276,7 @@ class ContainerServiceTest {
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
@@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("预装与装柜清单状态服务")
@@ -102,6 +103,25 @@ class LoadingListServiceTest {
eq("operator"), eq("补录箱号"));
}
@Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("CSLU1234567");
before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.preloadDetail(before)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "noop-key",
new TransportUpdateRequest(" CSLU1234567 ", " 023E ", 3, "重复保存"));
verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(),
any(), any(), anyLong(), any());
verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), any(), any(),
any(), any(), anyLong(), any(), any());
verifyNoInteractions(loadingPickService);
}
@Test
void pushedEditingPreloadCanReceiveContainerNumber() {
AggregateRow before = row("EDITING", 3);
@@ -503,6 +503,26 @@ class SmartLoadingServiceTest {
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
}
@Test
void unchangedWarehouseRemarkDoesNotTouchPlanItemOrHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("待核对外箱");
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" 待核对外箱 ", 2, 0, "warehouse-noop"), 7L, "u");
assertThat(result.version()).isEqualTo(2);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);