diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 33555998d..de489a7d2 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -26,7 +26,7 @@ | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | -| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | +| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | | 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index b81e9ce57..23b784239 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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 regionalMembers = members.stream() + .filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER) + .toList(); + List 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 allocationMembers = members.stream() + List allocationMembers = regionalMembers.stream() .map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity())) .toList(); List 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(); diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 202690ffa..e297c6c29 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 77b1c98a6..fa199eb69 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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> 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"); diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 3afa72303..1b89c166a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -482,11 +482,11 @@ Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURICo Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' -Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁' +Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁' Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段' Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程' -Assert-Contains $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号' -Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁' +Assert-Contains $loadingListsOpenApi 'PDA 创建前置条件是否全部满足,包含真实集装箱号已录入' 'PDA task 真实柜号门禁' +Assert-Contains $adminAgents '确认预装和创建 PDA 任务前必须补齐' 'Admin 真实柜号确认门禁' Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API' Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'