From 2e60f2cd773a44f3ad64bc95bfc1b0c926c89d8e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:13:43 +0800 Subject: [PATCH] feat(firstleg): enforce pricing adjustment bounds --- .../baiyue/firstleg/dto/FbaPricingDtos.java | 8 +- .../baiyue/firstleg/dto/PriceBookDtos.java | 13 ++- .../firstleg/entity/InboundFeeEntity.java | 4 + .../pricing/ActivePriceBookEstimator.java | 18 ++++ .../repository/FbaPricingRepository.java | 2 +- .../firstleg/service/FbaPricingService.java | 72 ++++++++++++++- .../service/MerchantPriceBookService.java | 4 + .../resources/db/phase2-inbound-schema.sql | 28 ++++++ .../resources/openapi/fba-pricing-v1.yaml | 17 +++- .../service/FbaPricingServiceTest.java | 88 +++++++++++++++++-- .../service/MerchantPriceBookServiceTest.java | 1 + 11 files changed, 236 insertions(+), 19 deletions(-) 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 6ceff5682..84aa64bde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -80,7 +81,8 @@ public final class FbaPricingDtos { @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount, - @NotBlank @Size(max = 500) String reason + @NotBlank @Size(max = 500) String reason, + @NotNull @AssertTrue Boolean toleranceConfirmed ) {} public record FeeSnapshotResponse( @@ -96,7 +98,9 @@ public final class FbaPricingDtos { @NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale, @NotBlank String roundingMode, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm, - BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit, + BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity, + BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity, + BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit, BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount, BigDecimal totalAmount, String currency, LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index cad3a507c..b81b8b94f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -119,9 +119,20 @@ public final class PriceBookDtos { @Size(max = 128) String destinationName, PriceRegion destinationRegion, PricingTaxMode pricingTaxMode, + @Pattern(regexp = "KG|CBM") String chargeMode, @DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg, @DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm - ) {} + ) { + public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode, + PricingDestinationType destinationType, Long destinationWarehouseId, + String destinationName, PriceRegion destinationRegion, + PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg, + BigDecimal actualVolumeCbm) { + this(originWarehouseId, channelCode, destinationCode, destinationType, + destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode, + null, actualWeightKg, actualVolumeCbm); + } + } public record EstimateResponse( Long priceBookVersionId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java index 667d3a527..bf8158474 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -54,6 +54,10 @@ public class InboundFeeEntity { private BigDecimal actualWeightKg; private BigDecimal actualVolumeCbm; private BigDecimal convertedQuantity; + /** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */ + private BigDecimal baselineChargeableQuantity; + /** 人工计费量相对系统基准允许的最大偏差百分比。 */ + private BigDecimal tolerancePercent; private BigDecimal chargeableQuantity; private String chargeUnit; private String currency; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 9eba2b66d..aca7ccbcf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -65,6 +65,8 @@ public class ActivePriceBookEstimator { } else { throw validation("私人地址暂不支持参考估价"); } + String chargeMode = requiredChargeMode(request); + rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList(); RateRow rate = uniqueRate(rates); requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE); var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm()); @@ -81,6 +83,9 @@ public class ActivePriceBookEstimator { if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); } + if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) { + throw validation("区域重量阶梯仅支持KG计费"); + } PriceRegion region = request.destinationRegion() == null ? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion(); List tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode, @@ -113,6 +118,19 @@ public class ActivePriceBookEstimator { return rates.get(0); } + private static String requiredChargeMode(EstimateRequest request) { + if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) { + if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) { + throw validation("包税报价必须按KG计费"); + } + return "KG"; + } + if (!StringUtils.hasText(request.chargeMode())) { + throw validation("不包税报价请选择客户计费方式"); + } + return request.chargeMode(); + } + private static void requireModel(RateRow rate, PriceModel expected) { if (rate == null || !expected.name().equals(rate.getPriceModel())) { throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 26c6ffcda..867e31cde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -171,7 +171,7 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType()) - .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode()) + .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode()) .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") .le(CustomerChargeRuleEntity::getEffectiveFrom, at) .and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo) 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 5a36673a5..7183beaba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -35,6 +35,8 @@ import java.util.List; @Service public class FbaPricingService { private static final int MONEY_SCALE = 2; + private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00"); + private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认"; @Resource @@ -114,10 +116,12 @@ public class FbaPricingService { || !StringUtils.hasText(item.getCalculationRuleVersion()))); if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); } + PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, + pricingTaxMode(inbound), policy.chargeMode(), + selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); RateRow rate = estimate.rate(); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); @@ -133,6 +137,13 @@ public class FbaPricingService { InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version, weight, volume, converted, chargeable, subtotal, additional, total, now); + if (policy.rule() != null) { + fee.setChargeRuleId(policy.rule().getId()); + fee.setChargeRuleVersion(policy.rule().getRuleVersion()); + fee.setRuleCode(policy.rule().getRuleCode()); + fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom()); + fee.setRuleEffectiveTo(policy.rule().getEffectiveTo()); + } fee.setMeasurementId(measurement == null ? null : measurement.getId()); fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource); if (published != null) { @@ -283,7 +294,8 @@ public class FbaPricingService { } String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" + request.chargeableQuantity() + ":" + request.unitPrice() + ":" - + request.additionalAmount() + ":" + request.reason().trim(); + + request.additionalAmount() + ":" + request.reason().trim() + ":" + + request.toleranceConfirmed(); InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); if ("COMPLETED".equals(token.getStatus())) { @@ -297,6 +309,17 @@ public class FbaPricingService { adjusted.setId(null); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeVersion(nextVersion); + BigDecimal baseline = baseline(current); + ToleranceRange range = toleranceRange(baseline); + if (request.chargeableQuantity().compareTo(range.lower()) < 0 + || request.chargeableQuantity().compareTo(range.upper()) > 0) { + throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)"); + } + if (!Boolean.TRUE.equals(request.toleranceConfirmed())) { + throw conflict("请二次确认计费量调整范围后再提交"); + } + adjusted.setBaselineChargeableQuantity(baseline); + adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP)); adjusted.setConvertedQuantity(adjusted.getChargeableQuantity()); adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice()) @@ -401,6 +424,27 @@ public class FbaPricingService { } } + private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) { + PricingTaxMode taxMode = pricingTaxMode(inbound); + if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null); + List rules = repository.matchRules(inbound, at); + if (rules == null || rules.isEmpty()) { + throw conflict("客户未配置当前不包税报价的计费方式"); + } + List defaults = rules.stream() + .filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList(); + com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1 + ? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null; + if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一"); + if (!List.of("KG", "CBM").contains(selected.getChargeMode())) { + throw conflict("客户计费方式配置无效"); + } + return new PricingPolicy(selected.getChargeMode(), selected); + } + + private record PricingPolicy(String chargeMode, + com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { } + private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId, long inboundId, String operation, String key, String hash) { @@ -456,6 +500,8 @@ public class FbaPricingService { fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo()); fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor()); fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted); + fee.setBaselineChargeableQuantity(chargeable); + fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency()); fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false); @@ -497,6 +543,8 @@ public class FbaPricingService { private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee, BigDecimal unitPrice, String nextStatus) { + BigDecimal baseline = baseline(fee); + ToleranceRange range = toleranceRange(baseline); return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(), fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(), fee.getMeasurementId(), fee.getMeasurementSource(), @@ -508,7 +556,9 @@ public class FbaPricingService { fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(), fee.getConversionFactor(), fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(), - fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, + fee.getConvertedQuantity(), baseline, range.lower(), range.upper(), + fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(), + fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(), fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(), fee.getSentAt(), nextStatus, @@ -516,6 +566,22 @@ public class FbaPricingService { fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt()); } + private static BigDecimal baseline(InboundFeeEntity fee) { + BigDecimal value = fee.getBaselineChargeableQuantity(); + if (value == null) value = fee.getChargeableQuantity(); + if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零"); + return value.setScale(6, RoundingMode.HALF_UP); + } + + private static ToleranceRange toleranceRange(BigDecimal baseline) { + BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT) + .divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP); + return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP), + baseline.add(delta).setScale(6, RoundingMode.HALF_UP)); + } + + private record ToleranceRange(BigDecimal lower, BigDecimal upper) { } + private static String hash(String operation, long inboundId, String discriminator) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index d913613d4..a4e5e6a9d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -42,6 +42,10 @@ public class MerchantPriceBookService { public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) { requireMerchant(merchantTenantId); + if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED + && request.chargeMode() != null && !"KG".equals(request.chargeMode())) { + throw validation("包税报价必须按KG计费"); + } ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request); return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(), estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 76de2bd92..894c8e38d 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -672,6 +672,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', + baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量', + tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含', chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', currency CHAR(3) NOT NULL COMMENT '币种', @@ -701,6 +703,32 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +SET @fee_baseline_quantity_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'), + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity' +); +PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql; +EXECUTE fee_baseline_quantity_stmt; +DEALLOCATE PREPARE fee_baseline_quantity_stmt; +UPDATE fl_inbound_fee + SET baseline_chargeable_quantity = chargeable_quantity + WHERE baseline_chargeable_quantity IS NULL; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL + COMMENT '系统按权威测量与生效报价计算的原始计费量'; + +SET @fee_tolerance_percent_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'), + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity' +); +PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql; +EXECUTE fee_tolerance_percent_stmt; +DEALLOCATE PREPARE fee_tolerance_percent_stmt; + CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ec62caa77..df24f0f7b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -165,7 +165,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/published: put: summary: 调整初算或已发布费用 - description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 + description: 需要 firstleg:fba-pricing:adjust 权限;计费量只能在系统原计费量上下10%范围内(含边界),请求必须携带当前费用版本、必填原因及二次确认标志,越界或并发冲突返回A0409。小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -330,6 +330,10 @@ components: actualWeightKg: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true} convertedQuantity: {type: number, nullable: true} + baselineChargeableQuantity: {type: number, description: 系统按权威测量与生效报价计算的原计费量,人工调整后保持不变} + lowerChargeableQuantity: {type: number, description: 允许人工调整的计费量下界,包含边界} + upperChargeableQuantity: {type: number, description: 允许人工调整的计费量上界,包含边界} + tolerancePercent: {type: number, enum: [10.00], description: 相对系统原计费量允许的上下偏差百分比} chargeableQuantity: {type: number, nullable: true} chargeUnit: {type: string, nullable: true} unitPrice: {type: number, nullable: true} @@ -466,16 +470,22 @@ components: timestamp: {type: string, format: date-time, nullable: true} AdjustFeeRequest: type: object - required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] + required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed] properties: expectedFeeVersion: {type: integer, minimum: 1} chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} + toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本} CalculateRequest: type: object - properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}} + properties: + pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间} + selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威重量快照} + selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威体积快照} + weightSource: {type: string, maxLength: 32, nullable: true} + volumeSource: {type: string, maxLength: 32, nullable: true} CustomerRuleVersionRequest: type: object required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] @@ -668,5 +678,6 @@ components: destinationName: {type: string, maxLength: 128, nullable: true} destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} + chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致} actualWeightKg: {type: number, minimum: 0} actualVolumeCbm: {type: number, minimum: 0} 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 19954f276..bd95d463f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; @@ -37,6 +38,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -217,6 +219,10 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("363.001", "0.5000"); inbound.setPricingTaxMode("TAX_EXCLUDED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); + rule.setChargeMode("CBM"); rule.setDefaultRule(true); + when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); when(priceBookEstimator.estimate(any())).thenReturn( estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); @@ -228,6 +234,8 @@ class FbaPricingServiceTest { assertThat(response.conversionFactor()).isEqualByComparingTo("363"); assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingMode()).isEqualTo("HALF_UP"); + assertThat(response.chargeRuleId()).isEqualTo(601L); + assertThat(response.ruleCode()).isEqualTo("CBM-RULE"); } @Test @@ -483,15 +491,19 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-1"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-1"); assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("SENT"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); + assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000"); + assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000"); + assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000"); + assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); - assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000"); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000"); verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); @@ -538,7 +550,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "locked-adjust")) + BigDecimal.ZERO, "客服修正", true), "locked-adjust")) .isInstanceOf(BizException.class) .hasMessageContaining("客户已确认"); @@ -574,11 +586,11 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-calculated"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-calculated"); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); @@ -598,7 +610,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "stale-adjust")) + BigDecimal.ZERO, "客服修正", true), "stale-adjust")) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -607,6 +619,64 @@ class FbaPricingServiceTest { verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); } + @Test + @DisplayName("人工计费量允许上下10%含边界") + void acceptsInclusiveToleranceBoundaries() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) + .when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current); + + service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO, + "下界确认", true), "lower-bound"); + + verify(repository).insertFee(argThat(fee -> + fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0)); + } + + @Test + @DisplayName("人工计费量越过10%或未二次确认返回冲突") + void rejectsOutOfToleranceOrUnconfirmedAdjustment() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED")); + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO, + "越界", true), "outside")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO, + "未确认", false), "unconfirmed")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8f6329212..8ead0e597 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -292,6 +292,7 @@ class MerchantPriceBookServiceTest { String weight, String volume) { return new EstimateRequest(10L, channelCode, destinationCode, destinationType, destinationWarehouseId, null, destinationRegion, pricingTaxMode, + pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM", decimal(weight), decimal(volume)); }