This commit is contained in:
2026-09-16 19:32:52 +08:00
parent dfcd280dd7
commit 8797c0bef9
5 changed files with 88 additions and 18 deletions
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
@@ -278,29 +279,33 @@ public class FbaPricingService {
if ("COMPLETED".equals(token.getStatus())) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0);
}
BigDecimal aggregate = members.stream().map(ContainerFeeMember::chargeableQuantity)
List<ContainerFeeMember> regionalMembers = members.stream()
.filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER)
.toList();
List<ContainerFeeMember> unlockedRegionalMembers = regionalMembers.stream()
.filter(member -> !member.locked()).toList();
if (unlockedRegionalMembers.isEmpty()) {
complete(token, containerBookingId, now);
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
}
BigDecimal aggregate = regionalMembers.stream().map(ContainerFeeMember::chargeableQuantity)
.reduce(BigDecimal.ZERO, BigDecimal::add);
List<ContainerTierAmountAllocator.Member> allocationMembers = members.stream()
List<ContainerTierAmountAllocator.Member> allocationMembers = regionalMembers.stream()
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
.toList();
List<ContainerFeeQuote> quotes = new ArrayList<>();
for (ContainerFeeMember member : unlockedMembers) {
for (ContainerFeeMember member : unlockedRegionalMembers) {
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));
if (estimate.priceModel() != PriceModel.REGION_WEIGHT_TIER) {
throw conflict("柜内区域阶梯报价模型已变化,请重新试算费用后重试");
}
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());
@@ -312,6 +317,18 @@ public class FbaPricingService {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.RECALCULATED, quotes.size());
}
private PriceModel priceModel(InboundFeeEntity fee) {
CarrierFbaRateEntity rate = repository.findRate(fee.getCarrierRateId());
if (rate == null || !StringUtils.hasText(rate.getPriceModel())) {
throw conflict("费用快照引用的报价模型不存在,不能重算阶梯费用");
}
try {
return PriceModel.valueOf(rate.getPriceModel());
} catch (IllegalArgumentException exception) {
throw conflict("费用快照引用的报价模型无效,不能重算阶梯费用");
}
}
private void persistContainerRecalculation(long tenantId, ContainerFeeQuote quote,
BigDecimal subtotal, LocalDateTime now) {
ContainerFeeMember member = quote.member();
@@ -66,7 +66,7 @@ paths:
/api/platform/v1/first-leg/loading-plans/{planId}/confirm:
post:
summary: 原子确认整份预装计划
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED;真实集装箱号可在PDA确认装柜完成前补录。任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED,且真实集装箱号已经补录。任一条件不满足均不写入,也不创建 PDA task。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。
x-permission: platform:firstleg:preload:edit
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
@@ -329,11 +329,11 @@ components:
pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态}
confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态}
canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口}
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;不依赖真实集装箱号,task 已创建后仍为 true}
readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足,包含真实集装箱号已录入}
blockingReasons:
type: array
description: 阻止计划确认的稳定原因码
items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, CONTAINER_NO_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
ConfirmPreloadRequest:
type: object
required: [expectedVersion, operationReason]
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
@@ -770,6 +771,7 @@ class FbaPricingServiceTest {
when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
@@ -824,6 +826,7 @@ class FbaPricingServiceTest {
when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
@@ -847,6 +850,50 @@ class FbaPricingServiceTest {
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("同柜混合固定仓价和区域阶梯时仅重算区域阶梯成员")
void recalculatesOnlyRegionalMembersWhenContainerUsesMixedPriceModels() {
readyRepository();
InboundPricingEntity regionalInbound = inbound("90", "0.1");
InboundPricingEntity fixedInbound = inbound("110", "0.1");
fixedInbound.setId(30002L); fixedInbound.setInboundCode("RK-FIXED");
InboundFeeEntity regional = savedFee("CALCULATED");
regional.setActualWeightKg(new BigDecimal("90")); regional.setActualVolumeCbm(new BigDecimal("0.1"));
InboundFeeEntity fixed = savedFee("CALCULATED");
fixed.setId(502L); fixed.setInboundId(30002L); fixed.setCarrierRateId(203L);
fixed.setActualWeightKg(new BigDecimal("110")); fixed.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "90", "0.1"), memberRow(901L, 30002L, "110", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(regionalInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(fixedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(regional);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(fixed);
when(repository.findRate(201L)).thenReturn(storedRate(201L, PriceModel.REGION_WEIGHT_TIER));
when(repository.findRate(203L)).thenReturn(storedRate(203L, PriceModel.FBA_WAREHOUSE_RATE));
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(INBOUND), anyString(),
any(), any(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("90", "8", "720"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("90"), new BigDecimal("720"),
Map.of(INBOUND, new BigDecimal("720.00"))));
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "mixed-models");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED);
assertThat(result.recalculatedCount()).isEqualTo(1);
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(repository, never()).supersedeCurrent(TENANT, 502L, "CALCULATED");
ArgumentCaptor<List<ContainerTierAmountAllocator.Member>> members = ArgumentCaptor.forClass(List.class);
verify(containerTierAllocator).allocate(members.capture(), eq(new BigDecimal("8")));
assertThat(members.getValue()).extracting(ContainerTierAmountAllocator.Member::inboundId)
.containsExactly(INBOUND);
}
@Test
@DisplayName("柜内费用全部锁定时关系变更无需重算且不报错")
void skipsContainerRecalculationWhenEveryFeeIsImmutable() {
@@ -973,6 +1020,12 @@ class FbaPricingServiceTest {
new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP");
}
private CarrierFbaRateEntity storedRate(long id, PriceModel model) {
CarrierFbaRateEntity rate = new CarrierFbaRateEntity();
rate.setId(id); rate.setPriceModel(model.name());
return rate;
}
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED");