From b2e4f0d246a889331d18cac8d6ec827099df9943 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 28 Oct 2026 10:30:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=BE=E5=A4=87=E4=B8=8E?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E6=B5=8B=E9=87=8F=E9=BB=98=E8=AE=A4=E7=AE=97?= =?UTF-8?q?=E6=B3=95=E5=92=8C=E5=A4=96=E7=AE=B1=E8=AE=A1=E8=B4=B9=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/docs/business-index.md | 4 +- .../service/CartonMeasurementService.java | 35 ++----- .../CustomerDimensionWeightingService.java | 38 ++++---- .../service/MeasurementBatchService.java | 63 +++++++----- .../main/resources/openapi/firstleg-v1.yaml | 2 +- .../service/CartonMeasurementServiceTest.java | 70 ++++++++++++- ...CustomerDimensionWeightingServiceTest.java | 29 +++--- .../service/MeasurementBatchServiceTest.java | 97 ++++++++++++++++++- 8 files changed, 243 insertions(+), 95 deletions(-) diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index e9b8b8f95..18c73cce2 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -30,7 +30,7 @@ | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 称重确认与整票库位块 | `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`,默认算法只允许 `ADD/MULTIPLY` | +| 客户四档箱规与默认算法 | `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`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | 客户专属价格簿、柜级阶梯运费与调价边界 | `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` | @@ -76,7 +76,7 @@ - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 -- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 +- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ACTUAL` 不调整尺寸,仓库修正值替换对应实测边;`ADD` 以修正后的实测尺寸为基准对三个维度分别增加命中箱规的尺寸值;`MULTIPLY` 使用选定的启用系数配置,未指定配置时取当前客户唯一默认配置,由服务端按规格档位选择系数并分别作用于长宽高,体积按系数三次方变化。设备与人工录入共用计算入口,行明确选择的算法优先,否则仅使用客户默认算法;预置或保存系数配置不等于启用该算法。测量行必须保存实际尺寸、选用规则和算法快照,修改确认箱数复用已保存单箱快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 9e8086042..1c9370c79 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -44,21 +44,22 @@ public class CartonMeasurementService { private final ObjectProvider resolverProvider; private final MeasurementImageClient imageClient; private final TransactionTemplate transactionTemplate; - private final CustomerDimensionWeightingService dimensionWeighting; + private final MeasurementBatchService measurementBatchService; @Value("${baiyue.security.internal-service-token}") private String internalToken; + /** 设备及手工单箱录入复用测量批次的计算规则。 */ public CartonMeasurementService(InboundMeasurementRepository repository, ObjectProvider resolverProvider, MeasurementImageClient imageClient, TransactionTemplate transactionTemplate, - CustomerDimensionWeightingService dimensionWeighting) { + MeasurementBatchService measurementBatchService) { this.repository = repository; this.resolverProvider = resolverProvider; this.imageClient = imageClient; this.transactionTemplate = transactionTemplate; - this.dimensionWeighting = dimensionWeighting; + this.measurementBatchService = measurementBatchService; } public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) { @@ -178,6 +179,7 @@ public class CartonMeasurementService { return result(measurement, item, false); } + /** 保存设备原始尺寸与证据,按客户默认算法或已保存的行算法生成计费快照。 */ private UpsertOutcome persist(long tenantId, long operatorId, CartonLabelBindingResolver.Binding binding, String boxCode, CartonMeasurementCommand command, long imageFileId, @@ -228,7 +230,7 @@ public class CartonMeasurementService { item.setMachineNo(command.machineNo().trim()); item.setDeviceMeasuredAt(command.measuredAt()); item.setUpdatedAt(LocalDateTime.now()); - applyCalculatedValues(item, dimensionWeighting.load(tenantId)); + measurementBatchService.calculateCartonDimensions(item); if (created) { repository.insertItem(item); @@ -265,33 +267,12 @@ public class CartonMeasurementService { return measurement; } - private void applyCalculatedValues(InboundMeasurementItemEntity item, - CustomerDimensionWeightingService.RuleSet dimensionRules) { - CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolveDeviceMeasurement( - item.getLengthCm(), item.getWidthCm(), item.getHeightCm()); - BigDecimal unitRaw = weighted.rawVolumeCbm(); - BigDecimal unitVolume = weighted.unitVolumeCbm(); - BigDecimal count = BigDecimal.valueOf(item.getPackageCount()); - BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP); - BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY); - item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm()) - .multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING)); - item.setUnitVolumeCbm(unitVolume); - item.setVolumeRawCbm(totalRaw); - item.setVolumeCbm(totalVolume); - item.setTotalVolumeCbm(totalVolume); - item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); - item.setCalculationRuleVersion(weighted.calculationRuleVersion()); - item.setOuterLengthCm(weighted.outerLengthCm()); - item.setOuterWidthCm(weighted.outerWidthCm()); - item.setOuterHeightCm(weighted.outerHeightCm()); - } - + /** 仅修改确认箱数时复用已保存的单箱快照,不重新套用当前客户规则。 */ private void applyCalculatedValuesFromSnapshot(InboundMeasurementItemEntity item) { if (!positive(item.getOuterLengthCm()) || !positive(item.getOuterWidthCm()) || !positive(item.getOuterHeightCm()) || !positive(item.getUnitVolumeCbm()) || !StringUtils.hasText(item.getCalculationRuleVersion())) { - applyCalculatedValues(item, dimensionWeighting.load(item.getTenantId())); + measurementBatchService.calculateCartonDimensions(item); return; } BigDecimal count = BigDecimal.valueOf(item.getPackageCount()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java index a4c1c73ce..4dd3ba850 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java @@ -25,6 +25,7 @@ public class CustomerDimensionWeightingService { this.repository = repository; } + /** 加载完整四档箱规及客户明确选择的默认算法,配置存在不代表算法已启用。 */ public RuleSet load(long tenantId) { List values = repository.findAll(tenantId); if (values == null || values.size() != CustomerDimensionBoxType.values().length @@ -33,7 +34,14 @@ public class CustomerDimensionWeightingService { .collect(java.util.stream.Collectors.toSet()).size() != CustomerDimensionBoxType.values().length) { throw new BizException(ResultCode.CONFLICT, "客户箱规尺寸配置不完整,不能计算计费体积"); } - return new RuleSet(values.stream().map(Rule::from).toList()); + List modes = values.stream().map(value -> value.getDefaultCalculationMode() == null + || value.getDefaultCalculationMode().isBlank() ? "ACTUAL" + : value.getDefaultCalculationMode().trim().toUpperCase(java.util.Locale.ROOT)) + .distinct().toList(); + if (modes.size() != 1 || !List.of("ACTUAL", "ADD", "MULTIPLY").contains(modes.get(0))) { + throw new BizException(ResultCode.CONFLICT, "客户默认箱规计算方式配置不一致或无效"); + } + return new RuleSet(values.stream().map(Rule::from).toList(), modes.get(0)); } private static boolean invalidRule(CustomerDimensionConfigEntity value) { @@ -48,14 +56,22 @@ public class CustomerDimensionWeightingService { public static final class RuleSet { private final List rules; + private final String defaultCalculationMode; - private RuleSet(List rules) { + /** 保存同一客户的箱规和默认算法,供设备及人工测量统一使用。 */ + private RuleSet(List rules, String defaultCalculationMode) { + this.defaultCalculationMode = defaultCalculationMode; this.rules = rules.stream() .sorted(Comparator.comparing(Rule::innerVolume) .thenComparing(rule -> rule.boxType().ordinal())) .toList(); } + /** 返回客户已启用的默认算法;ACTUAL 表示尺寸不调整。 */ + public String defaultCalculationMode() { + return defaultCalculationMode; + } + public WeightedDimensions resolve(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { List measured = sortedDimensions(lengthCm, widthCm, heightCm); return rules.stream() @@ -66,24 +82,6 @@ public class CustomerDimensionWeightingService { .weightedActualDimensions(lengthCm, widthCm, heightCm)); } - public WeightedDimensions resolveDeviceMeasurement(BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { - List measured = sortedDimensions(lengthCm, widthCm, heightCm); - return rules.stream() - .filter(rule -> rule.contains(measured)) - .findFirst() - .map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm)) - .orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm)); - } - - private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { - BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm) - .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); - return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm, - rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP), - "ACTUAL_DIMENSION_V1"); - } } public record WeightedDimensions( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 711862eab..bb4c2b6f1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -56,6 +56,7 @@ public class MeasurementBatchService { @Resource private FbaPricingRepository pricingRepository; + /** 按已选算法保存测量批次及计算快照,不推进履约状态。 */ @Transactional public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) { var inbound = repository.findInbound(command.tenantId(), command.inboundId()); @@ -68,7 +69,7 @@ public class MeasurementBatchService { } validateSource(command); List calculatedItems = calculateItems( - command, dimensionWeighting.load(command.tenantId())); + command.tenantId(), command.items(), dimensionWeighting.load(command.tenantId())); InboundMeasurementEntity measurement; if (command.measurementId() == null) { @@ -93,6 +94,7 @@ public class MeasurementBatchService { return measurement; } + /** 修改批次算法时,以实测及人工修正基数重算,避免重复叠加。 */ void recalculateStrategy(long tenantId, InboundMeasurementEntity measurement, List existingItems, String coefficientMode, Long chargeRuleId) { @@ -112,7 +114,7 @@ public class MeasurementBatchService { item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(), item.getManualOuterHeightCm(), chargeRuleId, coefficientMode, item.getRemark())).toList()); List recalculated = calculateItems( - command, dimensionWeighting.load(tenantId)); + tenantId, command.items(), dimensionWeighting.load(tenantId)); for (int index = 0; index < existingItems.size(); index++) { InboundMeasurementItemEntity target = existingItems.get(index); InboundMeasurementItemEntity source = recalculated.get(index); @@ -121,6 +123,7 @@ public class MeasurementBatchService { applyTotals(measurement, existingItems); } + /** 按保存的算法重算单行外箱修正,并更新批次汇总。 */ void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement, List existingItems, long itemId, BigDecimal outerLengthCm, BigDecimal outerWidthCm, @@ -143,14 +146,42 @@ public class MeasurementBatchService { outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(), target.getRemark()))); InboundMeasurementItemEntity recalculated = calculateItems( - command, dimensionWeighting.load(tenantId)).get(0); + tenantId, command.items(), dimensionWeighting.load(tenantId)).get(0); applyRecalculatedItem(target, recalculated, true); applyTotals(measurement, existingItems); } + /** 设备首次测量与复测共用批次算法,保留已有人工修正及明确选择的算法。 */ + void calculateCartonDimensions(InboundMeasurementItemEntity item) { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), + item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getSourceImageFileId(), + item.getOverLimitWarning(), item.getManualOuterLengthCm(), item.getManualOuterWidthCm(), + item.getManualOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark()); + InboundMeasurementItemEntity calculated = calculateItems(item.getTenantId(), List.of(source), + dimensionWeighting.load(item.getTenantId())).get(0); + copyCalculatedValues(item, calculated); + } + + /** 保存重算后的行,保留箱码、设备证据和批次身份。 */ private void applyRecalculatedItem(InboundMeasurementItemEntity target, InboundMeasurementItemEntity source, boolean updateManualDimensions) { + copyCalculatedValues(target, source); + if (updateManualDimensions) { + target.setManualOuterLengthCm(source.getManualOuterLengthCm()); + target.setManualOuterWidthCm(source.getManualOuterWidthCm()); + target.setManualOuterHeightCm(source.getManualOuterHeightCm()); + } + target.setUpdatedAt(LocalDateTime.now()); + if (!repository.updateItem(target)) { + throw concurrentModification(); + } + } + + /** 统一复制尺寸、体积、材积重及算法快照,不覆盖原始测量值。 */ + private void copyCalculatedValues(InboundMeasurementItemEntity target, + InboundMeasurementItemEntity source) { target.setGirthCm(source.getGirthCm()); target.setUnitVolumeCbm(source.getUnitVolumeCbm()); target.setVolumeRawCbm(source.getVolumeRawCbm()); @@ -163,15 +194,6 @@ public class MeasurementBatchService { target.setOuterLengthCm(source.getOuterLengthCm()); target.setOuterWidthCm(source.getOuterWidthCm()); target.setOuterHeightCm(source.getOuterHeightCm()); - if (updateManualDimensions) { - target.setManualOuterLengthCm(source.getManualOuterLengthCm()); - target.setManualOuterWidthCm(source.getManualOuterWidthCm()); - target.setManualOuterHeightCm(source.getManualOuterHeightCm()); - } - target.setUpdatedAt(LocalDateTime.now()); - if (!repository.updateItem(target)) { - throw concurrentModification(); - } } /** @@ -257,10 +279,12 @@ public class MeasurementBatchService { measurement.setActualGoodsName(command.actualGoodsName()); } + /** 明确的行算法优先;未选择时仅使用客户默认算法,不因预置系数而自动启用。 */ private List calculateItems( - MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) { + long tenantId, List sourceItems, + CustomerDimensionWeightingService.RuleSet dimensionRules) { Set itemNumbers = new HashSet<>(); - return command.items().stream().map(source -> { + return sourceItems.stream().map(source -> { if (!itemNumbers.add(source.itemNo())) { throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复"); } @@ -279,10 +303,9 @@ public class MeasurementBatchService { CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve( baseLength, baseWidth, baseHeight); String requestedMode = normalizeMode(source.coefficientMode()); - CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode) - ? requireCoefficientRule(command.tenantId(), source.chargeRuleId()) - : requestedMode == null ? findDefaultCoefficientRule(command.tenantId()) : null; - String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode; + String coefficientMode = requestedMode == null ? dimensionRules.defaultCalculationMode() : requestedMode; + CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(coefficientMode) + ? requireCoefficientRule(tenantId, source.chargeRuleId()) : null; boolean useDimensionRule = "ADD".equals(coefficientMode); BigDecimal addition = weighted.edgeAdditionCm() == null ? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2)); @@ -374,10 +397,6 @@ public class MeasurementBatchService { return normalized; } - private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId) { - return pricingRepository.findDefaultRule(tenantId, tenantId); - } - private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long ruleId) { CustomerChargeRuleEntity rule = ruleId == null ? pricingRepository.findDefaultRule(tenantId, tenantId) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 69dadcc0c..4d77099fe 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -2245,7 +2245,7 @@ components: outerWidthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱宽度修正;保存后参与联动计算} outerHeightCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱高度修正;保存后参与联动计算} chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} - coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} + coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true, description: 明确选择的行算法优先;未指定时仅使用客户默认算法,ACTUAL 不调整修正后的实测尺寸,系数配置存在不代表启用 MULTIPLY} remark: {type: string, maxLength: 500, nullable: true} InboundMeasurementDetailEnvelope: type: object diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 09452d6af..3b1eb4c83 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -7,12 +7,14 @@ import com.baiyue.firstleg.dto.CartonMeasurementCommand; import com.baiyue.firstleg.dto.CartonMeasurementResult; import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundWeightResultEntity; import com.baiyue.firstleg.repository.InboundMeasurementRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; +import com.baiyue.firstleg.repository.FbaPricingRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -46,13 +48,18 @@ class CartonMeasurementServiceTest { private final CustomerDimensionConfigRepository dimensionConfigs = mock(CustomerDimensionConfigRepository.class); private final CustomerDimensionWeightingService dimensionWeighting = new CustomerDimensionWeightingService(dimensionConfigs); + private final MeasurementBatchService measurementBatchService = new MeasurementBatchService(); + private final FbaPricingRepository pricingRepository = mock(FbaPricingRepository.class); private CartonMeasurementService service; private final AtomicReference insertedItem = new AtomicReference<>(); @BeforeEach + /** 构造真实共享尺寸计算入口,设备回归不以模拟计算结果代替业务算法。 */ void setUp() { + ReflectionTestUtils.setField(measurementBatchService, "dimensionWeighting", dimensionWeighting); + ReflectionTestUtils.setField(measurementBatchService, "pricingRepository", pricingRepository); service = new CartonMeasurementService(repository, resolverProvider, images, transactions, - dimensionWeighting); + measurementBatchService); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); when(resolverProvider.getIfAvailable()).thenReturn(resolver); when(resolver.resolveByBoxCode(10001L, "RK-001#5-1")) @@ -164,7 +171,9 @@ class CartonMeasurementServiceTest { } @Test + /** 超出四档尺寸且未启用调整规则时,仍按设备原尺寸计算。 */ void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesActualDimensions() { + useDefaultMode("ACTUAL"); InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); @@ -187,7 +196,7 @@ class CartonMeasurementServiceTest { assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("60.100"); assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("50.100"); assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("50.100"); - assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.151"); + assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1509"); assertThat(insertedItem.get().getOverLimitWarning()).isNull(); verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE)); } @@ -347,6 +356,61 @@ class CartonMeasurementServiceTest { verify(repository).updateInbound(inbound); } + @Test + /** 预置系数记录存在但未启用时,设备首次测量不加边也不乘系数。 */ + void firstScanUsesActualDimensionsWhenCoefficientIsNotEnabled() { + useDefaultMode("ACTUAL"); + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setLengthFactor(new BigDecimal("1.25")); + when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule); + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + service.upsert(10001L, 90001L, acceptanceCommand()); + + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("59.500"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("41.200"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("31.300"); + assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("ACTUAL"); + assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.0768"); + verify(pricingRepository, never()).findDefaultRule(10001L, 10001L); + } + + @Test + /** 客户明确启用系数后,设备首次录入与人工批次使用相同系数算法。 */ + void firstScanAppliesExplicitlyEnabledMultiplyDefault() { + useDefaultMode("MULTIPLY"); + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(77L); + rule.setTenantId(10001L); + rule.setStatus("ACTIVE"); + rule.setRuleVersion(2); + rule.setLengthFactor(new BigDecimal("1.25")); + when(pricingRepository.findDefaultRule(10001L, 10001L)).thenReturn(rule); + InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); + when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); + when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get())); + when(repository.updateMeasurement(measurement)).thenReturn(true); + + service.upsert(10001L, 90001L, acceptanceCommand()); + + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("74.375"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("51.500"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("39.125"); + assertThat(insertedItem.get().getCoefficientMode()).isEqualTo("MULTIPLY"); + assertThat(insertedItem.get().getChargeRuleId()).isEqualTo(77L); + assertThat(insertedItem.get().getUnitVolumeCbm()).isEqualByComparingTo("0.1499"); + } + + /** 设置客户默认算法,与是否保存系数规则分开。 */ + private void useDefaultMode(String mode) { + List rules = dimensionRules(10001L); + rules.forEach(rule -> rule.setDefaultCalculationMode(mode)); + when(dimensionConfigs.findAll(10001L)).thenReturn(rules); + } + private CartonMeasurementCommand command() { return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), new BigDecimal("40.200"), new BigDecimal("30.300"), @@ -388,6 +452,7 @@ class CartonMeasurementServiceTest { return item; } + /** 既有加边用例显式启用 ADD,避免依赖配置存在即生效的旧行为。 */ private List dimensionRules(long tenantId) { return java.util.Arrays.stream(CustomerDimensionBoxType.values()).map(type -> { CustomerDimensionConfigEntity rule = new CustomerDimensionConfigEntity(); @@ -395,6 +460,7 @@ class CartonMeasurementServiceTest { rule.setInnerLengthCm(type.defaultLengthCm()); rule.setInnerWidthCm(type.defaultWidthCm()); rule.setInnerHeightCm(type.defaultHeightCm()); rule.setEdgeAdditionCm(new BigDecimal("2.000")); + rule.setDefaultCalculationMode("ADD"); return rule; }).toList(); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java index 1f331b71e..403a6b160 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java @@ -39,15 +39,9 @@ class CustomerDimensionWeightingServiceTest { } @Test - void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() { - var weighted = service.load(10001L).resolveDeviceMeasurement( - new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); - - assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); - assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000"); - assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000"); - assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000"); - assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + /** 未勾选默认调整算法时,四档尺寸配置不能隐式启用加边。 */ + void defaultCalculationModeRemainsActualWhenNoAdjustmentWasSelected() { + assertThat(service.load(10001L).defaultCalculationMode()).isEqualTo("ACTUAL"); } @Test @@ -81,16 +75,15 @@ class CustomerDimensionWeightingServiceTest { } @Test - void deviceDimensionsOutsideConfiguredRulesRemainUnchanged() { - var weighted = service.load(10001L).resolveDeviceMeasurement( - new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50")); + /** 默认算法必须在四档配置中一致,防止各录入入口取得不同计算方式。 */ + void inconsistentCustomerDefaultModesAreRejected() { + List rules = defaultRules(10001L); + rules.get(0).setDefaultCalculationMode("MULTIPLY"); + when(repository.findAll(10001L)).thenReturn(rules); - assertThat(weighted.boxType()).isNull(); - assertThat(weighted.edgeAdditionCm()).isNull(); - assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001"); - assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50.000"); - assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50.000"); - assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); + assertThatThrownBy(() -> service.load(10001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("默认箱规计算方式配置不一致"); } @Test 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 34c2f2a2a..a0e03a570 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -52,6 +52,7 @@ class MeasurementBatchServiceTest { } @Test + /** 未选择调整算法时,第三方与人工批次均保存实际外箱快照。 */ void manualAndExternalUseSameTablesAndExternalSaveNeverAdvancesInbound() { MeasurementBatchCommand command = new MeasurementBatchCommand( 1L, 10L, null, 0, "THIRD_PARTY", "DEVICE_VENDOR", "EVENT-001", @@ -73,10 +74,10 @@ class MeasurementBatchServiceTest { assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000"); assertThat(items.getValue().get(0).getCalculationRuleVersion()) .isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); - assertThat(items.getValue().get(0).getCoefficientMode()).isNull(); + assertThat(items.getValue().get(0).getCoefficientMode()).isEqualTo("ACTUAL"); assertThat(items.getValue().get(0).getChargeRuleId()).isNull(); assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000"); - verify(pricingRepository).findDefaultRule(1L, 1L); + verify(pricingRepository, never()).findDefaultRule(1L, 1L); verify(repository, never()).updateInbound(any()); } @@ -174,7 +175,42 @@ class MeasurementBatchServiceTest { } @Test + /** 客户未启用算法时,预置系数不能放大仓库修正的实际外箱体积。 */ + void savedCoefficientDoesNotOverrideActualDefaultMode() { + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(77L); + rule.setTenantId(1L); + rule.setCustomerId(1L); + rule.setStatus("ACTIVE"); + rule.setDefaultRule(true); + rule.setLengthFactor(new BigDecimal("1.25")); + when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule); + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 22, new BigDecimal("62"), new BigDecimal("52"), + new BigDecimal("52"), 500L, null, new BigDecimal("63"), new BigDecimal("53"), + new BigDecimal("53"), null, null, "仓库修正"); + + service.saveBatch(new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source))); + + ArgumentCaptor> items = ArgumentCaptor.forClass(List.class); + verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(100L), items.capture()); + InboundMeasurementItemEntity calculated = items.getValue().get(0); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("53.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("53.000"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("3.8940"); + assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL"); + assertThat(calculated.getChargeRuleId()).isNull(); + verify(pricingRepository, never()).findDefaultRule(1L, 1L); + } + + @Test + /** 只有客户明确启用系数算法,才自动选取当前默认系数配置。 */ void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { + useDefaultMode("MULTIPLY"); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); rule.setId(77L); rule.setTenantId(1L); @@ -212,7 +248,9 @@ class MeasurementBatchServiceTest { } @Test + /** 系数模式按当前尺寸匹配档位,同一系数作用于三个维度。 */ void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() { + useDefaultMode("MULTIPLY"); CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); rule.setId(78L); rule.setTenantId(1L); @@ -251,6 +289,7 @@ class MeasurementBatchServiceTest { } @Test + /** 无默认系数且未选择调整算法时,显式保存 ACTUAL 计算方式。 */ void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() { MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"), @@ -269,7 +308,7 @@ class MeasurementBatchServiceTest { assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000"); assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000"); assertThat(calculated.getChargeRuleId()).isNull(); - assertThat(calculated.getCoefficientMode()).isNull(); + assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL"); assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1"); } @@ -530,6 +569,58 @@ class MeasurementBatchServiceTest { assertThat(saved.getDeviceMeasuredAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); } + @Test + /** 行明确选择实测算法后,即使客户默认系数已启用,也不能被覆盖。 */ + void explicitActualModeOverridesCustomerMultiplyDefault() { + useDefaultMode("MULTIPLY"); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + 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"); + + service.calculateCartonDimensions(item); + + assertThat(item.getTotalVolumeCbm()).isEqualByComparingTo("3.8940"); + assertThat(item.getCoefficientMode()).isEqualTo("ACTUAL"); + assertThat(item.getChargeRuleId()).isNull(); + verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong()); + } + + @Test + /** 客户明确启用加边规则后,未指定行算法的设备及人工录入均采用该默认方式。 */ + void appliesCustomerAddDefaultWhenRowModeIsNotSelected() { + useDefaultMode("ADD"); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setTenantId(1L); + item.setItemNo("B1"); + item.setPackageCount(1); + item.setLengthCm(new BigDecimal("30")); + item.setWidthCm(new BigDecimal("20")); + item.setHeightCm(new BigDecimal("20")); + + service.calculateCartonDimensions(item); + + assertThat(item.getOuterLengthCm()).isEqualByComparingTo("34.000"); + assertThat(item.getOuterWidthCm()).isEqualByComparingTo("24.000"); + assertThat(item.getOuterHeightCm()).isEqualByComparingTo("24.000"); + assertThat(item.getCoefficientMode()).isEqualTo("ADD"); + verify(pricingRepository, never()).findDefaultRule(anyLong(), anyLong()); + } + + /** 为用例设置客户真正启用的算法,独立于系数配置是否存在。 */ + private void useDefaultMode(String mode) { + List rules = dimensionRules(1L); + rules.forEach(rule -> rule.setDefaultCalculationMode(mode)); + when(dimensionConfigs.findAll(1L)).thenReturn(rules); + } + private MeasurementBatchCommand.Item item(BigDecimal unitWeight) { return new MeasurementBatchCommand.Item( 80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),