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
@@ -41,6 +41,27 @@ public interface ContainerTierPricingMapper {
List<MemberRow> members(@Param("customerId") long customerId,
@Param("inboundId") long inboundId);
/** 锁住当前柜次内某客户全部有效明细,供阶梯费用组重算使用。 */
@Select("""
SELECT plan.container_booking_id AS containerBookingId,
inbound.id AS inboundId,
inbound.actual_net_weight_kg AS actualWeightKg,
inbound.actual_volume_cbm AS actualVolumeCbm
FROM fl_loading_plan plan
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id
AND item.deleted=0
AND item.account_id=#{customerId}
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id
AND inbound.tenant_id=#{customerId}
AND inbound.deleted=0
WHERE plan.container_booking_id=#{containerBookingId}
AND plan.plan_status IN ('EDITING','LOADING','COMPLETED')
ORDER BY item.inbound_id,item.id
FOR UPDATE
""")
List<MemberRow> membersForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("customerId") long customerId);
record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg,
BigDecimal actualVolumeCbm) {}
}
@@ -48,6 +48,11 @@ public class FbaPricingRepository {
return containerTierPricing.members(customerId, inboundId);
}
public List<ContainerTierPricingMapper.MemberRow> containerTierMembersForUpdate(
long containerBookingId, long customerId) {
return containerTierPricing.membersForUpdate(containerBookingId, customerId);
}
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
@@ -311,6 +316,19 @@ public class FbaPricingRepository {
return inbounds.update(null, update);
}
public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus,
String chargeMode, java.math.BigDecimal chargeable) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getProcessStatus, processStatus)
.in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED"))
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
return inbounds.update(null, update);
}
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
@@ -13,6 +13,7 @@ import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.mapper.ContainerTierPricingMapper;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator;
import com.baiyue.firstleg.domain.PriceModel;
@@ -30,6 +31,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.LinkedHashMap;
@@ -43,6 +45,7 @@ public class FbaPricingService {
private static final BigDecimal ONE_HUNDRED = new BigDecimal("100");
private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L;
private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认";
private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE";
@Resource
private FbaPricingRepository repository;
@Resource
@@ -213,6 +216,171 @@ public class FbaPricingService {
.setScale(6, RoundingMode.HALF_UP);
}
/**
* 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。
* 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。
*/
@Transactional
public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees(
long containerBookingId, long tenantId, String trigger) {
if (containerBookingId <= 0 || tenantId <= 0 || !StringUtils.hasText(trigger)) {
throw new BizException(ResultCode.BAD_REQUEST, "柜次、客户和重算触发标识必填");
}
String normalizedTrigger = trigger.trim();
String key = "CONTAINER:" + containerBookingId + ":" + normalizedTrigger;
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "重算触发标识不能超过96字符");
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, normalizedTrigger);
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
}
Map<Long, ContainerTierPricingMapper.MemberRow> rows = new LinkedHashMap<>();
for (var row : repository.containerTierMembersForUpdate(containerBookingId, tenantId)) {
rows.putIfAbsent(row.inboundId(), row);
}
LocalDateTime now = LocalDateTime.now(clock);
if (rows.isEmpty()) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
}
List<ContainerFeeMember> members = new ArrayList<>();
boolean notReady = false;
for (var row : rows.values()) {
InboundPricingEntity inbound = repository.findInbound(tenantId, row.inboundId());
if (inbound == null) throw conflict("柜内入库单已变化,请刷新后重试");
InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, row.inboundId());
if (current == null) {
notReady = true;
continue;
}
if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) {
throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用");
}
if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) {
throw conflict("柜内费用状态已变化,不能重算阶梯费用");
}
BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量");
BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积");
members.add(new ContainerFeeMember(inbound, current,
weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP),
chargeableWeight(weight, volume)));
}
if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
InboundPricingIdempotencyEntity token = beginContainerRecalculation(
tenantId, containerBookingId, key, normalizedTrigger);
if ("COMPLETED".equals(token.getStatus())) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
}
BigDecimal aggregate = members.stream().map(ContainerFeeMember::chargeableQuantity)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<ContainerTierAmountAllocator.Member> allocationMembers = members.stream()
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
.toList();
List<ContainerFeeQuote> quotes = new ArrayList<>();
for (ContainerFeeMember member : members) {
PricingPolicy policy = pricingPolicy(member.inbound(), now);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(),
destinationType(member.inbound()), member.inbound().getDestinationWarehouseId(),
member.inbound().getDestinationName(), null, pricingTaxMode(member.inbound()), policy.chargeMode(),
aggregate, BigDecimal.ZERO));
quotes.add(new ContainerFeeQuote(member, policy, estimate));
}
boolean regionalTier = quotes.stream().anyMatch(quote -> quote.estimate().priceModel() == PriceModel.REGION_WEIGHT_TIER);
if (!regionalTier) {
complete(token, containerBookingId, now);
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
}
if (quotes.stream().anyMatch(quote -> quote.estimate().priceModel() != PriceModel.REGION_WEIGHT_TIER)) {
throw conflict("柜内客户费用报价模型不一致,不能重算阶梯费用");
}
for (ContainerFeeQuote quote : quotes) {
ContainerTierAmountAllocator.Allocation allocation = containerTierAllocator.allocate(
allocationMembers, quote.estimate().unitPrice());
BigDecimal subtotal = allocation.memberAmounts().get(quote.member().inbound().getId());
if (subtotal == null) throw conflict("柜级阶梯分摊结果不完整");
persistContainerRecalculation(tenantId, quote, subtotal, now);
}
complete(token, containerBookingId, now);
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.RECALCULATED, quotes.size());
}
private void persistContainerRecalculation(long tenantId, ContainerFeeQuote quote,
BigDecimal subtotal, LocalDateTime now) {
ContainerFeeMember member = quote.member();
ActivePriceBookEstimator.Estimate estimate = quote.estimate();
RateRow rate = estimate.rate();
int version = repository.nextFeeVersion(tenantId, member.inbound().getId());
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
InboundFeeEntity recalculated = snapshot(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, member.inbound(), estimate,
rate, version, member.weight(), member.volume(), member.chargeableQuantity(),
member.chargeableQuantity(), subtotal.setScale(MONEY_SCALE, RoundingMode.HALF_UP), additional,
subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP), now);
PricingPolicy policy = quote.policy();
if (policy.rule() != null) {
recalculated.setChargeRuleId(policy.rule().getId());
recalculated.setChargeRuleVersion(policy.rule().getRuleVersion());
recalculated.setRuleCode(policy.rule().getRuleCode());
recalculated.setRuleEffectiveFrom(policy.rule().getEffectiveFrom());
recalculated.setRuleEffectiveTo(policy.rule().getEffectiveTo());
}
recalculated.setMeasurementId(member.current().getMeasurementId());
recalculated.setMeasurementSource(member.current().getMeasurementSource());
if (repository.supersedeCurrent(tenantId, member.current().getId(), member.current().getFeeStatus()) != 1) {
throw conflict("柜内费用已变化,不能重算阶梯费用");
}
repository.insertFee(recalculated);
repository.insertItem(baseItem(tenantId, recalculated, rate, member.chargeableQuantity(),
recalculated.getSubtotalAmount(), now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now));
if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(),
member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) {
throw conflict("柜内入库单状态已变化,不能重算阶梯费用");
}
feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(),
AUTOMATIC_CONFIRM_OPERATOR_ID, now);
}
private InboundPricingIdempotencyEntity beginContainerRecalculation(long tenantId, long containerBookingId,
String key, String trigger) {
String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, trigger);
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId,
AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key);
if (existing != null) {
if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次");
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中");
return existing;
}
InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity();
token.setTenantId(tenantId); token.setOperation(CONTAINER_RECALCULATE_OPERATION);
token.setOperatorId(AUTOMATIC_CONFIRM_OPERATOR_ID); token.setInboundId(0L);
token.setIdempotencyKey(key); token.setRequestHash(requestHash); token.setStatus("PROCESSING");
token.setCreatedAt(LocalDateTime.now(clock)); token.setUpdatedAt(LocalDateTime.now(clock));
repository.insertIdempotency(token);
return token;
}
private static BigDecimal requiredFrozenQuantity(BigDecimal value, String field) {
if (value == null || value.signum() < 0) throw conflict("费用快照缺少冻结" + field + ",不能重算阶梯费用");
return value;
}
private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current,
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { }
private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy,
ActivePriceBookEstimator.Estimate estimate) { }
public enum ContainerFeeRecalculationStatus { RECALCULATED, NOT_READY, NOT_APPLICABLE, REPLAYED }
public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status,
int recalculatedCount) { }
@Transactional
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
String idempotencyKey) {