feat(firstleg): tier container customer freight
This commit is contained in:
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerTierAmountAllocatorTest {
|
||||
private final ContainerTierAmountAllocator allocator = new ContainerTierAmountAllocator();
|
||||
|
||||
@Test
|
||||
void allocatesOneGroupByChargeableWeightAndConservesRoundedTotal() {
|
||||
var result = allocator.allocate(List.of(
|
||||
member(31, "100"), member(11, "101"), member(21, "102")
|
||||
), new BigDecimal("9.99"));
|
||||
|
||||
assertThat(result.totalQuantity()).isEqualByComparingTo("303");
|
||||
assertThat(result.totalAmount()).isEqualByComparingTo("3026.97");
|
||||
assertThat(result.memberAmounts()).containsExactly(
|
||||
java.util.Map.entry(11L, new BigDecimal("1008.99")),
|
||||
java.util.Map.entry(21L, new BigDecimal("1018.98")),
|
||||
java.util.Map.entry(31L, new BigDecimal("999.00")));
|
||||
assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.isEqualByComparingTo(result.totalAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void distributesRoundingCentsDeterministically() {
|
||||
var result = allocator.allocate(List.of(member(3, "1"), member(2, "1"), member(1, "1")),
|
||||
new BigDecimal("0.005"));
|
||||
|
||||
assertThat(result.memberAmounts()).containsExactly(
|
||||
java.util.Map.entry(1L, new BigDecimal("0.01")),
|
||||
java.util.Map.entry(2L, new BigDecimal("0.01")),
|
||||
java.util.Map.entry(3L, new BigDecimal("0.00")));
|
||||
assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.isEqualByComparingTo("0.02");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSingleInboundAmountAndQuantity() {
|
||||
var result = allocator.allocate(List.of(member(11, "1.337")), new BigDecimal("10"));
|
||||
|
||||
assertThat(result.totalQuantity()).isEqualByComparingTo("1.337");
|
||||
assertThat(result.totalAmount()).isEqualByComparingTo("13.37");
|
||||
assertThat(result.memberAmounts()).containsExactly(java.util.Map.entry(11L, new BigDecimal("13.37")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateInboundMembers() {
|
||||
assertThatThrownBy(() -> allocator.allocate(List.of(member(11, "1"), member(11, "2")), BigDecimal.ONE))
|
||||
.hasMessageContaining("不能重复");
|
||||
}
|
||||
|
||||
private static ContainerTierAmountAllocator.Member member(long inboundId, String weight) {
|
||||
return new ContainerTierAmountAllocator.Member(inboundId, new BigDecimal(weight));
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,16 @@ import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -31,6 +34,7 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -58,6 +62,7 @@ class FbaPricingServiceTest {
|
||||
@MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository;
|
||||
@MockBean private InboundFeeDetailService feeDetails;
|
||||
@MockBean private ActivePriceBookEstimator priceBookEstimator;
|
||||
@MockBean private ContainerTierAmountAllocator containerTierAllocator;
|
||||
@Resource private FbaPricingService service;
|
||||
|
||||
@Test
|
||||
@@ -677,6 +682,80 @@ class FbaPricingServiceTest {
|
||||
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同客户同柜区域阶梯按总计费重命中临界档并按票分摊")
|
||||
void appliesContainerCustomerTierAndAllocatedAmount() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100", "0.1");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of(
|
||||
memberRow(901L, INBOUND, "100", "0.1"),
|
||||
memberRow(901L, 30002L, "100", "0.1")
|
||||
));
|
||||
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
|
||||
regionEstimate("100", "10", "1000"), 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("800.00"), 30002L, new BigDecimal("800.00"))));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "container-tier");
|
||||
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("100");
|
||||
assertThat(response.unitPrice()).isEqualByComparingTo("8");
|
||||
assertThat(response.subtotalAmount()).isEqualByComparingTo("800.00");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("800.00");
|
||||
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().get(1).actualWeightKg()).isEqualByComparingTo("200");
|
||||
assertThat(requests.getAllValues().get(1).actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单票柜次保持原区域阶梯结果,不进行无意义分摊")
|
||||
void keepsIndividualResultForSingleInboundContainer() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100", "0.1");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of(
|
||||
memberRow(901L, INBOUND, "100", "0.1")
|
||||
));
|
||||
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000"));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "single-container-tier");
|
||||
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("1000");
|
||||
verify(containerTierAllocator, never()).allocate(any(), any());
|
||||
verify(priceBookEstimator).estimate(eq(TENANT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无柜次关系不改变单票区域阶梯计价")
|
||||
void keepsIndividualResultWithoutContainerRelation() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100", "0.1");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of());
|
||||
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000"));
|
||||
|
||||
assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "no-container-tier").totalAmount())
|
||||
.isEqualByComparingTo("1000");
|
||||
verify(containerTierAllocator, never()).allocate(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("柜次成员查询只按客户和同一柜次隔离")
|
||||
void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException {
|
||||
String sql = ContainerTierPricingMapper.class.getMethod("members", 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");
|
||||
}
|
||||
|
||||
private void readyRepository() {
|
||||
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
@@ -725,6 +804,25 @@ class FbaPricingServiceTest {
|
||||
"KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP");
|
||||
}
|
||||
|
||||
private ContainerTierPricingMapper.MemberRow memberRow(long containerId, long inboundId,
|
||||
String weight, String volume) {
|
||||
return new ContainerTierPricingMapper.MemberRow(containerId, inboundId,
|
||||
new BigDecimal(weight), new BigDecimal(volume));
|
||||
}
|
||||
|
||||
private ActivePriceBookEstimator.Estimate regionEstimate(String quantity, String unitPrice, String amount) {
|
||||
RateRow rate = new RateRow();
|
||||
rate.setId(202L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-TIER-1"); rate.setRateVersion(1);
|
||||
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
|
||||
rate.setPriceModel(PriceModel.REGION_WEIGHT_TIER.name()); rate.setDestinationType("FBA");
|
||||
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit("KG");
|
||||
rate.setUnitPrice(new BigDecimal(unitPrice)); rate.setCurrency("USD"); rate.setAdditionalFee(BigDecimal.ZERO);
|
||||
rate.setEffectiveFrom(NOW.minusDays(1));
|
||||
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.REGION_WEIGHT_TIER,
|
||||
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), "KG", new BigDecimal(unitPrice),
|
||||
new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP");
|
||||
}
|
||||
|
||||
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
|
||||
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
||||
value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED");
|
||||
|
||||
Reference in New Issue
Block a user