diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0376e6313..d4de2e435 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2286,8 +2286,8 @@ CREATE TABLE fl_inbound_fee ( fee_version int unsigned NOT NULL DEFAULT '1' COMMENT '费用快照版本', charge_rule_id bigint unsigned DEFAULT NULL COMMENT '历史客户计费规则ID;渠道价格可为空', charge_rule_version int unsigned DEFAULT NULL COMMENT '历史客户规则版本快照', - carrier_rate_id bigint unsigned NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version int unsigned NOT NULL COMMENT '船司价格版本快照', + carrier_rate_id bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', + carrier_rate_version int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', price_book_version_id bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID快照', rule_code varchar(50) DEFAULT NULL COMMENT '历史客户规则编码快照', customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID快照', @@ -2309,7 +2309,7 @@ CREATE TABLE fl_inbound_fee ( destination_warehouse_id bigint unsigned DEFAULT NULL COMMENT '海外仓或FBA目的仓ID快照', destination_name varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', destination_code varchar(32) DEFAULT NULL COMMENT 'FBA仓编码快照;海外仓可为空', - rate_effective_from datetime(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_from datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空', rate_effective_to datetime(3) DEFAULT NULL COMMENT '价格生效结束快照', charge_mode varchar(16) DEFAULT NULL COMMENT '计费口径:KG或CBM', conversion_factor decimal(18,6) DEFAULT NULL COMMENT '历史客户规则使用的换算系数快照', @@ -2350,6 +2350,11 @@ CREATE TABLE fl_inbound_fee ( CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN carrier_rate_id bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', + MODIFY COLUMN carrier_rate_version int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', + MODIFY COLUMN rate_effective_from datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空'; + CREATE TABLE fl_inbound_fee_detail ( id bigint unsigned NOT NULL COMMENT '入库费用明细ID', tenant_id bigint unsigned NOT NULL COMMENT '客户ID(客户隔离键);费用明细引用平台公共费用项目', 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 b4bcec533..c3c315a72 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -8,6 +8,7 @@ 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.AdjustFeeRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; @@ -50,6 +51,18 @@ public class PlatformFbaPricingController { request == null ? null : request.volumeSource(), idempotencyKey)); } + @PostMapping("/manual-quote") + @PreAuthorize("hasAuthority('firstleg:inbound:confirm-send-fee')") + @BusinessAudit(event = "FBA_FEE_MANUAL_QUOTE", resourceType = "INBOUND", resourceId = "#inboundId", + description = "价格未命中时人工确认头程运费单价") + public ApiResponse manualQuote( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualQuoteRequest request) { + long scope = FirstlegScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.manualQuote(scope, user.userId(), inboundId, request, idempotencyKey)); + } + @PostMapping("/confirm") @PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee'," + " 'firstleg:fba-pricing:confirm')") 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 84aa64bde..e6401d4bc 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,13 @@ public final class FbaPricingDtos { } } + public record ManualQuoteRequest( + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice, + @NotBlank @Size(min = 2, max = 500) String reason + ) {} + public record AdjustFeeRequest( @NotNull @Positive Integer expectedFeeVersion, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, 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 23b784239..831441e84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; @@ -46,6 +47,7 @@ public class FbaPricingService { private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE"; + private static final String MANUAL_RATE_CODE = "MANUAL_QUOTE"; @Resource private FbaPricingRepository repository; @Resource @@ -177,6 +179,94 @@ public class FbaPricingService { return response(inbound, fee, rate.getUnitPrice(), nextStatus); } + @Transactional + public FeeSnapshotResponse manualQuote(long tenantId, long operatorId, long inboundId, + ManualQuoteRequest request, String idempotencyKey) { + String reason = request.reason().trim(); + if (reason.length() < 2 || request.unitPrice().signum() <= 0 + || request.selectedWeightKg().signum() <= 0 || request.selectedVolumeCbm().signum() <= 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, "人工定价的单价、计费重量、体积和原因必须有效"); + } + String discriminator = request.selectedWeightKg() + ":" + request.selectedVolumeCbm() + + ":" + request.unitPrice() + ":" + reason; + InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, + "FEE_MANUAL_QUOTE", idempotencyKey, hash("MANUAL_QUOTE", inboundId, discriminator)); + if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); + + InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); + if (!"FBA".equals(inbound.getDestinationType())) throw conflict("仅FBA头程运费支持未命中人工定价"); + var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); + if (measurement == null || measurementRepository.listItems(tenantId, measurement.getId()).isEmpty()) { + throw conflict("实际测量明细不存在,不能生成人工头程费用"); + } + InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, inboundId); + if (feeRevisionLocked(current)) throw conflict("客户已确认,不允许再修改费用"); + if (current != null && !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { + throw conflict("当前费用状态不允许重新定价"); + } + LocalDateTime now = LocalDateTime.now(clock); + PricingPolicy policy = pricingPolicy(inbound, now); + try { + priceBookEstimator.estimate(tenantId, new EstimateRequest( + inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), + destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, + pricingTaxMode(inbound), policy.chargeMode(), request.selectedWeightKg(), request.selectedVolumeCbm())); + throw conflict("当前条件已有匹配价格,请使用正常试算并调整单价"); + } catch (BizException exception) { + if (exception.getResultCode() != ResultCode.NOT_FOUND) throw exception; + } + BigDecimal weight = request.selectedWeightKg().setScale(3, RoundingMode.HALF_UP); + 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 subtotal = quantity.multiply(request.unitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + int version = repository.nextFeeVersion(tenantId, inboundId); + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setTenantId(tenantId); fee.setInboundId(inboundId); + fee.setFeeNo("FBAF-" + inboundId + "-" + version); fee.setFeeVersion(version); + fee.setMeasurementId(measurement.getId()); fee.setMeasurementSource("ACTUAL"); + fee.setCarrierRateId(null); fee.setCarrierRateVersion(null); fee.setPriceBookVersionId(null); + fee.setRateCode(MANUAL_RATE_CODE); fee.setRateEffectiveFrom(null); fee.setRateEffectiveTo(null); + fee.setCustomerId(tenantId); fee.setCustomerName(inbound.getCustomerName()); + fee.setBusinessType(inbound.getBusinessType()); fee.setTaxMode(inbound.getTaxMode()); + fee.setPricingTaxMode(inbound.getPricingTaxMode()); fee.setOriginWarehouseId(inbound.getWarehouseId()); + fee.setOriginWarehouseName(inbound.getWarehouseName()); fee.setChannelCode(inbound.getChannelCode()); + fee.setDestinationType(inbound.getDestinationType()); fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); + fee.setDestinationName(inbound.getDestinationName()); fee.setDestinationCode(inbound.getDestinationCode()); + fee.setChargeMode(unit); fee.setChargeUnit(unit); fee.setConversionFactor(factor); + fee.setRoundingScale(6); fee.setRoundingMode(RoundingMode.HALF_UP.name()); + fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(quantity); + fee.setBaselineChargeableQuantity(quantity); fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); + fee.setChargeableQuantity(quantity); fee.setCurrency("CNY"); + fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(BigDecimal.ZERO.setScale(MONEY_SCALE)); + fee.setTotalAmount(subtotal); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); + fee.setExceptionRelease(false); fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); + fee.setCreatedAt(now); fee.setUpdatedAt(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()); + } + if (current != null && repository.supersedeCurrent(tenantId, current.getId(), current.getFeeStatus()) != 1) { + throw conflict("费用已被其他操作员修改,请刷新后重试"); + } + repository.insertFee(fee); + InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", subtotal, now); + item.setQuantity(quantity); item.setUnit(unit); item.setUnitPrice(request.unitPrice()); + item.setAutomaticFlag(false); item.setRemark(reason); + repository.insertItem(item); + if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(), unit, + weight, volume, quantity) != 1) throw conflict("入库单状态已变化,费用未保存"); + feeDetails.materializeCalculatedFreight(tenantId, inboundId, fee.getId(), operatorId, now); + complete(token, fee.getId(), now); + String nextStatus = preserveProcessStatusDuringPricing(inbound.getProcessStatus()) + ? inbound.getProcessStatus() : "FEE_CONFIRMING"; + return response(inbound, fee, request.unitPrice(), nextStatus); + } + private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, BigDecimal currentWeight, BigDecimal currentVolume) { @@ -280,6 +370,7 @@ public class FbaPricingService { return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0); } List regionalMembers = members.stream() + .filter(member -> member.current().getCarrierRateId() != null) .filter(member -> priceModel(member.current()) == PriceModel.REGION_WEIGHT_TIER) .toList(); List unlockedRegionalMembers = regionalMembers.stream() diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 9ae7b8d18..9e951acfe 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -665,8 +665,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空', charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照', - carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照', + carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照', rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照', customer_id BIGINT UNSIGNED NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', @@ -688,7 +688,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空', - rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照', + rate_effective_from DATETIME(3) NULL COMMENT '价格生效开始快照;人工报价为空', rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照', charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM', conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照', @@ -726,6 +726,11 @@ 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='入库头程费用计算和确认快照'; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', + MODIFY COLUMN rate_effective_from DATETIME(3) NULL 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'), diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 8ee870441..68c7e1349 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -154,6 +154,13 @@ paths: parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} + /api/platform/v1/inbounds/{inboundId}/fees/manual-quote: + post: + summary: FBA价格未命中时人工确定头程单价 + description: 仅FBA且价格查询返回未命中时可用;按客户计费方式与选定测量重量、体积计算计费量,附加费为零。生成正式头程费用快照,后续复用确认与发送;需要 firstleg:inbound:confirm-send-fee 权限。 + parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ManualQuoteRequest'}}}} + responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/confirm: post: summary: 确认最新费用快照 @@ -488,6 +495,14 @@ components: additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本} + ManualQuoteRequest: + type: object + required: [selectedWeightKg, selectedVolumeCbm, unitPrice, reason] + properties: + selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0} + selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0} + unitPrice: {type: number, exclusiveMinimum: true, minimum: 0} + reason: {type: string, minLength: 2, maxLength: 500} 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 717cd6f6d..b989c459d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -25,6 +25,7 @@ class FbaPricingContractTest { + " 'firstleg:inbound:send-fee')"); assertPricingAuthority("adjustPublished", "hasAnyAuthority('firstleg:fba-pricing:adjust'," + " 'firstleg:inbound:confirm-send-fee')"); + assertPricingAuthority("manualQuote", "hasAuthority('firstleg:inbound:confirm-send-fee')"); } @Test @@ -60,6 +61,7 @@ class FbaPricingContractTest { .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:") + .contains("/api/platform/v1/inbounds/{inboundId}/fees/manual-quote:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/send:") .contains("/api/platform/v1/inbounds/{inboundId}/fees/published:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java index a6e9f0fe6..8d12aa16c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundFeeOpenApiContractTest.java @@ -26,6 +26,7 @@ class InboundFeeOpenApiContractTest { Map paths = map(document.get("paths")); assertThat(paths).containsKeys( "/api/platform/v1/inbounds/{inboundId}/fees/calculate", + "/api/platform/v1/inbounds/{inboundId}/fees/manual-quote", "/api/platform/v1/inbounds/{inboundId}/fees/confirm", "/api/platform/v1/inbounds/{inboundId}/fees/send", "/api/platform/v1/inbounds/{inboundId}/fees/latest", 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 fa199eb69..887652f7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import com.baiyue.firstleg.dto.FbaPricingDtos.ManualQuoteRequest; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; @@ -365,6 +366,56 @@ class FbaPricingServiceTest { verify(repository, never()).insertFee(any()); } + @Test + @DisplayName("FBA价格未命中时按人工单价生成零附加费头程费用") + void createsManualQuoteWhenFbaRateIsMissing() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "0.3840"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( + new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); + + FeeSnapshotResponse result = service.manualQuote(TENANT, OPERATOR, INBOUND, + new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"), + new BigDecimal("12.2000"), "FBA价格未维护"), "manual-1"); + + assertThat(result.rateCode()).isEqualTo("MANUAL_QUOTE"); + assertThat(result.unitPrice()).isEqualByComparingTo("12.2000"); + assertThat(result.chargeableQuantity()).isEqualByComparingTo("64.128000"); + assertThat(result.additionalAmount()).isEqualByComparingTo("0.00"); + assertThat(result.totalAmount()).isEqualByComparingTo("782.36"); + assertThat(result.carrierRateId()).isNull(); + ArgumentCaptor feeItem = ArgumentCaptor.forClass(InboundFeeItemEntity.class); + verify(repository).insertItem(feeItem.capture()); + assertThat(feeItem.getValue().getAutomaticFlag()).isFalse(); + assertThat(feeItem.getValue().getRemark()).isEqualTo("FBA价格未维护"); + } + + @Test + @DisplayName("已有匹配价格时拒绝绕过价格表创建人工报价") + void rejectsManualQuoteWhenRateExists() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "0.3840"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); + when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(new InboundMeasurementItemEntity())); + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( + estimate("KG", "64.128", "12.2", "782.36", "0")); + + assertThatThrownBy(() -> service.manualQuote(TENANT, OPERATOR, INBOUND, + new ManualQuoteRequest(new BigDecimal("62.500"), new BigDecimal("0.3840"), + new BigDecimal("12.2000"), "人工价格"), "manual-existing")) + .isInstanceOf(BizException.class).hasMessageContaining("已有匹配价格"); + verify(repository, never()).insertFee(any()); + } + @Test @DisplayName("同一幂等键重复试算直接返回原费用快照") void returnsOriginalSnapshotForRepeatedCalculation() {