Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
38 changed files with 643 additions and 206 deletions
Showing only changes of commit 1a3f94dfcb - Show all commits
@@ -2801,15 +2801,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 '最后更新时间',
@@ -3098,6 +3101,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;渠道价格可为空',
@@ -3147,6 +3152,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),
@@ -3391,6 +3399,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');
@@ -3651,6 +3665,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");
@@ -3668,6 +3684,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");
@@ -8064,8 +8083,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',
@@ -13278,7 +13297,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', '创建流程幂等键及补偿归属');
@@ -13573,9 +13592,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', '测量明细备注');
@@ -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<MerchantInboundBillingResponse> 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<FeeSnapshotResponse> 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 = "查看已发布计费明细中的测量或称重图片")
@@ -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")
@@ -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(
@@ -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
) {}
}
@@ -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);
}
}
@@ -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,
@@ -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);
}
}
@@ -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(
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */
/** 客户固定箱规的内径和每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -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;
}
@@ -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;
@@ -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<InboundFeeEntity>()
.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<InboundFeeEntity>()
@@ -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;
@@ -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));
}
@@ -71,7 +72,7 @@ public class CustomerDimensionWeightingService {
return rules.stream()
.filter(rule -> rule.contains(measured))
.findFirst()
.map(Rule::weighted)
.map(rule -> rule.weightedActualDimensions(lengthCm, widthCm, heightCm))
.orElseGet(() -> actualDimensions(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);
@@ -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) {
@@ -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<InboundMeasurementItemEntity> items = measurement == null
? List.of() : repository.listItems(tenantId, measurement.getId());
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
List<InboundWeightResultEntity> 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<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
@@ -46,7 +46,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
@@ -203,7 +203,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 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准,
// 不能把箱规边界尺寸当成本次货物的实际外尺寸。
@@ -212,7 +213,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);
@@ -258,13 +259,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;
@@ -286,7 +287,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;
@@ -323,14 +324,14 @@ public class MeasurementBatchService {
private BigDecimal resolveSpecificationCoefficient(CustomerChargeRuleEntity rule,
BigDecimal length, BigDecimal width,
BigDecimal height) {
BigDecimal height, BigDecimal unitWeightKg) {
List<SpecificationCoefficient> tiers = parseSpecificationCoefficients(rule.getRemark());
if (tiers.isEmpty()) {
return rule.getLengthFactor() == null ? BigDecimal.ONE : rule.getLengthFactor();
}
List<BigDecimal> 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();
@@ -344,7 +345,8 @@ public class MeasurementBatchService {
List<BigDecimal> 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;
@@ -356,15 +358,16 @@ public class MeasurementBatchService {
return dimensions;
}
private record SpecificationCoefficient(List<BigDecimal> dimensions, BigDecimal coefficient) {
private record SpecificationCoefficient(List<BigDecimal> dimensions, BigDecimal coefficient, BigDecimal maxWeightKg) {
private BigDecimal volume() {
return dimensions.get(0).multiply(dimensions.get(1)).multiply(dimensions.get(2));
}
private boolean contains(List<BigDecimal> measured) {
private boolean contains(List<BigDecimal> 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);
}
}
@@ -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<BigDecimal> 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());
@@ -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',
+2 -2
View File
@@ -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',
@@ -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");
@@ -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',
@@ -273,15 +273,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 '最后更新时间',
@@ -623,6 +626,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;渠道价格可为空',
@@ -672,6 +677,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),
@@ -864,8 +872,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',
@@ -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]
@@ -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
@@ -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
@@ -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,9 +155,9 @@ 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);
@@ -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
@@ -206,7 +206,7 @@ class CustomerDimensionConfigServiceTest {
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(new BigDecimal("2.000"));
value.setDefaultCalculationMode("ADD");
value.setDefaultCalculationMode(CustomerDimensionConfigService.DEFAULT_CALCULATION_MODE);
value.setRemark(type.defaultRemark());
value.setVersion(0);
value.setInitializationKey(initializationKey);
@@ -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");
@@ -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");
}
@@ -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,
@@ -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<InboundFeeEntity> 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() {
@@ -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
@@ -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<List<InboundMeasurementItemEntity>> 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");
@@ -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) {
@@ -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();
}
@@ -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);