This commit is contained in:
2026-09-08 18:16:54 +08:00
parent 1a1a19dea7
commit fcb4c055e2
32 changed files with 1239 additions and 46 deletions
@@ -748,12 +748,129 @@ class FbaPricingServiceTest {
void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException {
String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class)
.getAnnotation(Select.class).value()[0];
String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", long.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id")
.contains("grouped_item.account_id=#{customerId}")
.contains("grouped_inbound.tenant_id=#{customerId}")
.doesNotContain("grouped_inbound.destination_code")
.doesNotContain("grouped_inbound.destination_warehouse_id");
assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}")
.contains("item.account_id=#{customerId}").contains("FOR UPDATE");
}
@Test
@DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊")
void recalculatesUnlockedContainerFeesFromFrozenSelections() {
readyRepository();
InboundPricingEntity firstInbound = inbound("999", "9");
InboundPricingEntity secondInbound = inbound("888", "8");
secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002");
InboundFeeEntity first = savedFee("CALCULATED");
first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000"));
first.setMeasurementId(301L); first.setMeasurementSource("OUTER");
InboundFeeEntity second = savedFee("CONFIRMED");
second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000"));
second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L);
second.setMeasurementSource("ACTUAL");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second);
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"),
Map.of(INBOUND, new BigDecimal("720.00"), 30002L, new BigDecimal("880.00"))));
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1");
ArgumentCaptor<InboundFeeEntity> fees = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository, times(2)).insertFee(fees.capture());
assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000");
assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000");
assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L);
assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER");
assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00");
assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000");
assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L);
assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL");
ArgumentCaptor<com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest> requests =
ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class);
verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture());
assertThat(requests.getAllValues()).allSatisfy(request -> {
assertThat(request.actualWeightKg()).isEqualByComparingTo("200");
assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO);
});
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("柜内任一已发送或客户确认费用阻断整组重算")
void rejectsContainerRecalculationWhenAnyFeeIsImmutable() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
InboundFeeEntity sent = savedFee("SENT");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"))
.isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认");
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString());
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪")
void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() {
readyRepository();
InboundPricingEntity pricedInbound = inbound("100", "0.1");
InboundPricingEntity pendingInbound = inbound("80", "0.1");
pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING");
InboundFeeEntity priced = savedFee("CALCULATED");
priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null);
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("相同柜次重算触发标识重放不重复创建费用版本")
void replaysCompletedContainerRecalculationTrigger() {
InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger");
when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger"))
.thenReturn(completed);
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger");
verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong());
verify(repository, never()).insertFee(any());
}
private void readyRepository() {
@@ -834,6 +951,20 @@ class FbaPricingServiceTest {
return value;
}
private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) {
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED");
value.setResultResourceId(containerBookingId);
try {
value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger)
.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new AssertionError(exception);
}
return value;
}
private InboundFeeEntity savedFee(String status) {
InboundFeeEntity value = new InboundFeeEntity();
value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1");