Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
This commit is contained in:
2026-09-03 11:03:25 +08:00
57 changed files with 1016 additions and 111 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ baiyue:
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
+1 -1
View File
@@ -26,7 +26,7 @@ baiyue:
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
@@ -1321,24 +1321,25 @@ ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
-- 平台订单写入与导出权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0)
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025)
WHERE id IN (5102019, 5102024, 5102025, 5102026)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025)
WHERE id IN (5102019, 5102024, 5102025, 5102026)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -1363,7 +1364,9 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%'
WHERE permission_code LIKE 'platform:order%'
OR permission_code LIKE 'platform:firstleg%'
OR permission_code = 'platform:download:list'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES
@@ -2794,6 +2797,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg',
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',
calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
@@ -3366,6 +3371,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm'
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg',
'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm');
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',
'VARCHAR(16) NULL COMMENT ''系数模式快照:ADD或MULTIPLY'' AFTER charge_rule_id');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning',
'VARCHAR(500) NULL COMMENT ''超线提醒快照'' AFTER source_image_file_id');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'machine_no',
@@ -3632,6 +3641,10 @@ CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NU
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '长度计算系数' AFTER conversion_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
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', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code");
@@ -3887,7 +3900,9 @@ VALUES
(5102045, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0),
(5102194, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0)
'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0),
(5102195, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:warehouse-suggestion:edit', '维护仓库建议测量数据', NULL, NULL, 27, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -3895,12 +3910,21 @@ ON DUPLICATE KEY UPDATE
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194)
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194)
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code = 'platform:firstleg:inbound:warehouse-suggestion:edit'
WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
@@ -4031,19 +4055,29 @@ VALUES
(5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0),
(5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0),
(5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0),
(5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0)
(5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0),
(5102069, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:confirm-send-fee', '确认并推送客户费用', NULL, NULL, 90, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id BETWEEN 5102061 AND 5102068
WHERE id BETWEEN 5102061 AND 5102069
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068)
WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068, 5102069)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code = 'firstleg:inbound:confirm-send-fee'
WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 客户尺寸计算配置权限;与 FBA 计费规则相互独立。
@@ -4351,6 +4385,7 @@ CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'w
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'coefficient_mode');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_warehouse_mapping');
@@ -4376,10 +4411,11 @@ SET @phase2_permission_count = (
AND permission_code IN (
'platform:firstleg:inbound:list', 'platform:firstleg:inbound:view',
'platform:firstleg:inbound:measure:view', 'platform:firstleg:inbound:measure:edit',
'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:weight:edit',
'platform:firstleg:inbound:measure:submit', 'platform:firstleg:inbound:warehouse-suggestion:edit',
'platform:firstleg:inbound:weight:edit',
'platform:firstleg:inbound:weight:confirm', 'firstleg:inbound:measurement-review',
'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release',
'firstleg:inbound:send-fee', 'firstleg:fba-pricing:rule:view',
'firstleg:inbound:send-fee', 'firstleg:inbound:confirm-send-fee', 'firstleg:fba-pricing:rule:view',
'firstleg:fba-pricing:rule:edit', 'firstleg:fba-pricing:rate:view',
'firstleg:fba-pricing:rate:edit', 'firstleg:fba-pricing:calculate',
'firstleg:fba-pricing:confirm', 'firstleg:fba-pricing:view',
@@ -4395,9 +4431,9 @@ DROP PROCEDURE IF EXISTS unified_assert_permission_count;
DELIMITER $$
CREATE PROCEDURE unified_assert_permission_count()
BEGIN
IF @phase2_permission_count <> 30 THEN
IF @phase2_permission_count <> 32 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 30 项';
SET MESSAGE_TEXT = '统一迁移失败:二期 IAM 权限资源未达到 32 项';
END IF;
END$$
DELIMITER ;
@@ -8024,6 +8060,7 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
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',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -8043,6 +8080,25 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
DROP PROCEDURE IF EXISTS unified_dimension_config_add_default_mode;
DELIMITER $$
CREATE PROCEDURE unified_dimension_config_add_default_mode()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND column_name = 'default_calculation_mode'
) THEN
ALTER TABLE baiyue_firstleg.fl_customer_dimension_config
ADD COLUMN default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD'
COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY' AFTER edge_addition_cm;
END IF;
END$$
DELIMITER ;
CALL unified_dimension_config_add_default_mode();
DROP PROCEDURE IF EXISTS unified_dimension_config_add_default_mode;
-- 新结构首次部署或旧结构受控重建后,为所有现有企业客户补齐缺失箱规。
-- 复跑命中 (tenant_id, box_type) 唯一键时只做空更新,不覆盖尺寸、备注、版本或审计字段。
INSERT INTO baiyue_firstleg.fl_customer_dimension_config (
@@ -13213,6 +13269,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'cr
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', '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', '创建流程幂等键及补偿归属');
CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'inner_height_cm', '箱规内高(厘米)');
@@ -13494,6 +13551,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement', 'warehous
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'box_code', '柏悦唛头箱号;设备单箱测量时同一客户内唯一');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'box_specification', '箱规标识');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'calculation_rule_version', '体积计算和舍入规则版本');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'coefficient_mode', '系数模式快照:ADD或MULTIPLY');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'device_measured_at', '本次设备测量时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no', '第三方测量明细唯一号,平台手工明细可为空');
@@ -15156,8 +15214,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_customer_dimension_config'
AND column_name IN ('id', 'tenant_id', 'box_type', 'inner_length_cm', 'inner_width_cm',
'inner_height_cm', 'edge_addition_cm', 'remark', 'initialization_key',
'created_by', 'created_at', 'updated_by', 'updated_at', 'deleted', 'version')) <> 15 THEN
'inner_height_cm', 'edge_addition_cm', 'default_calculation_mode', 'remark',
'initialization_key', 'created_by', 'created_at', 'updated_by', 'updated_at',
'deleted', 'version')) <> 16 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整';
END IF;
@@ -15261,9 +15320,11 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND ((id = 5103040 AND permission_code = 'platform:download:list'
AND ((id = 5102026 AND permission_code = 'platform:order:export'
AND permission_name = '导出订单列表' AND parent_id = 5102010)
OR (id = 5103040 AND permission_code = 'platform:download:list'
AND permission_name = '下载中心' AND route_path = '/downloads')
OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 2
OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 3
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND ((id = 5103060 AND permission_code = 'platform:location:list' AND parent_id = 5103000)
@@ -15272,7 +15333,18 @@ BEGIN
OR (id = 5103063 AND permission_code = 'platform:location:status' AND parent_id = 5103060)
OR (id = 5103064 AND permission_code = 'platform:location:delete' AND parent_id = 5103060))) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:下载中心或库位权限语义未修复';
SET MESSAGE_TEXT = '统一迁移失败:订单导出、下载中心或库位权限语义未修复';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code, ':', p.permission_code))
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id = rp.permission_id AND p.tenant_id = rp.tenant_id
WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM'
AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND p.permission_code IN ('platform:order:export', 'platform:download:list')
AND p.status = 'ACTIVE' AND p.deleted = 0) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
@@ -19,6 +19,7 @@ public final class ExportTaskDtos {
String id, String taskNo, String exportType, String format, String taskName,
String status, Long totalRows, long processedRows, String fileName,
String contentType, Long sizeBytes, String failureMessage,
int attemptCount, Integer maxAttempts, LocalDateTime nextRetryAt,
String requestedBy, String requestedUsername, LocalDateTime requestedAt,
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
}
@@ -33,6 +33,13 @@ public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
int sendFailed(@Param("id") long id, @Param("owner") String owner,
@Param("backoffSeconds") int backoffSeconds, @Param("error") String error);
@Update("""
UPDATE file_export_outbox SET next_retry_at=CURRENT_TIMESTAMP(3)
WHERE task_id=#{taskId} AND dispatch_version=#{dispatchVersion}
AND status='PENDING' AND next_retry_at>CURRENT_TIMESTAMP(3)
""")
int expeditePendingRetry(@Param("taskId") long taskId, @Param("dispatchVersion") int dispatchVersion);
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50")
int releaseExpiredClaims();
@@ -88,6 +88,13 @@ public interface FileExportTaskMapper extends BaseMapper<FileExportTask> {
""")
int manualRetry(@Param("id") long id);
@Update("""
UPDATE file_export_task SET next_retry_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status='PENDING' AND dispatch_version=#{dispatchVersion}
AND attempt_count>0 AND next_retry_at>CURRENT_TIMESTAMP(3)
""")
int expeditePendingRetry(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion);
@Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'")
int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
@Param("reason") String reason);
@@ -22,6 +22,9 @@ public class ExportOutboxRepository {
public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; }
public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; }
public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; }
public boolean expeditePendingRetry(long taskId, int dispatchVersion) {
return mapper.expeditePendingRetry(taskId, dispatchVersion) == 1;
}
public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); }
public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); }
public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; }
@@ -63,6 +63,9 @@ public class ExportTaskRepository {
return mapper.fail(id, dispatchVersion, owner, message) == 1;
}
public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; }
public boolean expeditePendingRetry(long id, int dispatchVersion) {
return mapper.expeditePendingRetry(id, dispatchVersion) == 1;
}
public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; }
public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; }
public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; }
@@ -65,6 +65,14 @@ public class ExportStateService {
return true;
}
@Transactional
public boolean expeditePendingRetry(FileExportTask task) {
if (!outbox.expeditePendingRetry(task.getId(), task.getDispatchVersion())) return false;
if (!tasks.expeditePendingRetry(task.getId(), task.getDispatchVersion())) {
throw new IllegalStateException("Unable to expedite pending export task");
}
return true;
}
@Transactional
public void quarantine(String eventId, long taskId, int version, String reason) {
FileExportTask current = tasks.find(taskId);
if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return;
@@ -103,21 +103,27 @@ public class ExportTaskService {
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
if (!"FAILED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
}
var definition = registry.require(task.getExportType());
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
String permission = definition.getRequiredPermission();
if (!user.permissions().contains(permission)) {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
}
if ("PENDING".equals(task.getStatus()) && task.getAttemptCount() != null && task.getAttemptCount() > 0) {
if (!stateService.expeditePendingRetry(task)) {
throw new BizException(ResultCode.CONFLICT, "任务已进入执行队列,无需重复重试");
}
return response(repository.find(id));
}
if (!"FAILED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试");
}
if (!storage.delete(task.getObjectKey())) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试");
}
boolean retried = stateService.manualRetry(task);
if (!retried) {
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试");
}
return response(repository.find(id));
}
@@ -205,6 +211,7 @@ public class ExportTaskService {
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(),
task.getContentType(), task.getSizeBytes(), task.getFailureMessage(),
task.getAttemptCount() == null ? 0 : task.getAttemptCount(), task.getMaxAttempts(), task.getNextRetryAt(),
String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(),
task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt());
}
+1 -1
View File
@@ -109,7 +109,7 @@ baiyue:
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
@@ -19,8 +19,27 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/CreateDomesticOrderExportTask'}
responses:
'200': {description: 返回异步任务,初始状态 PENDING}
'200':
description: 返回异步任务,初始状态 PENDING
content:
application/json:
schema: {$ref: '#/components/schemas/ExportTaskRetryState'}
'403': {description: 缺少 platform:order:export}
/api/platform/v1/files/export-tasks/{taskId}/retry:
post:
summary: 人工重试订单导出任务
description: >-
FAILED 任务重新开始一次执行;PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息,
不新增并发任务。RUNNING、初次等待消费及已完成任务不允许重复重试。
parameters:
- {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 返回已重新进入立即执行队列的任务
content:
application/json:
schema: {$ref: '#/components/schemas/ExportTaskRetryState'}
'409': {description: 任务当前状态不允许重试或已进入执行队列}
/api/platform/v1/files/export-tasks/{taskId}/download:
get:
summary: 下载本人已完成的订单导出文件
@@ -37,6 +56,16 @@ paths:
schema: {type: string, format: binary}
components:
schemas:
ExportTaskRetryState:
type: object
required: [id, status, attemptCount, maxAttempts]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
status: {type: string, enum: [PENDING, RUNNING, SUCCEEDED, FAILED, EXPIRED]}
failureMessage: {type: string, nullable: true}
attemptCount: {type: integer, minimum: 0}
maxAttempts: {type: integer, minimum: 1}
nextRetryAt: {type: string, format: date-time, nullable: true}
CreateDomesticOrderExportTask:
type: object
required: [exportType, format, taskName, parameters]
@@ -22,6 +22,15 @@ class DomesticWarehouseOrderExportContractTest {
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
.contains("required-permission: platform:order:export")
.contains("app-scope: PLATFORM");
Map<String, Object> root = map(new Yaml().load(source));
Map<String, Object> baiyue = map(root.get("baiyue"));
Map<String, Object> fileConfig = map(baiyue.get("file"));
Map<String, Object> export = map(fileConfig.get("export"));
Map<String, Object> definitions = map(export.get("definitions"));
Map<String, Object> orderDefinition = map(definitions.get(TYPE));
assertThat(orderDefinition.get("batch-size"))
.as("%s must stay within Firstleg's 200-order summary limit", file)
.isEqualTo(200);
}
}
@@ -30,6 +39,7 @@ class DomesticWarehouseOrderExportContractTest {
"file/src/main/resources/openapi/domestic-order-export-v1.yaml"));
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> paths = map(document.get("paths"));
Map<String, Object> parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties"));
Map<String, Object> columns = map(map(schemas.get("DomesticOrderExportColumns")).get("properties"));
@@ -48,6 +58,10 @@ class DomesticWarehouseOrderExportContractTest {
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId",
"destinationWarehouseId");
assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store");
assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry");
assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties")))
.containsKeys("attemptCount", "maxAttempts", "nextRetryAt", "failureMessage");
assertThat(source).contains("PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息");
}
private static Path repositoryRoot() {
@@ -43,6 +43,19 @@ class ExportMapperContractTest {
assertFalse(sql.contains("CLAIMED"));
}
@Test void manualExpediteOnlyAdvancesTheCurrentPendingBackoffMessage() throws Exception {
String taskSql=update(FileExportTaskMapper.class,"expeditePendingRetry");
String outboxSql=update(FileExportOutboxMapper.class,"expeditePendingRetry");
for (String sql : java.util.List.of(taskSql,outboxSql)) {
assertTrue(sql.contains("dispatch_version=#{dispatchVersion}"));
assertTrue(sql.contains("status='PENDING'"));
assertTrue(sql.contains("next_retry_at>CURRENT_TIMESTAMP(3)"));
assertFalse(sql.contains("dispatch_version=dispatch_version+1"));
}
assertTrue(taskSql.contains("attempt_count>0"));
assertTrue(outboxSql.contains("task_id=#{taskId}"));
}
private String update(Class<?> type,String method) throws Exception {
Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow();
return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," ");
@@ -95,6 +95,19 @@ class ExportStateServiceTest {
verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString());
}
@Test void pendingBackoffIsExpeditedWithoutCreatingADuplicateCommand() {
FileExportTask task=task(9L,3,"PENDING");
when(outbox.expeditePendingRetry(9L,3)).thenReturn(true);
when(tasks.expeditePendingRetry(9L,3)).thenReturn(true);
org.junit.jupiter.api.Assertions.assertTrue(service.expeditePendingRetry(task));
InOrder order=inOrder(outbox,tasks);
order.verify(outbox).expeditePendingRetry(9L,3);
order.verify(tasks).expeditePendingRetry(9L,3);
verifyNoInteractions(commands);
}
private FileExportTask task(long id,int version,String status){
FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task;
}
@@ -171,6 +171,23 @@ class ExportTaskServiceTest {
assertTrue(denied.getMessage().contains("权限"));
}
@Test
void pendingTaskInAutomaticBackoffCanBeRetriedImmediately() {
FileExportTask task = new FileExportTask();
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(1L);
task.setExportType("CUSTOMER_LIST"); task.setStatus("PENDING"); task.setAttemptCount(2);
when(repository.find(9L)).thenReturn(task);
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
when(states.expeditePendingRetry(task)).thenReturn(true);
var response = service.retry(user(1L, Set.of("platform:customer:export")), 9L);
assertEquals("PENDING", response.status());
assertEquals(2, response.attemptCount());
verify(states).expeditePendingRetry(task);
verify(storage, org.mockito.Mockito.never()).delete(any());
}
private AuthenticatedUser user(long id, Set<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
}
@@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.DefaultCalculationModeUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -47,4 +48,19 @@ public class PlatformCustomerDimensionConfigController {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.update(scope, user.userId(), request));
}
@PutMapping("/default-calculation-mode")
@PreAuthorize("hasAuthority('firstleg:dimension-config:edit')")
@BusinessAudit(event = "CUSTOMER_DIMENSION_DEFAULT_MODE_UPDATE",
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
description = "平台设置客户默认箱规计算方式")
@Operation(summary = "设置客户默认箱规计算方式")
public ApiResponse<GroupResponse> updateDefaultCalculationMode(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody DefaultCalculationModeUpdateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.updateDefaultCalculationMode(
scope, user.userId(), request.defaultCalculationMode()));
}
}
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -41,8 +42,9 @@ public class PlatformFbaMasterDataController {
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String ruleCode) {
return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), ruleCode));
return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), customerId, ruleCode));
}
@GetMapping("/customer-rules/{id}")
@@ -66,13 +68,24 @@ public class PlatformFbaMasterDataController {
@DeleteMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
description = "停用客户FBA计费规则版本")
description = "删除客户系数配置")
public ApiResponse<Void> retireRule(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
@PutMapping("/customer-rules/{id}/default")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_DEFAULT_SET", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
description = "设置客户默认FBA计费规则")
public ApiResponse<Void> setDefaultRule(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
service.setDefaultRule(scope, id, user.userId());
return ApiResponse.success(null);
}
@GetMapping("/carrier-rates")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
@Operation(summary = "查询船司 FBA 价格版本", description = "查询平台公共价格;所有客户共享")
@@ -43,11 +43,14 @@ public class PlatformFbaPricingController {
@Valid @RequestBody(required = false) CalculateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
request == null ? null : request.pricingAt(), idempotencyKey));
request == null ? null : request.pricingAt(),
request == null ? null : request.selectedWeightKg(),
request == null ? null : request.selectedVolumeCbm(), idempotencyKey));
}
@PostMapping("/confirm")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')")
@BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认FBA费用快照")
public ApiResponse<FeeSnapshotResponse> confirm(
@@ -58,7 +61,8 @@ public class PlatformFbaPricingController {
}
@PostMapping("/send")
@PreAuthorize("hasAuthority('firstleg:inbound:send-fee')")
@PreAuthorize("hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
public ApiResponse<FeeSnapshotResponse> send(
@@ -80,7 +80,8 @@ public class PlatformInboundMeasurementController {
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
+ " 'platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.DraftSaved> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@@ -8,6 +8,7 @@ import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
@@ -21,6 +22,10 @@ public final class CustomerDimensionConfigDtos {
@NotNull @Size(min = 4, max = 4) List<@Valid RuleUpdateRequest> rules
) { }
public record DefaultCalculationModeUpdateRequest(
@NotBlank @Pattern(regexp = "ADD|MULTIPLY") String defaultCalculationMode
) { }
public record RuleUpdateRequest(
@NotNull CustomerDimensionBoxType boxType,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerLengthCm,
@@ -32,7 +37,7 @@ public final class CustomerDimensionConfigDtos {
@NotNull @Min(0) Integer version
) { }
public record GroupResponse(Long tenantId, List<RuleResponse> rules) { }
public record GroupResponse(Long tenantId, String defaultCalculationMode, List<RuleResponse> rules) { }
public record RuleResponse(
Long id,
@@ -29,8 +29,21 @@ public final class FbaPricingDtos {
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
@Size(max = 500) String remark,
@DecimalMin(value = "0", inclusive = false) BigDecimal lengthFactor,
@DecimalMin(value = "0", inclusive = false) BigDecimal widthFactor,
@DecimalMin(value = "0", inclusive = false) BigDecimal heightFactor,
Boolean defaultRule
) {
public CustomerRuleVersionRequest(String ruleCode, Long customerId, String customerName,
String businessType, String chargeMode, String taxMode,
BigDecimal conversionFactor, Integer roundingScale,
String roundingMode, String currency, LocalDateTime effectiveFrom,
LocalDateTime effectiveTo, String remark) {
this(ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor,
roundingScale, roundingMode, currency, effectiveFrom, effectiveTo, remark, null, null, null, null);
}
}
public record CarrierRateVersionRequest(
@NotBlank @Size(max = 50) String rateCode,
@@ -50,7 +63,17 @@ public final class FbaPricingDtos {
@Size(max = 500) String remark
) {}
public record CalculateRequest(LocalDateTime pricingAt) {}
public record CalculateRequest(
LocalDateTime pricingAt,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedWeightKg,
@DecimalMin(value = "0", inclusive = false) BigDecimal selectedVolumeCbm,
@Size(max = 32) String weightSource,
@Size(max = 32) String volumeSource
) {
public CalculateRequest(LocalDateTime pricingAt) {
this(pricingAt, null, null, null, null);
}
}
public record AdjustFeeRequest(
@NotNull @Positive Integer expectedFeeVersion,
@@ -55,8 +55,29 @@ public final class InboundMeasurementRequests {
@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,
@Size(max = 500) String remark
) {
public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer 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,
chargeRuleId, null, remark);
}
public ItemRequest(Long inboundDetailId, String itemNo, String boxSpecification, Integer packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
Long intelligentImageFileId, String overLimitWarning,
BigDecimal outerLengthCm, BigDecimal outerWidthCm, BigDecimal outerHeightCm,
String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, null, remark);
}
}
public record SubmitRequest(
@@ -90,6 +90,8 @@ public final class InboundMeasurementResponses {
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
Long chargeRuleId,
String coefficientMode,
String remark,
String boxCode,
String scannedShipmentId,
@@ -40,7 +40,28 @@ public record MeasurementBatchCommand(
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
Long chargeRuleId,
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 remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
chargeRuleId, null, 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,
String remark) {
this(inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm,
intelligentImageFileId, overLimitWarning, outerLengthCm, outerWidthCm, outerHeightCm,
null, null, remark);
}
}
}
@@ -26,6 +26,11 @@ public class CustomerChargeRuleEntity extends BaseEntity {
private String chargeMode;
private String taxMode;
private BigDecimal conversionFactor;
/** 体积计算各方向系数;历史数据为空时按 1 处理。 */
private BigDecimal lengthFactor;
private BigDecimal widthFactor;
private BigDecimal heightFactor;
private Boolean defaultRule;
private Integer roundingScale;
private String roundingMode;
private String currency;
@@ -23,6 +23,7 @@ public class CustomerDimensionConfigEntity extends BaseEntity {
private BigDecimal innerWidthCm;
private BigDecimal innerHeightCm;
private BigDecimal edgeAdditionCm;
private String defaultCalculationMode;
private String remark;
private String initializationKey;
}
@@ -34,6 +34,8 @@ public class InboundMeasurementItemEntity {
private BigDecimal volumeCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private Long chargeRuleId;
private String coefficientMode;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String machineNo;
@@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@@ -17,12 +18,12 @@ public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimens
<script>
INSERT IGNORE INTO fl_customer_dimension_config
(id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
edge_addition_cm, default_calculation_mode, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.id}, #{row.tenantId}, #{row.boxType}, #{row.innerLengthCm}, #{row.innerWidthCm},
#{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.remark}, #{row.initializationKey},
#{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.defaultCalculationMode}, #{row.remark}, #{row.initializationKey},
#{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
</foreach>
</script>
@@ -32,7 +33,7 @@ public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimens
@Select("""
SELECT id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
edge_addition_cm, default_calculation_mode, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version
FROM fl_customer_dimension_config
WHERE tenant_id = #{tenantId}
@@ -43,6 +44,16 @@ public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimens
List<CustomerDimensionConfigEntity> selectDefaultsForCompensation(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Update("""
UPDATE fl_customer_dimension_config
SET default_calculation_mode = #{mode}, updated_by = #{operatorId},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE tenant_id = #{tenantId} AND deleted = 0
""")
int updateDefaultCalculationMode(@Param("tenantId") long tenantId,
@Param("mode") String mode,
@Param("operatorId") long operatorId);
@Delete("""
DELETE FROM fl_customer_dimension_config
WHERE tenant_id = #{tenantId}
@@ -26,6 +26,10 @@ public class CustomerDimensionConfigRepository {
return mapper.updateById(value);
}
public int updateDefaultCalculationMode(long tenantId, String mode, long operatorId) {
return mapper.updateDefaultCalculationMode(tenantId, mode, operatorId);
}
public List<CustomerDimensionConfigEntity> findDefaultsForCompensation(long tenantId,
String initializationKey) {
return mapper.selectDefaultsForCompensation(tenantId, initializationKey);
@@ -42,6 +42,24 @@ public class FbaPricingRepository {
private InboundOutboxMapper outbox;
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getCustomerId, customerId)
.eq(CustomerChargeRuleEntity::getDefaultRule, true)
.set(CustomerChargeRuleEntity::getDefaultRule, false)
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
}
public int setDefaultRule(long tenantId, long id, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getId, id)
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.set(CustomerChargeRuleEntity::getDefaultRule, true)
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
}
public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); }
public CustomerChargeRuleEntity findRule(long tenantId, long id) {
@@ -50,6 +68,15 @@ public class FbaPricingRepository {
.eq(CustomerChargeRuleEntity::getId, id));
}
public CustomerChargeRuleEntity findDefaultRule(long tenantId, long customerId) {
return rules.selectOne(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(CustomerChargeRuleEntity::getCustomerId, customerId)
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.eq(CustomerChargeRuleEntity::getDefaultRule, true)
.last("LIMIT 1"));
}
public CarrierFbaRateEntity findRate(long id) {
return rates.selectOne(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE)
@@ -57,10 +84,16 @@ public class FbaPricingRepository {
}
public List<CustomerChargeRuleEntity> listRules(Long tenantId, String ruleCode) {
return listRules(tenantId, null, ruleCode);
}
public List<CustomerChargeRuleEntity> listRules(Long tenantId, Long customerId, String ruleCode) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(customerId != null, CustomerChargeRuleEntity::getCustomerId, customerId)
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
.orderByDesc(CustomerChargeRuleEntity::getDefaultRule)
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
}
@@ -101,6 +134,7 @@ public class FbaPricingRepository {
.eq(CustomerChargeRuleEntity::getId, id)
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
.set(CustomerChargeRuleEntity::getStatus, "INACTIVE")
.set(CustomerChargeRuleEntity::getDefaultRule, false)
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
}
@@ -26,6 +26,7 @@ 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";
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@@ -65,6 +66,17 @@ public class CustomerDimensionConfigService {
return response(tenantId, existing);
}
@Transactional
public GroupResponse updateDefaultCalculationMode(long tenantId, long operatorId, String mode) {
String normalizedMode = normalizeCalculationMode(mode);
requireCompleteRules(repository.findAll(tenantId));
if (repository.updateDefaultCalculationMode(tenantId, normalizedMode, operatorId)
!= CustomerDimensionBoxType.values().length) {
throw staleConfiguration();
}
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
requireTenant(tenantId);
@@ -159,6 +171,7 @@ public class CustomerDimensionConfigService {
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(DEFAULT_EDGE_ADDITION_CM);
value.setDefaultCalculationMode(DEFAULT_CALCULATION_MODE);
value.setRemark(type.defaultRemark());
value.setInitializationKey(initializationKey);
rows.add(value);
@@ -181,6 +194,7 @@ public class CustomerDimensionConfigService {
|| value.getVersion() == null || value.getVersion() != 0
|| value.getInnerLengthCm() == null || value.getInnerWidthCm() == null
|| value.getInnerHeightCm() == null || value.getEdgeAdditionCm() == null
|| !DEFAULT_CALCULATION_MODE.equals(normalizeCalculationMode(value.getDefaultCalculationMode()))
|| value.getInnerLengthCm().compareTo(type.defaultLengthCm()) != 0
|| value.getInnerWidthCm().compareTo(type.defaultWidthCm()) != 0
|| value.getInnerHeightCm().compareTo(type.defaultHeightCm()) != 0
@@ -200,7 +214,12 @@ public class CustomerDimensionConfigService {
}
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
return new GroupResponse(tenantId, values.stream().map(CustomerDimensionConfigService::ruleResponse).toList());
String mode = normalizeCalculationMode(values.get(0).getDefaultCalculationMode());
if (values.stream().anyMatch(value -> !mode.equals(normalizeCalculationMode(value.getDefaultCalculationMode())))) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户默认箱规计算方式配置不一致");
}
return new GroupResponse(tenantId, mode,
values.stream().map(CustomerDimensionConfigService::ruleResponse).toList());
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
@@ -232,6 +251,15 @@ public class CustomerDimensionConfigService {
return value.trim();
}
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)) {
throw new BizException(ResultCode.BAD_REQUEST, "默认箱规计算方式无效");
}
return normalized;
}
private static void requireTenant(long tenantId) {
if (tenantId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户ID无效");
}
@@ -69,7 +69,7 @@ public class CustomerDimensionWeightingService {
BigDecimal heightCm) {
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
return new WeightedDimensions(null, null, lengthCm, widthCm, heightCm,
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
"ACTUAL_DIMENSION_V1");
}
@@ -78,6 +78,7 @@ public class CustomerDimensionWeightingService {
public record WeightedDimensions(
CustomerDimensionBoxType boxType,
Integer ruleVersion,
BigDecimal edgeAdditionCm,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
@@ -119,7 +120,7 @@ public class CustomerDimensionWeightingService {
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, outerLength, outerWidth, outerHeight,
return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight,
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
}
}
@@ -26,15 +26,6 @@ public class FbaMasterDataService {
public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId,
CustomerRuleVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
boolean overlaps = repository.listRules(tenantId, null).stream()
.filter(v -> "ACTIVE".equals(v.getStatus()))
.filter(v -> Objects.equals(v.getCustomerId(), request.customerId()))
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
.filter(v -> Objects.equals(v.getTaxMode(), request.taxMode()))
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
request.effectiveFrom(), request.effectiveTo()));
if (overlaps) throw conflict("客户计费规则有效期重叠,无法保证唯一匹配");
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setTenantId(tenantId);
value.setRuleCode(request.ruleCode());
@@ -45,6 +36,10 @@ public class FbaMasterDataService {
value.setChargeMode(request.chargeMode());
value.setTaxMode(request.taxMode());
value.setConversionFactor(request.conversionFactor());
value.setLengthFactor(defaultFactor(request.lengthFactor()));
value.setWidthFactor(defaultFactor(request.widthFactor()));
value.setHeightFactor(defaultFactor(request.heightFactor()));
value.setDefaultRule(false);
value.setRoundingScale(request.roundingScale());
value.setRoundingMode(request.roundingMode());
value.setCurrency(request.currency());
@@ -58,6 +53,15 @@ public class FbaMasterDataService {
return value;
}
@Transactional
public void setDefaultRule(long tenantId, long id, long operatorId) {
CustomerChargeRuleEntity value = rule(tenantId, id);
repository.clearDefaultRule(tenantId, value.getCustomerId(), operatorId);
if (repository.setDefaultRule(tenantId, id, operatorId) != 1) {
throw conflict("规则已停用或不存在");
}
}
@Transactional
public CarrierFbaRateEntity createRateVersion(long operatorId, CarrierRateVersionRequest request) {
validatePeriod(request.effectiveFrom(), request.effectiveTo());
@@ -102,6 +106,10 @@ public class FbaMasterDataService {
return repository.listRules(tenantId, code);
}
public List<CustomerChargeRuleEntity> listRules(Long tenantId, Long customerId, String code) {
return repository.listRules(tenantId, customerId, code);
}
public List<CarrierFbaRateEntity> listRates(String code) {
return repository.listRates(code);
}
@@ -142,4 +150,8 @@ public class FbaMasterDataService {
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private static java.math.BigDecimal defaultFactor(java.math.BigDecimal value) {
return value == null ? java.math.BigDecimal.ONE : value;
}
}
@@ -49,7 +49,17 @@ public class FbaPricingService {
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
return calculate(tenantId, operatorId, inboundId, requestedAt, null, null, idempotencyKey);
}
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, BigDecimal selectedWeightKg,
BigDecimal selectedVolumeCbm, String idempotencyKey) {
String discriminator = (requestedAt == null ? "CURRENT" : requestedAt.toString()) + ":"
+ (selectedWeightKg == null ? "ACTUAL_WEIGHT" : selectedWeightKg) + ":"
+ (selectedVolumeCbm == null ? "ACTUAL_VOLUME" : selectedVolumeCbm);
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);
@@ -58,10 +68,11 @@ public class FbaPricingService {
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), inbound.getActualNetWeightKg(), inbound.getActualVolumeCbm()));
pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg,
selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm));
RateRow rate = estimate.rate();
BigDecimal weight = inbound.getActualNetWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP);
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
@@ -54,6 +54,8 @@ public class InboundMeasurementService {
@Resource
private MeasurementBatchService batchService;
@Resource
private FbaMasterDataService fbaMasterDataService;
@Resource
private OrderCargoClient orderCargoClient;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
@@ -91,6 +93,13 @@ public class InboundMeasurementService {
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
var rule = fbaMasterDataService.rule(tenantId, ruleId);
if (!"ACTIVE".equals(rule.getStatus()) || !java.util.Objects.equals(rule.getCustomerId(), inbound.getCustomerId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细绑定的计费规则不属于当前客户或已停用");
}
});
InboundMeasurementEntity existing = request.measurementId() == null ? null
: repository.findMeasurement(tenantId, inboundId, request.measurementId());
OrderCargoClient.MeasurementDeclarationSnapshot declaration = loadDeclaration(inbound);
@@ -112,7 +121,7 @@ public class InboundMeasurementService {
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@@ -531,7 +540,8 @@ public class InboundMeasurementService {
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getOuterHeightCm(), item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
@@ -7,6 +7,8 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,10 +17,14 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 后台录入和第三方回传共用的测量批次持久化服务。
@@ -39,10 +45,14 @@ public class MeasurementBatchService {
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
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+)?)");
@Resource
private InboundMeasurementRepository repository;
@Resource
private CustomerDimensionWeightingService dimensionWeighting;
@Resource
private FbaPricingRepository pricingRepository;
@Transactional
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
@@ -52,7 +62,7 @@ public class MeasurementBatchService {
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()));
command, dimensionWeighting.load(command.tenantId()), inbound.getCustomerId());
InboundMeasurementEntity measurement;
if (command.measurementId() == null) {
@@ -166,7 +176,8 @@ public class MeasurementBatchService {
}
private List<InboundMeasurementItemEntity> calculateItems(
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules) {
MeasurementBatchCommand command, CustomerDimensionWeightingService.RuleSet dimensionRules,
Long customerId) {
Set<String> itemNumbers = new HashSet<>();
return command.items().stream().map(source -> {
if (!itemNumbers.add(source.itemNo())) {
@@ -174,8 +185,49 @@ public class MeasurementBatchService {
}
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
source.lengthCm(), source.widthCm(), source.heightCm());
BigDecimal unitVolumeRaw = weighted.rawVolumeCbm();
BigDecimal unitVolume = weighted.unitVolumeCbm();
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId())
: requestedMode == null ? findDefaultCoefficientRule(command.tenantId(), customerId) : null;
String coefficientMode = coefficientRule != null ? "MULTIPLY" : requestedMode;
boolean useDimensionRule = "ADD".equals(coefficientMode);
// The box-dimension strategy bills against the matched carton tier.
// This is important when a measured carton is smaller than a standard
// carton: use the configured outer dimensions, rather than adding the
// edge allowance to each measured side.
BigDecimal addition = weighted.edgeAdditionCm();
boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null;
BigDecimal outerLength = hasMatchedAddition ? weighted.outerLengthCm() : source.lengthCm();
BigDecimal outerWidth = hasMatchedAddition ? weighted.outerWidthCm() : source.widthCm();
BigDecimal outerHeight = hasMatchedAddition ? weighted.outerHeightCm() : source.heightCm();
boolean hasOuterOverride = source.outerLengthCm() != null
|| source.outerWidthCm() != null
|| source.outerHeightCm() != null;
if (hasOuterOverride && (source.outerLengthCm() == null
|| source.outerWidthCm() == null || source.outerHeightCm() == null
|| source.outerLengthCm().signum() <= 0 || source.outerWidthCm().signum() <= 0
|| source.outerHeightCm().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"仓库修改外箱长、宽、高必须同时填写且大于0,明细 " + source.itemNo());
}
if (hasOuterOverride) {
// Warehouse-confirmed outer dimensions are authoritative. Derived
// values below (girth, volume and volumetric weight) use them directly.
outerLength = source.outerLengthCm();
outerWidth = source.outerWidthCm();
outerHeight = source.outerHeightCm();
} else if (coefficientRule != null) {
BigDecimal specificationCoefficient = resolveSpecificationCoefficient(
coefficientRule, source.lengthCm(), source.widthCm(), source.heightCm());
outerLength = applyCoefficient(outerLength, specificationCoefficient);
outerWidth = applyCoefficient(outerWidth, specificationCoefficient);
outerHeight = applyCoefficient(outerHeight, specificationCoefficient);
}
BigDecimal unitVolumeRaw = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = hasMatchedAddition && !hasOuterOverride
? unitVolumeRaw.setScale(3, RoundingMode.HALF_UP)
: unitVolumeRaw.setScale(4, RoundingMode.CEILING);
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume;
@@ -197,25 +249,115 @@ public class MeasurementBatchService {
item.setLengthCm(source.lengthCm());
item.setWidthCm(source.widthCm());
item.setHeightCm(source.heightCm());
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
BigDecimal girth = outerLength.add(outerWidth.add(outerHeight).multiply(BigDecimal.valueOf(2)));
item.setGirthCm(girth.setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalVolumeRaw);
item.setVolumeCbm(totalVolume);
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
item.setChargeRuleId(coefficientRule == null ? null : coefficientRule.getId());
item.setCoefficientMode(coefficientMode);
item.setCalculationRuleVersion(hasOuterOverride ? "WAREHOUSE_OUTER_OVERRIDE_V1"
: coefficientRule != null
? "CUSTOMER_COEFFICIENT_V" + coefficientRule.getRuleVersion() + "_MULTIPLY"
: useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1");
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning());
item.setOuterLengthCm(weighted.outerLengthCm());
item.setOuterWidthCm(weighted.outerWidthCm());
item.setOuterHeightCm(weighted.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;
}).toList();
}
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)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "系数模式仅支持 ADD 或 MULTIPLY");
}
return normalized;
}
private CustomerChargeRuleEntity findDefaultCoefficientRule(long tenantId, Long customerId) {
if (customerId == null || customerId <= 0) return null;
return pricingRepository.findDefaultRule(tenantId, customerId);
}
private CustomerChargeRuleEntity requireCoefficientRule(long tenantId, Long customerId, Long ruleId) {
if (customerId == null || customerId <= 0) {
if (ruleId == null) return null;
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单客户无效,不能应用计费系数");
}
CustomerChargeRuleEntity rule = ruleId == null
? pricingRepository.findDefaultRule(tenantId, customerId)
: pricingRepository.findRule(tenantId, ruleId);
if (rule == null || !"ACTIVE".equals(rule.getStatus())
|| !Boolean.TRUE.equals(rule.getDefaultRule()) || !customerId.equals(rule.getCustomerId())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "计费规则不属于当前客户、未启用或未设置默认规则");
}
return rule;
}
private BigDecimal applyCoefficient(BigDecimal dimension, BigDecimal coefficient) {
if (coefficient == null) coefficient = BigDecimal.ONE;
BigDecimal result = dimension.multiply(coefficient);
if (result.compareTo(BigDecimal.ZERO) <= 0 || result.compareTo(new BigDecimal("999999999.999")) > 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应用计费系数后的箱规尺寸超出允许范围");
}
return result;
}
private BigDecimal resolveSpecificationCoefficient(CustomerChargeRuleEntity rule,
BigDecimal length, BigDecimal width,
BigDecimal height) {
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))
.findFirst()
.orElse(tiers.get(tiers.size() - 1))
.coefficient();
}
private List<SpecificationCoefficient> parseSpecificationCoefficients(String remark) {
if (remark == null || remark.isBlank()) return List.of();
Matcher matcher = SPECIFICATION_COEFFICIENT_PATTERN.matcher(remark);
List<SpecificationCoefficient> tiers = new ArrayList<>();
while (matcher.find()) {
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));
}
tiers.sort(Comparator.comparing(SpecificationCoefficient::volume));
return tiers;
}
private List<BigDecimal> sortedDimensions(BigDecimal length, BigDecimal width, BigDecimal height) {
List<BigDecimal> dimensions = new ArrayList<>(List.of(length, width, height));
dimensions.sort(Comparator.reverseOrder());
return dimensions;
}
private record SpecificationCoefficient(List<BigDecimal> dimensions, BigDecimal coefficient) {
private BigDecimal volume() {
return dimensions.get(0).multiply(dimensions.get(1)).multiply(dimensions.get(2));
}
private boolean contains(List<BigDecimal> measured) {
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;
}
}
private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) {
return new BizException(ResultCode.VALIDATION_FAILED,
"箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":"
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
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',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
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',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -20,6 +20,10 @@ CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NU
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'business_type', "VARCHAR(32) NOT NULL DEFAULT 'FBA' COMMENT '业务或计费类型' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '长度计算系数' AFTER conversion_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
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', 'customer_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '客户ID快照' AFTER rule_code");
@@ -59,6 +59,10 @@ 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', 'charge_rule_id',
'BIGINT UNSIGNED NULL COMMENT ''该测量明细绑定的客户计费规则ID'' AFTER volume_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'coefficient_mode',
'VARCHAR(16) NULL COMMENT ''系数模式快照:ADD或MULTIPLY'' AFTER charge_rule_id');
DROP PROCEDURE IF EXISTS phase2_measure_add_column;
@@ -270,6 +270,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3',
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',
calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
@@ -860,6 +862,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
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',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -12,6 +12,10 @@ paths:
description: 需要 firstleg:fba-pricing:rule:view 权限
parameters:
- {$ref: '#/components/parameters/OptionalTenantId'}
- name: customerId
in: query
required: false
schema: {type: integer, format: int64, minimum: 1}
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
responses:
'200':
@@ -23,10 +27,16 @@ paths:
/api/platform/v1/fba-pricing/customer-rules/versions:
post:
summary: 新建客户计费规则版本
description: 版本内容不可原地修改;有效期使用 [effectiveFrom,effectiveTo) 半开区间。
description: 新增配置固定为未启用,可保存有效期重叠的多条配置;保存后通过设置默认规则接口启用其中一条。
parameters: [{$ref: '#/components/parameters/TenantId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CustomerRuleVersionRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/customer-rules/{id}/default:
put:
summary: 启用客户计费规则
description: 将指定配置设为当前客户唯一默认生效规则,并取消原默认规则。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/customer-rules/{id}:
get:
summary: 查询客户计费规则版本详情
@@ -137,13 +147,13 @@ paths:
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
summary: 确认最新费用快照
description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:fba-pricing:confirm。确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
post:
summary: 发送已确认费用快照
description: 人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
description: 需要 firstleg:inbound:confirm-send-fee 或兼容权限 firstleg:inbound:send-fee。人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest:
@@ -168,6 +168,20 @@ paths:
responses:
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse}
'403': {description: 无编辑权限}
/api/platform/v1/first-leg/customer-dimension-config/default-calculation-mode:
put:
summary: 设置客户订单外箱规默认计算方式
description: 需要 firstleg:dimension-config:edit 权限;ADD 表示按箱规尺寸加宽,MULTIPLY 表示按默认权重系数计算。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerDimensionDefaultCalculationModeUpdateRequest'}
responses:
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse}
'403': {description: 无编辑权限}
/internal/firstleg/v1/inbounds/order-approved:
post:
summary: 订单审核通过后幂等创建入库单
@@ -762,7 +776,8 @@ paths:
post:
summary: 平台模拟单箱设备扫描
description: >-
需要 platform:firstleg:inbound:measure:edit 权限;仅用于平台联调和代设备录入,
需要 platform:firstleg:inbound:warehouse-suggestion:edit 或兼容权限
platform:firstleg:inbound:measure:edit;仅用于平台联调和代设备录入,
复用正式单箱覆盖规则,不代表最终第三方设备协议或鉴权方式。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1354,6 +1369,11 @@ components:
maxItems: 4
description: 必须恰好包含 SMALL、MEDIUM、LARGE、EXTRA_LARGE 各一行
items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleUpdateRequest'}
CustomerDimensionDefaultCalculationModeUpdateRequest:
type: object
required: [defaultCalculationMode]
properties:
defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]}
CustomerDimensionConfigRuleUpdateRequest:
type: object
required: [boxType, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, version]
@@ -1373,9 +1393,10 @@ components:
version: {type: integer, minimum: 0, description: 乐观锁版本号}
CustomerDimensionConfigGroupResponse:
type: object
required: [tenantId, rules]
required: [tenantId, defaultCalculationMode, rules]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
defaultCalculationMode: {type: string, enum: [ADD, MULTIPLY]}
rules:
type: array
minItems: 4
@@ -1508,6 +1529,8 @@ components:
outerLengthCm: {type: number, format: double, nullable: true}
outerWidthCm: {type: number, format: double, nullable: true}
outerHeightCm: {type: number, format: double, nullable: true}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
coefficientMode: {type: string, enum: [ADD, MULTIPLY], nullable: true}
remark: {type: string, nullable: true}
boxCode: {type: string, nullable: true}
scannedShipmentId: {type: string, nullable: true}
@@ -1622,6 +1645,8 @@ components:
outerLengthCm: {type: number, minimum: 0, nullable: true}
outerWidthCm: {type: number, minimum: 0, nullable: true}
outerHeightCm: {type: number, minimum: 0, nullable: true}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
coefficientMode: {type: string, enum: [ADD, MULTIPLY], nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementDetailEnvelope:
type: object
@@ -58,5 +58,11 @@ class CustomerDimensionConfigControllerContractTest {
long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
assertThat(PlatformCustomerDimensionConfigController.class
.getDeclaredMethod("updateDefaultCalculationMode",
com.baiyue.common.security.AuthenticatedUser.class, long.class,
com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.DefaultCalculationModeUpdateRequest.class)
.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
}
}
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import org.yaml.snakeyaml.Yaml;
@@ -15,6 +16,15 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("FBA计费OpenAPI契约")
class FbaPricingContractTest {
@Test
@DisplayName("确认和发送费用兼容新的组合权限与原有权限")
void allowsCombinedConfirmSendPermissionAndLegacyPermissions() {
assertPricingAuthority("confirm", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:fba-pricing:confirm')");
assertPricingAuthority("send", "hasAnyAuthority('firstleg:inbound:confirm-send-fee',"
+ " 'firstleg:inbound:send-fee')");
}
@Test
@DisplayName("客户规则保留客户参数,船司价格使用平台公共作用域")
void keepsCustomerRuleScopeAndRemovesCarrierRateTenantScope() {
@@ -25,7 +35,7 @@ class FbaPricingContractTest {
Method rates = method("rates");
assertThat(rates.getParameterTypes()).doesNotContain(Long.class);
for (String scopedMethod : new String[] {"rule", "createRule", "retireRule"}) {
for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "setDefaultRule"}) {
Method method = method(scopedMethod);
assertThat(method.getParameterTypes()[1]).isEqualTo(long.class);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue();
@@ -45,6 +55,7 @@ class FbaPricingContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/customer-rules/versions:")
.contains("/api/platform/v1/fba-pricing/customer-rules/{id}/default:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/versions:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/calculate:")
.contains("/api/platform/v1/inbounds/{inboundId}/fees/confirm:")
@@ -146,6 +157,13 @@ class FbaPricingContractTest {
.findFirst().orElseThrow();
}
private static void assertPricingAuthority(String name, String expected) {
Method method = java.util.Arrays.stream(PlatformFbaPricingController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
@@ -58,7 +58,8 @@ class PlatformInboundMeasurementControllerContractTest {
"hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class, long.class, long.class, String.class);
assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')",
assertAuthority("saveDraft", "hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
+ " 'platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.SaveDraftRequest.class);
assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')",
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
@@ -34,6 +35,7 @@ class CustomerDimensionConfigSqlContractTest {
.contains("inner_width_cm DECIMAL(9,3) NOT NULL")
.contains("inner_height_cm DECIMAL(9,3) NOT NULL")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("default_calculation_mode VARCHAR(16) NOT NULL DEFAULT 'ADD'")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("created_by BIGINT UNSIGNED NOT NULL")
.contains("updated_by BIGINT UNSIGNED NOT NULL")
@@ -88,6 +90,19 @@ class CustomerDimensionConfigSqlContractTest {
.contains("remark = CASE box_type");
}
@Test
void defaultCalculationModeUpdateCoversTheWholeCustomerRuleSet() throws Exception {
Update annotation = CustomerDimensionConfigMapper.class
.getDeclaredMethod("updateDefaultCalculationMode", long.class, String.class, long.class)
.getAnnotation(Update.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("default_calculation_mode = #{mode}")
.contains("tenant_id = #{tenantId}")
.contains("version = version + 1");
}
@Test
void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
@@ -140,6 +140,20 @@ class CustomerDimensionConfigServiceTest {
verify(repository, times(2)).update(any());
}
@Test
void savesAndReturnsTheCustomerDefaultCalculationMode() {
List<CustomerDimensionConfigEntity> existing = defaultConfigs("registration-1");
List<CustomerDimensionConfigEntity> saved = defaultConfigs("registration-1");
saved.forEach(value -> value.setDefaultCalculationMode("MULTIPLY"));
when(repository.findAll(8L)).thenReturn(existing, saved);
when(repository.updateDefaultCalculationMode(8L, "MULTIPLY", 100L)).thenReturn(4);
GroupResponse result = service.updateDefaultCalculationMode(8L, 100L, "MULTIPLY");
assertThat(result.defaultCalculationMode()).isEqualTo("MULTIPLY");
verify(repository).updateDefaultCalculationMode(8L, "MULTIPLY", 100L);
}
@Test
void compensationLocksAndDeletesExactlyFourUnchangedDefaults() {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
@@ -192,6 +206,7 @@ class CustomerDimensionConfigServiceTest {
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(new BigDecimal("2.000"));
value.setDefaultCalculationMode("ADD");
value.setRemark(type.defaultRemark());
value.setVersion(0);
value.setInitializationKey(initializationKey);
@@ -1,6 +1,5 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
@@ -17,10 +16,10 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,10 +46,9 @@ class FbaMasterDataServiceTest {
}
@Test
@DisplayName("半开有效期首尾相接不视为重叠")
void acceptsAdjacentHalfOpenPeriods() {
@DisplayName("有效期合法时创建客户规则")
void createsRuleWithValidPeriod() {
CustomerChargeRuleEntity old = activeRule(AUGUST.minusMonths(1), AUGUST);
when(repository.listRules(1L, null)).thenReturn(List.of(old));
when(repository.listRules(1L, "CR-1")).thenReturn(List.of(old));
when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2);
doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(10L); return null; })
@@ -61,18 +59,25 @@ class FbaMasterDataServiceTest {
assertThat(created.getId()).isEqualTo(10L);
assertThat(created.getRuleVersion()).isEqualTo(2);
assertThat(created.getEffectiveFrom()).isEqualTo(AUGUST);
assertThat(created.getEffectiveTo()).isEqualTo(AUGUST.plusMonths(1));
}
@Test
@DisplayName("任意重叠有效期在保存阶段拒绝避免多规则匹配")
void rejectsOverlappingPeriods() {
when(repository.listRules(1L, null)).thenReturn(List.of(
activeRule(AUGUST, AUGUST.plusMonths(1))));
@DisplayName("重叠配置允许新增且新增时固定不启用")
void createsOverlappingRuleAsNonDefault() {
when(repository.nextRuleVersion(1L, "CR-1")).thenReturn(2);
doAnswer(invocation -> { ((CustomerChargeRuleEntity) invocation.getArgument(0)).setId(11L); return null; })
.when(repository).insertRule(any());
assertThatThrownBy(() -> service.createRuleVersion(
1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2))))
.isInstanceOf(BizException.class)
.hasMessageContaining("有效期重叠");
CustomerChargeRuleEntity created = service.createRuleVersion(
1L, 2L, request(AUGUST.plusDays(1), AUGUST.plusMonths(2), true));
assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getDefaultRule()).isFalse();
verify(repository, never()).clearDefaultRule(anyLong(), anyLong(), anyLong());
verify(repository, never()).setDefaultRule(anyLong(), anyLong(), anyLong());
verify(repository, never()).listRules(1L, null);
}
@Test
@@ -92,8 +97,13 @@ class FbaMasterDataServiceTest {
}
private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) {
return request(from, to, null);
}
private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to, Boolean defaultRule) {
return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG",
"TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null);
"TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null,
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, defaultRule);
}
private CarrierRateVersionRequest carrierRateRequest() {
@@ -7,6 +7,8 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
@@ -34,6 +36,7 @@ import static org.mockito.Mockito.when;
class MeasurementBatchServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private CustomerDimensionConfigRepository dimensionConfigs;
@MockBean private FbaPricingRepository pricingRepository;
@Resource private MeasurementBatchService service;
@BeforeEach
@@ -62,14 +65,17 @@ class MeasurementBatchServiceTest {
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0510");
assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.1020");
assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("44.000");
assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("34.000");
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("34.000");
assertThat(items.getValue().get(0).getUnitVolumeCbm()).isEqualByComparingTo("0.0267");
assertThat(items.getValue().get(0).getTotalVolumeCbm()).isEqualByComparingTo("0.0534");
assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("41.000");
assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("31.000");
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("21.000");
assertThat(items.getValue().get(0).getCalculationRuleVersion())
.isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0");
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
.isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
assertThat(items.getValue().get(0).getCoefficientMode()).isNull();
assertThat(items.getValue().get(0).getChargeRuleId()).isNull();
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("145.000");
verify(pricingRepository).findDefaultRule(1L, 1L);
verify(repository, never()).updateInbound(any());
}
@@ -83,14 +89,163 @@ class MeasurementBatchServiceTest {
InboundMeasurementEntity saved = service.saveBatch(command);
assertThat(saved.getStatus()).isEqualTo("DRAFT");
assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.1020");
assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("17.034");
assertThat(saved.getTotalVolumeCbm()).isEqualByComparingTo("0.0534");
assertThat(saved.getVolumeWeightKg()).isEqualByComparingTo("8.918");
ArgumentCaptor<List<InboundMeasurementItemEntity>> items = ArgumentCaptor.forClass(List.class);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
}
@Test
void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(77L);
rule.setTenantId(1L);
rule.setCustomerId(1L);
rule.setRuleCode("DEFAULT");
rule.setRuleVersion(2);
rule.setStatus("ACTIVE");
rule.setDefaultRule(true);
rule.setLengthFactor(new BigDecimal("1.1"));
rule.setWidthFactor(new BigDecimal("1.1"));
rule.setHeightFactor(new BigDecimal("1.1"));
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), 500L, null, null, null, null, null,
null, "备注");
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.getChargeRuleId()).isEqualTo(77L);
assertThat(calculated.getCoefficientMode()).isEqualTo("MULTIPLY");
assertThat(calculated.getOuterLengthCm()).isEqualByComparingTo("44.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("33.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("22.000");
assertThat(calculated.getGirthCm()).isEqualByComparingTo("154.000");
assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0320");
assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.0640");
}
@Test
void usesOneMatchedTierCoefficientForAllDimensionsOfEachSpecification() {
CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();
rule.setId(78L);
rule.setTenantId(1L);
rule.setCustomerId(1L);
rule.setRuleCode("TIERED");
rule.setRuleVersion(3);
rule.setStatus("ACTIVE");
rule.setDefaultRule(true);
rule.setLengthFactor(new BigDecimal("1.05"));
rule.setWidthFactor(new BigDecimal("1.25"));
rule.setHeightFactor(new BigDecimal("1.35"));
rule.setRemark("SMALL:30,20,20;系数:1.1;小箱:—;MEDIUM:40,30,30;系数:1.2;中箱:—;"
+ "LARGE:50,40,40;系数:1.3;大箱:—;EXTRA_LARGE:60,50,50;系数:1.4;特大箱:—");
when(pricingRepository.findDefaultRule(1L, 1L)).thenReturn(rule);
MeasurementBatchCommand.Item small = new MeasurementBatchCommand.Item(
80001L, "001", "A", 1, new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("20"), 500L, null, null, null, null, null, null, "小箱");
MeasurementBatchCommand.Item medium = new MeasurementBatchCommand.Item(
80002L, "002", "B", 1, new BigDecimal("31"), new BigDecimal("20"),
new BigDecimal("20"), 501L, null, null, null, null, null, null, "中箱");
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, null, null, null, null, 9L, List.of(small, medium));
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());
assertThat(items.getValue().get(0).getOuterLengthCm()).isEqualByComparingTo("33.000");
assertThat(items.getValue().get(0).getOuterWidthCm()).isEqualByComparingTo("22.000");
assertThat(items.getValue().get(0).getOuterHeightCm()).isEqualByComparingTo("22.000");
assertThat(items.getValue().get(1).getOuterLengthCm()).isEqualByComparingTo("37.200");
assertThat(items.getValue().get(1).getOuterWidthCm()).isEqualByComparingTo("24.000");
assertThat(items.getValue().get(1).getOuterHeightCm()).isEqualByComparingTo("24.000");
}
@Test
void keepsActualMeasurementUnchangedWhenCustomerHasNoDefaultRule() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), 500L, null, null, null, null, null, null, "备注");
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.getOuterLengthCm()).isEqualByComparingTo("40.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("30.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("20.000");
assertThat(calculated.getChargeRuleId()).isNull();
assertThat(calculated.getCoefficientMode()).isNull();
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
}
@Test
void addsMatchedEdgeValueToMeasuredDimensionsInsteadOfRuleBoundary() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("59.5"), new BigDecimal("41.2"),
new BigDecimal("31.3"), 500L, null, null, null, null, null, "ADD", "备注");
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.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");
}
@Test
void warehouseOuterDimensionsOverrideCalculatedCartonAndAllDerivedValues() {
MeasurementBatchCommand.Item source = new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), 500L, null, new BigDecimal("50"),
new BigDecimal("40"), new BigDecimal("30"), null, "ADD", "仓库修正");
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.getOuterLengthCm()).isEqualByComparingTo("50.000");
assertThat(calculated.getOuterWidthCm()).isEqualByComparingTo("40.000");
assertThat(calculated.getOuterHeightCm()).isEqualByComparingTo("30.000");
assertThat(calculated.getGirthCm()).isEqualByComparingTo("190.000");
assertThat(calculated.getUnitVolumeCbm()).isEqualByComparingTo("0.0600");
assertThat(calculated.getTotalVolumeCbm()).isEqualByComparingTo("0.1200");
assertThat(calculated.getVolumeWeightKg()).isEqualByComparingTo("20.040");
assertThat(calculated.getCalculationRuleVersion()).isEqualTo("WAREHOUSE_OUTER_OVERRIDE_V1");
}
@Test
void staleDraftVersionIsRejectedBeforeReplacingItems() {
InboundMeasurementEntity existing = new InboundMeasurementEntity();
@@ -184,6 +339,7 @@ class MeasurementBatchServiceTest {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L);
inbound.setTenantId(1L);
inbound.setCustomerId(1L);
inbound.setProcessStatus("WAIT_MEASURE");
inbound.setVersion(0);
return inbound;
@@ -78,6 +78,7 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
UPDATE iam_permission p
JOIN (SELECT 'platform:firstleg:inbound:measure:edit' code, 5102041 parent_id UNION ALL
SELECT 'platform:firstleg:inbound:warehouse-suggestion:edit', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:measure:submit', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:measure:manual-entry', 5102041 UNION ALL
SELECT 'platform:firstleg:inbound:weight:edit', 5102041 UNION ALL
@@ -123,24 +123,25 @@ ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
-- 平台订单写入与导出权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0)
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025)
WHERE id IN (5102019, 5102024, 5102025, 5102026)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025)
WHERE id IN (5102019, 5102024, 5102025, 5102026)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -165,7 +166,9 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%'
WHERE permission_code LIKE 'platform:order%'
OR permission_code LIKE 'platform:firstleg%'
OR permission_code = 'platform:download:list'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES
@@ -13,17 +13,27 @@ VALUES
(5102065, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:calculate', '计算入库费用', NULL, NULL, 50, 'ACTIVE', 1, 0),
(5102066, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:confirm', '内部确认入库费用', NULL, NULL, 60, 'ACTIVE', 1, 0),
(5102067, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:view', '查看入库费用', NULL, NULL, 70, 'ACTIVE', 1, 0),
(5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0)
(5102068, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:fba-pricing:adjust', '调整已发布费用', NULL, NULL, 80, 'ACTIVE', 1, 0),
(5102069, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:confirm-send-fee', '确认并推送客户费用', NULL, NULL, 90, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id BETWEEN 5102061 AND 5102068
WHERE id BETWEEN 5102061 AND 5102069
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068)
WHERE id IN (5102061, 5102063, 5102065, 5102066, 5102067, 5102068, 5102069)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code = 'firstleg:inbound:confirm-send-fee'
WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -17,7 +17,9 @@ VALUES
(5102045, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:weight:confirm', '确认OCR重量', NULL, NULL, 40, 'ACTIVE', 0),
(5102194, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0)
'platform:firstleg:inbound:measure:manual-entry', '手动录入箱规', NULL, NULL, 25, 'ACTIVE', 0),
(5102195, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:warehouse-suggestion:edit', '维护仓库建议测量数据', NULL, NULL, 27, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -25,12 +27,21 @@ ON DUPLICATE KEY UPDATE
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194)
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194)
WHERE id IN (5102041, 5102042, 5102043, 5102044, 5102045, 5102194, 5102195)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, 0
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code = 'platform:firstleg:inbound:warehouse-suggestion:edit'
WHERE role.tenant_id = 0 AND UPPER(role.role_code) = 'PLATFORM_CUSTOM_ADMIN'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
@@ -15,3 +15,12 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102026, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 订单运营角色创建导出任务后必须能够进入下载中心获取结果文件。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0
FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code = 'platform:download:list'
AND status = 'ACTIVE' AND deleted = 0
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -0,0 +1,41 @@
package com.baiyue.order.controller;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticWarehouseOrderExportPermissionSqlContractTest {
@Test
void keepsOrderExportPermissionAndDownloadGrantInAllInitializationEntrypoints() throws Exception {
String moduleSql = new ClassPathResource("db/platform-order-export-permission.sql")
.getContentAsString(StandardCharsets.UTF_8);
String iamSeed = Files.readString(resolve("iam/src/main/resources/db/phase1-admin-rbac.sql"),
StandardCharsets.UTF_8);
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String sql : new String[] {moduleSql, iamSeed, unified}) {
assertThat(sql)
.contains("5102026")
.contains("platform:order:export")
.contains("导出订单列表")
.contains("platform:download:list")
.contains("4000003");
}
assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)");
assertThat(unified)
.contains("WHERE id IN (5102019, 5102024, 5102025, 5102026)")
.contains("管理员或订单运营角色缺少订单导出与下载中心权限");
}
private static Path resolve(String relative) {
Path direct = Path.of(relative);
return Files.exists(direct) ? direct : Path.of("..").resolve(relative);
}
}