diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java new file mode 100644 index 000000000..70553bdce --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +/** 同柜同客户区域重量阶梯计费查询。 */ +@Mapper +public interface ContainerTierPricingMapper { + + @Select(""" + + """) + List members(@Param("customerId") long customerId, + @Param("inboundId") long inboundId); + + record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg, + BigDecimal actualVolumeCbm) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java new file mode 100644 index 000000000..e8a23645c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java @@ -0,0 +1,72 @@ +package com.baiyue.firstleg.pricing; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 将同柜同客户的阶梯总额按各票计费重分摊,并保证分摊总额守恒。 */ +@Component +public class ContainerTierAmountAllocator { + private static final int MONEY_SCALE = 2; + private static final BigDecimal CENT = new BigDecimal("0.01"); + + public Allocation allocate(List source, BigDecimal unitPrice) { + if (source == null || source.isEmpty() || unitPrice == null || unitPrice.signum() < 0) { + throw validation("柜级阶梯计费参数无效"); + } + List members = source.stream() + .sorted(Comparator.comparingLong(Member::inboundId)) + .toList(); + if (members.stream().map(Member::inboundId).distinct().count() != members.size()) { + throw validation("柜级阶梯计费成员不能重复"); + } + BigDecimal totalQuantity = members.stream().map(Member::chargeableQuantity) + .peek(value -> { + if (value == null || value.signum() < 0) throw validation("计费重不能小于0"); + }) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalQuantity.signum() <= 0) throw validation("柜级合计计费重必须大于0"); + + BigDecimal totalAmount = totalQuantity.multiply(unitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + List shares = new ArrayList<>(members.size()); + BigDecimal allocated = BigDecimal.ZERO.setScale(MONEY_SCALE); + for (Member member : members) { + BigDecimal exact = totalAmount.multiply(member.chargeableQuantity()) + .divide(totalQuantity, 12, RoundingMode.HALF_UP); + BigDecimal floor = exact.setScale(MONEY_SCALE, RoundingMode.DOWN); + allocated = allocated.add(floor); + shares.add(new Share(member.inboundId(), floor, exact.subtract(floor))); + } + int cents = totalAmount.subtract(allocated).movePointRight(MONEY_SCALE).intValueExact(); + shares.sort(Comparator.comparing(Share::remainder).reversed().thenComparingLong(Share::inboundId)); + for (int index = 0; index < cents; index++) { + Share share = shares.get(index % shares.size()); + shares.set(index % shares.size(), share.add(CENT)); + } + shares.sort(Comparator.comparingLong(Share::inboundId)); + Map amounts = new LinkedHashMap<>(); + shares.forEach(share -> amounts.put(share.inboundId(), share.amount())); + return new Allocation(totalQuantity, totalAmount, amounts); + } + + private static BizException validation(String message) { + return new BizException(ResultCode.VALIDATION_FAILED, message); + } + + public record Member(long inboundId, BigDecimal chargeableQuantity) {} + public record Allocation(BigDecimal totalQuantity, BigDecimal totalAmount, + Map memberAmounts) {} + private record Share(long inboundId, BigDecimal amount, BigDecimal remainder) { + private Share add(BigDecimal increment) { + return new Share(inboundId, amount.add(increment), remainder); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 867e31cde..ae148006f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -40,6 +41,12 @@ public class FbaPricingRepository { private InboundPricingIdempotencyMapper idempotencies; @Resource private InboundOutboxMapper outbox; + @Resource + private ContainerTierPricingMapper containerTierPricing; + + public List containerTierMembers(long customerId, long inboundId) { + return containerTierPricing.members(customerId, inboundId); + } public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 501fd42d8..8ad0ebfea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,6 +14,8 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; +import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; @@ -30,6 +32,8 @@ import java.time.Clock; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; /** FBA 费用试算、确认和发送状态机。 */ @Service @@ -51,6 +55,8 @@ public class FbaPricingService { private InboundFeeDetailService feeDetails; @Resource private FinanceFeeSourceOutboxService financeOutbox; + @Resource + private ContainerTierAmountAllocator containerTierAllocator; @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, @@ -123,9 +129,10 @@ public class FbaPricingService { pricingTaxMode(inbound), policy.chargeMode(), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); - RateRow rate = estimate.rate(); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP); + estimate = applyContainerCustomerTier(inbound, estimate, weight, volume); + RateRow rate = estimate.rate(); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP); @@ -167,6 +174,45 @@ public class FbaPricingService { return response(inbound, fee, rate.getUnitPrice(), nextStatus); } + private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( + InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, + BigDecimal currentWeight, BigDecimal currentVolume) { + if (individual.priceModel() != PriceModel.REGION_WEIGHT_TIER) return individual; + var rows = repository.containerTierMembers(inbound.getTenantId(), inbound.getId()); + if (rows == null || rows.isEmpty()) return individual; + long containerId = rows.get(0).containerBookingId(); + if (rows.stream().anyMatch(row -> row.containerBookingId() != containerId)) { + throw conflict("同一入仓单已分配到多个柜子,不能生成柜级阶梯费用"); + } + Map unique = new LinkedHashMap<>(); + for (var row : rows) { + BigDecimal weight = row.inboundId() == inbound.getId() ? currentWeight : row.actualWeightKg(); + BigDecimal volume = row.inboundId() == inbound.getId() ? currentVolume : row.actualVolumeCbm(); + BigDecimal quantity = chargeableWeight(weight, volume); + unique.putIfAbsent(row.inboundId(), new ContainerTierAmountAllocator.Member(row.inboundId(), quantity)); + } + if (!unique.containsKey(inbound.getId()) || unique.size() < 2) return individual; + BigDecimal aggregate = unique.values().stream().map(ContainerTierAmountAllocator.Member::chargeableQuantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + ActivePriceBookEstimator.Estimate group = priceBookEstimator.estimate(inbound.getTenantId(), new EstimateRequest( + inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), + destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, + pricingTaxMode(inbound), "KG", aggregate, BigDecimal.ZERO)); + var allocation = containerTierAllocator.allocate(List.copyOf(unique.values()), group.unitPrice()); + BigDecimal memberQuantity = unique.get(inbound.getId()).chargeableQuantity(); + return new ActivePriceBookEstimator.Estimate(group.priceBookVersionId(), group.channelCode(), + group.priceModel(), group.pricingTaxMode(), group.rate(), memberQuantity, group.chargeUnit(), + group.unitPrice(), allocation.memberAmounts().get(inbound.getId()), group.currency(), + group.conversionFactor(), group.roundingScale(), group.roundingMode()); + } + + private static BigDecimal chargeableWeight(BigDecimal weight, BigDecimal volume) { + BigDecimal safeWeight = weight == null ? BigDecimal.ZERO : weight; + BigDecimal safeVolume = volume == null ? BigDecimal.ZERO : volume; + return safeWeight.max(safeVolume.multiply(BigDecimal.valueOf(167))) + .setScale(6, RoundingMode.HALF_UP); + } + @Transactional public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java new file mode 100644 index 000000000..9a4d8e0f8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java @@ -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)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 5b206acf2..c5e7d40c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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 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");