Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user