diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c0057abd7..006089058 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2749,15 +2749,18 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', charge_rule_id BIGINT UNSIGNED NULL COMMENT '该测量明细绑定的客户计费规则ID', - coefficient_mode VARCHAR(16) NULL COMMENT '系数模式快照:ADD或MULTIPLY', + coefficient_mode VARCHAR(16) NULL COMMENT '箱规计算策略快照:ACTUAL、ADD或MULTIPLY', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间', over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', - outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', - outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', - outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', + outer_length_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规长度cm', + outer_width_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规宽度cm', + outer_height_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规高度cm', + manual_outer_length_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规长度计算基数cm', + manual_outer_width_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规宽度计算基数cm', + manual_outer_height_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规高度计算基数cm', remark VARCHAR(500) NULL COMMENT '测量明细备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', @@ -3044,6 +3047,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照', + measurement_source VARCHAR(32) NULL COMMENT '计费尺寸来源快照:OUTER为外箱规数据', fee_no VARCHAR(40) NOT NULL COMMENT '客户内唯一费用单号', fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空', @@ -3093,6 +3098,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( confirmed_at DATETIME(3) NULL COMMENT '确认时间', sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', sent_at DATETIME(3) NULL COMMENT '发送客户时间', + customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID', + customer_confirmed_by_name VARCHAR(64) NULL COMMENT '客户确认人名称快照', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认费用时间;非空后费用版本锁定', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), @@ -3337,6 +3345,12 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_length_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规长度计算基数cm'' AFTER outer_height_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_width_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规宽度计算基数cm'' AFTER manual_outer_length_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_height_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规高度计算基数cm'' AFTER manual_outer_width_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at'); @@ -3597,6 +3611,8 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL( CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); +CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_source', "VARCHAR(32) NULL COMMENT '计费尺寸来源快照:OUTER为外箱规数据' AFTER measurement_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); @@ -3614,6 +3630,9 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT N CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_by', "BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID' AFTER sent_at"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_by_name', "VARCHAR(64) NULL COMMENT '客户确认人名称快照' AFTER customer_confirmed_by"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_at', "DATETIME(3) NULL COMMENT '客户确认费用时间;非空后费用版本锁定' AFTER customer_confirmed_by_name"); CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', "BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id"); @@ -7946,8 +7965,8 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config ( inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)', inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', - edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数', - default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL' COMMENT '订单外箱规默认计算方式:ACTUAL不调整、ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', @@ -12997,7 +13016,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'bo CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'created_at', '创建时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'created_by', '创建人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'deleted', '逻辑删除'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'edge_addition_cm', '长宽高各自增加一次的厘米数'); +CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'edge_addition_cm', '每个尺寸方向的单侧加宽厘米数'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'default_calculation_mode', '订单外箱规默认计算方式:ADD或MULTIPLY'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'id', '配置ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'initialization_key', '创建流程幂等键及补偿归属'); @@ -13292,9 +13311,12 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'ite CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'length_cm', '原始长度cm'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'machine_no', '本次测量设备编号'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'measurement_id', '测量批次ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_height_cm', '外箱规高度cm'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_length_cm', '外箱规长度cm'); -CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_width_cm', '外箱规宽度cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_height_cm', '策略计算后的最终外箱规高度cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_length_cm', '策略计算后的最终外箱规长度cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'outer_width_cm', '策略计算后的最终外箱规宽度cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'manual_outer_height_cm', '仓库人工外箱规高度计算基数cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'manual_outer_length_cm', '仓库人工外箱规长度计算基数cm'); +CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'manual_outer_width_cm', '仓库人工外箱规宽度计算基数cm'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'over_limit_warning', '超线提醒快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'package_count', '相同箱规箱数'); CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'remark', '测量明细备注'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java index bbb3636ef..94e804676 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundBillingController.java @@ -13,11 +13,16 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import jakarta.validation.Valid; +import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; @Tag(name = "客户端入库结果与费用") @RestController @@ -31,12 +36,27 @@ public class MerchantInboundBillingController { @Operation(summary = "查询订单入库结果和最新费用", description = "客户按登录态客户范围只读查看;入库流程达到 WAIT_SPLIT 后," + "在 LOADING、LOADED、DELIVERED 仍持续可见,且始终要求 APPROVED + SENT " - + "并存在已发布入库运费明细,不需要客户确认费用。") + + "并存在已发布入库运费明细;同时返回当前费用版本的客户确认状态。") public ApiResponse find( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) { return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId)); } + @PostMapping("/{orderId}/inbound-billing/confirm") + @PreAuthorize("hasAuthority('merchant:order:edit')") + @Operation(summary = "客户确认已发布的入库费用", + description = "仅允许当前客户确认 SENT 状态的最新费用版本;确认后该版本永久锁定,平台不得再修改。") + public ApiResponse confirm( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long orderId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody MerchantInboundBillingResponse.ConfirmFeeRequest request) { + long tenantId = FirstlegScopeGuard.merchant(user); + String operatorName = user.displayName() == null ? user.username() : user.displayName(); + return ApiResponse.success(service.confirmFee(tenantId, user.userId(), operatorName, + orderId, request.expectedFeeVersion(), idempotencyKey)); + } + @GetMapping("/{orderId}/inbound-billing/images/{fileId}") @PreAuthorize("hasAuthority('merchant:order:view')") @Operation(summary = "查看已发布计费明细中的测量或称重图片") 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 b5c07df49..1c02c042f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaPricingController.java @@ -45,7 +45,9 @@ public class PlatformFbaPricingController { return ApiResponse.success(service.calculate(scope, user.userId(), inboundId, request == null ? null : request.pricingAt(), request == null ? null : request.selectedWeightKg(), - request == null ? null : request.selectedVolumeCbm(), idempotencyKey)); + request == null ? null : request.selectedVolumeCbm(), + request == null ? null : request.weightSource(), + request == null ? null : request.volumeSource(), idempotencyKey)); } @PostMapping("/confirm") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java index 99e332c43..1d8e12098 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -23,7 +23,7 @@ public final class CustomerDimensionConfigDtos { ) { } public record DefaultCalculationModeUpdateRequest( - @NotBlank @Pattern(regexp = "ADD|MULTIPLY") String defaultCalculationMode + @NotBlank @Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String defaultCalculationMode ) { } public record RuleUpdateRequest( 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 42de4cfb5..6ceff5682 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -86,6 +86,7 @@ public final class FbaPricingDtos { public record FeeSnapshotResponse( Long feeId, String feeNo, Integer feeVersion, String feeStatus, Long inboundId, String inboundCode, + Long measurementId, String measurementSource, Long chargeRuleId, String ruleCode, Integer chargeRuleVersion, Long carrierRateId, String rateCode, Integer carrierRateVersion, Long priceBookVersionId, Long customerId, String customerName, Long originWarehouseId, String originWarehouseName, @@ -100,6 +101,8 @@ public final class FbaPricingDtos { BigDecimal totalAmount, String currency, LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo, LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt, - String nextProcessStatus + String nextProcessStatus, String customerConfirmationStatus, + Long customerConfirmedBy, String customerConfirmedByName, + LocalDateTime customerConfirmedAt ) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index b1d60ed3f..957556113 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -50,13 +50,14 @@ public final class InboundMeasurementRequests { @NotNull @DecimalMin(value = "0.001", message = "箱规高度必须大于等于 0.001 cm") @DecimalMax(value = "999999999.999", message = "箱规高度不能超过 999,999,999.999 cm") @Digits(integer = 9, fraction = 3, message = "箱规高度最多 9 位整数、3 位小数") BigDecimal heightCm, + @DecimalMin("0.000") @Digits(integer = 9, fraction = 4) BigDecimal unitWeightKg, @Positive Long intelligentImageFileId, @Size(max = 500) String overLimitWarning, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm, @DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm, @Positive Long chargeRuleId, - @jakarta.validation.constraints.Pattern(regexp = "ADD|MULTIPLY") String coefficientMode, + @jakarta.validation.constraints.Pattern(regexp = "ACTUAL|ADD|MULTIPLY") String coefficientMode, @Size(max = 500) String remark ) { public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount, @@ -65,7 +66,7 @@ public final class InboundMeasurementRequests { BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, Long chargeRuleId, String remark) { this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, - intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, chargeRuleId, null, remark); } @@ -75,7 +76,7 @@ public final class InboundMeasurementRequests { BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, String remark) { this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, - intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, null, null, remark); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 74c2b5956..6863911ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -90,6 +90,10 @@ public final class InboundMeasurementResponses { BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + BigDecimal calculatedOuterLengthCm, + BigDecimal calculatedOuterWidthCm, + BigDecimal calculatedOuterHeightCm, + String calculationRuleVersion, Long chargeRuleId, String coefficientMode, String remark, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java index 8e7ecfda9..336ad318c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MeasurementBatchCommand.java @@ -35,6 +35,7 @@ public record MeasurementBatchCommand( BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + BigDecimal unitWeightKg, Long intelligentImageFileId, String overLimitWarning, BigDecimal outerLengthCm, @@ -44,13 +45,23 @@ public record MeasurementBatchCommand( String coefficientMode, String remark ) { + public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, + Long intelligentImageFileId, String overLimitWarning, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, + Long chargeRuleId, String coefficientMode, String remark) { + this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, + null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + chargeRuleId, coefficientMode, remark); + } + public Item(Long inboundDetailId, String itemNo, String boxSpecification, int packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, Long intelligentImageFileId, String overLimitWarning, BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, Long chargeRuleId, String remark) { this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, - intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, chargeRuleId, null, remark); } @@ -60,7 +71,7 @@ public record MeasurementBatchCommand( BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm, String remark) { this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, - intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, + null, intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm, null, null, remark); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index b884526ed..32fd1d44b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.dto; import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.math.BigDecimal; import java.time.LocalDate; @@ -16,11 +18,14 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, BillingBasis billingBasis, FeeSnapshotResponse latestFee, PublishedFeeDetail publishedFeeDetail) { + public record ConfirmFeeRequest(@NotNull @Positive Integer expectedFeeVersion) { + } public record MeasurementSummary( Long measurementId, String sourceType, String measurementStatus, Integer actualPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, - BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) { + BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName, + String calculationSource) { } public record ImageReference( @@ -32,7 +37,8 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound, Long id, String itemNo, String boxSpecification, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm, - BigDecimal volumeWeightKg) { + BigDecimal volumeWeightKg, Long chargeRuleId, String coefficientMode, + String calculationRuleVersion) { } public record WeightResult( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java index 3633b203a..4ed2769d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java @@ -10,7 +10,7 @@ import lombok.Setter; import java.math.BigDecimal; -/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */ +/** 客户固定箱规的内径和每侧加宽配置。 */ @Getter @Setter @TableName("fl_customer_dimension_config") 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 e88c7afc0..667d3a527 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -18,6 +18,8 @@ public class InboundFeeEntity { private Long id; private Long tenantId; private Long inboundId; + private Long measurementId; + private String measurementSource; private String feeNo; private Integer feeVersion; private Long chargeRuleId; @@ -67,6 +69,9 @@ public class InboundFeeEntity { private LocalDateTime confirmedAt; private Long sentBy; private LocalDateTime sentAt; + private Long customerConfirmedBy; + private String customerConfirmedByName; + private LocalDateTime customerConfirmedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index 9342d5412..fe5aa1ee9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -44,6 +44,9 @@ public class InboundMeasurementItemEntity { private BigDecimal outerLengthCm; private BigDecimal outerWidthCm; private BigDecimal outerHeightCm; + private BigDecimal manualOuterLengthCm; + private BigDecimal manualOuterWidthCm; + private BigDecimal manualOuterHeightCm; private String remark; private LocalDateTime createdAt; private LocalDateTime updatedAt; 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 f2534b195..73e311255 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -331,6 +331,20 @@ public class FbaPricingRepository { .set(InboundFeeEntity::getSentAt, now)); } + public int confirmFeeByCustomer(long tenantId, long feeId, int expectedFeeVersion, + long operatorId, String operatorName, LocalDateTime now) { + return fees.update(null, new LambdaUpdateWrapper() + .eq(InboundFeeEntity::getTenantId, tenantId) + .eq(InboundFeeEntity::getId, feeId) + .eq(InboundFeeEntity::getFeeVersion, expectedFeeVersion) + .eq(InboundFeeEntity::getFeeStatus, "SENT") + .isNull(InboundFeeEntity::getCustomerConfirmedAt) + .set(InboundFeeEntity::getCustomerConfirmedBy, operatorId) + .set(InboundFeeEntity::getCustomerConfirmedByName, operatorName) + .set(InboundFeeEntity::getCustomerConfirmedAt, now) + .set(InboundFeeEntity::getUpdatedAt, now)); + } + public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion, LocalDateTime now) { return fees.update(null, new LambdaUpdateWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java index 7a7311f88..321663e43 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -26,7 +26,8 @@ import java.util.Map; @Service public class CustomerDimensionConfigService { public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000"); - public static final String DEFAULT_CALCULATION_MODE = "ADD"; + public static final String DEFAULT_CALCULATION_MODE = "ACTUAL"; + private static final BigDecimal TWO = new BigDecimal("2"); private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); @Resource @@ -115,7 +116,7 @@ public class CustomerDimensionConfigService { requirePositive(rule.innerLengthCm(), "箱规内长"); requirePositive(rule.innerWidthCm(), "箱规内宽"); requirePositive(rule.innerHeightCm(), "箱规内高"); - requirePositive(rule.edgeAdditionCm(), "箱规尺寸增加值"); + requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽"); if (rule.version() == null || rule.version() < 0) { throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效"); } @@ -222,10 +223,10 @@ public class CustomerDimensionConfigService { } private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) { - BigDecimal addition = value.getEdgeAdditionCm(); - BigDecimal outerLength = value.getInnerLengthCm().add(addition); - BigDecimal outerWidth = value.getInnerWidthCm().add(addition); - BigDecimal outerHeight = value.getInnerHeightCm().add(addition); + BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO); + BigDecimal outerLength = value.getInnerLengthCm().add(edgePair); + BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair); + BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair); BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight) .divide(CUBIC_CENTIMETERS_PER_CBM) .setScale(3, RoundingMode.HALF_UP); @@ -253,7 +254,7 @@ public class CustomerDimensionConfigService { private static String normalizeCalculationMode(String value) { if (value == null || value.isBlank()) return DEFAULT_CALCULATION_MODE; String normalized = value.trim().toUpperCase(); - if (!"ADD".equals(normalized) && !"MULTIPLY".equals(normalized)) { + if (!"ACTUAL".equals(normalized) && !"ADD".equals(normalized) && !"MULTIPLY".equals(normalized)) { throw new BizException(ResultCode.BAD_REQUEST, "默认箱规计算方式无效"); } return normalized; 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 a976ac6f2..637dd601b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java @@ -16,6 +16,7 @@ import java.util.List; /** Resolves dimensions to the smallest containing rule, capped at the largest configured tier. */ @Service public class CustomerDimensionWeightingService { + private static final BigDecimal TWO = new BigDecimal("2"); private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000"); private final CustomerDimensionConfigRepository repository; @@ -60,7 +61,7 @@ public class CustomerDimensionWeightingService { return rules.stream() .filter(rule -> rule.contains(measured)) .findFirst() - .map(Rule::weighted) + .map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm)) .orElseGet(() -> rules.get(rules.size() - 1) .weightedActualDimensions(lengthCm, widthCm, heightCm)); } @@ -122,22 +123,12 @@ public class CustomerDimensionWeightingService { && measured.get(2).compareTo(inner.get(2)) <= 0; } - private WeightedDimensions weighted() { - BigDecimal outerLength = innerLengthCm.add(edgeAdditionCm); - BigDecimal outerWidth = innerWidthCm.add(edgeAdditionCm); - BigDecimal outerHeight = innerHeightCm.add(edgeAdditionCm); - BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight) - .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); - BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP); - return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight, - rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version); - } - private WeightedDimensions weightedActualDimensions(BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { - BigDecimal outerLength = lengthCm.add(edgeAdditionCm); - BigDecimal outerWidth = widthCm.add(edgeAdditionCm); - BigDecimal outerHeight = heightCm.add(edgeAdditionCm); + BigDecimal edgePair = edgeAdditionCm.multiply(TWO); + BigDecimal outerLength = lengthCm.add(edgePair); + BigDecimal outerWidth = widthCm.add(edgePair); + BigDecimal outerHeight = heightCm.add(edgePair); BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight) .divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP); BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP); 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 7d0c17d18..50918fc23 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -38,6 +38,8 @@ public class FbaPricingService { @Resource private FbaPricingRepository repository; @Resource + private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository; + @Resource private ActivePriceBookEstimator priceBookEstimator; @Resource private Clock clock; @@ -56,15 +58,41 @@ public class FbaPricingService { public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, LocalDateTime requestedAt, BigDecimal selectedWeightKg, BigDecimal selectedVolumeCbm, String idempotencyKey) { + return calculate(tenantId, operatorId, inboundId, requestedAt, selectedWeightKg, + selectedVolumeCbm, null, null, idempotencyKey); + } + + @Transactional + public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, + LocalDateTime requestedAt, BigDecimal selectedWeightKg, + BigDecimal selectedVolumeCbm, String weightSource, + String volumeSource, String idempotencyKey) { String discriminator = (requestedAt == null ? "CURRENT" : requestedAt.toString()) + ":" + (selectedWeightKg == null ? "ACTUAL_WEIGHT" : selectedWeightKg) + ":" + (selectedVolumeCbm == null ? "ACTUAL_VOLUME" : selectedVolumeCbm); + if (weightSource != null || volumeSource != null) { + discriminator += ":" + (weightSource == null ? "UNSPECIFIED" : weightSource) + + ":" + (volumeSource == null ? "UNSPECIFIED" : volumeSource); + } String hash = hash("CALCULATE", inboundId, discriminator); InboundPricingIdempotencyEntity token = beginIdempotency( tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash); if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null); InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId); + var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId); + if ("OUTER".equalsIgnoreCase(volumeSource) && measurement == null) { + throw conflict("外箱规测量批次不存在,不能生成对客费用"); + } + if ("OUTER".equalsIgnoreCase(volumeSource)) { + var outerItems = measurementRepository.listItems(tenantId, measurement.getId()); + boolean incomplete = outerItems.isEmpty() || outerItems.stream().anyMatch(item -> + item.getOuterLengthCm() == null || item.getOuterLengthCm().signum() <= 0 + || item.getOuterWidthCm() == null || item.getOuterWidthCm().signum() <= 0 + || item.getOuterHeightCm() == null || item.getOuterHeightCm().signum() <= 0 + || !StringUtils.hasText(item.getCalculationRuleVersion())); + if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); + } ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, @@ -84,6 +112,8 @@ public class FbaPricingService { InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version, weight, volume, converted, chargeable, subtotal, additional, total, now); + fee.setMeasurementId(measurement == null ? null : measurement.getId()); + fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource); repository.supersedeCalculated(tenantId, inboundId); repository.insertFee(fee); repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now)); @@ -102,6 +132,10 @@ public class FbaPricingService { @Transactional public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { + InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); + if (!feeEditableBeforeLoading(inbound.getProcessStatus())) { + throw conflict("订单已进入装柜流程,不能再修改或发送费用"); + } InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); if (current == null) throw conflict("入库单尚无可确认的费用试算"); String hash = hash("CONFIRM", inboundId, current.getId().toString()); @@ -121,6 +155,9 @@ public class FbaPricingService { public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId, String idempotencyKey) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); + if (!feeEditableBeforeLoading(inbound.getProcessStatus())) { + throw conflict("订单已进入装柜流程,不能再修改或发送费用"); + } InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); if (current == null) throw conflict("入库单尚无费用快照"); String hash = hash("SEND", inboundId, current.getId().toString()); @@ -151,6 +188,39 @@ public class FbaPricingService { return response(tenantId, fee.getId(), null); } + @Transactional + public FeeSnapshotResponse confirmByCustomer(long tenantId, long operatorId, String operatorName, + long inboundId, int expectedFeeVersion, + String idempotencyKey) { + InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId); + if (current == null || !"SENT".equals(current.getFeeStatus())) { + throw conflict("费用尚未发送给客户,不能确认"); + } + if (!Integer.valueOf(expectedFeeVersion).equals(current.getFeeVersion())) { + throw conflict("费用版本已变化,请刷新后重新确认"); + } + String hash = hash("CUSTOMER_CONFIRM", inboundId, + current.getId() + ":" + expectedFeeVersion); + InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, + "CUSTOMER_FEE_CONFIRM", idempotencyKey, hash); + if ("COMPLETED".equals(token.getStatus())) { + return response(tenantId, token.getResultResourceId(), null); + } + if (current.getCustomerConfirmedAt() != null) { + throw conflict("客户已确认该费用,不允许重复确认"); + } + String confirmedName = StringUtils.hasText(operatorName) + ? operatorName.trim() : String.valueOf(operatorId); + if (confirmedName.length() > 64) confirmedName = confirmedName.substring(0, 64); + LocalDateTime now = LocalDateTime.now(clock); + if (repository.confirmFeeByCustomer(tenantId, current.getId(), expectedFeeVersion, + operatorId, confirmedName, now) != 1) { + throw conflict("客户已确认或费用版本已变化,请刷新后重试"); + } + complete(token, current.getId(), now); + return response(tenantId, current.getId(), null); + } + @Transactional public FeeSnapshotResponse adjustPublished(long tenantId, long operatorId, long inboundId, AdjustFeeRequest request, String idempotencyKey) { @@ -163,6 +233,9 @@ public class FbaPricingService { if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) { throw conflict("入库单没有可修改的初算或已发布费用"); } + if (current.getCustomerConfirmedAt() != null) { + throw conflict("客户已确认该费用,不允许再修改"); + } if (!request.expectedFeeVersion().equals(current.getFeeVersion())) { throw conflict("费用版本已变化,请刷新后重试"); } @@ -237,7 +310,7 @@ public class FbaPricingService { private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) { InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId); - if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus()) + if (!feeEditableBeforeLoading(inbound.getProcessStatus()) || !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) { throw conflict("当前入库单状态不允许费用试算"); } @@ -250,11 +323,6 @@ public class FbaPricingService { if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) { throw conflict("当前目的地类型不支持自动计费"); } - if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照"); - if (inbound.getActualNetWeightKg() == null || inbound.getActualNetWeightKg().signum() <= 0 - || inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) { - throw conflict("审核通过的测量快照必须包含大于零的货物净重和体积"); - } if (inbound.getCustomerId() == null || inbound.getCustomerId() <= 0) { throw conflict("入库单缺少订单客户归属,不能生成对客费用"); } @@ -268,6 +336,11 @@ public class FbaPricingService { return "WAIT_SPLIT".equals(processStatus); } + private static boolean feeEditableBeforeLoading(String processStatus) { + return List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", + "WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(processStatus); + } + private static PricingDestinationType destinationType(InboundPricingEntity inbound) { try { return PricingDestinationType.valueOf(inbound.getDestinationType()); @@ -383,6 +456,7 @@ public class FbaPricingService { BigDecimal unitPrice, String nextStatus) { return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(), fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(), + fee.getMeasurementId(), fee.getMeasurementSource(), fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(), fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(), fee.getPriceBookVersionId(), fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(), @@ -394,7 +468,9 @@ public class FbaPricingService { fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(), fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(), - fee.getSentAt(), nextStatus); + fee.getSentAt(), nextStatus, + fee.getCustomerConfirmedAt() == null ? "PENDING_CONFIRMATION" : "CONFIRMED", + fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt()); } private static String hash(String operation, long inboundId, String discriminator) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index df644942b..72772cbea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -67,10 +67,22 @@ public class InboundMeasurementService { public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId); + return detail(tenantId, inbound, measurement); + } + + public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId, long measurementId) { + InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId); + if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "费用绑定的测量批次不存在"); + return detail(tenantId, inbound, measurement); + } + + private InboundMeasurementResponses.Detail detail(long tenantId, InboundOrderEntity inbound, + InboundMeasurementEntity measurement) { List items = measurement == null ? List.of() : repository.listItems(tenantId, measurement.getId()); - List weights = repository.listWeights(tenantId, inboundId); - MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId); + List weights = repository.listWeights(tenantId, inbound.getId()); + MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); return toDetail(inbound, measurement, items, weights, review, latestDeclaration); } @@ -119,7 +131,7 @@ public class InboundMeasurementService { actualGoodsName, operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item( item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(), - item.widthCm(), item.heightCm(), item.intelligentImageFileId(), + item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(), item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(), item.chargeRuleId(), item.coefficientMode(), item.remark())).toList()); InboundMeasurementEntity saved = batchService.saveBatch(command); @@ -154,9 +166,6 @@ public class InboundMeasurementService { inbound.setProcessStatus("MEASURE_PENDING"); inbound.setMeasureReviewStatus("PENDING"); inbound.setMeasureCompletedAt(LocalDateTime.now()); - if (measurement.getTotalPackageCount() != null) { - inbound.setTotalPackageCount(measurement.getTotalPackageCount()); - } inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg()); inbound.setActualNetWeightKg(confirmedTotals.netWeightKg()); inbound.setPalletCount(confirmedTotals.palletCount()); @@ -534,36 +543,21 @@ public class InboundMeasurementService { } private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { - String overrideVersion = item.getCalculationRuleVersion(); - boolean legacyFullOverride = "WAREHOUSE_OUTER_OVERRIDE_V1".equals(overrideVersion); - boolean lengthOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'L'); - boolean widthOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'W'); - boolean heightOverridden = legacyFullOverride || warehouseOverrideContains(overrideVersion, 'H'); return new InboundMeasurementResponses.Item( item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), item.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), - item.getOverLimitWarning(), lengthOverridden ? item.getOuterLengthCm() : null, - widthOverridden ? item.getOuterWidthCm() : null, - heightOverridden ? item.getOuterHeightCm() : null, + item.getOverLimitWarning(), item.getManualOuterLengthCm(), + item.getManualOuterWidthCm(), item.getManualOuterHeightCm(), + item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), + item.getCalculationRuleVersion(), item.getChargeRuleId(), item.getCoefficientMode(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } - private boolean warehouseOverrideContains(String calculationRuleVersion, char dimension) { - if (calculationRuleVersion == null - || !calculationRuleVersion.startsWith("WAREHOUSE_OUTER_OVERRIDE_") - || !calculationRuleVersion.endsWith("_V1")) { - return false; - } - String dimensions = calculationRuleVersion.substring( - "WAREHOUSE_OUTER_OVERRIDE_".length(), calculationRuleVersion.length() - 3); - return dimensions.indexOf(dimension) >= 0; - } - private void requireConfirmedPackageTotal(InboundOrderEntity inbound, List items) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { 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 f7be95ab3..ee8bb0032 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -44,7 +44,7 @@ public class MeasurementBatchService { private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); private static final Pattern SPECIFICATION_COEFFICIENT_PATTERN = Pattern.compile( - "(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?);系数:(\\d+(?:\\.\\d+)?)"); + "(SMALL|MEDIUM|LARGE|EXTRA_LARGE):(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?);系数:(\\d+(?:\\.\\d+)?)(?:;重量:(\\d+(?:\\.\\d+)?))?"); @Resource private InboundMeasurementRepository repository; @Resource @@ -167,7 +167,8 @@ public class MeasurementBatchService { : requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null; String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode; boolean useDimensionRule = "ADD".equals(coefficientMode); - BigDecimal addition = weighted.edgeAdditionCm(); + BigDecimal addition = weighted.edgeAdditionCm() == null + ? null : weighted.edgeAdditionCm().multiply(BigDecimal.valueOf(2)); boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null; // ADD 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准, // 不能把箱规边界尺寸当成本次货物的实际外尺寸。 @@ -176,7 +177,7 @@ public class MeasurementBatchService { BigDecimal outerHeight = hasMatchedAddition ? baseHeight.add(addition) : baseHeight; if (coefficientRule != null) { BigDecimal specificationCoefficient = resolveSpecificationCoefficient( - coefficientRule, baseLength, baseWidth, baseHeight); + coefficientRule, baseLength, baseWidth, baseHeight, source.unitWeightKg()); outerLength = applyCoefficient(outerLength, specificationCoefficient); outerWidth = applyCoefficient(outerWidth, specificationCoefficient); outerHeight = applyCoefficient(outerHeight, specificationCoefficient); @@ -222,13 +223,13 @@ public class MeasurementBatchService { : useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1"); item.setSourceImageFileId(source.intelligentImageFileId()); item.setOverLimitWarning(source.overLimitWarning()); - // Override fields retain the operator's base values so repeated saves do not compound the strategy. - item.setOuterLengthCm((source.outerLengthCm() != null ? source.outerLengthCm() : outerLength) - .setScale(3, RoundingMode.HALF_UP)); - item.setOuterWidthCm((source.outerWidthCm() != null ? source.outerWidthCm() : outerWidth) - .setScale(3, RoundingMode.HALF_UP)); - item.setOuterHeightCm((source.outerHeightCm() != null ? source.outerHeightCm() : outerHeight) - .setScale(3, RoundingMode.HALF_UP)); + // 人工外箱值是策略计算基数;最终外箱值单独落库,避免覆盖基数或重复叠加策略。 + item.setManualOuterLengthCm(source.outerLengthCm()); + item.setManualOuterWidthCm(source.outerWidthCm()); + item.setManualOuterHeightCm(source.outerHeightCm()); + item.setOuterLengthCm(outerLength.setScale(3, RoundingMode.HALF_UP)); + item.setOuterWidthCm(outerWidth.setScale(3, RoundingMode.HALF_UP)); + item.setOuterHeightCm(outerHeight.setScale(3, RoundingMode.HALF_UP)); item.setRemark(source.remark()); item.setCreatedAt(LocalDateTime.now()); return item; @@ -250,7 +251,7 @@ public class MeasurementBatchService { private String normalizeMode(String mode) { if (mode == null || mode.isBlank()) return null; String normalized = mode.trim().toUpperCase(java.util.Locale.ROOT); - if (!Set.of("ADD", "MULTIPLY").contains(normalized)) { + if (!Set.of("ACTUAL", "ADD", "MULTIPLY").contains(normalized)) { throw new BizException(ResultCode.VALIDATION_FAILED, "系数模式仅支持 ADD 或 MULTIPLY"); } return normalized; @@ -287,14 +288,14 @@ public class MeasurementBatchService { private BigDecimal resolveSpecificationCoefficient(CustomerChargeRuleEntity rule, BigDecimal length, BigDecimal width, - BigDecimal height) { + BigDecimal height, BigDecimal unitWeightKg) { List tiers = parseSpecificationCoefficients(rule.getRemark()); if (tiers.isEmpty()) { return rule.getLengthFactor() == null ? BigDecimal.ONE : rule.getLengthFactor(); } List measured = sortedDimensions(length, width, height); return tiers.stream() - .filter(tier -> tier.contains(measured)) + .filter(tier -> tier.contains(measured, unitWeightKg)) .findFirst() .orElse(tiers.get(tiers.size() - 1)) .coefficient(); @@ -308,7 +309,8 @@ public class MeasurementBatchService { List dimensions = sortedDimensions(new BigDecimal(matcher.group(2)), new BigDecimal(matcher.group(3)), new BigDecimal(matcher.group(4))); BigDecimal coefficient = new BigDecimal(matcher.group(5)); - if (coefficient.signum() > 0) tiers.add(new SpecificationCoefficient(dimensions, coefficient)); + BigDecimal weight = matcher.group(6) == null ? null : new BigDecimal(matcher.group(6)); + if (coefficient.signum() > 0) tiers.add(new SpecificationCoefficient(dimensions, coefficient, weight)); } tiers.sort(Comparator.comparing(SpecificationCoefficient::volume)); return tiers; @@ -320,15 +322,16 @@ public class MeasurementBatchService { return dimensions; } - private record SpecificationCoefficient(List dimensions, BigDecimal coefficient) { + private record SpecificationCoefficient(List dimensions, BigDecimal coefficient, BigDecimal maxWeightKg) { private BigDecimal volume() { return dimensions.get(0).multiply(dimensions.get(1)).multiply(dimensions.get(2)); } - private boolean contains(List measured) { + private boolean contains(List measured, BigDecimal unitWeightKg) { return measured.get(0).compareTo(dimensions.get(0)) <= 0 && measured.get(1).compareTo(dimensions.get(1)) <= 0 - && measured.get(2).compareTo(dimensions.get(2)) <= 0; + && measured.get(2).compareTo(dimensions.get(2)) <= 0 + && (maxWeightKg == null || unitWeightKg == null || unitWeightKg.compareTo(maxWeightKg) <= 0); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 954e1d890..e84e241f1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -18,6 +18,7 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Set; +import java.math.BigDecimal; @Service public class MerchantInboundBillingService { @@ -55,13 +56,31 @@ public class MerchantInboundBillingService { platformDetail.files().stream() .filter(file -> "PUBLIC".equals(file.visibility()) || "MERCHANT".equals(file.visibility())) .toList(), List.of(), List.of()); - InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId()); FeeSnapshotResponse fee = pricing.latest(tenantId, inbound.getId()); + InboundMeasurementResponses.Detail measurement = fee.measurementId() == null || fee.measurementId() <= 0 + ? measurements.detail(tenantId, inbound.getId()) + : measurements.detail(tenantId, inbound.getId(), fee.measurementId()); + var measurementItems = measurement.items().stream() + .map(item -> new MerchantInboundBillingResponse.MeasurementItem( + item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(), + requireOuterDimension(item.calculatedOuterLengthCm(), item.itemNo(), "长"), + requireOuterDimension(item.calculatedOuterWidthCm(), item.itemNo(), "宽"), + requireOuterDimension(item.calculatedOuterHeightCm(), item.itemNo(), "高"), + item.unitVolumeCbm(), item.totalVolumeCbm(), item.volumeWeightKg(), + item.chargeRuleId(), item.coefficientMode(), item.calculationRuleVersion())) + .toList(); + int outerPackageCount = measurementItems.stream() + .map(MerchantInboundBillingResponse.MeasurementItem::packageCount) + .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); + BigDecimal outerVolume = sum(measurementItems.stream() + .map(MerchantInboundBillingResponse.MeasurementItem::totalVolumeCbm).toList()); + BigDecimal outerVolumeWeight = sum(measurementItems.stream() + .map(MerchantInboundBillingResponse.MeasurementItem::volumeWeightKg).toList()); var summary = new MerchantInboundBillingResponse.MeasurementSummary( measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(), - measurement.actualPackageCount(), measurement.actualGrossWeightKg(), + outerPackageCount, measurement.actualGrossWeightKg(), measurement.actualNetWeightKg(), - measurement.actualVolumeCbm(), measurement.volumeWeightKg(), measurement.actualGoodsName()); + outerVolume, outerVolumeWeight, measurement.actualGoodsName(), "OUTER"); var measurementImages = measurement.items().stream() .filter(item -> item.intelligentImageFileId() != null) .map(item -> new MerchantInboundBillingResponse.ImageReference( @@ -73,12 +92,6 @@ public class MerchantInboundBillingService { .map(weight -> new MerchantInboundBillingResponse.ImageReference( weight.fileId(), "WEIGHT", null, weight.id(), previewUrl(orderId, weight.fileId()))) .toList(); - var measurementItems = measurement.items().stream() - .map(item -> new MerchantInboundBillingResponse.MeasurementItem( - item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(), - item.lengthCm(), item.widthCm(), item.heightCm(), item.unitVolumeCbm(), - item.totalVolumeCbm(), item.volumeWeightKg())) - .toList(); var weightResults = measurement.weightResults().stream() .map(weight -> new MerchantInboundBillingResponse.WeightResult( weight.id(), weight.fileId(), weight.palletId(), weight.palletCode(), weight.palletType(), @@ -96,6 +109,14 @@ public class MerchantInboundBillingService { weightResults, measurementImages, weightImages, basis, fee, publishedFee(publishedFee)); } + public FeeSnapshotResponse confirmFee(long tenantId, long operatorId, String operatorName, + long orderId, int expectedFeeVersion, + String idempotencyKey) { + InboundOrderEntity inbound = requirePublished(tenantId, orderId); + return pricing.confirmByCustomer(tenantId, operatorId, operatorName, inbound.getId(), + expectedFeeVersion, idempotencyKey); + } + public ImageDownload downloadImage(long tenantId, long orderId, long fileId) { InboundOrderEntity inbound = requirePublished(tenantId, orderId); InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId()); @@ -134,6 +155,19 @@ public class MerchantInboundBillingService { + "/inbound-billing/images/" + fileId; } + private static BigDecimal requireOuterDimension(BigDecimal value, String itemNo, String dimension) { + if (value == null || value.signum() <= 0) { + throw new BizException(ResultCode.CONFLICT, + "外箱规数据不完整,明细 " + (itemNo == null ? "—" : itemNo) + " 缺少外箱" + dimension); + } + return value; + } + + private static BigDecimal sum(List values) { + return values.stream().filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + private String chargeableBasis(FeeSnapshotResponse fee) { int compared = ("KG".equals(fee.chargeMode()) ? fee.actualWeightKg() : fee.actualVolumeCbm()) .compareTo(fee.convertedQuantity()); diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql index 42cae340c..52c9ad840 100644 --- a/firstleg/src/main/resources/db/customer-dimension-config.sql +++ b/firstleg/src/main/resources/db/customer-dimension-config.sql @@ -9,8 +9,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)', inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', - edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数', - default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL' COMMENT '订单外箱规默认计算方式:ACTUAL不调整、ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql index c99f8df9b..06ba31adf 100644 --- a/firstleg/src/main/resources/db/firstleg.sql +++ b/firstleg/src/main/resources/db/firstleg.sql @@ -56,8 +56,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)', inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', - edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数', - default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL' COMMENT '订单外箱规默认计算方式:ACTUAL不调整、ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5a99b447b..62d521929 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -26,6 +26,8 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL( CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); +CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id"); +CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_source', "VARCHAR(32) NULL COMMENT '计费尺寸来源快照:OUTER为外箱规数据' AFTER measurement_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code"); CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name"); @@ -43,6 +45,9 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT N CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name"); CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code"); CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_by', "BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID' AFTER sent_at"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_by_name', "VARCHAR(64) NULL COMMENT '客户确认人名称快照' AFTER customer_confirmed_by"); +CALL phase2_fba_add_column('fl_inbound_fee', 'customer_confirmed_at', "DATETIME(3) NULL COMMENT '客户确认费用时间;非空后费用版本锁定' AFTER customer_confirmed_by_name"); CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json"); CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id', "BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id"); diff --git a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql index 142a7d045..7f413d5bc 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-measurement-maintenance.sql @@ -59,6 +59,12 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_width_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规宽度cm'' AFTER outer_length_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'outer_height_cm', 'DECIMAL(12,3) NULL COMMENT ''外箱规高度cm'' AFTER outer_width_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_length_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规长度计算基数cm'' AFTER outer_height_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_width_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规宽度计算基数cm'' AFTER manual_outer_length_cm'); +CALL phase2_measure_add_column('fl_inbound_measurement_item', 'manual_outer_height_cm', + 'DECIMAL(12,3) NULL COMMENT ''仓库人工外箱规高度计算基数cm'' AFTER manual_outer_width_cm'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'charge_rule_id', 'BIGINT UNSIGNED NULL COMMENT ''该测量明细绑定的客户计费规则ID'' AFTER volume_weight_kg'); CALL phase2_measure_add_column('fl_inbound_measurement_item', 'coefficient_mode', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 79bb01ec1..2a2591f80 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -271,15 +271,18 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', charge_rule_id BIGINT UNSIGNED NULL COMMENT '该测量明细绑定的客户计费规则ID', - coefficient_mode VARCHAR(16) NULL COMMENT '系数模式快照:ADD或MULTIPLY', + coefficient_mode VARCHAR(16) NULL COMMENT '箱规计算策略快照:ACTUAL、ADD或MULTIPLY', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间', over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照', - outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm', - outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm', - outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm', + outer_length_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规长度cm', + outer_width_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规宽度cm', + outer_height_cm DECIMAL(12,3) NULL COMMENT '策略计算后的最终外箱规高度cm', + manual_outer_length_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规长度计算基数cm', + manual_outer_width_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规宽度计算基数cm', + manual_outer_height_cm DECIMAL(12,3) NULL COMMENT '仓库人工外箱规高度计算基数cm', remark VARCHAR(500) NULL COMMENT '测量明细备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', @@ -621,6 +624,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照', + measurement_source VARCHAR(32) NULL COMMENT '计费尺寸来源快照:OUTER为外箱规数据', fee_no VARCHAR(40) NOT NULL COMMENT '客户内唯一费用单号', fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本', charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空', @@ -670,6 +675,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( confirmed_at DATETIME(3) NULL COMMENT '确认时间', sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID', sent_at DATETIME(3) NULL COMMENT '发送客户时间', + customer_confirmed_by BIGINT UNSIGNED NULL COMMENT '客户确认人用户ID', + customer_confirmed_by_name VARCHAR(64) NULL COMMENT '客户确认人名称快照', + customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认费用时间;非空后费用版本锁定', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), @@ -862,8 +870,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)', inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)', inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)', - edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '长宽高各自增加一次的厘米数', - default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数', + default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ACTUAL' COMMENT '订单外箱规默认计算方式:ACTUAL不调整、ADD或MULTIPLY', remark VARCHAR(500) NULL COMMENT '备注', initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', created_by 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 6e88e7c78..dd8dd9044 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -341,6 +341,10 @@ components: confirmedAt: {type: string, format: date-time, nullable: true} sentAt: {type: string, format: date-time, nullable: true} nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT} + customerConfirmationStatus: {type: string, enum: [PENDING_CONFIRMATION, CONFIRMED], description: 客户对当前费用版本的确认状态} + customerConfirmedBy: {type: string, nullable: true, description: 客户确认人用户ID} + customerConfirmedByName: {type: string, nullable: true, description: 客户确认人名称快照} + customerConfirmedAt: {type: string, format: date-time, nullable: true, description: 客户确认时间;非空后当前版本不可修改} InboundFeeDetailSaveRequest: type: object required: [feeItemId, direction, businessDate, unitPrice, currency, amount] diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9edcebd47..b9a13c2d5 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1044,7 +1044,7 @@ paths: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: get: summary: 客户查询已完成入库结果和最新已发布费用 - description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围只读查看。入库流程达到 WAIT_SPLIT 后,费用在 LOADING、LOADED、DELIVERED 仍持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细,不需要客户确认费用。CONFIRMED 只表示发送前确认完成,尚未生成可对客发布明细。 + description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。入库流程达到 WAIT_SPLIT 后,费用在 LOADING、LOADED、DELIVERED 仍持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。latestFee.customerConfirmationStatus 表示客户是否已确认当前费用版本。 parameters: - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -1054,6 +1054,26 @@ paths: application/json: schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'} '403': {description: 非当前客户或无订单查看权限} + /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm: + post: + summary: 客户确认已发布的入库费用 + description: 需要 merchant:order:edit 权限;仅可确认当前客户 SENT 状态的最新费用版本。确认成功后记录当前成员与确认时间,并永久锁定该费用版本,平台不得再修改。 + parameters: + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {$ref: '#/components/parameters/IdempotencyKey'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedFeeVersion] + properties: + expectedFeeVersion: {type: integer, minimum: 1} + responses: + '200': {description: 成功时返回 customerConfirmationStatus=CONFIRMED 的当前费用快照} + '403': {description: 非当前客户或无订单编辑权限} + '409': {description: 费用未发送、版本已变化或已经确认} /api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust: post: summary: 调整已审核柜次费用的日期和金额 @@ -1429,7 +1449,7 @@ components: type: object required: [defaultCalculationMode] properties: - defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]} + defaultCalculationMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]} CustomerDimensionConfigRuleUpdateRequest: type: object required: [boxType, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, version] @@ -1443,7 +1463,7 @@ components: minimum: 0.001 maximum: 100 multipleOf: 0.001 - description: 箱规长、宽、高三个方向分别增加一次的厘米数 + description: 箱规长、宽、高三个方向分别使用的每侧加宽厘米数;每个完整尺寸增加该值的2倍 example: 2.000 remark: {type: string, maxLength: 500, nullable: true} version: {type: integer, minimum: 0, description: 乐观锁版本号} @@ -1452,7 +1472,7 @@ components: required: [tenantId, defaultCalculationMode, rules] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} - defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]} + defaultCalculationMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]} rules: type: array minItems: 4 @@ -1469,9 +1489,9 @@ components: innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001} innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001} edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000} - outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm} - outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm} - outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm} + outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm * 2} + outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm * 2} + outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm * 2} volumeCbm: {type: number, multipleOf: 0.001, description: 使用未舍入外径完整计算后四舍五入保留3位} remark: {type: string, nullable: true} version: {type: integer, minimum: 0} @@ -1585,8 +1605,12 @@ components: outerLengthCm: {type: number, format: double, nullable: true, description: 仓库手工保存的外箱长度;未保存人工修正时为空} outerWidthCm: {type: number, format: double, nullable: true, description: 仓库手工保存的外箱宽度;未保存人工修正时为空} outerHeightCm: {type: number, format: double, nullable: true, description: 仓库手工保存的外箱高度;未保存人工修正时为空} + calculatedOuterLengthCm: {type: number, format: double, nullable: true, description: 已落库的最终外箱规长度} + calculatedOuterWidthCm: {type: number, format: double, nullable: true, description: 已落库的最终外箱规宽度} + calculatedOuterHeightCm: {type: number, format: double, nullable: true, description: 已落库的最终外箱规高度} + calculationRuleVersion: {type: string, nullable: true, description: 当前外箱规所绑定的箱规或系数规则版本} chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} - coefficientMode: {type: string, enum: [ADD, MULTIPLY], nullable: true} + coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} remark: {type: string, nullable: true} boxCode: {type: string, nullable: true} scannedShipmentId: {type: string, nullable: true} @@ -1696,13 +1720,14 @@ components: lengthCm: {type: number, minimum: 0.001} widthCm: {type: number, minimum: 0.001} heightCm: {type: number, minimum: 0.001} + unitWeightKg: {type: number, minimum: 0, multipleOf: 0.0001, nullable: true, description: 实际总毛重按总箱数均分后的单箱毛重;用于系数挡位匹配} intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} overLimitWarning: {type: string, maxLength: 500, nullable: true} outerLengthCm: {type: number, minimum: 0, nullable: true, description: 可单独提交的仓库外箱长度修正;保存后参与联动计算} 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: [ADD, MULTIPLY], nullable: true} + coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} remark: {type: string, maxLength: 500, nullable: true} InboundMeasurementDetailEnvelope: type: object diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java index af2b23340..1f07be958 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundBillingControllerContractTest.java @@ -35,14 +35,19 @@ class MerchantInboundBillingControllerContractTest { .isEqualTo("hasAuthority('merchant:order:view')"); assertThat(method.getAnnotation(Operation.class).description()) .contains("WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "APPROVED + SENT", "已发布入库运费明细"); - assertThat(MerchantInboundBillingController.class.getDeclaredMethods()) - .noneMatch(candidate -> candidate.getName().toLowerCase().contains("confirm")); + var confirm = MerchantInboundBillingController.class.getDeclaredMethod( + "confirm", AuthenticatedUser.class, long.class, String.class, + com.baiyue.firstleg.dto.MerchantInboundBillingResponse.ConfirmFeeRequest.class); + assertThat(confirm.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('merchant:order:edit')"); String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:") .contains("WAIT_SPLIT", "LOADING、LOADED、DELIVERED", "measureReviewStatus=APPROVED", - "feeStatus=SENT", "PUBLISHED 入库运费明细", "CONFIRMED 只表示发送前确认完成"); + "feeStatus=SENT", "PUBLISHED 入库运费明细", "customerConfirmationStatus") + .contains("/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:", + "确认成功后记录当前成员与确认时间", "平台不得再修改"); } @Test 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 2070eeea6..a70eee172 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -90,10 +90,10 @@ class CartonMeasurementServiceTest { assertThat(result.scannedShipmentId()).isEqualTo("FBA-SCAN-002"); assertThat(insertedItem.get().getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L); - assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1680"); - assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("62.000"); - assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("52.000"); - assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("52.000"); + assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1010"); + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("63.500"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("45.200"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("35.300"); assertThat(measurement.getSourceType()).isEqualTo(MeasurementBatchService.SOURCE_THIRD_PARTY); assertThat(measurement.getSourceCode()).isEqualTo("DEVICE"); } @@ -155,16 +155,16 @@ class CartonMeasurementServiceTest { assertThat(result.created()).isFalse(); assertThat(result.confirmedPackageCount()).isEqualTo(4); assertThat(existing.getLengthCm()).isEqualByComparingTo("59.500"); - assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.168"); - assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.6720"); - assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("112.224"); + assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.101"); + assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.4040"); + assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("67.468"); assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); assertThat(existing.getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); } @Test - void scanOutsideConfiguredWeightingRulesKeepsImageAndActualDimensions() { + void scanOutsideConfiguredWeightingRulesKeepsImageAndUsesLargestTierAddition() { InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0); when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null); when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement); @@ -182,8 +182,11 @@ class CartonMeasurementServiceTest { assertThat(result.created()).isTrue(); assertThat(insertedItem.get().getSourceImageFileId()).isEqualTo(99001L); assertThat(insertedItem.get().getLengthCm()).isEqualByComparingTo("60.100"); + assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("64.100"); + assertThat(insertedItem.get().getOuterWidthCm()).isEqualByComparingTo("54.100"); + assertThat(insertedItem.get().getOuterHeightCm()).isEqualByComparingTo("54.100"); assertThat(insertedItem.get().getCalculationRuleVersion()) - .isEqualTo("ACTUAL_DIMENSION_V1"); + .isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V0"); assertThat(insertedItem.get().getOverLimitWarning()).isNull(); verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java index 8913594b6..bd6179c64 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -95,10 +95,10 @@ class CustomerDimensionConfigServiceTest { assertThat(result.rules()).extracting(rule -> rule.boxType()) .containsExactly(CustomerDimensionBoxType.values()); assertThat(result.rules().get(0).boxTypeName()).isEqualTo("小箱"); - assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("32.000"); - assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("22.000"); - assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("22.000"); - assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.015"); + assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("34.000"); + assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("24.000"); + assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("24.000"); + assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.020"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java index cd59ff673..2251a0256 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -32,7 +32,7 @@ class CustomerDimensionPricingFlowTest { var kgEstimate = pricing.estimate(rate("KG", "8.700000"), actualWeight, twoCartonsVolume); var cbmEstimate = pricing.estimate(rate("CBM", "1400.000000"), actualWeight, twoCartonsVolume); - assertThat(twoCartonsVolume).isEqualByComparingTo("0.030"); + assertThat(twoCartonsVolume).isEqualByComparingTo("0.006"); assertThat(kgEstimate.quantity()).isEqualByComparingTo("24.000000"); assertThat(kgEstimate.amount()).isEqualByComparingTo("208.80"); assertThat(cbmEstimate.quantity()).isEqualByComparingTo("0.066116"); 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 423983954..ad60942b4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java @@ -26,16 +26,16 @@ class CustomerDimensionWeightingServiceTest { } @Test - void measuredDimensionsUseSmallestContainingRuleAndAddConfiguredValueOncePerDimension() { + void measuredDimensionsUseSmallestContainingRuleAndAddEachSide() { var weighted = service.load(10001L).resolve( new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); - assertThat(weighted.outerLengthCm()).isEqualByComparingTo("32.000"); - assertThat(weighted.outerWidthCm()).isEqualByComparingTo("22.000"); - assertThat(weighted.outerHeightCm()).isEqualByComparingTo("22.000"); - assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.015488"); - assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.015"); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.rawVolumeCbm()).isEqualByComparingTo("0.002744"); + assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.003"); } @Test @@ -52,7 +52,7 @@ class CustomerDimensionWeightingServiceTest { new BigDecimal("30.001"), new BigDecimal("20"), new BigDecimal("20")); assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.MEDIUM); - assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.043"); + assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.020"); } @Test @@ -62,9 +62,9 @@ class CustomerDimensionWeightingServiceTest { assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.EXTRA_LARGE); assertThat(weighted.edgeAdditionCm()).isEqualByComparingTo("2.000"); - assertThat(weighted.outerLengthCm()).isEqualByComparingTo("62.001"); - assertThat(weighted.outerWidthCm()).isEqualByComparingTo("52.000"); - assertThat(weighted.outerHeightCm()).isEqualByComparingTo("52.000"); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("64.001"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("54.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("54.000"); assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V3"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java index 8408df171..591141b94 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingTerminalRulesTest.java @@ -24,13 +24,13 @@ class CustomerDimensionWeightingTerminalRulesTest { var ruleSet = service.load(10001L); assertWeighted(ruleSet, "10", "10", "10", CustomerDimensionBoxType.SMALL, - "32", "22", "22", "0.015"); + "14", "14", "14", "0.003"); assertWeighted(ruleSet, "35", "25", "25", CustomerDimensionBoxType.MEDIUM, - "42", "32", "32", "0.043"); + "39", "29", "29", "0.033"); assertWeighted(ruleSet, "45", "35", "35", CustomerDimensionBoxType.LARGE, - "52", "42", "42", "0.092"); + "49", "39", "39", "0.075"); assertWeighted(ruleSet, "55", "45", "45", CustomerDimensionBoxType.EXTRA_LARGE, - "62", "52", "52", "0.168"); + "59", "49", "49", "0.142"); } private static void assertWeighted(CustomerDimensionWeightingService.RuleSet rules, 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 061287b76..aa4034c48 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -9,6 +9,8 @@ import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; 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.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; @@ -27,6 +29,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.concurrent.atomic.AtomicReference; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -49,6 +52,7 @@ class FbaPricingServiceTest { private static final long INBOUND = 30001L; private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0); @MockBean private FbaPricingRepository repository; + @MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository; @MockBean private InboundFeeDetailService feeDetails; @MockBean private ActivePriceBookEstimator priceBookEstimator; @Resource private FbaPricingService service; @@ -100,6 +104,35 @@ class FbaPricingServiceTest { assertThat(fee.getValue().getRoundingMode()).isEqualTo("HALF_UP"); } + @Test + @DisplayName("外箱规计费保存测量批次和尺寸来源快照") + void bindsSavedOuterMeasurementWhenCalculatingCustomerFee() { + readyRepository(); + InboundPricingEntity inbound = inbound("62.500", "1.0019"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(301L); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setOuterLengthCm(new BigDecimal("34")); + item.setOuterWidthCm(new BigDecimal("24")); + item.setOuterHeightCm(new BigDecimal("64")); + item.setCalculationRuleVersion("CUSTOMER_DIMENSION_SMALL_V3"); + 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(any())).thenReturn( + estimate("CBM", "1.0019", "100", "100.19", "0")); + + service.calculate(TENANT, OPERATOR, INBOUND, NOW, + new BigDecimal("62.500"), new BigDecimal("1.0019"), + "OUTER", "OUTER", "calc-outer"); + + ArgumentCaptor fee = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository).insertFee(fee.capture()); + assertThat(fee.getValue().getMeasurementId()).isEqualTo(301L); + assertThat(fee.getValue().getMeasurementSource()).isEqualTo("OUTER"); + assertThat(fee.getValue().getActualVolumeCbm()).isEqualByComparingTo("1.0019"); + } + @Test @DisplayName("缺少订单客户归属时拒绝生成对客费用") void rejectsMissingOrderCustomerOwnership() { @@ -182,44 +215,31 @@ class FbaPricingServiceTest { } @Test - @DisplayName("装柜完成后的自动费用仍可确认") - void confirmsCalculatedFeeAfterLoadingCompleted() { + @DisplayName("装柜完成后禁止确认费用") + void rejectsConfirmAfterLoadingCompleted() { InboundPricingEntity inbound = inbound("10.000", "1.0000"); inbound.setProcessStatus("LOADED"); inbound.setFeeStatus("CALCULATED"); - InboundFeeEntity fee = savedFee("CALCULATED"); - when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee); - when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded")) - .thenReturn(null); - doAnswer(invocation -> { - ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); - return null; - }).when(repository).insertIdempotency(any()); - when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1); - when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1); - when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); - when(repository.findFee(TENANT, 501L)).thenReturn(fee); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - FeeSnapshotResponse response = service.confirm( - TENANT, OPERATOR, INBOUND, "confirm-loaded"); - - assertThat(response.nextProcessStatus()).isEqualTo("LOADED"); - verify(repository).markInboundConfirmed(TENANT, INBOUND); + assertThatThrownBy(() -> service.confirm(TENANT, OPERATOR, INBOUND, "confirm-loaded")) + .isInstanceOf(BizException.class) + .hasMessageContaining("装柜流程"); + verify(repository, never()).markInboundConfirmed(TENANT, INBOUND); } @Test - @DisplayName("审核未通过的测量快照禁止计费") - void rejectsUnapprovedMeasurementSnapshot() { + @DisplayName("审核未通过的测量快照也允许计费") + void allowsUnapprovedMeasurementSnapshot() { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); inbound.setMeasureReviewStatus("REJECTED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(priceBookEstimator.estimate(any())).thenReturn( + estimate("CBM", "1.0000", "10", "10", "0")); - assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")) - .isInstanceOf(BizException.class) - .hasMessageContaining("审核通过"); - verify(priceBookEstimator, never()).estimate(any()); + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull(); + verify(priceBookEstimator).estimate(any()); } @Test @@ -368,6 +388,55 @@ class FbaPricingServiceTest { eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); } + @Test + @DisplayName("客户确认已发送费用后记录确认人并锁定版本") + void customerConfirmsSentFeeAndLocksVersion() { + InboundFeeEntity current = savedFee("SENT"); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "CUSTOMER_FEE_CONFIRM", "customer-confirm-1")) + .thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(910L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.confirmFeeByCustomer(TENANT, 501L, 1, OPERATOR, "客户成员", NOW)) + .thenAnswer(invocation -> { + current.setCustomerConfirmedBy(OPERATOR); + current.setCustomerConfirmedByName("客户成员"); + current.setCustomerConfirmedAt(NOW); + return 1; + }); + when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 501L)).thenReturn(current); + + FeeSnapshotResponse response = service.confirmByCustomer( + TENANT, OPERATOR, "客户成员", INBOUND, 1, "customer-confirm-1"); + + assertThat(response.customerConfirmationStatus()).isEqualTo("CONFIRMED"); + assertThat(response.customerConfirmedBy()).isEqualTo(OPERATOR); + assertThat(response.customerConfirmedByName()).isEqualTo("客户成员"); + assertThat(response.customerConfirmedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("客户已确认费用时拒绝平台再次修改") + void rejectsAdjustmentAfterCustomerConfirmation() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("SENT"); + current.setCustomerConfirmedAt(NOW); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + + 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")) + .isInstanceOf(BizException.class) + .hasMessageContaining("客户已确认"); + + verify(repository, never()).insertIdempotency(any()); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + @Test @DisplayName("初算费用调整后保持初算状态且不发布财务来源") void adjustsCalculatedFeeWithoutPublishingIt() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 5eea7657b..d9abf1beb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -41,6 +41,8 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -235,6 +237,9 @@ class InboundMeasurementServiceTest { item.setOuterLengthCm(new BigDecimal("65.000")); item.setOuterWidthCm(new BigDecimal("55.000")); item.setOuterHeightCm(new BigDecimal("55.000")); + item.setManualOuterLengthCm(new BigDecimal("65.000")); + item.setManualOuterWidthCm(new BigDecimal("55.000")); + item.setManualOuterHeightCm(new BigDecimal("55.000")); item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_V1"); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); @@ -260,6 +265,7 @@ class InboundMeasurementServiceTest { item.setOuterLengthCm(new BigDecimal("65.000")); item.setOuterWidthCm(new BigDecimal("54.000")); item.setOuterHeightCm(new BigDecimal("54.000")); + item.setManualOuterLengthCm(new BigDecimal("65.000")); item.setCalculationRuleVersion("WAREHOUSE_OUTER_OVERRIDE_L_V1"); when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); @@ -894,7 +900,8 @@ class InboundMeasurementServiceTest { @Test void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() { - InboundOrderEntity inbound = inbound("MEASURING"); + InboundOrderEntity inbound = spy(inbound("MEASURING")); + clearInvocations(inbound); InboundMeasurementEntity measurement = new InboundMeasurementEntity(); measurement.setId(30L); measurement.setTenantId(1L); @@ -928,6 +935,8 @@ class InboundMeasurementServiceTest { assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("20.000"); assertThat(inbound.getPalletCount()).isEqualTo(1); assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.2345"); + assertThat(inbound.getTotalPackageCount()).isEqualTo(3); + verify(inbound, never()).setTotalPackageCount(any()); } @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 3bc37d323..14ad0a2bf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -212,12 +212,12 @@ class MeasurementBatchServiceTest { 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("61.500"); - assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("43.200"); - assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("33.300"); - assertThat(calculated.getGirthCm()).isEqualByComparingTo("214.500"); - assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.088"); - assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1760"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("63.500"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("45.200"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("35.300"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("224.500"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.101"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.2020"); } @Test @@ -235,12 +235,12 @@ class MeasurementBatchServiceTest { 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("62.001"); - assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("52.000"); - assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("52.000"); - assertThat(calculated.getGirthCm()).isEqualByComparingTo("270.001"); - assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.168"); - assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.3360"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("64.001"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("280.001"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.187"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.3740"); assertThat(calculated.getCalculationRuleVersion()) .isEqualTo("CUSTOMER_DIMENSION_EXTRA_LARGE_V0"); } @@ -261,13 +261,16 @@ class MeasurementBatchServiceTest { 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("50.000"); - assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("40.000"); - assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("30.000"); - assertThat(calculated.getGirthCm()).isEqualByComparingTo("200.000"); - assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0699"); - assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1398"); - assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("23.347"); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("50"); + assertThat(calculated.getManualOuterWidthCm()).isEqualByComparingTo("40"); + assertThat(calculated.getManualOuterHeightCm()).isEqualByComparingTo("30"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("44.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("34.000"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("210.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0808"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1616"); + assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("26.988"); assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1"); } @@ -287,15 +290,44 @@ class MeasurementBatchServiceTest { 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("50.000"); - assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("32.000"); - assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("22.000"); - assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0367"); - assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0734"); - assertThat(calculated.getGirthCm()).isEqualByComparingTo("160.000"); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("50"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isNull(); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("54.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("34.000"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("24.000"); + assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0441"); + assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0882"); + assertThat(calculated.getGirthCm()).isEqualByComparingTo("170.000"); assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_L_V1"); } + @Test + void actualStrategyUsesEachWarehouseValueAndFillsBlankDimensionsFromMeasurement() { + MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item( + 80001L, "001", "A", 1, new BigDecimal("61.1"), new BigDecimal("41.2"), + new BigDecimal("31.3"), 500L, null, new BigDecimal("11"), + null, new BigDecimal("11"), null, "ACTUAL", "宽未填写"); + MeasurementBatchCommand command = new MeasurementBatchCommand( + 1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null, + LocalDateTime.now(), null, null, null, null, null, 9L, List.of(source)); + + service.saveBatch(command); + + 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.getManualOuterLengthCm()).isEqualByComparingTo("11"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isEqualByComparingTo("11"); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("11.000"); + assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("41.200"); + assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("11.000"); + assertThat(calculated.getCoefficientMode()).isEqualTo("ACTUAL"); + assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_LH_V1"); + } + @Test void warehouseOverrideUsesTheTierMatchedByItsBaseDimensionsBeforeMultiplyStrategy() { CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); @@ -325,7 +357,10 @@ class MeasurementBatchServiceTest { 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("31.000"); + assertThat(calculated.getManualOuterLengthCm()).isEqualByComparingTo("31"); + assertThat(calculated.getManualOuterWidthCm()).isNull(); + assertThat(calculated.getManualOuterHeightCm()).isNull(); + assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("37.200"); assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("24.000"); assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("24.000"); assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0215"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java index ca54a977d..11bbbc6b8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java @@ -154,6 +154,59 @@ class MerchantInboundBillingServiceTest { verify(pricing, never()).latest(10001L, 88L); } + @Test + void shouldPublishSavedOuterDimensionsAndOuterTotalsInsteadOfMeasuredDimensions() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT", "APPROVED", "SENT"); + InboundOrderDetail detail = new InboundOrderDetail( + mock(com.baiyue.firstleg.dto.InboundOrderSummary.class), null, + List.of(), List.of(), List.of(), List.of()); + InboundMeasurementResponses.Detail measurement = mock(InboundMeasurementResponses.Detail.class); + InboundMeasurementResponses.Item item = mock(InboundMeasurementResponses.Item.class); + FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class); + InboundFeeDetailEntity published = new InboundFeeDetailEntity(); + published.setId(601L); + when(orders.findByOrderId(10001L, 77L)).thenReturn(inbound); + when(orders.detail(10001L, inbound)).thenReturn(detail); + when(measurements.detail(10001L, 88L)).thenReturn(measurement); + when(measurement.items()).thenReturn(List.of(item)); + when(measurement.weightResults()).thenReturn(List.of()); + when(measurement.actualGrossWeightKg()).thenReturn(new BigDecimal("62.5")); + when(item.itemNo()).thenReturn("1"); + when(item.packageCount()).thenReturn(2); + when(item.lengthCm()).thenReturn(new BigDecimal("30")); + when(item.widthCm()).thenReturn(new BigDecimal("20")); + when(item.heightCm()).thenReturn(new BigDecimal("60")); + when(item.calculatedOuterLengthCm()).thenReturn(new BigDecimal("34")); + when(item.calculatedOuterWidthCm()).thenReturn(new BigDecimal("24")); + when(item.calculatedOuterHeightCm()).thenReturn(new BigDecimal("64")); + when(item.totalVolumeCbm()).thenReturn(new BigDecimal("0.1046")); + when(item.volumeWeightKg()).thenReturn(new BigDecimal("17.469")); + when(item.coefficientMode()).thenReturn("ADD"); + when(item.calculationRuleVersion()).thenReturn("CUSTOMER_DIMENSION_SMALL_V3"); + when(pricing.latest(10001L, 88L)).thenReturn(fee); + when(feeDetails.publishedFreight(10001L, 88L)).thenReturn(published); + when(fee.chargeMode()).thenReturn("CBM"); + when(fee.measurementId()).thenReturn(301L); + when(fee.chargeUnit()).thenReturn("CBM"); + when(fee.actualVolumeCbm()).thenReturn(new BigDecimal("0.1046")); + when(fee.convertedQuantity()).thenReturn(new BigDecimal("0.1000")); + when(measurements.detail(10001L, 88L, 301L)).thenReturn(measurement); + + var result = service.find(10001L, 77L); + + assertThat(result.measurementItems()).singleElement().satisfies(value -> { + assertThat(value.lengthCm()).isEqualByComparingTo("34"); + assertThat(value.widthCm()).isEqualByComparingTo("24"); + assertThat(value.heightCm()).isEqualByComparingTo("64"); + assertThat(value.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + }); + assertThat(result.measurementSummary().actualPackageCount()).isEqualTo(2); + assertThat(result.measurementSummary().actualVolumeCbm()).isEqualByComparingTo("0.1046"); + assertThat(result.measurementSummary().volumeWeightKg()).isEqualByComparingTo("17.469"); + assertThat(result.measurementSummary().calculationSource()).isEqualTo("OUTER"); + verify(measurements).detail(10001L, 88L, 301L); + } + @ParameterizedTest @ValueSource(strings = {"FEE_CONFIRMING", "VOIDED"}) void shouldRejectProcessStatusesOutsidePublishedFulfillmentLifecycle(String processStatus) { diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 8796736a0..38851b385 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -306,7 +306,7 @@ public class UnifiedOrderQueryService { order.getCustomerId(), order.getCustomerName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), - order.getExpectedShipDate(), inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), + order.getExpectedShipDate(), displayedPackageCount(order, cargo, inbound), null, inbound.locationCodes(), order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), @@ -333,7 +333,7 @@ public class UnifiedOrderQueryService { order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), null, - inbound.totalPackageCount() != null ? inbound.totalPackageCount() : totalPackageCount(cargo), null, inbound.locationCodes(), + displayedPackageCount(order, cargo, inbound), null, inbound.locationCodes(), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), @@ -416,6 +416,15 @@ public class UnifiedOrderQueryService { return cargo == null ? null : cargo.totalPackageCount(); } + private Integer displayedPackageCount(OrderEntity order, CargoSummary cargo, + FirstlegUnifiedOrderClient.Inbound inbound) { + Integer declaredPackageCount = totalPackageCount(cargo); + boolean splitChild = order != null + && (order.getParentOrderId() != null || "CHILD".equals(order.getOrderRole())); + if (!splitChild && declaredPackageCount != null) return declaredPackageCount; + return inbound.totalPackageCount() != null ? inbound.totalPackageCount() : declaredPackageCount; + } + private BigDecimal grossWeightKg(CargoSummary cargo) { return cargo == null ? null : cargo.grossWeightKg(); } diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index f1d3c1de6..299527428 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -167,6 +167,25 @@ class UnifiedOrderQueryServiceTest { .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(35); } + @Test + void regularOrderAlwaysDisplaysDeclaredCargoCountInsteadOfMeasuredInboundSnapshot() { + UnifiedOrderQuery query = query(null); + OrderEntity order = order(OrderStatus.APPROVED); + OrderCargoEntity declaredCargo = new OrderCargoEntity(); + declaredCargo.setOrderId(77L); + declaredCargo.setPackageCount(100); + var summary = summary("WAIT_SPLIT", null); + when(summary.inbound().totalPackageCount()).thenReturn(3); + when(orders.unifiedPage(eq(10001L), eq(query), any())) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of(summary))); + when(orders.findCargoByOrderIds(10001L, List.of(77L))).thenReturn(List.of(declaredCargo)); + + assertThat(service.page(10001L, query).records()).singleElement() + .extracting(UnifiedOrderResponse.Row::totalPackageCount).isEqualTo(100); + } + @Test void inboundMasterUsesTotalPackageCountEvenWhenLegacyPackageCountIsAbsent() { UnifiedOrderQuery query = query(null);