Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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("""
|
||||
<script>
|
||||
SELECT plan.container_booking_id AS containerBookingId,
|
||||
grouped_inbound.id AS inboundId,
|
||||
grouped_inbound.actual_net_weight_kg AS actualWeightKg,
|
||||
grouped_inbound.actual_volume_cbm AS actualVolumeCbm
|
||||
FROM fl_loading_plan_item current_item
|
||||
JOIN fl_loading_plan current_plan ON current_plan.id=current_item.loading_plan_id
|
||||
JOIN fl_loading_plan plan ON plan.container_booking_id=current_plan.container_booking_id
|
||||
JOIN fl_loading_plan_item grouped_item ON grouped_item.loading_plan_id=plan.id
|
||||
AND grouped_item.deleted=0
|
||||
AND grouped_item.account_id=#{customerId}
|
||||
JOIN fl_inbound_order current_inbound ON current_inbound.id=#{inboundId}
|
||||
AND current_inbound.tenant_id=#{customerId}
|
||||
AND current_inbound.deleted=0
|
||||
JOIN fl_inbound_order grouped_inbound ON grouped_inbound.id=grouped_item.inbound_id
|
||||
AND grouped_inbound.tenant_id=#{customerId}
|
||||
AND grouped_inbound.deleted=0
|
||||
WHERE current_item.inbound_id=#{inboundId}
|
||||
AND current_item.account_id=#{customerId}
|
||||
AND current_item.deleted=0
|
||||
AND plan.plan_status IN ('EDITING','LOADING','COMPLETED')
|
||||
GROUP BY plan.container_booking_id,grouped_inbound.id,
|
||||
grouped_inbound.actual_net_weight_kg,grouped_inbound.actual_volume_cbm
|
||||
ORDER BY plan.container_booking_id,grouped_inbound.id
|
||||
</script>
|
||||
""")
|
||||
List<MemberRow> members(@Param("customerId") long customerId,
|
||||
@Param("inboundId") long inboundId);
|
||||
|
||||
record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg,
|
||||
BigDecimal actualVolumeCbm) {}
|
||||
}
|
||||
@@ -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<Member> source, BigDecimal unitPrice) {
|
||||
if (source == null || source.isEmpty() || unitPrice == null || unitPrice.signum() < 0) {
|
||||
throw validation("柜级阶梯计费参数无效");
|
||||
}
|
||||
List<Member> 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<Share> 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<Long, BigDecimal> 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<Long, BigDecimal> memberAmounts) {}
|
||||
private record Share(long inboundId, BigDecimal amount, BigDecimal remainder) {
|
||||
private Share add(BigDecimal increment) {
|
||||
return new Share(inboundId, amount.add(increment), remainder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ContainerTierPricingMapper.MemberRow> containerTierMembers(long customerId, long inboundId) {
|
||||
return containerTierPricing.members(customerId, inboundId);
|
||||
}
|
||||
|
||||
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
|
||||
|
||||
|
||||
@@ -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<Long, ContainerTierAmountAllocator.Member> 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) {
|
||||
|
||||
Reference in New Issue
Block a user