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) {
@@ -748,12 +748,129 @@ class FbaPricingServiceTest {
void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException {
String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class)
.getAnnotation(Select.class).value()[0];
String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", 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");
assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}")
.contains("item.account_id=#{customerId}").contains("FOR UPDATE");
}
@Test
@DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊")
void recalculatesUnlockedContainerFeesFromFrozenSelections() {
readyRepository();
InboundPricingEntity firstInbound = inbound("999", "9");
InboundPricingEntity secondInbound = inbound("888", "8");
secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002");
InboundFeeEntity first = savedFee("CALCULATED");
first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000"));
first.setMeasurementId(301L); first.setMeasurementSource("OUTER");
InboundFeeEntity second = savedFee("CONFIRMED");
second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000"));
second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L);
second.setMeasurementSource("ACTUAL");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second);
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(
regionEstimate("200", "8", "1600"), 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("720.00"), 30002L, new BigDecimal("880.00"))));
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1");
ArgumentCaptor<InboundFeeEntity> fees = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository, times(2)).insertFee(fees.capture());
assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000");
assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000");
assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L);
assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER");
assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00");
assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000");
assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L);
assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL");
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()).allSatisfy(request -> {
assertThat(request.actualWeightKg()).isEqualByComparingTo("200");
assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO);
});
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("柜内任一已发送或客户确认费用阻断整组重算")
void rejectsContainerRecalculationWhenAnyFeeIsImmutable() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
InboundFeeEntity sent = savedFee("SENT");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"))
.isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认");
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString());
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪")
void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() {
readyRepository();
InboundPricingEntity pricedInbound = inbound("100", "0.1");
InboundPricingEntity pendingInbound = inbound("80", "0.1");
pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING");
InboundFeeEntity priced = savedFee("CALCULATED");
priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null);
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
}
@Test
@DisplayName("相同柜次重算触发标识重放不重复创建费用版本")
void replaysCompletedContainerRecalculationTrigger() {
InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger");
when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger"))
.thenReturn(completed);
service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger");
verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong());
verify(repository, never()).insertFee(any());
}
private void readyRepository() {
@@ -834,6 +951,20 @@ class FbaPricingServiceTest {
return value;
}
private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) {
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED");
value.setResultResourceId(containerBookingId);
try {
value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger)
.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new AssertionError(exception);
}
return value;
}
private InboundFeeEntity savedFee(String status) {
InboundFeeEntity value = new InboundFeeEntity();
value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1");