From ca61f79b6d68c1cbabb6077f28266ed33da443ac Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 28 Oct 2026 11:03:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=A4=96=E7=AE=B1=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E6=B8=85=E7=A9=BA=E5=B9=B6=E6=8F=90=E4=BE=9B=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E8=AE=A1=E8=B4=B9=E9=87=8F=E5=8F=AA=E8=AF=BB=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/docs/business-index.md | 2 +- .../PlatformFbaPricingController.java | 12 ++ .../baiyue/firstleg/dto/FbaPricingDtos.java | 19 +++ .../entity/InboundMeasurementItemEntity.java | 7 + .../firstleg/service/FbaPricingService.java | 133 +++++++++++++----- .../resources/openapi/fba-pricing-v1.yaml | 42 ++++++ .../controller/FbaPricingContractTest.java | 3 + ...InboundMeasurementItemPersistenceTest.java | 76 ++++++++++ .../service/FbaPricingServiceTest.java | 82 +++++++++++ .../service/MeasurementBatchServiceTest.java | 35 +++++ 10 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/InboundMeasurementItemPersistenceTest.java diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index 18c73cce2..c3f546d10 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -31,7 +31,7 @@ | 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 审核与初始计价 | `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`,默认 `ACTUAL` 不调整尺寸,可启用的调整算法为 `ADD/MULTIPLY` | -| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | +| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值;`POST /fees/preview` 与正式计价共用算法,只读返回当前选择值的计费量、参考单价和合计,不生成费用版本或改变履约、财务状态 | | 客户专属价格簿、柜级阶梯运费与调价边界 | `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` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java index c3c315a72..6f9dd8e5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -7,6 +7,8 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewResponse; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.service.FbaPricingService; @@ -34,6 +36,16 @@ public class PlatformFbaPricingController { @Resource private FbaPricingService service; + /** 预览当前重量、体积对应的计费量和价格,不生成费用版本或改变履约状态。 */ + @PostMapping("/preview") + @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')") + public ApiResponse preview( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @Valid @RequestBody FeePreviewRequest request) { + long scope = FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.preview(scope, inboundId, request)); + } + @PostMapping("/calculate") @PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')") @BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index e6401d4bc..3d6d33960 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -76,6 +76,25 @@ public final class FbaPricingDtos { } } + /** 当前输入的只读预览,人工单价仅用于预览金额,不写入费用。 */ + public record FeePreviewRequest( + LocalDateTime pricingAt, + @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, + @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, + @Size(max = 32) String weightSource, + @Size(max = 32) String volumeSource, + @DecimalMin(value = "0", inclusive = false) BigDecimal selectedUnitPrice + ) {} + + /** 当前选择值的只读计费预览,不包含费用版本或持久化标识。 */ + public record FeePreviewResponse( + BigDecimal actualWeightKg, BigDecimal actualVolumeCbm, + BigDecimal chargeableQuantity, String chargeUnit, + BigDecimal conversionFactor, Integer roundingScale, String roundingMode, + BigDecimal unitPrice, BigDecimal additionalAmount, BigDecimal totalAmount, + String currency, boolean priceMatched + ) {} + public record ManualQuoteRequest( @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index fe5aa1ee9..1bee1a9e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Getter; @@ -34,6 +36,7 @@ public class InboundMeasurementItemEntity { private BigDecimal volumeCbm; private BigDecimal totalVolumeCbm; private BigDecimal volumeWeightKg; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long chargeRuleId; private String coefficientMode; private String calculationRuleVersion; @@ -44,8 +47,12 @@ public class InboundMeasurementItemEntity { private BigDecimal outerLengthCm; private BigDecimal outerWidthCm; private BigDecimal outerHeightCm; + // 清空修正表示恢复对应实测边,必须显式写入 NULL,不能被默认的非空更新策略忽略。 + @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal manualOuterLengthCm; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal manualOuterWidthCm; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal manualOuterHeightCm; private String remark; private LocalDateTime createdAt; 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 831441e84..7a3e68948 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,8 @@ 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.dto.FbaPricingDtos.FeePreviewResponse; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; @@ -63,6 +65,41 @@ public class FbaPricingService { @Resource private ContainerTierAmountAllocator containerTierAllocator; + /** 按当前选择值只读预览,复用正式计价和柜级阶梯规则,不写费用、幂等或财务记录。 */ + public FeePreviewResponse preview(long tenantId, long inboundId, FeePreviewRequest request) { + InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); + requireMeasurementSources(tenantId, inboundId, request.weightSource(), request.volumeSource()); + BigDecimal weight = selectedWeight(inbound, request.selectedWeightKg()); + BigDecimal volume = selectedVolume(inbound, request.selectedVolumeCbm()); + PricingPolicy policy = pricingPolicy(inbound, + request.pricingAt() == null ? LocalDateTime.now(clock) : request.pricingAt()); + try { + ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume); + BigDecimal additional = estimate.rate().getAdditionalFee() == null ? BigDecimal.ZERO + : estimate.rate().getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP); + BigDecimal subtotal = request.selectedUnitPrice() == null + || request.selectedUnitPrice().compareTo(estimate.unitPrice()) == 0 + ? estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP) + : estimate.chargeableQuantity().multiply(request.selectedUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + return new FeePreviewResponse(weight, volume, estimate.chargeableQuantity(), estimate.chargeUnit(), + estimate.conversionFactor(), estimate.roundingScale(), estimate.roundingMode(), + estimate.unitPrice(), additional, + subtotal.add(additional), + estimate.currency(), true); + } catch (BizException exception) { + // 仅价格未命中且可人工定价的 FBA 返回计费量;其他业务错误继续明确拒绝。 + if (exception.getResultCode() != ResultCode.NOT_FOUND || !"FBA".equals(inbound.getDestinationType())) { + throw exception; + } + BigDecimal factor = new BigDecimal("KG".equals(policy.chargeMode()) ? "167" : "363"); + return new FeePreviewResponse(weight, volume, manualChargeableQuantity(policy.chargeMode(), weight, volume), + policy.chargeMode(), factor, 6, "HALF_UP", null, + BigDecimal.ZERO.setScale(MONEY_SCALE), request.selectedUnitPrice() == null ? null + : manualChargeableQuantity(policy.chargeMode(), weight, volume).multiply(request.selectedUnitPrice()) + .setScale(MONEY_SCALE, RoundingMode.HALF_UP), "CNY", false); + } + } + @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, LocalDateTime requestedAt, String idempotencyKey) { @@ -77,6 +114,7 @@ public class FbaPricingService { selectedVolumeCbm, null, null, idempotencyKey); } + /** 复用预览的计价规则生成费用版本,仅正式确认流程写入计费事实。 */ @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, LocalDateTime requestedAt, BigDecimal selectedWeightKg, @@ -105,38 +143,11 @@ public class FbaPricingService { throw conflict("客户已确认,不允许再修改或重新发送费用"); } } - var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); - boolean actualMeasurementSource = "ACTUAL".equalsIgnoreCase(weightSource) - || "ACTUAL".equalsIgnoreCase(volumeSource); - boolean outerMeasurementSource = "OUTER".equalsIgnoreCase(weightSource) - || "OUTER".equalsIgnoreCase(volumeSource); - if ((actualMeasurementSource || outerMeasurementSource) && measurement == null) { - throw conflict(actualMeasurementSource - ? "实际测量数据不存在,不能生成对客费用" - : "外箱规测量批次不存在,不能生成对客费用"); - } - if (actualMeasurementSource || outerMeasurementSource) { - var measurementItems = measurementRepository.listItems(tenantId, measurement.getId()); - if (actualMeasurementSource && measurementItems.isEmpty()) { - throw conflict("实际测量明细不存在,不能生成对客费用"); - } - boolean incomplete = outerMeasurementSource && (measurementItems.isEmpty() || measurementItems.stream().anyMatch(item -> - item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0 - || item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0 - || item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0 - || !StringUtils.hasText(item.getCalculationRuleVersion()))); - if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); - } + var measurement = requireMeasurementSources(tenantId, inboundId, weightSource, volumeSource); PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); - ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( - inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), - destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), policy.chargeMode(), - selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, - selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); - BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); - BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP); - estimate = applyContainerCustomerTier(inbound, estimate, weight, volume); + BigDecimal weight = selectedWeight(inbound, selectedWeightKg); + BigDecimal volume = selectedVolume(inbound, selectedVolumeCbm); + ActivePriceBookEstimator.Estimate estimate = estimateFee(tenantId, inbound, policy, weight, volume); RateRow rate = estimate.rate(); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; @@ -179,6 +190,7 @@ public class FbaPricingService { return response(inbound, fee, rate.getUnitPrice(), nextStatus); } + /** 价格未命中时按人工单价生成正式费用,计费量与只读预览保持一致。 */ @Transactional public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId, ManualQuoteRequest request, String idempotencyKey) { @@ -219,9 +231,7 @@ public class FbaPricingService { BigDecimal volume = request.selectedVolumeCbm().setScale(4, RoundingMode.HALF_UP); String unit = policy.chargeMode(); BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363"); - BigDecimal quantity = ("KG".equals(unit) ? weight.max(volume.multiply(factor)) - : volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP))) - .setScale(6, RoundingMode.HALF_UP); + BigDecimal quantity = manualChargeableQuantity(unit, weight, volume); BigDecimal subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); int version = repository.nextFeeVersion(tenantId, inboundId); InboundFeeEntity fee = new InboundFeeEntity(); @@ -267,6 +277,61 @@ public class FbaPricingService { return response(inbound, fee, request.unitPrice(), nextStatus); } + /** 预览和正式计价共用价格匹配与柜级阶梯分摊,避免前端复制计价公式。 */ + private ActivePriceBookEstimator.Estimate estimateFee(long tenantId, InboundPricingEntity inbound, + PricingPolicy policy, BigDecimal weight, BigDecimal volume) { + ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( + inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), + destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, + pricingTaxMode(inbound), policy.chargeMode(), weight, volume)); + return applyContainerCustomerTier(inbound, estimate, weight, volume); + } + + /** 计费重量统一保留三位精度,预览与最终费用使用同一基数。 */ + private BigDecimal selectedWeight(InboundPricingEntity inbound, BigDecimal selected) { + BigDecimal weight = selected == null ? inbound.getActualNetWeightKg() : selected; + if (weight == null || weight.signum() <= 0) throw conflict("计费重量必须大于0"); + return weight.setScale(3, RoundingMode.HALF_UP); + } + + /** 计费体积统一保留四位精度,预览与最终费用使用同一基数。 */ + private BigDecimal selectedVolume(InboundPricingEntity inbound, BigDecimal selected) { + BigDecimal volume = selected == null ? inbound.getActualVolumeCbm() : selected; + if (volume == null || volume.signum() <= 0) throw conflict("计费体积必须大于0"); + return volume.setScale(4, RoundingMode.HALF_UP); + } + + /** 人工定价的重量与体积换算规则由服务端统一提供给预览和正式报价。 */ + private BigDecimal manualChargeableQuantity(String unit, BigDecimal weight, BigDecimal volume) { + BigDecimal factor = new BigDecimal("KG".equals(unit) ? "167" : "363"); + return ("KG".equals(unit) ? weight.max(volume.multiply(factor)) + : volume.max(weight.divide(factor, 12, RoundingMode.HALF_UP))) + .setScale(6, RoundingMode.HALF_UP); + } + + /** 仅验证测量来源及保存快照完整性,不写入或重新计算测量数据。 */ + private com.baiyue.firstleg.entity.InboundMeasurementEntity requireMeasurementSources( + long tenantId, long inboundId, String weightSource, String volumeSource) { + var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); + boolean actual = "ACTUAL".equalsIgnoreCase(weightSource) || "ACTUAL".equalsIgnoreCase(volumeSource); + boolean outer = "OUTER".equalsIgnoreCase(weightSource) || "OUTER".equalsIgnoreCase(volumeSource); + if ((actual || outer) && measurement == null) { + throw conflict(actual ? "实际测量数据不存在,不能生成对客费用" : "外箱规测量批次不存在,不能生成对客费用"); + } + if (actual || outer) { + var items = measurementRepository.listItems(tenantId, measurement.getId()); + if (actual && items.isEmpty()) throw conflict("实际测量明细不存在,不能生成对客费用"); + if (outer && (items.isEmpty() || items.stream().anyMatch(item -> + item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0 + || item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0 + || item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0 + || !StringUtils.hasText(item.getCalculationRuleVersion())))) { + throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); + } + } + return measurement; + } + private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, BigDecimal currentWeight, BigDecimal currentVolume) { diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 68c7e1349..610ce0bc4 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -147,6 +147,13 @@ paths: description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。 requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}} responses: {'200': {description: 成功或业务失败}} + /api/platform/v1/inbounds/{inboundId}/fees/preview: + post: + summary: 只读预览当前计费重量和体积 + description: 不生成费用版本,不修改测量、费用、履约状态或财务记录;与正式计价共用价格匹配及柜级阶梯规则。FBA价格未命中时返回 priceMatched=false、计费量和空单价,供人工单价预览使用;其他错误明确返回。需要 firstleg:inbound:confirm-send-fee 或 firstleg:fba-pricing:calculate 权限。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeePreviewApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: summary: 按审核通过测量快照试算 FBA 费用 @@ -296,6 +303,32 @@ components: updatedAt: {type: string, format: date-time, nullable: true} deleted: {type: boolean, nullable: true} version: {type: integer, nullable: true} + FeePreviewApiResponse: + type: object + required: [code, message, data] + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/FeePreviewResponse'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time, nullable: true} + FeePreviewResponse: + type: object + required: [actualWeightKg, actualVolumeCbm, chargeableQuantity, chargeUnit, conversionFactor, roundingScale, roundingMode, unitPrice, additionalAmount, totalAmount, currency, priceMatched] + properties: + actualWeightKg: {type: number} + actualVolumeCbm: {type: number} + chargeableQuantity: {type: number} + chargeUnit: {type: string, enum: [KG, CBM]} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + unitPrice: {type: number, nullable: true} + additionalAmount: {type: number} + totalAmount: {type: number, nullable: true, description: 按本次人工单价或参考单价计算并取整后的费用合计;两种单价都缺失时为空} + currency: {type: string} + priceMatched: {type: boolean} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -503,6 +536,15 @@ components: selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0} unitPrice: {type: number, exclusiveMinimum: true, minimum: 0} reason: {type: string, minLength: 2, maxLength: 500} + FeePreviewRequest: + type: object + properties: + pricingAt: {type: string, format: date-time, nullable: true} + selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true} + weightSource: {type: string, maxLength: 32, nullable: true} + volumeSource: {type: string, maxLength: 32, nullable: true} + selectedUnitPrice: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 本次人工单价;为空时使用当前参考单价,仅用于只读金额预览} CalculateRequest: type: object properties: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index b989c459d..cfb25680d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -19,6 +19,7 @@ class FbaPricingContractTest { @Test @DisplayName("确认和发送费用兼容新的组合权限与原有权限") void allowsCombinedConfirmSendPermissionAndLegacyPermissions() { + assertPricingAuthority("preview", "hasAnyAuthority('firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:calculate')"); assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:fba-pricing:confirm')"); assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee'," @@ -58,6 +59,8 @@ class FbaPricingContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi) .contains("/api/platform/v1/fba-pricing/customer-rules/versions:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/preview:") + .contains("不生成费用版本") .contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:") .contains("/api/platform/v1/fba-pricing/carrier-rates/versions:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundMeasurementItemPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundMeasurementItemPersistenceTest.java new file mode 100644 index 000000000..51d5804c8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundMeasurementItemPersistenceTest.java @@ -0,0 +1,76 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 使用真实 MyBatis 更新语句验证清空外箱修正,不以 Mock 掩盖 NULL 被忽略的问题。 */ +class InboundMeasurementItemPersistenceTest { + private SqlSession session; + private JdbcTemplate sql; + private InboundMeasurementRepository repository; + + /** 每项用例使用隔离内存表,仅保留本轮更新涉及的列。 */ + @BeforeEach + void setUp() { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:measurement-clear-" + UUID.randomUUID() + ";MODE=MySQL"); + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); + config.addMapper(InboundMeasurementItemMapper.class); + session = new MybatisSqlSessionFactoryBuilder().build(config).openSession(true); + session.getConnection(); + sql = new JdbcTemplate(ds); + sql.execute("CREATE TABLE fl_inbound_measurement_item(id BIGINT PRIMARY KEY, tenant_id BIGINT, " + + "manual_outer_length_cm DECIMAL(12,3), manual_outer_width_cm DECIMAL(12,3), " + + "manual_outer_height_cm DECIMAL(12,3), charge_rule_id BIGINT)"); + sql.update("INSERT INTO fl_inbound_measurement_item VALUES (88, 10001, 63, 53, 53, 77)"); + repository = new InboundMeasurementRepository(); + ReflectionTestUtils.setField(repository, "items", session.getMapper(InboundMeasurementItemMapper.class)); + } + + /** 关闭隔离数据库的最后一个会话,释放本项测试资源。 */ + @AfterEach + void tearDown() { + if (session != null) session.close(); + } + + /** 单边清空与全部清空都须真正持久化,并可解除已取消的系数规则关联。 */ + @ParameterizedTest + @CsvSource({"false,true,true", "true,false,true", "true,true,false", "false,false,false"}) + void persistsClearedWarehouseDimensions(boolean keepLength, boolean keepWidth, boolean keepHeight) { + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(88L); + item.setTenantId(10001L); + item.setManualOuterLengthCm(keepLength ? new BigDecimal("63") : null); + item.setManualOuterWidthCm(keepWidth ? new BigDecimal("53") : null); + item.setManualOuterHeightCm(keepHeight ? new BigDecimal("53") : null); + item.setChargeRuleId(null); + + assertThat(repository.updateItem(item)).isTrue(); + var stored = sql.queryForMap("SELECT * FROM fl_inbound_measurement_item WHERE id=88"); + assertThat(stored.get("MANUAL_OUTER_LENGTH_CM")).isEqualTo(keepLength ? new BigDecimal("63.000") : null); + assertThat(stored.get("MANUAL_OUTER_WIDTH_CM")).isEqualTo(keepWidth ? new BigDecimal("53.000") : null); + assertThat(stored.get("MANUAL_OUTER_HEIGHT_CM")).isEqualTo(keepHeight ? new BigDecimal("53.000") : null); + assertThat(stored.get("CHARGE_RULE_ID")).isNull(); + + item.setTenantId(20002L); + assertThat(repository.updateItem(item)).isFalse(); + } +} 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 170e617d6..6361760e3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeePreviewRequest; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; @@ -70,6 +71,87 @@ class FbaPricingServiceTest { @MockBean private ContainerTierAmountAllocator containerTierAllocator; @Resource private FbaPricingService service; + @Test + /** 预览只按当前选择值估价,不复用旧费用,也不产生任何计费写入。 */ + void previewsSelectedOuterVolumeWithoutWritingFeeState() { + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(901L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setOuterLengthCm(new BigDecimal("63")); + item.setOuterWidthCm(new BigDecimal("53")); + item.setOuterHeightCm(new BigDecimal("53")); + item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1"); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 901L)).thenReturn(List.of(item)); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "650.298000", "13.6", "8844.05", "0")); + + var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW, + new BigDecimal("570.500"), new BigDecimal("3.8940"), "ACTUAL", "OUTER", null)); + + assertThat(preview.actualVolumeCbm()).isEqualByComparingTo("3.8940"); + assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000"); + assertThat(preview.totalAmount()).isEqualByComparingTo("8844.05"); + assertThat(preview.priceMatched()).isTrue(); + verify(priceBookEstimator).estimate(eq(TENANT), argThat(request -> + request.actualWeightKg().compareTo(new BigDecimal("570.5")) == 0 + && request.actualVolumeCbm().compareTo(new BigDecimal("3.894")) == 0)); + verify(repository, never()).insertFee(any()); + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).nextFeeVersion(anyLong(), anyLong()); + verify(repository, never()).markInboundCalculated(anyLong(), anyLong(), any(), any(), any(), any(), any()); + org.mockito.Mockito.verifyNoInteractions(financeOutbox, feeDetails); + } + + @Test + /** FBA 未命中价格时仍返回服务端计费量,供人工单价预览使用。 */ + void previewsManualQuantityWhenPriceIsMissing() { + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("570.500", "3.6894")); + when(priceBookEstimator.estimate(anyLong(), any())) + .thenThrow(new BizException(ResultCode.NOT_FOUND, "价格未命中")); + + var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW, + new BigDecimal("570.5"), new BigDecimal("3.894"), null, null, null)); + + assertThat(preview.priceMatched()).isFalse(); + assertThat(preview.unitPrice()).isNull(); + assertThat(preview.chargeableQuantity()).isEqualByComparingTo("650.298000"); + assertThat(preview.additionalAmount()).isZero(); + assertThat(preview.totalAmount()).isNull(); + verify(repository, never()).insertFee(any()); + } + + @Test + /** 外箱快照缺失时拒绝预览,不把当前规则的前端推导结果视作已保存事实。 */ + void rejectsOuterPreviewWithoutSavedMeasurementSnapshot() { + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("100", "1")); + assertThatThrownBy(() -> service.preview(TENANT, INBOUND, + new FeePreviewRequest(NOW, new BigDecimal("100"), BigDecimal.ONE, "ACTUAL", "OUTER", null))) + .isInstanceOf(BizException.class).hasMessageContaining("测量数据不存在"); + org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails); + } + + @Test + /** 预览不能读取其他客户的入库单。 */ + void rejectsCrossCustomerPreview() { + assertThatThrownBy(() -> service.preview(10002L, INBOUND, new FeePreviewRequest(NOW, null, null, null, null, null))) + .isInstanceOf(BizException.class).hasMessageContaining("入库单不存在"); + org.mockito.Mockito.verifyNoInteractions(priceBookEstimator, financeOutbox, feeDetails); + } + + @Test + /** 人工单价的金额也由服务端精确取整,不在页面重复实现舍入。 */ + void previewsManualUnitPriceUsingExactMoneyRounding() { + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("1", "0.001")); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn(estimate("KG", "1", "2", "2", "0")); + var preview = service.preview(TENANT, INBOUND, new FeePreviewRequest(NOW, + BigDecimal.ONE, new BigDecimal("0.001"), null, null, new BigDecimal("1.005"))); + assertThat(preview.unitPrice()).isEqualByComparingTo("2"); + assertThat(preview.totalAmount()).isEqualByComparingTo("1.01"); + verify(repository, never()).insertFee(any()); + } + @Test @DisplayName("客户B不能读取客户A的费用快照") void rejectsCrossCustomerFeeLookup() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index a0e03a570..22a5e95ab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -174,6 +174,41 @@ class MeasurementBatchServiceTest { verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); } + /** 全部清空人工修正后,计算与批次汇总须恢复设备实测尺寸。 */ + @Test + void clearsWarehouseOverridesAndRecalculatesFromDeviceDimensions() { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(100L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setVersion(3); + measurement.setCreatedBy(9L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(200L); + item.setTenantId(1L); + item.setItemNo("B1"); + item.setPackageCount(22); + item.setLengthCm(new BigDecimal("62")); + item.setWidthCm(new BigDecimal("52")); + item.setHeightCm(new BigDecimal("52")); + item.setManualOuterLengthCm(new BigDecimal("63")); + item.setManualOuterWidthCm(new BigDecimal("53")); + item.setManualOuterHeightCm(new BigDecimal("53")); + item.setCoefficientMode("ACTUAL"); + when(repository.updateItem(item)).thenReturn(true); + + service.recalculateOuterDimensions(1L, measurement, List.of(item), 200L, null, null, null); + + assertThat(item.getManualOuterLengthCm()).isNull(); + assertThat(item.getManualOuterWidthCm()).isNull(); + assertThat(item.getManualOuterHeightCm()).isNull(); + assertThat(item.getOuterLengthCm()).isEqualByComparingTo("62.000"); + assertThat(item.getOuterWidthCm()).isEqualByComparingTo("52.000"); + assertThat(item.getOuterHeightCm()).isEqualByComparingTo("52.000"); + assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("3.6894"); + verify(repository).updateItem(item); + } + @Test /** 客户未启用算法时,预置系数不能放大仓库修正的实际外箱体积。 */ void savedCoefficientDoesNotOverrideActualDefaultMode() {