This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 deletions
@@ -40,6 +40,11 @@ public class InternalAccountController {
@Valid @RequestBody CreateAccountRequest request) {
verify(token); return ApiResponse.success(service.create(request));
}
@GetMapping("/availability")
public ApiResponse<Boolean> availability(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestParam String username) {
verify(token); return ApiResponse.success(service.usernameAvailable(username));
}
@GetMapping("/{id}")
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id) {
@@ -21,6 +21,10 @@ public class AccountManagementService {
@Resource private PasswordEncoder passwordEncoder;
@Resource private TokenSessionStore tokenSessionStore;
public boolean usernameAvailable(String username) {
return username != null && !username.isBlank() && !repository.existsByUsername(username.trim());
}
@Transactional
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
public void changePassword(Long accountId, ChangePasswordRequest request) {
@@ -75,6 +75,13 @@ class AccountManagementServiceTest {
assertNotEquals("Password1", captor.getValue().getPasswordHash());
}
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
when(repository.existsByUsername("13800138000")).thenReturn(true);
assertFalse(service.usernameAvailable("13800138000"));
assertTrue(service.usernameAvailable("13900139000"));
}
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
when(repository.findById(20L)).thenReturn(null);
when(repository.existsByUsername("merchant.user")).thenReturn(false);
+9
View File
@@ -18,6 +18,15 @@ firstleg:
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
# 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。
mojia:
enabled: ${MOJIA_MEASUREMENT_ENABLED:false}
secret: ${MOJIA_MEASUREMENT_SECRET:}
tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0}
subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0}
# 历史通用应用配置,保留兼容;新接入请使用 mojia。
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
+9
View File
@@ -17,6 +17,15 @@ firstleg:
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
# 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。
mojia:
enabled: ${MOJIA_MEASUREMENT_ENABLED:false}
secret: ${MOJIA_MEASUREMENT_SECRET:}
tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0}
subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0}
# 历史通用应用配置,保留兼容;新接入请使用 mojia。
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
+249 -23
View File
@@ -432,6 +432,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
@@ -1524,6 +1525,7 @@ CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
CALL phase1_add_column('ord_order', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT 'TAX_INCLUDED or TAX_EXCLUDED; independent from tax_mode'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
@@ -2324,7 +2326,19 @@ UPDATE baiyue_order.ord_order o
LEFT JOIN baiyue_firstleg.fl_inbound_order i
ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0
SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id))
WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND o.inbound_code IS NULL;
WHERE o.deleted = 0
AND o.status <> 'DRAFT'
AND (o.submitted_at IS NOT NULL
OR o.status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED'))
AND o.inbound_code IS NULL;
-- 从未提交的草稿或由草稿直接取消的订单不应拥有入仓码。
UPDATE baiyue_order.ord_order
SET inbound_code = NULL
WHERE deleted = 0
AND inbound_code IS NOT NULL
AND (status = 'DRAFT'
OR (status IN ('REJECTED', 'CANCELLED') AND submitted_at IS NULL));
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
@@ -2562,13 +2576,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0',
rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码',
rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID',
origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM',
unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价',
currency CHAR(3) NOT NULL COMMENT '币种',
@@ -2594,30 +2608,35 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照',
charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照',
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照',
rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
tax_mode VARCHAR(24) NULL COMMENT '包税模式快照',
rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照',
rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照',
rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度快照',
rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照',
rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照',
rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照',
rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照',
rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
destination_type VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空',
rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照',
rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照',
charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照',
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
@@ -3045,14 +3064,17 @@ DELIMITER ;
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name");
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_order', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED' AFTER tax_mode");
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_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', 'price_book_version_id', "BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照' 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");
CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type");
CALL phase2_fba_add_column('fl_inbound_fee', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT '报价包税维度快照' AFTER tax_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode");
@@ -3061,14 +3083,61 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空' AFTER channel_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_type', "VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS' AFTER carrier_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_warehouse_id', "BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照' AFTER destination_type");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_name', "VARCHAR(128) NULL COMMENT '目的仓名称快照' AFTER destination_warehouse_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空' AFTER destination_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from");
CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json");
CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id',
"BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'price_book_version_id',
"BIGINT UNSIGNED NULL COMMENT '整表价格版本ID;历史兼容价格可为空' AFTER tenant_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'origin_warehouse_code',
"VARCHAR(64) NULL COMMENT '起运仓稳定编码' AFTER origin_warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_type',
"VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空' AFTER carrier_name");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_warehouse_id',
"BIGINT UNSIGNED NULL COMMENT '平台海外仓稳定ID;不存环境专属ID' AFTER destination_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_name',
"VARCHAR(128) NULL COMMENT '目的仓名称快照' AFTER destination_warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'price_model',
"VARCHAR(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型' AFTER channel_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'region_code',
"VARCHAR(16) NULL COMMENT '区域阶梯模型区域编码' AFTER destination_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'pricing_tax_mode',
"VARCHAR(16) NULL COMMENT '报价包税维度;独立于订单tax_mode' AFTER region_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'weight_from_kg',
"DECIMAL(18,6) NULL COMMENT '区域阶梯起始计费重量KG' AFTER pricing_tax_mode");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_sheet_name',
"VARCHAR(128) NULL COMMENT '原始Excel Sheet' AFTER additional_fee");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_row_number',
"INT UNSIGNED NULL COMMENT '原始Excel行号' AFTER source_sheet_name");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_destination_text',
"VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本' AFTER source_row_number");
ALTER TABLE fl_carrier_fba_rate
MODIFY COLUMN origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID',
MODIFY COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
MODIFY COLUMN carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
MODIFY COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空',
MODIFY COLUMN destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空';
ALTER TABLE fl_inbound_fee
MODIFY COLUMN charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
MODIFY COLUMN charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
MODIFY COLUMN rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照',
MODIFY COLUMN rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照',
MODIFY COLUMN rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照',
MODIFY COLUMN rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照',
MODIFY COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
MODIFY COLUMN carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
MODIFY COLUMN destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空',
MODIFY COLUMN charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM',
MODIFY COLUMN conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照';
-- 仓储资源是平台公共主数据;存量分配快照统一迁移到平台作用域 0,再收口非空约束。
UPDATE fl_inbound_pallet_allocation
@@ -3083,16 +3152,152 @@ SET @sql = IF(@resource_tenant_needs_modify > 0,
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)',
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_unique_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_type, destination_code, destination_warehouse_id, charge_unit, status, effective_from, effective_to)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_book');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_book (price_book_version_id, channel_code, origin_warehouse_id, price_model, status)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_destination');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_destination (price_book_version_id, channel_code, origin_warehouse_id, destination_type, destination_warehouse_id, destination_code)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_region');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_carrier_fba_rate ADD INDEX idx_fba_rate_region (price_book_version_id, channel_code, origin_warehouse_id, region_code, weight_from_kg)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码',
display_name VARCHAR(128) NOT NULL COMMENT '平台渠道名称',
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_channel_code (channel_code),
KEY idx_price_channel_status (status, price_model)
) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录';
CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID',
alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_origin_alias (warehouse_code, alias_name),
KEY idx_price_origin_alias_name (alias_name, status)
) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射';
CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数',
rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数',
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_by BIGINT UNSIGNED NULL,
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
updated_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_version (tenant_id, version_no),
KEY idx_price_book_status (tenant_id, status, published_at),
KEY idx_price_book_source_hash (tenant_id, source_sha256)
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
ALTER TABLE fl_price_book_version
MODIFY COLUMN status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED';
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
channel_code VARCHAR(64) NOT NULL COMMENT '固定渠道编码',
channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照',
price_model VARCHAR(32) NOT NULL COMMENT '价格模型快照',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '原始Excel Sheet',
estimated_departure_date DATE NULL COMMENT '标准预计开船日期',
estimated_departure_text VARCHAR(128) NULL COMMENT '原始预计开船日期文本',
latest_warehouse_entry VARCHAR(128) NULL COMMENT '最晚进仓时间',
vessel_name VARCHAR(128) NULL COMMENT '船名',
voyage_no VARCHAR(64) NULL COMMENT '航次',
reference_transit VARCHAR(128) NULL COMMENT '参考时效',
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code),
KEY idx_price_book_channel_code (channel_code, price_book_version_id)
) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照';
CALL phase2_fba_add_column('fl_price_book_version', 'parse_issues_json',
"JSON NULL COMMENT '上传解析问题及待确认重新校验结果' AFTER warning_count");
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
INSERT INTO fl_price_channel_catalog
(id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status)
VALUES
(1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'),
(2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'),
(3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'),
(4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'),
(5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'),
(6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'),
(7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'),
(8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'),
(9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'),
(10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'),
(11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'),
(12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'),
(13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'),
(14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'),
(15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE')
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name), price_model = VALUES(price_model),
source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status);
INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status)
VALUES
(1, 'CN-YIWU-01', '华东仓(义乌)', 'ACTIVE'),
(2, 'CN-YIWU-01', '义乌仓', 'ACTIVE'),
(3, 'CN-YIWU-01', '义乌', 'ACTIVE'),
(4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'),
(5, 'CN-DONGGUAN-01', '东莞仓', 'ACTIVE'),
(6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE')
ON DUPLICATE KEY UPDATE status = VALUES(status);
-- ============================================================================
-- 来源:iam\src\main\resources\db\phase2-inbound-query-permissions.sql
-- ============================================================================
@@ -3472,9 +3677,23 @@ CALL unified_assert_table('baiyue_audit', 'sys_audit_log');
CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox');
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id');
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name');
CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no');
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_book_version');
CALL unified_assert_column('baiyue_firstleg', 'fl_price_book_version', 'parse_issues_json');
CALL unified_assert_table('baiyue_firstleg', 'fl_price_book_channel');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'price_book_version_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'pricing_tax_mode');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'source_sheet_name');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_type');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_warehouse_id');
CALL unified_assert_column('baiyue_firstleg', 'fl_carrier_fba_rate', 'destination_name');
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_fee', 'price_book_version_id');
CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at');
DROP PROCEDURE IF EXISTS unified_assert_table;
@@ -4775,8 +4994,15 @@ BEGIN
IF EXISTS (
SELECT 1 FROM baiyue_order.ord_order
WHERE deleted = 0
AND ((status = 'DRAFT' AND inbound_code IS NOT NULL)
OR (status <> 'DRAFT' AND inbound_code IS NULL))
AND (
((status = 'DRAFT' OR (status IN ('REJECTED', 'CANCELLED') AND submitted_at IS NULL))
AND inbound_code IS NOT NULL)
OR
(status <> 'DRAFT'
AND (submitted_at IS NOT NULL
OR status IN ('SUBMITTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED'))
AND inbound_code IS NULL)
)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致';
@@ -0,0 +1,89 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.PriceBookFileService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Internal source-file API used only by Firstleg price-book workflows. */
@RestController
@RequestMapping("/internal/file/v1/price-books")
public class InternalPriceBookFileController {
@Resource
private PriceBookFileService service;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping(value = "/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PriceBookFileService.FileMetadata> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@RequestPart("file") MultipartFile file) {
requireAccess(token, operatorId, businessId, null);
return ApiResponse.success(service.upload(businessId, operatorId, file));
}
@GetMapping("/{businessId}/{fileId}/metadata")
public ApiResponse<PriceBookFileService.FileMetadata> metadata(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@PathVariable long fileId) {
requireAccess(token, operatorId, businessId, fileId);
return ApiResponse.success(service.metadata(businessId, fileId));
}
@GetMapping("/{businessId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@PathVariable long fileId) {
requireAccess(token, operatorId, businessId, fileId);
PriceBookFileService.Download download = service.download(businessId, fileId);
PriceBookFileService.FileMetadata metadata = download.metadata();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(metadata.contentType()))
.contentLength(download.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(metadata.fileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff")
.body(download.content());
}
private void requireAccess(String token, long operatorId, long businessId, Long fileId) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
if (operatorId <= 0 || businessId <= 0 || (fileId != null && fileId <= 0)) {
throw new BizException(ResultCode.BAD_REQUEST, "操作主体、价格版本业务和文件ID必须大于0");
}
}
}
@@ -0,0 +1,88 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
/** Persists platform price-book source files and enforces exact business ownership. */
@Service
public class PriceBookFileService {
public static final long PLATFORM_SCOPE = 0L;
public static final String BUSINESS_TYPE = "FBA_PRICE_BOOK_SOURCE";
@Resource
private PriceBookFileStorage storage;
@Resource
private FileObjectMapper files;
@Transactional
public FileMetadata upload(long businessId, long operatorId, MultipartFile file) {
PriceBookFileStorage.Stored stored = storage.store(businessId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(PLATFORM_SCOPE);
object.setBusinessType(BUSINESS_TYPE);
object.setBusinessId(Long.toString(businessId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("price-books");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.sizeBytes());
object.setSha256(stored.sha256());
object.setStatus("ACTIVE");
object.setCreatedBy(operatorId);
object.setUpdatedBy(operatorId);
object.setCreatedAt(LocalDateTime.now());
object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false);
object.setVersion(0);
try {
if (files.insert(object) != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "价格表文件元数据保存失败");
}
} catch (RuntimeException exception) {
storage.delete(stored.objectKey());
throw exception;
}
return metadata(object);
}
public FileMetadata metadata(long businessId, long fileId) {
return metadata(requireObject(businessId, fileId));
}
public Download download(long businessId, long fileId) {
FileObjectEntity object = requireObject(businessId, fileId);
return new Download(metadata(object), storage.read(object.getObjectKey()));
}
private FileObjectEntity requireObject(long businessId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, PLATFORM_SCOPE)
.eq(FileObjectEntity::getBusinessType, BUSINESS_TYPE)
.eq(FileObjectEntity::getBusinessId, Long.toString(businessId))
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
return object;
}
private static FileMetadata metadata(FileObjectEntity object) {
return new FileMetadata(object.getId(), Long.parseLong(object.getBusinessId()),
object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getSha256(),
object.getBusinessType());
}
public record FileMetadata(Long fileId, Long businessId, String fileName, String contentType,
Long sizeBytes, String sha256, String businessType) {}
public record Download(FileMetadata metadata, byte[] content) {}
}
@@ -0,0 +1,173 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** Stores original price-book XLSX files without interpreting workbook table metadata. */
@Component
public class PriceBookFileStorage {
static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final Set<String> ACCEPTED_CONTENT_TYPES = Set.of(
XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip");
private static final Set<String> REQUIRED_ENTRIES = Set.of(
"[Content_Types].xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels");
private static final int MAX_ZIP_ENTRIES = 20_000;
private static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024;
@Value("${baiyue.file.storage-root}")
private Path configuredRoot;
@Value("${baiyue.file.price-book.max-size-bytes:10485760}")
private long maxSizeBytes;
private Path root;
@PostConstruct
void initializeRoot() {
root = configuredRoot.toAbsolutePath().normalize();
}
public Stored store(long businessId, MultipartFile file) {
validateFile(file);
String originalName = safeName(file);
verifyXlsxPackage(file);
String objectKey = "price-books/0/" + businessId + "/" + UUID.randomUUID() + ".xlsx";
Path target = resolve(objectKey);
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
return new Stored(objectKey, originalName, XLSX_CONTENT_TYPE, Files.size(target), sha256(target));
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败");
}
}
public byte[] read(String objectKey) {
Path target = resolve(objectKey);
if (!Files.isRegularFile(target)) {
throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
}
try {
return Files.readAllBytes(target);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败");
}
}
public void delete(String objectKey) {
Path target = resolve(objectKey);
try {
Files.deleteIfExists(target);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件清理失败");
}
}
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("价格表文件不能为空");
if (maxSizeBytes <= 0 || file.getSize() > maxSizeBytes) {
throw bad("价格表文件超过允许大小");
}
String name = file.getOriginalFilename();
if (name == null || name.isBlank() || name.length() > 200
|| name.indexOf('\0') >= 0 || name.contains("/") || name.contains("\\")) {
throw bad("价格表文件名不安全");
}
if (!name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
throw bad("价格表仅支持XLSX文件");
}
String contentType = file.getContentType() == null
? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!ACCEPTED_CONTENT_TYPES.contains(contentType)) {
throw bad("价格表文件类型不正确");
}
}
private void verifyXlsxPackage(MultipartFile file) {
Set<String> found = new java.util.HashSet<>();
int entries = 0;
long expandedBytes = 0;
try (InputStream raw = new BufferedInputStream(file.getInputStream())) {
raw.mark(4);
if (raw.read() != 'P' || raw.read() != 'K' || raw.read() != 3 || raw.read() != 4) {
throw bad("上传内容不是有效XLSX文件");
}
raw.reset();
try (ZipInputStream zip = new ZipInputStream(raw)) {
byte[] buffer = new byte[8192];
for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) {
if (++entries > MAX_ZIP_ENTRIES || unsafeEntry(entry.getName())) {
throw bad("XLSX压缩包结构不安全");
}
if (REQUIRED_ENTRIES.contains(entry.getName())) found.add(entry.getName());
for (int count; (count = zip.read(buffer)) >= 0;) {
expandedBytes += count;
if (expandedBytes > MAX_EXPANDED_BYTES) {
throw bad("XLSX解压内容超过安全限制");
}
}
}
}
} catch (BizException exception) {
throw exception;
} catch (IOException exception) {
throw bad("上传内容不是有效XLSX文件");
}
if (!found.containsAll(REQUIRED_ENTRIES)) throw bad("上传内容缺少XLSX必要结构");
}
private Path resolve(String objectKey) {
if (objectKey == null || !objectKey.matches("price-books/0/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) {
throw new BizException(ResultCode.FORBIDDEN, "价格表文件路径无效");
}
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
return target;
}
private static boolean unsafeEntry(String name) {
return name == null || name.startsWith("/") || name.startsWith("\\")
|| name.contains("../") || name.contains("..\\") || name.indexOf('\0') >= 0;
}
private static String safeName(MultipartFile file) {
return file.getOriginalFilename().trim();
}
private static String sha256(Path path) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
public record Stored(String objectKey, String originalName, String contentType,
long sizeBytes, String sha256) {}
}
+2
View File
@@ -55,6 +55,8 @@ baiyue:
url: ${INTEGRATION_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
price-book:
max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760}
export:
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
legacy-worker-enabled: false
@@ -0,0 +1,67 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.PriceBookFileService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalPriceBookFileController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalPriceBookFileControllerTest {
@MockBean PriceBookFileService service;
@Resource InternalPriceBookFileController controller;
@Test
void uploadsWithAuthenticatedOperatorAndBusinessId() {
var file = new MockMultipartFile("file", "price.xlsx", "application/octet-stream", new byte[]{1});
var metadata = metadata();
when(service.upload(41L, 7L, file)).thenReturn(metadata);
var response = controller.upload("internal-token", 7L, 41L, file);
assertThat(response.data()).isEqualTo(metadata);
verify(service).upload(41L, 7L, file);
}
@Test
void rejectsInvalidToken() {
assertThatThrownBy(() -> controller.metadata("bad-token", 7L, 41L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
}
@Test
void rejectsMissingOperatorOrBusinessOwnership() {
assertThatThrownBy(() -> controller.metadata("internal-token", 0L, 41L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("必须大于0");
assertThatThrownBy(() -> controller.metadata("internal-token", 7L, 0L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("必须大于0");
}
@Test
void downloadsAsAttachmentWithoutCaching() {
PriceBookFileService.FileMetadata metadata = metadata();
when(service.download(41L, 99L)).thenReturn(
new PriceBookFileService.Download(metadata, new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 7L, 41L, 99L);
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
assertThat(response.getHeaders().getContentDisposition().isAttachment()).isTrue();
}
private static PriceBookFileService.FileMetadata metadata() {
return new PriceBookFileService.FileMetadata(99L, 41L, "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "hash",
PriceBookFileService.BUSINESS_TYPE);
}
}
@@ -0,0 +1,79 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
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.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PriceBookFileServiceTest {
@Mock PriceBookFileStorage storage;
@Mock FileObjectMapper files;
@InjectMocks PriceBookFileService service;
@Test
void persistsPlatformOwnedPriceBookMetadata() {
var file = new MockMultipartFile("file", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE,
new byte[]{1});
when(storage.store(41L, file)).thenReturn(new PriceBookFileStorage.Stored(
"price-books/0/41/a.xlsx", "price.xlsx", PriceBookFileStorage.XLSX_CONTENT_TYPE,
1L, "hash"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(99L);
return 1;
});
PriceBookFileService.FileMetadata result = service.upload(41L, 7L, file);
assertThat(result.fileId()).isEqualTo(99L);
assertThat(result.businessId()).isEqualTo(41L);
assertThat(result.businessType()).isEqualTo(PriceBookFileService.BUSINESS_TYPE);
verify(files).insert(any(FileObjectEntity.class));
}
@Test
void rejectsDownloadWhenBusinessOwnershipDoesNotMatch() {
when(files.selectOne(any(Wrapper.class))).thenReturn(null);
assertThatThrownBy(() -> service.download(42L, 99L))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void downloadsOnlyOwnedPriceBookObject() {
FileObjectEntity object = object(42L, 99L);
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
when(storage.read("price-books/0/42/a.xlsx")).thenReturn(new byte[]{1, 2});
PriceBookFileService.Download result = service.download(42L, 99L);
assertThat(result.content()).containsExactly(1, 2);
assertThat(result.metadata().businessId()).isEqualTo(42L);
}
private static FileObjectEntity object(long businessId, long fileId) {
FileObjectEntity object = new FileObjectEntity();
object.setId(fileId);
object.setTenantId(0L);
object.setBusinessId(Long.toString(businessId));
object.setBusinessType(PriceBookFileService.BUSINESS_TYPE);
object.setOriginalName("price.xlsx");
object.setObjectKey("price-books/0/" + businessId + "/a.xlsx");
object.setContentType(PriceBookFileStorage.XLSX_CONTENT_TYPE);
object.setSizeBytes(2L);
object.setSha256("hash");
return object;
}
}
@@ -0,0 +1,97 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PriceBookFileStorageTest {
private static final String XLSX = PriceBookFileStorage.XLSX_CONTENT_TYPE;
@TempDir Path root;
private PriceBookFileStorage storage;
@BeforeEach
void setUp() {
storage = new PriceBookFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
ReflectionTestUtils.setField(storage, "maxSizeBytes", 1024L * 1024);
storage.initializeRoot();
}
@Test
void storesValidPackageEvenWhenTableMetadataIsMalformed() throws IOException {
Map<String, byte[]> entries = requiredEntries();
entries.put("xl/tables/table1.xml", "<table ref=\"5:31\">".getBytes());
var file = new MockMultipartFile("file", "2026-price.xlsx", XLSX, zip(entries));
PriceBookFileStorage.Stored stored = storage.store(101L, file);
assertThat(stored.originalName()).isEqualTo("2026-price.xlsx");
assertThat(stored.objectKey()).matches("price-books/0/101/[0-9a-f-]{36}\\.xlsx");
assertThat(stored.sha256()).hasSize(64);
assertThat(Files.isRegularFile(root.resolve(stored.objectKey()))).isTrue();
}
@Test
void rejectsFakeXlsxExtension() {
var file = new MockMultipartFile("file", "fake.xlsx", XLSX, "not-a-zip".getBytes());
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("有效XLSX");
}
@Test
void rejectsOversizedFile() throws IOException {
ReflectionTestUtils.setField(storage, "maxSizeBytes", 8L);
var file = new MockMultipartFile("file", "large.xlsx", XLSX, zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("超过允许大小");
}
@Test
void rejectsTraversalFileName() throws IOException {
var file = new MockMultipartFile("file", "../price.xlsx", XLSX, zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("文件名不安全");
}
@Test
void rejectsWrongMimeType() throws IOException {
var file = new MockMultipartFile("file", "price.xlsx", "text/plain", zip(requiredEntries()));
assertThatThrownBy(() -> storage.store(1L, file))
.isInstanceOf(BizException.class).hasMessageContaining("类型不正确");
}
private static Map<String, byte[]> requiredEntries() {
Map<String, byte[]> entries = new LinkedHashMap<>();
entries.put("[Content_Types].xml", "<Types/>".getBytes());
entries.put("xl/workbook.xml", "<workbook/>".getBytes());
entries.put("xl/_rels/workbook.xml.rels", "<Relationships/>".getBytes());
return entries;
}
private static byte[] zip(Map<String, byte[]> entries) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue());
zip.closeEntry();
}
}
return bytes.toByteArray();
}
}
+1
View File
@@ -18,6 +18,7 @@
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
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.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
/** File Service contract for immutable original price-book XLSX files. */
@FeignClient(name = "file-service", contextId = "firstlegPriceBookFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/price-books")
public interface PriceBookFileClient {
@PostMapping(value = "/{businessId}", consumes = "multipart/form-data")
ApiResponse<FileMetadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@RequestPart("file") MultipartFile file);
@GetMapping("/{businessId}/{fileId}/metadata")
ApiResponse<FileMetadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@PathVariable long fileId);
@GetMapping("/{businessId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long businessId,
@PathVariable long fileId);
record FileMetadata(Long fileId, Long businessId, String fileName, String contentType,
Long sizeBytes, String sha256, String businessType) {}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "firstlegPriceBookWarehouseClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface PriceBookWarehouseClient {
@GetMapping("/{tenantId}/{id}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId,
@PathVariable("id") long id);
record Detail(Warehouse warehouse, List<Address> addresses) {}
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
String warehouseType, String countryCode, String status) {}
record Address(String countryCode, String stateProvince, String city, String district,
String postalCode, String addressLine1, String addressLine2) {}
}
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.PriceBookChannelValidation;
import com.baiyue.firstleg.service.MerchantPriceBookService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/pricing")
public class InternalPriceBookController {
@Resource private MerchantPriceBookService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expectedToken;
@PostConstruct
void initializeToken() {
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/channels/validate")
public ApiResponse<Void> validateChannel(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PriceBookChannelValidation.Request request) {
requireToken(token);
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode());
return ApiResponse.success(null);
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.MerchantPriceBookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "客户端船期渠道与参考估价")
@RestController
@RequestMapping("/api/merchant/v1/first-leg/pricing")
public class MerchantPriceBookController {
@Resource private MerchantPriceBookService service;
@GetMapping("/channels")
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
@Operation(summary = "查询当前起运仓可选渠道")
public ApiResponse<List<CurrentChannel>> channels(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long originWarehouseId) {
return ApiResponse.success(service.channels(FirstlegScopeGuard.merchant(user), originWarehouseId));
}
@PostMapping("/estimate")
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
@Operation(summary = "计算头程基础参考费用")
public ApiResponse<EstimateResponse> estimate(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody EstimateRequest request) {
return ApiResponse.success(service.estimate(FirstlegScopeGuard.merchant(user), request));
}
}
@@ -0,0 +1,84 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.PlatformPriceBookService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "平台船期价格表")
@Validated
@RestController
@RequestMapping("/api/platform/v1/fba-pricing/carrier-rates/price-books")
public class PlatformPriceBookController {
@Resource private PlatformPriceBookService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
@Operation(summary = "查询船期价格表版本")
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestParam(required = false) PriceBookStatus status) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.list(status));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.upload(user.userId(), file));
}
@GetMapping("/{versionId}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> detail(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(versionId));
}
@PostMapping("/{versionId}/publish")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_PRICE_BOOK_CONFIRM", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "确认仓库映射并生效船期价格表")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> publish(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId,
@Valid @RequestBody PriceBookConfirmationRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.publish(versionId, user.userId(), request));
}
@GetMapping("/{versionId}/source")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ResponseEntity<byte[]> source(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId) {
FirstlegScopeGuard.platform(user);
return service.source(versionId, user.userId());
}
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.domain;
import java.util.List;
import java.util.Set;
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {}
public static final List<ChannelDefinition> CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"),
fba("EXX_TRUCK", "EXX海卡", "EXX海卡"),
fba("HEDE_TRUCK", "合德海卡", "合德海卡"),
fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"),
fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"),
fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"),
fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"),
fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"),
fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"),
fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"),
parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"),
parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"),
parcel("HEDE_PARCEL", "合德海派", "海派"),
parcel("OA_PARCEL", "普船海派", "海派")
);
private static ChannelDefinition fba(String code, String name, String sheet) {
return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name));
}
private static ChannelDefinition parcel(String code, String name, String sheet) {
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name));
}
public record ChannelDefinition(
String channelCode,
String displayName,
PriceModel priceModel,
String sourceSheetName,
Set<String> aliases
) {}
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.domain;
/** 平台公共船期价格表版本状态;上传后待确认映射,确认即生效。 */
public enum PriceBookStatus {
PENDING_CONFIRMATION,
ACTIVE,
SUPERSEDED,
FAILED
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 当前首版支持的两种明确价格模型,不扩展为通用规则引擎。 */
public enum PriceModel {
FBA_WAREHOUSE_RATE,
REGION_WEIGHT_TIER
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.domain;
/** 海派首版固定美国区域;优先由目的仓邮编或版本内区划数据解析。 */
public enum PriceRegion {
WEST,
CENTRAL,
EAST
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价。 */
public enum PricingDestinationType {
FBA,
OVERSEAS
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.domain;
/** 报价包税维度;与订单的买单/退税 taxMode 是两个独立概念。 */
public enum PricingTaxMode {
TAX_INCLUDED,
TAX_EXCLUDED
}
@@ -59,12 +59,13 @@ public final class FbaPricingDtos {
public record FeeSnapshotResponse(
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationCode, String taxMode, String chargeMode,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion, Long priceBookVersionId,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationType, Long destinationWarehouseId, String destinationName, String destinationCode,
String taxMode, String pricingTaxMode, String chargeMode,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
@@ -17,7 +17,7 @@ public record InboundOrderSummary(
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String taxMode, String containerMode, Boolean customsInspection,
String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
@@ -40,7 +40,8 @@ public record InboundOrderSummary(
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getContainerMode(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
@@ -21,6 +21,7 @@ public record OrderApprovedInboundCommand(
Long carrierId,
String carrierName,
String taxMode,
String pricingTaxMode,
String containerMode,
Boolean customsInspection,
String destinationType,
@@ -31,4 +32,19 @@ public record OrderApprovedInboundCommand(
String expressCompanyName,
String expressTrackingNo
) {
/** 兼容未携带报价包税维度的历史调用方。 */
public OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
String publicRemark, String expressCompanyName, String expressTrackingNo
) {
this(tenantId, orderId, orderNo, inboundCode, customerId, customerName, warehouseId, warehouseName,
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo);
}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public final class PriceBookChannelValidation {
private PriceBookChannelValidation() {}
public record Request(
@Min(1) long tenantId,
@Min(1) long originWarehouseId,
@NotBlank @Size(max = 64) String channelCode
) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* Confirmation only binds source warehouse dimensions to platform public warehouses.
* Price rows and channel metadata are immutable after workbook parsing.
*/
public record PriceBookConfirmationRequest(
@NotEmpty @Size(max = 2) List<@Valid OriginMapping> originMappings,
@Size(max = 2) List<@Valid OverseasMapping> overseasMappings
) {
public PriceBookConfirmationRequest {
overseasMappings = overseasMappings == null ? List.of() : List.copyOf(overseasMappings);
}
public record OriginMapping(
@NotBlank @Size(max = 64) String originWarehouseCode,
@NotNull @Min(1) Long warehouseId
) { }
public record OverseasMapping(
@NotBlank @Size(max = 128) String sourceDestinationName,
@NotNull @Min(1) Long warehouseId
) { }
}
@@ -0,0 +1,138 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.domain.PricingDestinationType;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 船期价格表上传、人工确认、当前渠道和参考估价的公共契约。 */
public final class PriceBookDtos {
private PriceBookDtos() {}
public record PriceBookVersionSummary(
Long id,
Integer versionNo,
String originalFileName,
Long sourceFileId,
String sourceSha256,
PriceBookStatus status,
Integer channelCount,
Integer rateCount,
Integer blockingErrorCount,
Integer warningCount,
String parseIssuesJson,
Long createdBy,
LocalDateTime createdAt,
Long publishedBy,
LocalDateTime publishedAt,
LocalDateTime supersededAt
) {}
public record PriceBookVersionDetail(
PriceBookVersionSummary version,
List<ParsedChannel> channels,
List<ParsedRate> rates,
List<ParseIssue> issues
) {}
/** Read-only channel data parsed from an uploaded workbook. */
public record ParsedChannel(
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotNull PriceModel priceModel,
@NotBlank @Size(max = 128) String sourceSheetName,
LocalDate estimatedDepartureDate,
@Size(max = 128) String estimatedDepartureText,
@Size(max = 128) String latestWarehouseEntry,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@Size(max = 128) String referenceTransit,
@Size(max = 2000) String surchargeDescription,
@Size(max = 2000) String remark
) {}
/** Read-only rate row parsed from an uploaded workbook. */
public record ParsedRate(
Long id,
@NotBlank @Size(max = 64) String channelCode,
@NotNull PriceModel priceModel,
Long originWarehouseId,
@NotBlank @Size(max = 64) String originWarehouseCode,
@NotBlank @Size(max = 128) String originWarehouseName,
@Size(max = 32) String destinationCode,
PricingDestinationType destinationType,
Long destinationWarehouseId,
@Size(max = 128) String destinationName,
PriceRegion regionCode,
PricingTaxMode pricingTaxMode,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
@DecimalMin(value = "0", inclusive = true) BigDecimal weightFromKg,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotBlank @Size(max = 128) String sourceSheetName,
@NotNull @Min(1) Integer sourceRowNumber,
@Size(max = 1000) String sourceDestinationText,
@Size(max = 500) String remark
) {}
public record ParseIssue(
String severity,
String issueCode,
String message,
String sourceSheetName,
Integer sourceRowNumber,
String sourceCell
) {}
public record CurrentChannel(
Long priceBookVersionId,
String channelCode,
String channelName,
PriceModel priceModel,
List<PricingTaxMode> pricingTaxModes,
LocalDate estimatedDepartureDate,
String estimatedDepartureText,
String latestWarehouseEntry,
String referenceTransit,
String surchargeDescription,
String remark
) {}
public record EstimateRequest(
@NotNull @Min(1) Long originWarehouseId,
@NotBlank @Size(max = 64) String channelCode,
@Size(max = 32) String destinationCode,
@NotNull PricingDestinationType destinationType,
Long destinationWarehouseId,
@Size(max = 128) String destinationName,
PriceRegion destinationRegion,
PricingTaxMode pricingTaxMode,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
) {}
public record EstimateResponse(
Long priceBookVersionId,
String channelCode,
PriceModel priceModel,
PricingTaxMode pricingTaxMode,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal unitPrice,
BigDecimal estimatedAmount,
String currency,
String disclaimer
) {}
}
@@ -21,19 +21,31 @@ public class CarrierFbaRateEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long priceBookVersionId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseCode;
private String originWarehouseName;
private String businessType;
private String channelCode;
private String priceModel;
private Long carrierId;
private String carrierName;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private String regionCode;
private String pricingTaxMode;
private BigDecimal weightFromKg;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private String sourceSheetName;
private Integer sourceRowNumber;
private String sourceDestinationText;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
@@ -24,11 +24,13 @@ public class InboundFeeEntity {
private Integer chargeRuleVersion;
private Long carrierRateId;
private Integer carrierRateVersion;
private Long priceBookVersionId;
private String ruleCode;
private Long customerId;
private String customerName;
private String businessType;
private String taxMode;
private String pricingTaxMode;
private Integer roundingScale;
private String roundingMode;
private LocalDateTime ruleEffectiveFrom;
@@ -39,6 +41,9 @@ public class InboundFeeEntity {
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationType;
private Long destinationWarehouseId;
private String destinationName;
private String destinationCode;
private LocalDateTime rateEffectiveFrom;
private LocalDateTime rateEffectiveTo;
@@ -43,6 +43,7 @@ public class InboundOrderEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
@@ -30,8 +30,11 @@ public class InboundPricingEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Getter @Setter
@TableName("fl_price_book_channel")
public class PriceBookChannelEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long priceBookVersionId;
private String channelCode;
private String channelName;
private String priceModel;
private String sourceSheetName;
private LocalDate estimatedDepartureDate;
private String estimatedDepartureText;
private String latestWarehouseEntry;
private String vesselName;
private String voyageNo;
private String referenceTransit;
private String surchargeDescription;
private String remark;
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter
@TableName("fl_price_book_version")
public class PriceBookVersionEntity extends BaseEntity {
public static final long PLATFORM_SCOPE = 0L;
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Integer versionNo;
private String originalFileName;
private Long sourceFileId;
private String sourceSha256;
private String status;
private Integer blockingErrorCount;
private Integer warningCount;
private String parseIssuesJson;
private Integer channelCount;
private Integer rateCount;
private Long publishedBy;
private LocalDateTime publishedAt;
private LocalDateTime supersededAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_price_channel_catalog")
public class PriceChannelCatalogEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String channelCode;
private String displayName;
private String priceModel;
private String sourceSheetName;
private String aliasesJson;
private String status;
}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
@TableName("fl_price_origin_alias")
public class PriceOriginAliasEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String warehouseCode;
private String aliasName;
private String status;
}
@@ -1,4 +1,10 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {
@Delete("DELETE FROM fl_carrier_fba_rate WHERE price_book_version_id = #{versionId} AND status = 'PENDING_CONFIRMATION'")
int physicallyDeletePendingByPriceBookVersionId(@Param("versionId") long versionId);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {}
@@ -0,0 +1,141 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** Dedicated read model for merchant channel selection and reference estimates. */
@Mapper
public interface PriceBookQueryMapper {
@Select("""
SELECT id
FROM fl_price_book_version
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
ORDER BY published_at DESC, version_no DESC
LIMIT 1
""")
Long findActiveVersionId();
@Select("""
SELECT c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
c.price_model AS priceModel,
c.estimated_departure_date AS estimatedDepartureDate,
c.estimated_departure_text AS estimatedDepartureText,
c.latest_warehouse_entry AS latestWarehouseEntry,
c.reference_transit AS referenceTransit,
c.surcharge_description AS surchargeDescription,
c.remark AS remark
FROM fl_price_book_channel c
WHERE c.price_book_version_id = #{versionId}
AND EXISTS (
SELECT 1 FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = c.price_book_version_id
AND r.channel_code = c.channel_code
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
)
ORDER BY c.id
""")
List<ChannelRow> findChannels(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId);
@Select("""
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.status = 'ACTIVE' AND r.deleted = 0
AND r.pricing_tax_mode IS NOT NULL
GROUP BY r.channel_code, r.pricing_tax_mode
ORDER BY r.channel_code, r.pricing_tax_mode
""")
List<ChannelTaxModeRow> findChannelTaxModes(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
AND UPPER(r.destination_code) = #{destinationCode}
AND r.pricing_tax_mode = #{pricingTaxMode}
AND r.status = 'ACTIVE' AND r.deleted = 0
""")
List<RateRow> findFbaRates(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("destinationCode") String destinationCode,
@Param("pricingTaxMode") String pricingTaxMode);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
AND r.destination_warehouse_id = #{destinationWarehouseId}
AND r.pricing_tax_mode = #{pricingTaxMode}
AND r.status = 'ACTIVE' AND r.deleted = 0
""")
List<RateRow> findOverseasRates(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("destinationWarehouseId") long destinationWarehouseId,
@Param("pricingTaxMode") String pricingTaxMode);
@Select("""
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
r.rate_code AS rateCode, r.rate_version AS rateVersion,
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
r.channel_code AS channelCode, r.price_model AS priceModel,
r.destination_type AS destinationType, r.destination_code AS destinationCode,
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
r.region_code AS regionCode,
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
r.currency AS currency, r.additional_fee AS additionalFee,
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
FROM fl_carrier_fba_rate r
WHERE r.price_book_version_id = #{versionId}
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
AND r.channel_code = #{channelCode}
AND r.price_model = 'REGION_WEIGHT_TIER'
AND r.region_code = #{regionCode}
AND ((#{pricingTaxMode} IS NULL AND r.pricing_tax_mode IS NULL)
OR r.pricing_tax_mode = #{pricingTaxMode})
AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.weight_from_kg
""")
List<RateRow> findRegionTiers(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId,
@Param("channelCode") String channelCode,
@Param("regionCode") String regionCode,
@Param("pricingTaxMode") String pricingTaxMode);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
public interface PriceBookVersionMapper extends BaseMapper<PriceBookVersionEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
public interface PriceChannelCatalogMapper extends BaseMapper<PriceChannelCatalogEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
public interface PriceOriginAliasMapper extends BaseMapper<PriceOriginAliasEntity> {}
@@ -0,0 +1,150 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
/** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */
@Component
public class ActivePriceBookEstimator {
@Resource private PriceBookQueryRepository repository;
@Resource private FbaWarehouseRateEstimator fbaEstimator;
@Resource private RegionWeightTierEstimator regionEstimator;
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
public Estimate estimate(EstimateRequest request) {
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
throw validation("请选择有效起运仓");
}
Long versionId = repository.activeVersionId();
if (versionId == null) {
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
}
String channelCode = normalizeChannel(request.channelCode());
ChannelRow channel = repository.channels(versionId, request.originWarehouseId()).stream()
.filter(value -> channelCode.equals(value.getChannelCode()))
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"));
return switch (priceModel(channel.getPriceModel())) {
case FBA_WAREHOUSE_RATE -> estimateFba(versionId, channelCode, request);
case REGION_WEIGHT_TIER -> estimateRegion(versionId, channelCode, request);
};
}
private Estimate estimateFba(long versionId, String channelCode, EstimateRequest request) {
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
List<RateRow> rates;
if (request.destinationType() == PricingDestinationType.FBA) {
if (!StringUtils.hasText(request.destinationCode())) {
throw validation("请选择有效FBA目的仓");
}
rates = repository.fbaRates(versionId, request.originWarehouseId(), channelCode,
request.destinationCode().trim().toUpperCase(Locale.ROOT), request.pricingTaxMode().name());
} else if (request.destinationType() == PricingDestinationType.OVERSEAS) {
if (request.destinationWarehouseId() == null || request.destinationWarehouseId() <= 0) {
throw validation("请选择有效海外仓");
}
rates = repository.overseasRates(versionId, request.originWarehouseId(), channelCode,
request.destinationWarehouseId(), request.pricingTaxMode().name());
} else {
throw validation("私人地址暂不支持参考估价");
}
RateRow rate = uniqueRate(rates);
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
return new Estimate(versionId, channelCode, PriceModel.FBA_WAREHOUSE_RATE, request.pricingTaxMode(),
rate, estimate.quantity(), estimate.unit(), estimate.unitPrice(), estimate.amount(), estimate.currency());
}
private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) {
if (request.destinationType() != PricingDestinationType.FBA) {
throw validation("海派渠道仅支持FBA目的仓参考估价");
}
if (request.pricingTaxMode() == null) {
throw validation("请选择包税或不包税报价");
}
PriceRegion region = request.destinationRegion() == null
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
region.name(), request.pricingTaxMode().name());
if (tiers == null || tiers.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "当前渠道没有匹配的区域价格");
}
tiers.forEach(rate -> requireModel(rate, PriceModel.REGION_WEIGHT_TIER));
var estimate = regionEstimator.estimate(tiers, request.actualWeightKg(), request.actualVolumeCbm());
RateRow selected = estimate.rate();
return new Estimate(versionId, channelCode, PriceModel.REGION_WEIGHT_TIER, request.pricingTaxMode(),
selected, estimate.quantity(), "KG", estimate.unitPrice(), estimate.amount(), estimate.currency());
}
private PriceRegion resolveWarehouseRegion(Long warehouseId) {
if (warehouseId == null || warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
}
return warehouseRegionResolver.resolve(warehouseId);
}
private static RateRow uniqueRate(List<RateRow> rates) {
if (rates == null || rates.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格");
}
if (rates.size() != 1) {
throw new BizException(ResultCode.CONFLICT, "当前条件匹配到多条价格,请联系平台处理");
}
return rates.get(0);
}
private static void requireModel(RateRow rate, PriceModel expected) {
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
}
}
private static PriceModel priceModel(String value) {
try {
return PriceModel.valueOf(value);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
}
}
private static String normalizeChannel(String value) {
if (!StringUtils.hasText(value)) {
throw validation("请选择有效渠道");
}
return value.trim().toUpperCase(Locale.ROOT);
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record Estimate(
Long priceBookVersionId,
String channelCode,
PriceModel priceModel,
PricingTaxMode pricingTaxMode,
RateRow rate,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal unitPrice,
BigDecimal amount,
String currency
) {}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class FbaWarehouseRateEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
private static final BigDecimal KG_PER_BILLABLE_CBM = BigDecimal.valueOf(363);
public Estimate estimate(RateRow rate, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
BigDecimal volume = nonNegative(actualVolumeCbm);
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal quantity = switch (rate.getChargeUnit()) {
case "KG" -> weight.max(volume.multiply(KG_PER_CBM));
case "CBM" -> volume.max(weight.divide(KG_PER_BILLABLE_CBM, 12, RoundingMode.HALF_UP));
default -> throw new BizException(ResultCode.CONFLICT, "当前价格计费单位不受支持");
};
quantity = quantity.setScale(6, RoundingMode.HALF_UP);
return new Estimate(quantity, rate.getChargeUnit(), rate.getUnitPrice(),
quantity.multiply(rate.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), rate.getCurrency());
}
private static BigDecimal nonNegative(BigDecimal value) {
if (value == null) return BigDecimal.ZERO;
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
return value;
}
public record Estimate(BigDecimal quantity, String unit, BigDecimal unitPrice,
BigDecimal amount, String currency) {}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceRegion;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */
@Component
public class PriceBookWarehouseRegionResolver {
@Resource private PriceBookWarehouseClient warehouses;
@Resource private PriceRegionResolver regionResolver;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PriceRegion resolve(long warehouseId) {
if (warehouseId <= 0) {
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
}
try {
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId);
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
}
if (!response.successful() || response.data() == null || response.data().warehouse() == null) {
throw validation("目的仓不是可用的平台FBA仓");
}
var warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|| !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) {
throw validation("目的仓不是可用的平台FBA仓");
}
String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream()
.filter(address -> address != null && StringUtils.hasText(address.postalCode()))
.map(PriceBookWarehouseClient.Address::postalCode)
.findFirst()
.orElse(null);
return regionResolver.fromPostalCode(postalCode);
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
}
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceRegion;
import org.springframework.stereotype.Component;
@Component
public class PriceRegionResolver {
public PriceRegion fromPostalCode(String rawPostalCode) {
String digits = rawPostalCode == null ? "" : rawPostalCode.replaceAll("[^0-9]", "");
if (digits.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓未配置可识别的美国邮编,无法确定海派区域");
}
if (digits.startsWith("46")) return PriceRegion.EAST;
return switch (digits.charAt(0)) {
case '0', '1', '2', '3' -> PriceRegion.EAST;
case '4', '5', '6', '7' -> PriceRegion.CENTRAL;
case '8', '9' -> PriceRegion.WEST;
default -> throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓邮编无法确定海派区域");
};
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
@Component
public class RegionWeightTierEstimator {
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
public Estimate estimate(List<RateRow> tiers, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
BigDecimal weight = nonNegative(actualWeightKg);
BigDecimal volume = nonNegative(actualVolumeCbm);
if (weight.signum() == 0 && volume.signum() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
}
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM)).setScale(6, RoundingMode.HALF_UP);
RateRow selected = tiers.stream()
.filter(rate -> "KG".equals(rate.getChargeUnit()) && rate.getWeightFromKg() != null)
.filter(rate -> rate.getWeightFromKg().compareTo(quantity) <= 0)
.max(Comparator.comparing(RateRow::getWeightFromKg))
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
"当前计费重量未达到该海派渠道的最低重量阶梯"));
return new Estimate(selected, quantity, selected.getUnitPrice(),
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency());
}
private static BigDecimal nonNegative(BigDecimal value) {
if (value == null) return BigDecimal.ZERO;
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
return value;
}
public record Estimate(RateRow rate, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency) {}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/** Pure, persistence-free result of parsing the platform price workbook. */
public record PriceWorkbookParseResult(
List<ParsedChannel> channels,
List<ParseIssue> issues,
List<String> ignoredSheets) {
public PriceWorkbookParseResult {
channels = List.copyOf(channels == null ? List.of() : channels);
issues = List.copyOf(issues == null ? List.of() : issues);
ignoredSheets = List.copyOf(ignoredSheets == null ? List.of() : ignoredSheets);
}
public boolean hasBlockingErrors() {
return issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR);
}
public long rateCount() {
return channels.stream().mapToLong(channel -> channel.rates().size()).sum();
}
public enum Severity { ERROR, WARNING }
public record ParseIssue(
Severity severity,
String code,
String message,
String sheetName,
Integer rowNumber,
String rawValue) {
public ParseIssue {
severity = severity == null ? Severity.WARNING : severity;
code = code == null ? "UNKNOWN" : code;
}
}
public record ParsedSailing(
String rawText,
String vesselName,
String voyageNumber,
LocalDate expectedDepartureDate) { }
public record ParsedChannel(
String channelCode,
String channelName,
String sourceSheet,
String sourceLabel,
PriceModel priceModel,
List<ParsedSailing> sailings,
String scheduleText,
String cutoffText,
String transitTimeText,
String surchargeText,
String remarkText,
List<ParsedRate> rates) {
public ParsedChannel {
sailings = List.copyOf(sailings == null ? List.of() : sailings);
rates = List.copyOf(rates == null ? List.of() : rates);
}
}
public record ParsedRate(
String sourceSheet,
int sourceRow,
String originWarehouseKey,
String originWarehouseLabel,
String rawDestinationGroup,
String destinationCode,
String regionCode,
BigDecimal minimumWeightKg,
PricingTaxMode taxMode,
String chargeUnit,
BigDecimal unitPrice,
String transitTimeText,
String cutoffText,
String scheduleText,
String surchargeText,
String remarkText,
/** Source-level destination classification; mapped to the public contract at the boundary. */
String destinationType,
String destinationName) { }
}
@@ -0,0 +1,418 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
import org.apache.poi.xssf.eventusermodel.XSSFReader;
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
import org.apache.poi.xssf.model.StylesTable;
import org.apache.poi.xssf.usermodel.XSSFComment;
import org.apache.poi.util.XMLHelper;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParseIssue;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedChannel;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedRate;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedSailing;
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.Severity;
/**
* Parses the supported price tabs without consulting Excel Table metadata or UsedRange.
* The SAX reader only visits real cells in supported sheets, so malformed Table refs and
* inflated dimensions in the workbook do not prevent the price tabs from being read.
*/
public final class PriceWorkbookParser {
private static final int MAX_ROWS = 200;
private static final int MAX_COLUMNS = 32;
private static final Pattern CODE_PATTERN = Pattern.compile("(?<![A-Z0-9])[A-Z][A-Z0-9]{2,9}(?![A-Z0-9])");
private static final Pattern DATE_PATTERN = Pattern.compile("预计开船日期\\s*[::]?\\s*(\\d{4})年(\\d{1,2})月(\\d{1,2})日");
private static final Pattern SAILING_PATTERN = Pattern.compile("船名航次\\s*[::]\\s*(.+?)\\s+V\\.([A-Za-z0-9-]+)");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(?:[,.]\\d+)?");
private static final List<StandardTab> STANDARD_TABS = List.of(
new StandardTab("美森正班海卡", "MATSON_CLX_TRUCK"),
new StandardTab("美森加班海卡", "MATSON_MAX_TRUCK"),
new StandardTab("EXX海卡", "EXX_TRUCK"),
new StandardTab("合德海卡", "HEDE_TRUCK"),
new StandardTab("纽约EXX海铁", "EXX_RAIL_NY"),
new StandardTab("洛杉矶普船海卡", "OA_LA_TRUCK"),
new StandardTab("洛杉矶普船全卡", "OA_LA_FULL_TRUCK"),
new StandardTab("芝加哥普船海铁", "OA_CHICAGO_RAIL"),
new StandardTab("休斯顿普船海卡", "OA_HOUSTON_TRUCK"),
new StandardTab("纽约普船海卡", "OA_NEW_YORK_TRUCK"),
new StandardTab("萨凡纳普船海卡", "OA_SAVANNAH_TRUCK"));
private static final List<ParcelChannel> PARCEL_CHANNELS = List.of(
new ParcelChannel("MATSON_CLX_PARCEL", "美森正班海派"),
new ParcelChannel("MATSON_MAX_PARCEL", "美森加班海派"),
new ParcelChannel("HEDE_PARCEL", "合德海派"),
new ParcelChannel("OA_PARCEL", "普船海派"));
private static final Set<String> IGNORED_SHEETS = Set.of(
"出货注意事项", "UPS派送售后服务指南", "快速查询", "UPS&FedEx偏远地区表",
"一件代发收费", "FDA申报清单", "FBA分区表", "亚马逊仓库代码表");
public PriceWorkbookParseResult parse(Path workbook) throws IOException {
if (workbook == null) throw new IllegalArgumentException("workbook must not be null");
try (OPCPackage packageRef = OPCPackage.open(workbook.toFile(), PackageAccess.READ)) {
return parse(packageRef);
} catch (Exception exception) {
if (exception instanceof IOException io) throw io;
throw new IOException("无法读取 Excel 工作簿", exception);
}
}
private PriceWorkbookParseResult parse(OPCPackage packageRef) throws Exception {
XSSFReader reader = new XSSFReader(packageRef);
StylesTable styles = reader.getStylesTable();
ReadOnlySharedStringsTable sharedStrings = new ReadOnlySharedStringsTable(packageRef);
Map<String, SheetData> sheets = new LinkedHashMap<>();
Set<String> allSheetNames = new LinkedHashSet<>();
XSSFReader.SheetIterator iterator = (XSSFReader.SheetIterator) reader.getSheetsData();
while (iterator.hasNext()) {
try (InputStream stream = iterator.next()) {
String sheetName = iterator.getSheetName();
allSheetNames.add(sheetName);
if (!isSupportedSheet(sheetName)) continue;
sheets.put(sheetName, readSheet(stream, styles, sharedStrings));
}
}
List<ParsedChannel> channels = new ArrayList<>();
List<ParseIssue> issues = new ArrayList<>();
List<String> ignored = new ArrayList<>();
for (String sheetName : allSheetNames) {
if (IGNORED_SHEETS.contains(sheetName)) ignored.add(sheetName);
}
for (StandardTab tab : STANDARD_TABS) {
SheetData data = sheets.get(tab.sheetName());
if (data == null) {
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
"标准渠道 Sheet 未找到,本版本不会提供该渠道", tab.sheetName(), null, null));
continue;
}
channels.add(parseStandard(tab, data, issues));
}
SheetData parcel = sheets.get("海派");
if (parcel == null) {
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
"海派 Sheet 未找到,本版本不会提供海派渠道", "海派", null, null));
} else {
channels.addAll(parseParcel(parcel, issues));
}
return new PriceWorkbookParseResult(channels, issues, ignored);
}
private static boolean isSupportedSheet(String name) {
return "海派".equals(name) || STANDARD_TABS.stream().anyMatch(tab -> tab.sheetName().equals(name));
}
private static SheetData readSheet(InputStream stream, StylesTable styles,
ReadOnlySharedStringsTable sharedStrings) throws Exception {
SheetData data = new SheetData();
XMLReader parser = XMLHelper.newXMLReader();
parser.setContentHandler(new XSSFSheetXMLHandler(styles, null, sharedStrings,
new CellHandler(data), new org.apache.poi.ss.usermodel.DataFormatter(Locale.CHINA), false));
parser.parse(new InputSource(stream));
return data;
}
private static ParsedChannel parseStandard(StandardTab tab, SheetData data, List<ParseIssue> issues) {
int headerRow = findRowContaining(data, "FBA仓库");
if (headerRow < 0) {
issues.add(issue(Severity.ERROR, "STANDARD_HEADER_NOT_FOUND",
"未识别标准海卡/海铁表头", tab.sheetName(), null, null));
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
PriceModel.FBA_WAREHOUSE_RATE, sailings(data.value(2, 0)), null, null, null, null, null, List.of());
}
int base = findCellColumn(data, headerRow, "FBA仓库");
List<ParsedRate> rates = new ArrayList<>();
String schedule = null, cutoff = null, transit = null, surcharge = null, remark = null;
for (int row = headerRow + 2; row <= MAX_ROWS; row++) {
String destination = clean(data.value(row, base));
if (destination == null) continue;
String nonTax = clean(data.value(row, base + 1));
String eastTax = clean(data.value(row, base + 2));
String southTax = clean(data.value(row, base + 3));
if (!hasPrice(nonTax) && !hasPrice(eastTax) && !hasPrice(southTax)) continue;
String rowTransit = clean(data.value(row, base + 4));
String rowCutoff = clean(data.value(row, base + 5));
String rowSchedule = clean(data.value(row, base + 6));
String rowSurcharge = clean(data.value(row, base + 7));
String rowRemark = clean(data.value(row, base + 8));
transit = firstNonBlank(transit, rowTransit);
cutoff = firstNonBlank(cutoff, rowCutoff);
schedule = firstNonBlank(schedule, rowSchedule);
surcharge = firstNonBlank(surcharge, rowSurcharge);
remark = firstNonBlank(remark, rowRemark);
List<String> codes = extractCodes(destination);
boolean overseas = destination.contains("海外仓");
if (codes.isEmpty() && !overseas) {
issues.add(issue(Severity.ERROR, "DESTINATION_MAPPING_REQUIRED",
"目的仓文本未识别为 FBA 仓库代码,需要确认映射或删除该行", tab.sheetName(), row, destination));
codes = Collections.singletonList(null);
}
if (codes.isEmpty()) codes = Collections.singletonList(null);
for (String code : codes) {
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"YIWU", "华东仓(义乌)", nonTax, PricingTaxMode.TAX_EXCLUDED, "CBM", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"YIWU", "华东仓(义乌)", eastTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
"DONGGUAN", "华南仓(深圳/东莞)", southTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
}
}
if (rates.isEmpty()) {
issues.add(issue(Severity.ERROR, "NO_STANDARD_PRICES", "标准渠道未解析出价格行", tab.sheetName(), null, null));
}
List<ParsedSailing> sailings = sailings(data.value(2, 0));
if (sailings.isEmpty() && clean(data.value(2, 0)) != null) {
issues.add(issue(Severity.WARNING, "SAILING_NOT_RECOGNIZED",
"船名航次文本未能结构化识别,保留原始文本", tab.sheetName(), 2, data.value(2, 0)));
}
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
PriceModel.FBA_WAREHOUSE_RATE, sailings, schedule, cutoff, transit, surcharge, remark, rates);
}
private static void addStandardRate(List<ParsedRate> rates, List<ParseIssue> issues,
String sheet, int row, String code, String rawDestination,
String originKey, String originLabel, String rawPrice,
PricingTaxMode taxMode, String unit, String transit, String cutoff,
String schedule, String surcharge, String remark) {
BigDecimal price = parsePrice(rawPrice);
if (price == null) return;
String destinationType = rawDestination != null && rawDestination.contains("海外仓")
? "OVERSEAS" : code != null ? "FBA" : null;
String destinationName = "OVERSEAS".equals(destinationType)
? normalizeOverseasName(rawDestination) : code;
rates.add(new ParsedRate(sheet, row, originKey, originLabel, rawDestination, code, null, null,
taxMode, unit, price, transit, cutoff, schedule, surcharge, remark,
destinationType, destinationName));
}
private static String normalizeOverseasName(String raw) {
if (raw == null) return null;
if (raw.contains("洛杉矶")) return "洛杉矶仓";
if (raw.contains("纽约")) return "纽约仓";
int open = raw.indexOf('(');
int close = raw.indexOf(')', open + 1);
if (open >= 0 && close > open) return raw.substring(open + 1, close).trim();
return raw.replace("海外仓", "").trim();
}
private static List<ParsedChannel> parseParcel(SheetData data, List<ParseIssue> issues) {
Map<String, ChannelBuilder> builders = new LinkedHashMap<>();
PARCEL_CHANNELS.forEach(channel -> builders.put(channel.code(),
new ChannelBuilder(channel.code(), channel.name())));
parseParcelOrigin(data, 5, 8, 9, "YIWU", "华东仓(义乌)", builders, issues);
parseParcelOrigin(data, 14, 17, 18, "DONGGUAN", "华南仓(深圳/东莞)", builders, issues);
List<ParsedChannel> result = new ArrayList<>();
for (ChannelBuilder builder : builders.values()) {
if (builder.rates.isEmpty()) {
issues.add(issue(Severity.ERROR, "NO_PARCEL_PRICES", "海派渠道未解析出价格阶梯", "海派", null, builder.name));
}
result.add(builder.toChannel());
}
return result;
}
private static void parseParcelOrigin(SheetData data, int scheduleRow, int tierRow,
int firstRegionRow, String originKey, String originLabel,
Map<String, ChannelBuilder> builders, List<ParseIssue> issues) {
int[] starts = {2, 5, 8, 11};
for (int channel = 0; channel < starts.length; channel++) {
int start = starts[channel];
ChannelBuilder builder = builders.get(PARCEL_CHANNELS.get(channel).code());
builder.scheduleText = firstNonBlank(builder.scheduleText, data.value(scheduleRow, start));
builder.transitText = firstNonBlank(builder.transitText, data.value(scheduleRow + 1, start));
builder.surchargeText = firstNonBlank(builder.surchargeText, data.value(scheduleRow, 14));
builder.remarkText = firstNonBlank(builder.remarkText, data.value(scheduleRow, 15));
int[] thresholds = {12, 51, 101};
for (int row = firstRegionRow; row < firstRegionRow + 3; row++) {
String rawRegion = clean(data.value(row, 0));
String region = regionCode(rawRegion);
if (region == null) {
issues.add(issue(Severity.ERROR, "REGION_NOT_RECOGNIZED",
"海派区域未识别", "海派", row, rawRegion));
continue;
}
for (int tier = 0; tier < thresholds.length; tier++) {
BigDecimal price = parsePrice(data.value(row, start + tier));
if (price == null) {
issues.add(issue(Severity.ERROR, "PARCEL_PRICE_MISSING",
"海派重量阶梯价格缺失", "海派", row, data.value(row, start + tier)));
continue;
}
builder.rates.add(new ParsedRate("海派", row, originKey, originLabel, rawRegion, null,
region, BigDecimal.valueOf(thresholds[tier]), PricingTaxMode.TAX_INCLUDED, "KG", price,
builder.transitText, null, builder.scheduleText, builder.surchargeText, builder.remarkText,
"REGION_WEIGHT_TIER", rawRegion));
}
}
}
}
private static List<String> extractCodes(String value) {
if (value == null) return List.of();
Matcher matcher = CODE_PATTERN.matcher(value.toUpperCase(Locale.ROOT));
Set<String> codes = new LinkedHashSet<>();
while (matcher.find()) codes.add(matcher.group());
return List.copyOf(codes);
}
private static String regionCode(String raw) {
if (raw == null) return null;
if (raw.contains("美西")) return "WEST";
if (raw.contains("美中")) return "CENTRAL";
if (raw.contains("美东")) return "EAST";
return null;
}
private static List<ParsedSailing> sailings(String raw) {
if (raw == null || raw.isBlank()) return List.of();
LocalDate date = expectedDate(raw);
List<ParsedSailing> result = new ArrayList<>();
Matcher matcher = SAILING_PATTERN.matcher(raw);
while (matcher.find()) result.add(new ParsedSailing(matcher.group(), matcher.group(1).trim(), matcher.group(2).trim(), date));
return List.copyOf(result);
}
private static LocalDate expectedDate(String raw) {
Matcher matcher = DATE_PATTERN.matcher(raw == null ? "" : raw);
if (!matcher.find()) return null;
try {
return LocalDate.of(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));
} catch (RuntimeException ignored) {
return null;
}
}
private static BigDecimal parsePrice(String raw) {
if (raw == null || raw.isBlank() || "/".equals(raw.trim()) || "-".equals(raw.trim())) return null;
Matcher matcher = NUMBER_PATTERN.matcher(raw.replace(",", ","));
if (!matcher.find()) return null;
try {
return new BigDecimal(matcher.group().replace(",", ""));
} catch (NumberFormatException ignored) {
return null;
}
}
private static boolean hasPrice(String value) { return parsePrice(value) != null; }
private static String clean(String value) {
if (value == null) return null;
String result = value.replace('\u00a0', ' ').trim();
return result.isBlank() ? null : result;
}
private static String firstNonBlank(String first, String second) { return clean(first) == null ? clean(second) : first; }
private static int findRowContaining(SheetData data, String text) {
for (int row = 1; row <= MAX_ROWS; row++) {
for (int col = 0; col < MAX_COLUMNS; col++) {
String value = clean(data.value(row, col));
if (value != null && value.contains(text)) return row;
}
}
return -1;
}
private static int findCellColumn(SheetData data, int row, String text) {
for (int col = 0; col < MAX_COLUMNS; col++) {
String value = clean(data.value(row, col));
if (value != null && value.contains(text)) return col;
}
return 0;
}
private static ParseIssue issue(Severity severity, String code, String message,
String sheet, Integer row, String raw) {
return new ParseIssue(severity, code, message, sheet, row, raw);
}
private record StandardTab(String sheetName, String channelCode) { }
private record ParcelChannel(String code, String name) { }
private static final class ChannelBuilder {
private final String code;
private final String name;
private final List<ParsedRate> rates = new ArrayList<>();
private String scheduleText;
private String transitText;
private String surchargeText;
private String remarkText;
private ChannelBuilder(String code, String name) { this.code = code; this.name = name; }
private ParsedChannel toChannel() {
return new ParsedChannel(code, name, "海派", name, PriceModel.REGION_WEIGHT_TIER,
List.of(), scheduleText, null, transitText, surchargeText, remarkText, rates);
}
}
private static final class SheetData {
private final Map<Integer, Map<Integer, String>> cells = new LinkedHashMap<>();
private String value(int row, int column) {
Map<Integer, String> values = cells.get(row);
return values == null ? null : values.get(column);
}
}
private static final class CellHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
private final SheetData data;
private int row;
private CellHandler(SheetData data) { this.data = data; }
@Override public void startRow(int rowNum) { row = rowNum + 1; }
@Override public void endRow(int rowNum) { }
@Override public void cell(String cellReference, String formattedValue, XSSFComment comment) {
int[] coordinates = coordinates(cellReference);
if (coordinates[0] <= MAX_ROWS && coordinates[1] < MAX_COLUMNS) {
data.cells.computeIfAbsent(coordinates[0], ignored -> new LinkedHashMap<>())
.put(coordinates[1], formattedValue);
}
}
private static int[] coordinates(String reference) {
int split = 0;
while (split < reference.length() && Character.isLetter(reference.charAt(split))) split++;
int column = 0;
for (int i = 0; i < split; i++) column = column * 26 + (Character.toUpperCase(reference.charAt(i)) - 'A' + 1);
return new int[] { Integer.parseInt(reference.substring(split)), column - 1 };
}
}
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class PriceBookQueryRepository {
@Resource
private PriceBookQueryMapper mapper;
public Long activeVersionId() {
return mapper.findActiveVersionId();
}
public List<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
return mapper.findChannels(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return mapper.findChannelTaxModes(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
String channelCode, String destinationCode,
String pricingTaxMode) {
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
String channelCode, long destinationWarehouseId,
String pricingTaxMode) {
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> regionTiers(long versionId, long originWarehouseId,
String channelCode, String regionCode,
String pricingTaxMode) {
return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
}
public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
return channels(versionId, originWarehouseId).stream()
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.repository;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
/** Read-only projections for the active price book. */
public final class PriceBookQueryRows {
private PriceBookQueryRows() {}
@Getter
@Setter
public static class ChannelRow {
private Long priceBookVersionId;
private String channelCode;
private String channelName;
private String priceModel;
private LocalDate estimatedDepartureDate;
private String estimatedDepartureText;
private String latestWarehouseEntry;
private String referenceTransit;
private String surchargeDescription;
private String remark;
}
@Getter
@Setter
public static class ChannelTaxModeRow {
private String channelCode;
private String pricingTaxMode;
}
@Getter
@Setter
public static class RateRow {
private Long id;
private Long priceBookVersionId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseName;
private String channelCode;
private String priceModel;
private String destinationType;
private String destinationCode;
private Long destinationWarehouseId;
private String destinationName;
private String regionCode;
private String pricingTaxMode;
private String chargeUnit;
private BigDecimal weightFromKg;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private java.time.LocalDateTime effectiveFrom;
private java.time.LocalDateTime effectiveTo;
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
import com.baiyue.firstleg.mapper.PriceBookChannelMapper;
import com.baiyue.firstleg.mapper.PriceBookVersionMapper;
import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper;
import com.baiyue.firstleg.mapper.PriceOriginAliasMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class PriceBookRepository {
@Resource private PriceBookVersionMapper versions;
@Resource private PriceBookChannelMapper channels;
@Resource private CarrierFbaRateMapper rates;
@Resource private PriceChannelCatalogMapper catalog;
@Resource private PriceOriginAliasMapper aliases;
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
public int updateVersion(PriceBookVersionEntity value) {
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, value.getId()));
}
public PriceBookVersionEntity findVersion(long id) {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getId, id)
.eq(PriceBookVersionEntity::getDeleted, false));
}
public List<PriceBookVersionEntity> listVersions(String status) {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
.eq(PriceBookVersionEntity::getDeleted, false)
.orderByDesc(PriceBookVersionEntity::getCreatedAt));
}
public PriceBookVersionEntity findActiveForUpdate() {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public List<PriceBookVersionEntity> listActiveForUpdate() {
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public PriceBookVersionEntity findActive() {
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
.eq(PriceBookVersionEntity::getDeleted, false));
}
/**
* Serializes publish operations for the platform scope. Locking one pending
* upload alone is insufficient when two different uploads are confirmed while no
* ACTIVE version exists (both transactions could otherwise pass the check).
*/
public void lockPlatformVersions() {
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.eq(PriceBookVersionEntity::getDeleted, false)
.last("FOR UPDATE"));
}
public int nextVersionNo() {
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
return latest == null ? 1 : latest.getVersionNo() + 1;
}
public void insertChannel(PriceBookChannelEntity value) { channels.insert(value); }
public void insertRate(CarrierFbaRateEntity value) { rates.insert(value); }
public List<PriceBookChannelEntity> listChannels(long versionId) {
return channels.selectList(new LambdaQueryWrapper<PriceBookChannelEntity>()
.eq(PriceBookChannelEntity::getPriceBookVersionId, versionId)
.orderByAsc(PriceBookChannelEntity::getChannelCode));
}
public List<CarrierFbaRateEntity> listRates(long versionId) {
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, versionId)
.orderByAsc(CarrierFbaRateEntity::getChannelCode)
.orderByAsc(CarrierFbaRateEntity::getOriginWarehouseCode)
.orderByAsc(CarrierFbaRateEntity::getDestinationCode)
.orderByAsc(CarrierFbaRateEntity::getWeightFromKg));
}
public void deletePendingChildren(long versionId) {
rates.physicallyDeletePendingByPriceBookVersionId(versionId);
channels.delete(new LambdaQueryWrapper<PriceBookChannelEntity>().eq(PriceBookChannelEntity::getPriceBookVersionId, versionId));
}
public List<PriceChannelCatalogEntity> activeCatalog() {
return catalog.selectList(new LambdaQueryWrapper<PriceChannelCatalogEntity>()
.eq(PriceChannelCatalogEntity::getStatus, "ACTIVE").orderByAsc(PriceChannelCatalogEntity::getId));
}
public List<PriceOriginAliasEntity> activeAliases() {
return aliases.selectList(new LambdaQueryWrapper<PriceOriginAliasEntity>()
.eq(PriceOriginAliasEntity::getStatus, "ACTIVE"));
}
public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) {
old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at);
updateVersion(old);
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, old.getId())
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getStatus, "SUPERSEDED")
.set(CarrierFbaRateEntity::getEffectiveTo, at)
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
public void activate(PriceBookVersionEntity value, LocalDateTime at, long operatorId) {
value.setStatus("ACTIVE"); value.setPublishedAt(at); value.setPublishedBy(operatorId);
value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
.set(CarrierFbaRateEntity::getStatus, "ACTIVE")
.set(CarrierFbaRateEntity::getEffectiveFrom, at)
.set(CarrierFbaRateEntity::getEffectiveTo, null)
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
}
@@ -6,13 +6,16 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -36,6 +39,8 @@ public class FbaPricingService {
@Resource
private FbaPricingRepository repository;
@Resource
private ActivePriceBookEstimator priceBookEstimator;
@Resource
private Clock clock;
@Resource
private InboundFeeDetailService feeDetails;
@@ -43,59 +48,42 @@ public class FbaPricingService {
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
LocalDateTime requestedAt, String idempotencyKey) {
LocalDateTime pricingAt = requestedAt == null ? LocalDateTime.now(clock) : requestedAt;
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
List<CustomerChargeRuleEntity> matchedRules = repository.matchRules(inbound, pricingAt);
CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则");
List<CarrierFbaRateEntity> matchedRates = repository.matchRates(inbound, rule.getChargeMode(), pricingAt);
CarrierFbaRateEntity rate = unique(matchedRates, "船司 FBA 价格");
if (!rule.getCurrency().equals(rate.getCurrency())) {
throw conflict("客户规则币种与船司价格币种不一致");
}
RoundingMode roundingMode;
try {
roundingMode = RoundingMode.valueOf(rule.getRoundingMode());
} catch (IllegalArgumentException exception) {
throw conflict("客户规则舍入方式无效: " + rule.getRoundingMode());
}
int scale = rule.getRoundingScale();
if (scale < 0 || scale > 4 || rule.getConversionFactor().signum() <= 0) {
throw conflict("客户规则换算系数或精度无效");
}
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm()));
RateRow rate = estimate.rate();
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
BigDecimal converted = "KG".equals(rule.getChargeMode())
? volume.multiply(rule.getConversionFactor()).setScale(scale, roundingMode)
: weight.divide(rule.getConversionFactor(), scale, roundingMode);
BigDecimal actual = "KG".equals(rule.getChargeMode()) ? weight : volume;
BigDecimal chargeable = actual.max(converted).setScale(scale, roundingMode);
BigDecimal subtotal = chargeable.multiply(rate.getUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal additional = rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal chargeable = estimate.chargeableQuantity();
BigDecimal converted = chargeable;
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
BigDecimal total = subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
LocalDateTime now = LocalDateTime.now(clock);
int version = repository.nextFeeVersion(tenantId, inboundId);
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, rule, rate, version,
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
weight, volume, converted, chargeable, subtotal, additional, total, now);
repository.supersedeCalculated(tenantId, inboundId);
repository.insertFee(fee);
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
rule.getChargeMode(), chargeable) != 1) {
estimate.chargeUnit(), chargeable) != 1) {
throw conflict("入库单状态已变化,费用试算未保存");
}
complete(token, fee.getId(), now);
String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
? "WAIT_SPLIT" : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
return response(inbound, fee, estimate.unitPrice(), nextStatus);
}
@Transactional
@@ -245,24 +233,36 @@ public class FbaPricingService {
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("二期仅支持 FBA 目的仓计费");
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
throw conflict("当前目的地类型不支持自动计费");
}
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
}
if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null
|| inbound.getCarrierId() == null || !StringUtils.hasText(inbound.getChannelCode())
|| !StringUtils.hasText(inbound.getDestinationCode())) {
throw conflict("入库单缺少客户、起运仓、渠道、船司或 FBA 仓匹配维度");
|| !StringUtils.hasText(inbound.getChannelCode())) {
throw conflict("入库单缺少客户、起运仓或渠道匹配维度");
}
return inbound;
}
private <T> T unique(List<T> values, String name) {
if (values.isEmpty()) throw conflict("未匹配到有效" + name);
if (values.size() > 1) throw conflict("匹配到多条有效" + name + ",请修正有效期配置");
return values.get(0);
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
try {
return PricingDestinationType.valueOf(inbound.getDestinationType());
} catch (RuntimeException exception) {
throw conflict("入库单目的地类型配置无效");
}
}
private static PricingTaxMode pricingTaxMode(InboundPricingEntity inbound) {
if (!StringUtils.hasText(inbound.getPricingTaxMode())) return null;
try {
return PricingTaxMode.valueOf(inbound.getPricingTaxMode());
} catch (RuntimeException exception) {
throw conflict("入库单报价包税维度配置无效");
}
}
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
@@ -296,34 +296,37 @@ public class FbaPricingService {
}
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
CustomerChargeRuleEntity rule, CarrierFbaRateEntity rate, int version,
ActivePriceBookEstimator.Estimate estimate, RateRow rate, int version,
BigDecimal weight, BigDecimal volume, BigDecimal converted,
BigDecimal chargeable, BigDecimal subtotal, BigDecimal additional,
BigDecimal total, LocalDateTime now) {
InboundFeeEntity fee = new InboundFeeEntity();
fee.setTenantId(tenantId); fee.setInboundId(inbound.getId());
fee.setFeeNo("FBAF-" + inbound.getId() + "-" + version); fee.setFeeVersion(version);
fee.setChargeRuleId(rule.getId()); fee.setChargeRuleVersion(rule.getRuleVersion());
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
fee.setRuleCode(rule.getRuleCode()); fee.setCustomerId(inbound.getCustomerId());
fee.setPriceBookVersionId(estimate.priceBookVersionId());
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
fee.setTaxMode(inbound.getTaxMode()); fee.setRoundingScale(rule.getRoundingScale());
fee.setRoundingMode(rule.getRoundingMode()); fee.setRuleEffectiveFrom(rule.getEffectiveFrom());
fee.setRuleEffectiveTo(rule.getEffectiveTo()); fee.setRateCode(rate.getRateCode());
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
fee.setRoundingScale(null); fee.setRoundingMode(null); fee.setRuleEffectiveFrom(null);
fee.setRuleEffectiveTo(null); fee.setRateCode(rate.getRateCode());
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(null);
fee.setCarrierName(null); fee.setDestinationType(inbound.getDestinationType());
fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); fee.setDestinationName(inbound.getDestinationName());
fee.setDestinationCode(inbound.getDestinationCode());
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(null);
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); fee.setCurrency(rate.getCurrency());
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); fee.setCreatedAt(now); fee.setUpdatedAt(now);
return fee;
}
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, CarrierFbaRateEntity rate,
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, RateRow rate,
BigDecimal quantity, BigDecimal amount, LocalDateTime now) {
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", amount, now);
item.setQuantity(quantity); item.setUnit(fee.getChargeUnit()); item.setUnitPrice(rate.getUnitPrice());
@@ -360,10 +363,12 @@ public class FbaPricingService {
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(),
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(),
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(), fee.getPriceBookVersionId(),
fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
fee.getDestinationCode(), fee.getTaxMode(), fee.getChargeMode(), fee.getConversionFactor(),
fee.getDestinationType(), fee.getDestinationWarehouseId(), fee.getDestinationName(), fee.getDestinationCode(),
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
fee.getConversionFactor(),
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
@@ -3,12 +3,15 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.dto.InboundOrderReference;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -25,11 +28,9 @@ public class InboundOrderCreationService {
@Transactional
public long createFromApprovedOrder(OrderApprovedInboundCommand command) {
InboundOrderEntity existing = orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, command.tenantId())
.eq(InboundOrderEntity::getOrderId, command.orderId()));
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
if (existing != null) {
return existing.getId();
return requireMatchingInboundCode(existing, command.inboundCode());
}
LocalDateTime now = LocalDateTime.now();
@@ -58,6 +59,7 @@ public class InboundOrderCreationService {
inbound.setCarrierId(command.carrierId());
inbound.setCarrierName(command.carrierName());
inbound.setTaxMode(command.taxMode());
inbound.setPricingTaxMode(command.pricingTaxMode());
inbound.setContainerMode(command.containerMode());
inbound.setCustomsInspection(command.customsInspection());
inbound.setDestinationType(command.destinationType());
@@ -73,7 +75,15 @@ public class InboundOrderCreationService {
inbound.setUpdatedAt(now);
inbound.setDeleted(false);
inbound.setVersion(0);
orders.insert(inbound);
try {
orders.insert(inbound);
} catch (DuplicateKeyException duplicate) {
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
if (winner == null) {
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
}
return requireMatchingInboundCode(winner, command.inboundCode());
}
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
history.setId(IdWorker.getId());
@@ -88,6 +98,23 @@ public class InboundOrderCreationService {
return inbound.getId();
}
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId);
if (currentRead) {
query.last("FOR UPDATE");
}
return orders.selectOne(query);
}
private long requireMatchingInboundCode(InboundOrderEntity existing, String inboundCode) {
if (!java.util.Objects.equals(existing.getInboundCode(), inboundCode)) {
throw new BizException(ResultCode.CONFLICT, "订单入仓码与已有入库单不一致");
}
return existing.getId();
}
public List<InboundOrderReference.Item> findReferences(long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
@@ -0,0 +1,105 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class MerchantPriceBookService {
public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准";
@Resource private PriceBookQueryRepository repository;
@Resource private ActivePriceBookEstimator estimator;
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
if (versionId == null) return List.of();
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
if (channels.isEmpty()) return List.of();
Map<String, List<PricingTaxMode>> taxModes = pricingTaxModes(repository.channelTaxModes(versionId, originWarehouseId));
return channels.stream().map(channel -> toChannel(channel, taxModes)).toList();
}
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
requireMerchant(merchantTenantId);
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
estimate.amount(), estimate.currency(), DISCLAIMER);
}
public void requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode) {
requireMerchant(merchantTenantId);
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
Long versionId = repository.activeVersionId();
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId,
normalizeChannel(channelCode))) {
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
}
}
private CurrentChannel toChannel(ChannelRow row, Map<String, List<PricingTaxMode>> taxModes) {
String channelCode = normalizeChannel(row.getChannelCode());
return new CurrentChannel(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(),
priceModel(row.getPriceModel()), taxModes.getOrDefault(channelCode, List.of()),
row.getEstimatedDepartureDate(), row.getEstimatedDepartureText(),
row.getLatestWarehouseEntry(), row.getReferenceTransit(), row.getSurchargeDescription(), row.getRemark());
}
private static Map<String, List<PricingTaxMode>> pricingTaxModes(List<ChannelTaxModeRow> rows) {
Map<String, EnumSet<PricingTaxMode>> values = new HashMap<>();
for (ChannelTaxModeRow row : rows) {
if (row == null) continue;
String channelCode = normalizeChannel(row.getChannelCode());
try {
values.computeIfAbsent(channelCode, ignored -> EnumSet.noneOf(PricingTaxMode.class))
.add(PricingTaxMode.valueOf(row.getPricingTaxMode()));
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道报价包税方式配置无效");
}
}
Map<String, List<PricingTaxMode>> result = new HashMap<>();
values.forEach((channelCode, modes) -> result.put(channelCode, List.copyOf(modes)));
return result;
}
private static PriceModel priceModel(String value) {
try {
return PriceModel.valueOf(value);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
}
}
private static String normalizeChannel(String value) {
if (!StringUtils.hasText(value)) throw validation("请选择有效渠道");
return value.trim().toUpperCase(Locale.ROOT);
}
private static void requireMerchant(long merchantTenantId) {
if (merchantTenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
private static BizException validation(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
}
@@ -0,0 +1,568 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookFileClient;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult;
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser;
import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class PlatformPriceBookService {
private static final String PENDING_RATE_STATUS = PriceBookStatus.PENDING_CONFIRMATION.name();
private static final BigDecimal ZERO = BigDecimal.ZERO;
private static final TypeReference<List<PriceBookDtos.ParseIssue>> ISSUE_LIST = new TypeReference<>() {};
private static final Set<String> REVALIDATED_ISSUE_CODES = Set.of(
"NO_CHANNELS", "NO_RATES", "DUPLICATE_CHANNEL", "CHANNEL_NOT_MAPPED", "CHANNEL_MODEL_MISMATCH",
"RATE_CHANNEL_MISSING", "RATE_MODEL_MISMATCH", "PRICE_NOT_POSITIVE", "SOURCE_LOCATION_MISSING",
"ORIGIN_MAPPING_REQUIRED", "ORIGIN_WAREHOUSE_INVALID", "OVERSEAS_MAPPING_REQUIRED",
"OVERSEAS_WAREHOUSE_INVALID", "WAREHOUSE_VALIDATION_FAILED", "REGION_TIER_INVALID",
"PRICE_MODEL_REQUIRED", "DESTINATION_TYPE_REQUIRED", "PRICING_TAX_MODE_REQUIRED", "FBA_CODE_REQUIRED",
"DUPLICATE_RATE", "DUPLICATE_ORIGIN_MAPPING", "DUPLICATE_OVERSEAS_MAPPING",
"UNUSED_ORIGIN_MAPPING", "UNUSED_OVERSEAS_MAPPING", "DUPLICATE_WAREHOUSE_MAPPING"
);
@Resource private PriceBookRepository repository;
@Resource private PriceBookFileClient files;
@Resource private PriceBookWarehouseClient warehouses;
@Resource private ObjectMapper objectMapper;
@Resource private TransactionTemplate transactions;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
String persistedStatus = status == null ? null : status.name();
return repository.listVersions(persistedStatus).stream().map(this::summary).toList();
}
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
requireFile(file);
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename()));
if (version == null) throw internal("价格版本创建失败");
Path temp = null;
try {
ApiResponse<PriceBookFileClient.FileMetadata> response = files.upload(internalToken, operatorId, version.getId(), file);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败");
}
var metadata = response.data();
if (!Objects.equals(metadata.businessId(), version.getId())
|| !Objects.equals(metadata.businessType(), "FBA_PRICE_BOOK_SOURCE")) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件归属校验失败");
}
version.setSourceFileId(metadata.fileId());
version.setSourceSha256(metadata.sha256());
if (metadata.fileId() == null || metadata.fileId() <= 0) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件标识无效");
}
ResponseEntity<byte[]> stored = files.download(internalToken, operatorId, version.getId(), metadata.fileId());
if (stored == null || stored.getBody() == null || stored.getBody().length == 0) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败");
}
temp = Files.createTempFile("price-book-" + version.getId() + "-", ".xlsx");
Files.write(temp, stored.getBody());
PriceWorkbookParseResult parsed = new PriceWorkbookParser().parse(temp);
ParsedPriceBook parsedPriceBook = parsedPreview(parsed);
transactions.executeWithoutResult(status -> persistParsedPreview(version, parsedPriceBook.channels(), parsedPriceBook.rates(),
mergeIssues(parsedPriceBook.issues(), validate(parsedPriceBook.channels(), parsedPriceBook.rates())), operatorId));
return detail(version.getId());
} catch (Exception exception) {
List<PriceBookDtos.ParseIssue> issues = List.of(issue("BLOCKING", "UPLOAD_OR_PARSE_FAILED",
safeMessage(exception, "价格表上传或解析失败"), null, null, null));
markFailed(version, issues, operatorId);
if (exception instanceof BizException biz) throw biz;
throw new BizException(ResultCode.VALIDATION_FAILED, safeMessage(exception, "价格表解析失败"));
} finally {
if (temp != null) try { Files.deleteIfExists(temp); } catch (IOException ignored) { }
}
}
public PriceBookDtos.PriceBookVersionDetail detail(long versionId) {
PriceBookVersionEntity version = requireVersion(versionId);
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
PriceBookVersionEntity active = repository.findActive();
if (active != null && !Objects.equals(active.getId(), versionId)) {
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
repository.listChannels(active.getId()).stream()
.filter(value -> !current.contains(normalizeChannel(value.getChannelCode())))
.forEach(value -> issues.add(issue("WARNING", "CHANNEL_MISSING_FROM_PENDING_UPLOAD",
"待确认上传记录未包含上一生效版本渠道,确认生效后该渠道将下线", value.getSourceSheetName(), null, null)));
}
}
return new PriceBookDtos.PriceBookVersionDetail(summary(version),
channelRows.stream().map(this::channelDto).toList(),
repository.listRates(versionId).stream().map(this::rateDto).toList(), issues);
}
@Transactional(noRollbackFor = BizException.class)
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
PriceBookConfirmationRequest confirmation) {
repository.lockPlatformVersions();
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
MappingResult mapped = applyMappings(rates, confirmation);
List<PriceBookDtos.ParseIssue> issues = mergeIssues(
mergeIssues(preservedParseIssues(version), mapped.issues()),
validate(channels, mapped.rates()));
applyIssueCounts(version, issues);
if (version.getBlockingErrorCount() > 0) {
repository.updateVersion(version);
throw new BizException(ResultCode.CONFLICT, "价格表仍有阻断错误,不能生效");
}
repository.deletePendingChildren(versionId);
persistChildren(version, channels, mapped.rates(), operatorId);
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
repository.listActiveForUpdate().stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
return detail(versionId);
}
public ResponseEntity<byte[]> source(long versionId, long operatorId) {
PriceBookVersionEntity version = requireVersion(versionId);
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
try {
ResponseEntity<byte[]> response = files.download(internalToken, operatorId, versionId, version.getSourceFileId());
if (response == null || response.getBody() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
HttpHeaders headers = new HttpHeaders();
headers.putAll(response.getHeaders());
headers.set(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
.filename(version.getOriginalFileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString());
headers.set("X-Content-Type-Options", "nosniff");
return new ResponseEntity<>(response.getBody(), headers, response.getStatusCode());
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件服务暂时不可用");
}
}
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE);
value.setVersionNo(repository.nextVersionNo());
value.setOriginalFileName(safeFileName(originalFileName));
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setDeleted(false); value.setVersion(0);
if (repository.insertVersion(value) != 1) throw internal("价格版本创建失败");
return value;
}
private void persistParsedPreview(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues,
long operatorId) {
repository.deletePendingChildren(version.getId());
persistChildren(version, channels, rates, operatorId);
version.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
version.setChannelCount(channels.size()); version.setRateCount(rates.size());
applyIssueCounts(version, issues);
version.setUpdatedBy(operatorId);
repository.updateVersion(version);
}
private void persistChildren(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates, long operatorId) {
LocalDateTime now = LocalDateTime.now();
for (PriceBookDtos.ParsedChannel parsedChannel : channels) {
PriceBookChannelEntity value = new PriceBookChannelEntity();
value.setPriceBookVersionId(version.getId()); value.setChannelCode(normalizeChannel(parsedChannel.channelCode()));
value.setChannelName(parsedChannel.channelName().trim()); value.setPriceModel(parsedChannel.priceModel().name());
value.setSourceSheetName(parsedChannel.sourceSheetName().trim()); value.setEstimatedDepartureDate(parsedChannel.estimatedDepartureDate());
value.setEstimatedDepartureText(trim(parsedChannel.estimatedDepartureText())); value.setLatestWarehouseEntry(trim(parsedChannel.latestWarehouseEntry()));
value.setVesselName(trim(parsedChannel.vesselName())); value.setVoyageNo(trim(parsedChannel.voyageNo()));
value.setReferenceTransit(trim(parsedChannel.referenceTransit())); value.setSurchargeDescription(trim(parsedChannel.surchargeDescription()));
value.setRemark(trim(parsedChannel.remark())); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now); value.setUpdatedAt(now); value.setDeleted(false); value.setVersion(0);
repository.insertChannel(value);
}
int number = 1;
for (PriceBookDtos.ParsedRate parsedRate : rates) repository.insertRate(rateEntity(version, parsedRate, operatorId, number++, now));
}
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
long operatorId, int number, LocalDateTime now) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId());
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
value.setChannelCode(normalizeChannel(parsedRate.channelCode())); value.setPriceModel(parsedRate.priceModel().name());
value.setCarrierName(parsedRate.channelCode());
value.setDestinationType(parsedRate.destinationType() == null ? null : parsedRate.destinationType().name());
value.setDestinationCode(upper(parsedRate.destinationCode())); value.setDestinationWarehouseId(parsedRate.destinationWarehouseId());
value.setDestinationName(trim(parsedRate.destinationName())); value.setRegionCode(parsedRate.regionCode() == null ? null : parsedRate.regionCode().name());
value.setPricingTaxMode(parsedRate.pricingTaxMode() == null ? null : parsedRate.pricingTaxMode().name());
value.setWeightFromKg(parsedRate.weightFromKg()); value.setChargeUnit(parsedRate.chargeUnit()); value.setUnitPrice(parsedRate.unitPrice());
value.setCurrency(parsedRate.currency()); value.setAdditionalFee(ZERO); value.setSourceSheetName(parsedRate.sourceSheetName());
value.setSourceRowNumber(parsedRate.sourceRowNumber()); value.setSourceDestinationText(trim(parsedRate.sourceDestinationText()));
value.setEffectiveFrom(now); value.setStatus(PENDING_RATE_STATUS); value.setRemark(trim(parsedRate.remark()));
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setCreatedAt(now); value.setUpdatedAt(now);
value.setDeleted(false); value.setVersion(0);
return value;
}
private ParsedPriceBook parsedPreview(PriceWorkbookParseResult parsed) {
List<PriceBookDtos.ParsedChannel> channels = new ArrayList<>();
List<PriceBookDtos.ParsedRate> rates = new ArrayList<>();
for (var channel : parsed.channels()) {
String code = normalizeChannel(channel.channelCode());
var sailing = channel.sailings().isEmpty() ? null : channel.sailings().get(0);
channels.add(new PriceBookDtos.ParsedChannel(code, channel.channelName(), channel.priceModel(),
channel.sourceSheet(), sailing == null ? null : sailing.expectedDepartureDate(),
sailing == null ? channel.scheduleText() : sailing.rawText(), channel.cutoffText(),
sailing == null ? null : sailing.vesselName(), sailing == null ? null : sailing.voyageNumber(),
channel.transitTimeText(), channel.surchargeText(), channel.remarkText()));
for (var rate : channel.rates()) rates.add(parsedRate(code, channel.priceModel(), rate));
}
List<PriceBookDtos.ParseIssue> issues = parsed.issues().stream().map(value -> issue(
value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING",
value.code(), value.message(), value.sheetName(), value.rowNumber(), null)).toList();
return new ParsedPriceBook(channels, rates, issues);
}
private PriceBookDtos.ParsedRate parsedRate(String channelCode, PriceModel model,
PriceWorkbookParseResult.ParsedRate rate) {
PricingDestinationType destinationType = "OVERSEAS".equals(rate.destinationType())
? PricingDestinationType.OVERSEAS
: model == PriceModel.REGION_WEIGHT_TIER ? null : PricingDestinationType.FBA;
PriceRegion region = rate.regionCode() == null ? null : PriceRegion.valueOf(rate.regionCode());
String originCode = "YIWU".equals(rate.originWarehouseKey()) ? "CN-YIWU-01"
: "DONGGUAN".equals(rate.originWarehouseKey()) ? "CN-DONGGUAN-01" : rate.originWarehouseKey();
return new PriceBookDtos.ParsedRate(null, channelCode, model, null, originCode,
rate.originWarehouseLabel(), rate.destinationCode(), destinationType, null, rate.destinationName(),
region, rate.taxMode(), rate.chargeUnit(), rate.minimumWeightKg(), rate.unitPrice(), "CNY",
rate.sourceSheet(), rate.sourceRow(), rate.rawDestinationGroup(), rate.remarkText());
}
private MappingResult applyMappings(List<PriceBookDtos.ParsedRate> rates,
PriceBookConfirmationRequest confirmation) {
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
if (confirmation == null) {
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "请确认起运仓映射", null, null, null));
return new MappingResult(rates, issues);
}
Map<String, Long> origins = mappingIndex(confirmation.originMappings(),
PriceBookConfirmationRequest.OriginMapping::originWarehouseCode,
PriceBookConfirmationRequest.OriginMapping::warehouseId,
"DUPLICATE_ORIGIN_MAPPING", "起运仓映射重复", issues);
Map<String, Long> overseas = mappingIndex(confirmation.overseasMappings(),
PriceBookConfirmationRequest.OverseasMapping::sourceDestinationName,
PriceBookConfirmationRequest.OverseasMapping::warehouseId,
"DUPLICATE_OVERSEAS_MAPPING", "海外仓映射重复", issues);
Set<String> sourceOrigins = rates.stream().map(PriceBookDtos.ParsedRate::originWarehouseCode)
.filter(StringUtils::hasText).map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
Set<String> sourceOverseas = rates.stream()
.filter(rate -> rate.destinationType() == PricingDestinationType.OVERSEAS)
.map(PriceBookDtos.ParsedRate::destinationName).filter(StringUtils::hasText)
.map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
origins.keySet().stream().filter(key -> !sourceOrigins.contains(key)).forEach(key ->
issues.add(issue("BLOCKING", "UNUSED_ORIGIN_MAPPING", "起运仓映射不属于当前价格表", null, null, null)));
overseas.keySet().stream().filter(key -> !sourceOverseas.contains(key)).forEach(key ->
issues.add(issue("BLOCKING", "UNUSED_OVERSEAS_MAPPING", "海外仓映射不属于当前价格表", null, null, null)));
List<PriceBookDtos.ParsedRate> mapped = new ArrayList<>(rates.size());
for (PriceBookDtos.ParsedRate rate : rates) {
Long originId = origins.get(mappingKey(rate.originWarehouseCode()));
PricingDestinationType destinationType = rate.priceModel() == PriceModel.REGION_WEIGHT_TIER
? null : rate.destinationType();
Long destinationId = destinationType == PricingDestinationType.OVERSEAS
? overseas.get(mappingKey(rate.destinationName())) : null;
mapped.add(new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(), originId,
rate.originWarehouseCode(), rate.originWarehouseName(), rate.destinationCode(), destinationType,
destinationId, rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(),
rate.weightFromKg(), rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(),
rate.sourceDestinationText(), rate.remark()));
}
return new MappingResult(List.copyOf(mapped), List.copyOf(issues));
}
private <T> Map<String, Long> mappingIndex(List<T> mappings,
java.util.function.Function<T, String> key,
java.util.function.Function<T, Long> warehouseId,
String duplicateCode, String duplicateMessage,
List<PriceBookDtos.ParseIssue> issues) {
Map<String, Long> values = new HashMap<>();
Map<Long, String> warehouseKeys = new HashMap<>();
if (mappings == null) return values;
for (T mapping : mappings) {
if (mapping == null) continue;
String normalized = mappingKey(key.apply(mapping));
if (!StringUtils.hasText(normalized) || warehouseId.apply(mapping) == null || warehouseId.apply(mapping) <= 0) {
continue;
}
Long id = warehouseId.apply(mapping);
if (values.putIfAbsent(normalized, id) != null) {
issues.add(issue("BLOCKING", duplicateCode, duplicateMessage, null, null, null));
}
String previousKey = warehouseKeys.putIfAbsent(id, normalized);
if (previousKey != null && !previousKey.equals(normalized)) {
issues.add(issue("BLOCKING", "DUPLICATE_WAREHOUSE_MAPPING",
"不同来源仓不能映射到同一平台仓", null, null, null));
}
}
return values;
}
private List<PriceBookDtos.ParseIssue> preservedParseIssues(PriceBookVersionEntity version) {
return readIssues(version.getParseIssuesJson()).stream()
.filter(issue -> !REVALIDATED_ISSUE_CODES.contains(issue.issueCode())).toList();
}
private List<PriceBookDtos.ParseIssue> validate(List<PriceBookDtos.ParsedChannel> channels,
List<PriceBookDtos.ParsedRate> rates) {
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
if (channels == null || channels.isEmpty()) {
issues.add(issue("BLOCKING", "NO_CHANNELS", "价格表未解析出可用渠道", null, null, null));
return issues;
}
if (rates == null || rates.isEmpty()) {
issues.add(issue("BLOCKING", "NO_RATES", "价格表未解析出价格行", null, null, null));
return issues;
}
Map<String, PriceChannelCatalogEntity> catalog = new HashMap<>();
repository.activeCatalog().forEach(value -> catalog.put(value.getChannelCode(), value));
Set<String> channelCodes = new HashSet<>();
for (var channel : channels) {
String code = normalizeChannel(channel.channelCode());
if (!channelCodes.add(code)) issues.add(issue("BLOCKING", "DUPLICATE_CHANNEL", "渠道重复", channel.sourceSheetName(), null, null));
PriceChannelCatalogEntity definition = catalog.get(code);
if (definition == null) issues.add(issue("BLOCKING", "CHANNEL_NOT_MAPPED", "渠道未映射到标准目录", channel.sourceSheetName(), null, null));
else if (channel.priceModel() == null || !definition.getPriceModel().equals(channel.priceModel().name()))
issues.add(issue("BLOCKING", "CHANNEL_MODEL_MISMATCH", "渠道价格模型与目录不一致", channel.sourceSheetName(), null, null));
}
Set<String> unique = new HashSet<>();
Map<Long, PriceBookWarehouseClient.Warehouse> warehouseCache = new HashMap<>();
for (var rate : rates) {
String code = normalizeChannel(rate.channelCode());
if (!channelCodes.contains(code)) issues.add(issue("BLOCKING", "RATE_CHANNEL_MISSING", "价格行没有对应渠道", rate.sourceSheetName(), rate.sourceRowNumber(), null));
PriceChannelCatalogEntity persistedDefinition = catalog.get(code);
if (persistedDefinition != null && rate.priceModel() != null
&& !persistedDefinition.getPriceModel().equals(rate.priceModel().name())) {
issues.add(issue("BLOCKING", "RATE_MODEL_MISMATCH", "价格行模型与渠道目录不一致",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.priceModel() == null) {
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.unitPrice() == null || rate.unitPrice().signum() <= 0) issues.add(issue("BLOCKING", "PRICE_NOT_POSITIVE", "单价必须大于0", rate.sourceSheetName(), rate.sourceRowNumber(), null));
if (rate.sourceRowNumber() == null || rate.sourceRowNumber() <= 0 || !StringUtils.hasText(rate.sourceSheetName()))
issues.add(issue("BLOCKING", "SOURCE_LOCATION_MISSING", "价格行缺少Excel来源位置", rate.sourceSheetName(), rate.sourceRowNumber(), null));
validateOrigin(rate, warehouseCache, issues);
validateDestination(rate, warehouseCache, issues);
String key = String.join("|", code, text(rate.originWarehouseId()), enumText(rate.destinationType()),
text(rate.destinationWarehouseId()), nullToEmpty(upper(rate.destinationCode())),
rate.regionCode() == null ? "" : rate.regionCode().name(), rate.pricingTaxMode() == null ? "" : rate.pricingTaxMode().name(),
rate.chargeUnit(), text(rate.weightFromKg()));
if (!unique.add(key)) issues.add(issue("BLOCKING", "DUPLICATE_RATE", "存在重复价格组合", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
return issues;
}
private void validateOrigin(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues) {
if (rate.originWarehouseId() == null || rate.originWarehouseId() <= 0) {
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "起运仓需要映射到平台国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.originWarehouseId(), cache, issues, rate, "起运仓");
if (warehouse != null && (!"DOMESTIC".equals(warehouse.warehouseType())
|| !Objects.equals(warehouse.warehouseCode(), rate.originWarehouseCode()))) {
issues.add(issue("BLOCKING", "ORIGIN_WAREHOUSE_INVALID", "起运仓必须是匹配编码的平台公共国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
}
private void validateDestination(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues) {
if (rate.priceModel() == PriceModel.REGION_WEIGHT_TIER) {
if (rate.regionCode() == null || rate.weightFromKg() == null || rate.pricingTaxMode() == null
|| rate.chargeUnit() == null || !"KG".equals(rate.chargeUnit()))
issues.add(issue("BLOCKING", "REGION_TIER_INVALID", "海派价格必须包含区域、重量阶梯和KG单位", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.priceModel() == null) {
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.destinationType() == null) {
issues.add(issue("BLOCKING", "DESTINATION_TYPE_REQUIRED", "FBA仓库价格必须选择目的地类型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
return;
}
if (rate.pricingTaxMode() == null) {
issues.add(issue("BLOCKING", "PRICING_TAX_MODE_REQUIRED", "FBA仓库价格必须选择包税或不包税", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
if (rate.destinationType() == PricingDestinationType.FBA) {
if (!StringUtils.hasText(rate.destinationCode())) issues.add(issue("BLOCKING", "FBA_CODE_REQUIRED", "FBA价格必须填写目的仓编码", rate.sourceSheetName(), rate.sourceRowNumber(), null));
} else {
if (rate.destinationWarehouseId() == null || rate.destinationWarehouseId() <= 0) {
issues.add(issue("BLOCKING", "OVERSEAS_MAPPING_REQUIRED", "海外仓价格必须映射平台海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
} else {
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.destinationWarehouseId(), cache, issues, rate, "海外仓");
if (warehouse != null && (!"OVERSEAS".equals(warehouse.warehouseType())
|| (StringUtils.hasText(rate.destinationCode())
&& !Objects.equals(warehouse.warehouseCode(), upper(rate.destinationCode())))))
issues.add(issue("BLOCKING", "OVERSEAS_WAREHOUSE_INVALID", "目的仓必须是匹配编码的平台公共海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
}
}
}
private PriceBookWarehouseClient.Warehouse warehouse(long id, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
List<PriceBookDtos.ParseIssue> issues,
PriceBookDtos.ParsedRate rate, String label) {
if (cache.containsKey(id)) return cache.get(id);
try {
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, id);
if (response == null || !response.successful() || response.data() == null || response.data().warehouse() == null)
throw new IllegalStateException("仓库不存在");
var warehouse = response.data().warehouse();
if (!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status())) throw new IllegalStateException("仓库不可用");
cache.put(id, warehouse); return warehouse;
} catch (RuntimeException exception) {
issues.add(issue("BLOCKING", "WAREHOUSE_VALIDATION_FAILED", label + "无法通过平台公共仓库校验",
rate.sourceSheetName(), rate.sourceRowNumber(), null));
cache.put(id, null); return null;
}
}
private void markFailed(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues, long operatorId) {
if (version == null || version.getId() == null) return;
try {
version.setStatus(PriceBookStatus.FAILED.name()); version.setUpdatedBy(operatorId);
applyIssueCounts(version, issues); repository.updateVersion(version);
} catch (RuntimeException ignored) { }
}
private void applyIssueCounts(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues) {
version.setBlockingErrorCount((int) issues.stream().filter(i -> "BLOCKING".equals(i.severity())).count());
version.setWarningCount((int) issues.stream().filter(i -> "WARNING".equals(i.severity())).count());
try { version.setParseIssuesJson(objectMapper.writeValueAsString(issues)); }
catch (JsonProcessingException exception) { throw internal("价格表校验结果序列化失败"); }
}
private List<PriceBookDtos.ParseIssue> readIssues(String json) {
if (!StringUtils.hasText(json)) return List.of();
try { return objectMapper.readValue(json, ISSUE_LIST); }
catch (JsonProcessingException exception) { throw internal("价格表校验结果读取失败"); }
}
private PriceBookVersionEntity requireVersion(long id) {
if (id <= 0) throw validation("价格版本ID必须大于0");
PriceBookVersionEntity value = repository.findVersion(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "价格表版本不存在");
return value;
}
private PriceBookVersionEntity requirePendingConfirmation(long id) {
PriceBookVersionEntity value = requireVersion(id);
if (!PriceBookStatus.PENDING_CONFIRMATION.name().equals(value.getStatus())) throw new BizException(ResultCode.CONFLICT, "只有待确认上传记录可以确认并生效");
return value;
}
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(),
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt());
}
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),
value.getLatestWarehouseEntry(), value.getVesselName(), value.getVoyageNo(), value.getReferenceTransit(),
value.getSurchargeDescription(), value.getRemark());
}
private PriceBookDtos.ParsedRate rateDto(CarrierFbaRateEntity value) {
return new PriceBookDtos.ParsedRate(value.getId(), value.getChannelCode(), PriceModel.valueOf(value.getPriceModel()),
value.getOriginWarehouseId(), value.getOriginWarehouseCode(), value.getOriginWarehouseName(),
value.getDestinationCode(), value.getDestinationType() == null ? null : PricingDestinationType.valueOf(value.getDestinationType()),
value.getDestinationWarehouseId(), value.getDestinationName(),
value.getRegionCode() == null ? null : PriceRegion.valueOf(value.getRegionCode()),
value.getPricingTaxMode() == null ? null : PricingTaxMode.valueOf(value.getPricingTaxMode()),
value.getChargeUnit(), value.getWeightFromKg(), value.getUnitPrice(), value.getCurrency(),
value.getSourceSheetName(), value.getSourceRowNumber(), value.getSourceDestinationText(), value.getRemark());
}
private static void requireFile(MultipartFile file) {
if (file == null || file.isEmpty()) throw validation("请选择价格表Excel文件");
String name = file.getOriginalFilename();
if (!StringUtils.hasText(name) || !name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw validation("仅支持XLSX价格表");
}
private static String safeFileName(String value) {
String name = value == null ? "price-book.xlsx" : value.replace('\\', '/');
name = name.substring(name.lastIndexOf('/') + 1).trim();
return name.isEmpty() ? "price-book.xlsx" : name.substring(0, Math.min(name.length(), 255));
}
private static String normalizeChannel(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String upper(String value) { return value == null ? null : value.trim().toUpperCase(Locale.ROOT); }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String text(Object value) { return value == null ? "" : value.toString(); }
private static String nullToEmpty(String value) { return value == null ? "" : value; }
private static String enumText(Enum<?> value) { return value == null ? "" : value.name(); }
private static String mappingKey(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
private static String safeMessage(Exception exception, String fallback) {
return StringUtils.hasText(exception.getMessage()) ? exception.getMessage() : fallback;
}
private static List<PriceBookDtos.ParseIssue> mergeIssues(List<PriceBookDtos.ParseIssue> first,
List<PriceBookDtos.ParseIssue> second) {
Map<String, PriceBookDtos.ParseIssue> result = new LinkedHashMap<>();
java.util.stream.Stream.concat(first.stream(), second.stream()).forEach(value -> result.put(
String.join("|", value.severity(), value.issueCode(), nullToEmpty(value.sourceSheetName()),
text(value.sourceRowNumber()), nullToEmpty(value.sourceCell())), value));
return List.copyOf(result.values());
}
private static PriceBookDtos.ParseIssue issue(String severity, String code, String message,
String sheet, Integer row, String cell) {
return new PriceBookDtos.ParseIssue(severity, code, message, sheet, row, cell);
}
private static BizException validation(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
private static BizException internal(String message) { return new BizException(ResultCode.INTERNAL_ERROR, message); }
private record ParsedPriceBook(List<PriceBookDtos.ParsedChannel> channels, List<PriceBookDtos.ParsedRate> rates,
List<PriceBookDtos.ParseIssue> issues) {}
private record MappingResult(List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues) {}
}
@@ -18,14 +18,17 @@ DELIMITER ;
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID' AFTER channel_name");
CALL phase2_fba_add_column('fl_inbound_order', 'carrier_name', "VARCHAR(128) NULL COMMENT '计费船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_order', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED' AFTER tax_mode");
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_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', 'price_book_version_id', "BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照' 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");
CALL phase2_fba_add_column('fl_inbound_fee', 'customer_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '客户名称快照' AFTER customer_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'business_type', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT '业务类型快照' AFTER customer_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'tax_mode', "VARCHAR(24) NULL COMMENT '包税模式快照' AFTER business_type");
CALL phase2_fba_add_column('fl_inbound_fee', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT '报价包税维度快照' AFTER tax_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_scale', "TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '计费量保留位数快照' AFTER tax_mode");
CALL phase2_fba_add_column('fl_inbound_fee', 'rounding_mode', "VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式快照' AFTER rounding_scale");
CALL phase2_fba_add_column('fl_inbound_fee', 'rule_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户规则生效开始快照' AFTER rounding_mode");
@@ -34,14 +37,41 @@ CALL phase2_fba_add_column('fl_inbound_fee', 'rate_code', "VARCHAR(50) NOT NULL
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '起运仓ID快照' AFTER rate_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'origin_warehouse_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '起运仓名称快照' AFTER origin_warehouse_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'channel_code', "VARCHAR(64) NOT NULL DEFAULT '' COMMENT '渠道编码快照' AFTER origin_warehouse_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '船司ID快照' AFTER channel_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NOT NULL DEFAULT '' COMMENT '船司名称快照' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'FBA仓编码快照' AFTER carrier_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_id', "BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空' AFTER channel_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'carrier_name', "VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空' AFTER carrier_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_type', "VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS' AFTER carrier_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_warehouse_id', "BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照' AFTER destination_type");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_name', "VARCHAR(128) NULL COMMENT '目的仓名称快照' AFTER destination_warehouse_id");
CALL phase2_fba_add_column('fl_inbound_fee', 'destination_code', "VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空' AFTER destination_name");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_from', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '价格生效开始快照' AFTER destination_code");
CALL phase2_fba_add_column('fl_inbound_fee', 'rate_effective_to', "DATETIME(3) NULL COMMENT '价格生效结束快照' AFTER rate_effective_from");
CALL phase2_fba_add_column('fl_inbound_idempotency', 'result_resource_id', "BIGINT UNSIGNED NULL COMMENT '成功结果资源ID' AFTER result_json");
CALL phase2_fba_add_column('fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id',
"BIGINT UNSIGNED NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键' AFTER warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_type',
"VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空' AFTER carrier_name");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'price_book_version_id',
"BIGINT UNSIGNED NULL COMMENT '整表价格版本ID;历史兼容价格可为空' AFTER tenant_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'origin_warehouse_code',
"VARCHAR(64) NULL COMMENT '起运仓稳定编码' AFTER origin_warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_warehouse_id',
"BIGINT UNSIGNED NULL COMMENT '平台海外仓稳定ID;不存环境专属ID' AFTER destination_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'destination_name',
"VARCHAR(128) NULL COMMENT '目的仓名称快照' AFTER destination_warehouse_id");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'price_model',
"VARCHAR(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型' AFTER channel_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'region_code',
"VARCHAR(16) NULL COMMENT '区域阶梯模型区域编码' AFTER destination_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'pricing_tax_mode',
"VARCHAR(16) NULL COMMENT '报价包税维度;独立于订单tax_mode' AFTER region_code");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'weight_from_kg',
"DECIMAL(18,6) NULL COMMENT '区域阶梯起始计费重量KG' AFTER pricing_tax_mode");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_sheet_name',
"VARCHAR(128) NULL COMMENT '原始Excel Sheet' AFTER additional_fee");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_row_number',
"INT UNSIGNED NULL COMMENT '原始Excel行号' AFTER source_sheet_name");
CALL phase2_fba_add_column('fl_carrier_fba_rate', 'source_destination_text',
"VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本' AFTER source_row_number");
-- 仓储资源是平台公共主数据;存量分配快照统一迁移到平台作用域 0,再收口非空约束。
UPDATE fl_inbound_pallet_allocation
@@ -56,6 +86,27 @@ SET @sql = IF(@resource_tenant_needs_modify > 0,
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 价格上传确认前,起运仓和区域阶梯目的地可以暂未绑定;确认生效时由 Firstleg 完成映射和类型校验。
ALTER TABLE fl_carrier_fba_rate
MODIFY COLUMN origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID',
MODIFY COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
MODIFY COLUMN carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
MODIFY COLUMN destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空',
MODIFY COLUMN destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空';
ALTER TABLE fl_inbound_fee
MODIFY COLUMN charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
MODIFY COLUMN charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
MODIFY COLUMN rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照',
MODIFY COLUMN rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照',
MODIFY COLUMN rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照',
MODIFY COLUMN rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照',
MODIFY COLUMN carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
MODIFY COLUMN carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
MODIFY COLUMN destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空',
MODIFY COLUMN charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM',
MODIFY COLUMN conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照';
DROP PROCEDURE IF EXISTS phase2_fba_add_column;
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
@@ -310,19 +311,31 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0',
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID;历史兼容价格可为空',
rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码',
rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID',
origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID',
origin_warehouse_code VARCHAR(64) NULL COMMENT '起运仓稳定编码',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
price_model VARCHAR(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空',
destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '平台海外仓稳定ID;不存环境专属ID',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
region_code VARCHAR(16) NULL COMMENT '区域阶梯模型区域编码',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度;独立于订单tax_mode',
weight_from_kg DECIMAL(18,6) NULL COMMENT '区域阶梯起始计费重量KG',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM',
unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价',
currency CHAR(3) NOT NULL COMMENT '币种',
additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费',
source_sheet_name VARCHAR(128) NULL COMMENT '原始Excel Sheet',
source_row_number INT UNSIGNED NULL COMMENT '原始Excel行号',
source_destination_text VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态',
@@ -335,39 +348,156 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version),
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_type, destination_code, destination_warehouse_id, charge_unit, status, effective_from, effective_to),
KEY idx_fba_rate_book (price_book_version_id, channel_code, origin_warehouse_id, price_model, status),
KEY idx_fba_rate_destination (price_book_version_id, channel_code, origin_warehouse_id, destination_type, destination_warehouse_id, destination_code),
KEY idx_fba_rate_region (price_book_version_id, channel_code, origin_warehouse_id, region_code, weight_from_kg)
) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本';
CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码',
display_name VARCHAR(128) NOT NULL COMMENT '平台渠道名称',
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_channel_code (channel_code),
KEY idx_price_channel_status (status, price_model)
) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录';
CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID',
alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_origin_alias (warehouse_code, alias_name),
KEY idx_price_origin_alias_name (alias_name, status)
) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射';
CREATE TABLE IF NOT EXISTS fl_price_book_version (
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数',
rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数',
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_by BIGINT UNSIGNED NULL,
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
updated_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_version (tenant_id, version_no),
KEY idx_price_book_status (tenant_id, status, published_at),
KEY idx_price_book_source_hash (tenant_id, source_sha256)
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
channel_code VARCHAR(64) NOT NULL COMMENT '固定渠道编码',
channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照',
price_model VARCHAR(32) NOT NULL COMMENT '价格模型快照',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '原始Excel Sheet',
estimated_departure_date DATE NULL COMMENT '标准预计开船日期',
estimated_departure_text VARCHAR(128) NULL COMMENT '原始预计开船日期文本',
latest_warehouse_entry VARCHAR(128) NULL COMMENT '最晚进仓时间',
vessel_name VARCHAR(128) NULL COMMENT '船名',
voyage_no VARCHAR(64) NULL COMMENT '航次',
reference_transit VARCHAR(128) NULL COMMENT '参考时效',
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code),
KEY idx_price_book_channel_code (channel_code, price_book_version_id)
) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照';
INSERT INTO fl_price_channel_catalog
(id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status)
VALUES
(1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'),
(2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'),
(3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'),
(4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'),
(5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'),
(6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'),
(7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'),
(8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'),
(9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'),
(10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'),
(11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'),
(12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'),
(13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'),
(14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'),
(15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE')
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name), price_model = VALUES(price_model),
source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status);
INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status)
VALUES
(1, 'CN-YIWU-01', '华东仓(义乌)', 'ACTIVE'),
(2, 'CN-YIWU-01', '义乌仓', 'ACTIVE'),
(3, 'CN-YIWU-01', '义乌', 'ACTIVE'),
(4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'),
(5, 'CN-DONGGUAN-01', '东莞仓', 'ACTIVE'),
(6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE')
ON DUPLICATE KEY UPDATE status = VALUES(status);
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照',
charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照',
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照',
rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
tax_mode VARCHAR(24) NULL COMMENT '包税模式快照',
rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照',
rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照',
rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照',
tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度快照',
rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照',
rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照',
rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照',
rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照',
rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
destination_type VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空',
rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照',
rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照',
charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照',
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
@@ -13,7 +13,13 @@ paths:
parameters:
- {$ref: '#/components/parameters/OptionalTenantId'}
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
responses:
'200':
description: 成功或业务失败
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'}
'403': {description: 无权限}
/api/platform/v1/fba-pricing/customer-rules/versions:
post:
summary: 新建客户计费规则版本
@@ -51,10 +57,80 @@ paths:
summary: 停用船司 FBA 价格版本
parameters: [{$ref: '#/components/parameters/Id'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books:
get:
summary: 查询船期价格表版本
description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。
parameters:
- {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}}
responses:
'200':
description: 成功或业务失败
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/PriceBookVersionSummary'}
'403': {description: 无权限}
post:
summary: 上传船期价格表并生成待确认记录
description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。
requestBody:
required: true
content:
multipart/form-data:
schema: {$ref: '#/components/schemas/PriceBookUploadRequest'}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:
get:
summary: 查询船期价格表版本详情和解析结果
parameters: [{$ref: '#/components/parameters/VersionId'}]
responses:
'200':
description: 成功或业务失败
content:
application/json:
schema: {$ref: '#/components/schemas/PriceBookVersionDetail'}
'403': {description: 无权限}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:
post:
summary: 确认并立即生效船期价格表版本
description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。
parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source:
get:
summary: 下载船期价格表原始文件
description: 需要 firstleg:fba-pricing:rate:view 权限;由 Firstleg 代理 File 服务内部下载。
parameters: [{$ref: '#/components/parameters/VersionId'}]
responses:
'200': {description: 原始 XLSX 附件, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
'403': {description: 无权限}
/api/merchant/v1/first-leg/pricing/channels:
get:
summary: 查询当前起运仓可选船期渠道
description: 客户只看到当前 ACTIVE 版本;不存在于当前版本的渠道不可选择。pricingTaxModes 来自当前起运仓下的生效价格行,前端不得写死包税选项。
parameters:
- {name: originWarehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: 成功或业务失败
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/MerchantPricingChannel'}
/api/merchant/v1/first-leg/pricing/estimate:
post:
summary: 计算头程参考费用
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
responses: {'200': {description: 成功或业务失败}}
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
post:
summary: 按审核通过测量快照试算 FBA 费用
description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
@@ -94,6 +170,7 @@ components:
TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
AdjustFeeRequest:
@@ -124,7 +201,7 @@ components:
effectiveTo: {type: string, format: date-time, nullable: true}
CarrierRateVersionRequest:
type: object
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationType, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
properties:
rateCode: {type: string, maxLength: 50}
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -133,10 +210,170 @@ components:
channelCode: {type: string}
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
carrierName: {type: string}
destinationCode: {type: string}
destinationType: {$ref: '#/components/schemas/PricingDestinationType'}
destinationCode: {type: string, nullable: true}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
destinationName: {type: string, maxLength: 128, nullable: true}
chargeUnit: {type: string, enum: [KG, CBM]}
unitPrice: {type: number, exclusiveMinimum: 0}
currency: {type: string, pattern: '^[A-Z]{3}$'}
additionalFee: {type: number, minimum: 0}
effectiveFrom: {type: string, format: date-time}
effectiveTo: {type: string, format: date-time, nullable: true}
PriceBookStatus:
type: string
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, FAILED]
PriceModel:
type: string
enum: [FBA_WAREHOUSE_RATE, REGION_WEIGHT_TIER]
PricingTaxMode:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
PricingDestinationType:
type: string
description: 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价
enum: [FBA, OVERSEAS]
PriceBookUploadRequest:
type: object
required: [file]
properties:
file: {type: string, format: binary, description: 原始 XLSX 文件}
PriceBookVersionSummary:
type: object
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
versionNo: {type: integer}
originalFileName: {type: string, maxLength: 255}
sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
sourceSha256: {type: string, maxLength: 64, nullable: true}
status: {$ref: '#/components/schemas/PriceBookStatus'}
channelCount: {type: integer, minimum: 0}
rateCount: {type: integer, minimum: 0}
blockingErrorCount: {type: integer, minimum: 0}
warningCount: {type: integer, minimum: 0}
parseIssuesJson: {type: string, nullable: true, description: 上传解析问题及待确认重新校验结果JSON}
createdBy: {type: string, pattern: '^[1-9][0-9]*$'}
createdAt: {type: string, format: date-time}
publishedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
supersededAt: {type: string, format: date-time, nullable: true}
PriceBookVersionDetail:
type: object
required: [version, channels, rates, issues]
properties:
version: {$ref: '#/components/schemas/PriceBookVersionSummary'}
channels:
type: array
items: {$ref: '#/components/schemas/PriceBookParsedChannel'}
rates:
type: array
items: {$ref: '#/components/schemas/PriceBookParsedRate'}
issues:
type: array
items: {$ref: '#/components/schemas/PriceBookParseIssue'}
PriceBookParseIssue:
type: object
properties:
severity: {type: string}
issueCode: {type: string}
message: {type: string}
sourceSheetName: {type: string, nullable: true}
sourceRowNumber: {type: integer, minimum: 1, nullable: true}
sourceCell: {type: string, nullable: true}
PriceBookConfirmationRequest:
type: object
required: [originMappings]
properties:
originMappings:
type: array
minItems: 1
maxItems: 2
items: {$ref: '#/components/schemas/PriceBookOriginMapping'}
overseasMappings:
type: array
maxItems: 2
items: {$ref: '#/components/schemas/PriceBookOverseasMapping'}
PriceBookOriginMapping:
type: object
required: [originWarehouseCode, warehouseId]
properties:
originWarehouseCode: {type: string, maxLength: 64, description: 上传表识别出的起运仓稳定编码}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$', description: 平台公共国内仓稳定ID}
PriceBookOverseasMapping:
type: object
required: [sourceDestinationName, warehouseId]
properties:
sourceDestinationName: {type: string, maxLength: 128, description: 上传表中的海外仓名称}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$', description: 平台公共海外仓稳定ID}
PriceBookParsedChannel:
type: object
required: [channelCode, channelName, priceModel, sourceSheetName]
properties:
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
priceModel: {$ref: '#/components/schemas/PriceModel'}
sourceSheetName: {type: string, maxLength: 128}
estimatedDepartureDate: {type: string, format: date}
estimatedDepartureText: {type: string, maxLength: 128}
latestWarehouseEntry: {type: string, maxLength: 128}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
referenceTransit: {type: string, maxLength: 128}
surchargeDescription: {type: string, maxLength: 2000}
remark: {type: string, maxLength: 2000}
PriceBookParsedRate:
type: object
required: [channelCode, priceModel, originWarehouseCode, originWarehouseName, chargeUnit, unitPrice, currency, sourceSheetName, sourceRowNumber]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string, maxLength: 64}
priceModel: {$ref: '#/components/schemas/PriceModel'}
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 确认时绑定的平台公共国内仓稳定ID}
originWarehouseCode: {type: string, maxLength: 64}
originWarehouseName: {type: string, maxLength: 128}
destinationType: {$ref: '#/components/schemas/PricingDestinationType', nullable: true, description: FBA_WAREHOUSE_RATE 必填;REGION_WEIGHT_TIER 为空}
destinationCode: {type: string, maxLength: 32, nullable: true}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 确认时绑定的平台海外仓稳定ID}
destinationName: {type: string, maxLength: 128, nullable: true, description: 目的仓名称快照}
regionCode: {type: string, maxLength: 16, nullable: true}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
chargeUnit: {type: string, enum: [KG, CBM]}
weightFromKg: {type: number, minimum: 0, nullable: true}
unitPrice: {type: number, exclusiveMinimum: 0}
currency: {type: string, pattern: '^[A-Z]{3}$'}
sourceSheetName: {type: string, maxLength: 128}
sourceRowNumber: {type: integer, minimum: 1}
sourceDestinationText: {type: string, maxLength: 1000}
remark: {type: string, maxLength: 500}
MerchantPricingChannel:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
properties:
priceBookVersionId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
priceModel: {$ref: '#/components/schemas/PriceModel'}
pricingTaxModes:
type: array
description: 当前起运仓、当前渠道的生效价格行支持的报价税模式;可能只包含包税或不包税其中一种。
items: {$ref: '#/components/schemas/PricingTaxMode'}
estimatedDepartureDate: {type: string, format: date, nullable: true}
estimatedDepartureText: {type: string, maxLength: 128, nullable: true}
latestWarehouseEntry: {type: string, maxLength: 128, nullable: true}
referenceTransit: {type: string, maxLength: 128, nullable: true}
surchargeDescription: {type: string, maxLength: 2000, nullable: true}
remark: {type: string, maxLength: 2000, nullable: true}
PriceEstimateRequest:
type: object
required: [originWarehouseId, channelCode, destinationType]
properties:
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string, maxLength: 64}
destinationType: {$ref: '#/components/schemas/PricingDestinationType'}
destinationCode: {type: string, maxLength: 32}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
destinationName: {type: string, maxLength: 128, nullable: true}
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
actualWeightKg: {type: number, minimum: 0}
actualVolumeCbm: {type: number, minimum: 0}
@@ -9,7 +9,7 @@ paths:
/internal/firstleg/v1/inbounds/order-approved:
post:
summary: 订单审核通过后幂等创建入库单
description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。承运商可在后续履约或计费前补充。
description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。
security:
- internalServiceToken: []
requestBody:
@@ -90,7 +90,9 @@ paths:
签名body的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
签名body = data原始UTF-8文本 + LF + image文件内容SHA-256小写十六进制。
三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作;
appKey + eventNo 标识第三方业务事件。
appKey + eventNo 标识第三方业务事件。多台设备可共用同一 appKey/secret,
但 deviceCode、eventNo 和 Idempotency-Key 必须由调用方生成全局唯一值,
不得仅在单台设备内从 1 开始递增。
security:
- measurementAppKey: []
measurementTimestamp: []
@@ -294,9 +296,10 @@ components:
businessType: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 可选,可在后续履约或计费前补充}
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照}
carrierName: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 报价包税维度;独立于订单taxMode}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
destinationType: {type: string, nullable: true}
@@ -391,6 +394,8 @@ components:
processStatus: {type: string}
customerId: {type: string, nullable: true}
companyId: {type: string, nullable: true}
taxMode: {type: string, nullable: true, description: 订单报关方式快照}
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 下单时选择的报价包税维度快照}
InboundMeasurementCallbackRequest:
type: object
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -44,6 +44,7 @@ class InboundOrderControllerContractTest {
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list")
@@ -0,0 +1,100 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表公共契约")
class PriceBookContractTest {
@Test
@DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly(
"MATSON_CLX_TRUCK", "MATSON_MAX_TRUCK", "EXX_TRUCK", "HEDE_TRUCK", "EXX_RAIL_NY",
"OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_CHICAGO_RAIL", "OA_HOUSTON_TRUCK",
"OA_NEW_YORK_TRUCK", "OA_SAVANNAH_TRUCK", "MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL",
"HEDE_PARCEL", "OA_PARCEL");
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.filter(channel -> channel.priceModel() == PriceModel.REGION_WEIGHT_TIER))
.extracting(PriceBookChannelCatalog.ChannelDefinition::channelCode)
.containsExactly("MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL", "HEDE_PARCEL", "OA_PARCEL");
}
@Test
@DisplayName("报价包税维度独立于订单税模式")
void keepsPricingTaxModeSeparate() {
assertThat(PriceBookStatus.values()).containsExactly(
PriceBookStatus.PENDING_CONFIRMATION, PriceBookStatus.ACTIVE,
PriceBookStatus.SUPERSEDED, PriceBookStatus.FAILED);
assertThat(PricingTaxMode.values()).containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(PricingDestinationType.values()).containsExactly(PricingDestinationType.FBA, PricingDestinationType.OVERSEAS);
assertThat(PriceBookDtos.ParsedRate.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxMode", "originWarehouseId", "destinationType", "destinationCode", "destinationWarehouseId", "destinationName", "regionCode");
assertThat(PriceBookDtos.EstimateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("destinationType", "destinationWarehouseId", "destinationName");
assertThat(PriceBookDtos.CurrentChannel.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxModes");
}
@Test
@DisplayName("渠道税模式只汇总当前版本和起运仓的生效价格行")
void scopesChannelPricingTaxModesToActiveRows() throws Exception {
Select select = PriceBookQueryMapper.class
.getMethod("findChannelTaxModes", long.class, long.class)
.getAnnotation(Select.class);
assertThat(String.join("\n", select.value()))
.contains("r.price_book_version_id = #{versionId}")
.contains("r.origin_warehouse_id = #{originWarehouseId}")
.contains("r.status = 'ACTIVE'")
.contains("r.deleted = 0")
.contains("r.pricing_tax_mode IS NOT NULL")
.contains("GROUP BY r.channel_code, r.pricing_tax_mode");
}
@Test
@DisplayName("OpenAPI冻结待确认上传、发布和客户参考估价路径")
void documentsPriceBookLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:")
.contains("/api/merchant/v1/first-leg/pricing/channels:")
.contains("/api/merchant/v1/first-leg/pricing/estimate:")
.contains("PriceBookStatus")
.contains("FBA_WAREHOUSE_RATE")
.contains("REGION_WEIGHT_TIER")
.contains("pricingTaxMode")
.contains("PricingDestinationType")
.contains("MerchantPricingChannel")
.contains("pricingTaxModes")
.contains("destinationWarehouseId")
.contains("parseIssuesJson")
.contains("PriceBookVersionDetail")
.contains("PriceBookParseIssue")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
.contains("确认并立即生效")
.contains("SUPERSEDED");
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FbaWarehouseRateEstimatorTest {
private final FbaWarehouseRateEstimator estimator = new FbaWarehouseRateEstimator();
@Test
void shouldUseGreaterOfActualAndVolumeWeightForKgRate() {
var estimate = estimator.estimate(rate("KG", "12.50"),
new BigDecimal("20"), new BigDecimal("0.20"));
assertThat(estimate.quantity()).isEqualByComparingTo("33.400000");
assertThat(estimate.unit()).isEqualTo("KG");
assertThat(estimate.unitPrice()).isEqualByComparingTo("12.50");
assertThat(estimate.amount()).isEqualByComparingTo("417.50");
assertThat(estimate.currency()).isEqualTo("CNY");
}
@Test
void shouldUseGreaterOfVolumeAndWeightConversionForCbmRate() {
var estimate = estimator.estimate(rate("CBM", "880"),
new BigDecimal("363"), new BigDecimal("0.50"));
assertThat(estimate.quantity()).isEqualByComparingTo("1.000000");
assertThat(estimate.amount()).isEqualByComparingTo("880.00");
}
@Test
void shouldRejectMissingWeightAndVolume() {
assertThatThrownBy(() -> estimator.estimate(rate("KG", "10"), null, BigDecimal.ZERO))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少填写一项");
}
private static RateRow rate(String unit, String price) {
RateRow row = new RateRow();
row.setChargeUnit(unit);
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceRegion;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PriceRegionResolverTest {
private final PriceRegionResolver resolver = new PriceRegionResolver();
@ParameterizedTest
@CsvSource({
"90001, WEST",
"60601, CENTRAL",
"10001, EAST",
"'US 46201', EAST"
})
void shouldResolveUnitedStatesPostalCode(String postalCode, PriceRegion expected) {
assertThat(resolver.fromPostalCode(postalCode)).isEqualTo(expected);
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "USA"})
void shouldRejectPostalCodeWithoutDigits(String postalCode) {
assertThatThrownBy(() -> resolver.fromPostalCode(postalCode))
.isInstanceOf(BizException.class)
.hasMessageContaining("无法确定海派区域");
}
}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RegionWeightTierEstimatorTest {
private final RegionWeightTierEstimator estimator = new RegionWeightTierEstimator();
private final List<RateRow> tiers = List.of(tier("12", "15"), tier("51", "13"), tier("101", "11"));
@Test
void shouldChooseHighestTierNotExceedingChargeableWeightAtBoundaries() {
assertThat(estimator.estimate(tiers, new BigDecimal("12"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("50.999999"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("51"), null).unitPrice())
.isEqualByComparingTo("13");
assertThat(estimator.estimate(tiers, new BigDecimal("101"), null).unitPrice())
.isEqualByComparingTo("11");
}
@Test
void shouldUseVolumeWeightBeforeSelectingTier() {
var estimate = estimator.estimate(tiers, new BigDecimal("20"), new BigDecimal("0.61"));
assertThat(estimate.quantity()).isEqualByComparingTo("101.870000");
assertThat(estimate.unitPrice()).isEqualByComparingTo("11");
assertThat(estimate.amount()).isEqualByComparingTo("1120.57");
}
@Test
void shouldRejectWeightBelowLowestTier() {
assertThatThrownBy(() -> estimator.estimate(tiers, new BigDecimal("11.999999"), null))
.isInstanceOf(BizException.class)
.hasMessageContaining("最低重量阶梯");
}
private static RateRow tier(String from, String price) {
RateRow row = new RateRow();
row.setChargeUnit("KG");
row.setWeightFromKg(new BigDecimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,206 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PricingTaxMode;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PriceWorkbookParserTest {
private static final List<String> STANDARD_SHEETS = List.of(
"美森正班海卡", "美森加班海卡", "EXX海卡", "合德海卡", "纽约EXX海铁",
"洛杉矶普船海卡", "洛杉矶普船全卡", "芝加哥普船海铁", "休斯顿普船海卡",
"纽约普船海卡", "萨凡纳普船海卡");
@Test
void parsesFifteenChannelsAndIgnoresMalformedQuickQueryTable() throws Exception {
Path workbook = malformedWorkbook();
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertEquals(15, result.channels().size());
assertFalse(result.hasBlockingErrors());
assertTrue(result.ignoredSheets().contains("快速查询"));
PriceWorkbookParseResult.ParsedChannel matson = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_TRUCK")).findFirst().orElseThrow();
List<PriceWorkbookParseResult.ParsedRate> ont = matson.rates().stream()
.filter(rate -> "ONT8".equals(rate.destinationCode())).toList();
assertEquals(3, ont.size());
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.taxMode() == PricingTaxMode.TAX_EXCLUDED
&& rate.chargeUnit().equals("CBM")
&& rate.unitPrice().compareTo(new BigDecimal("1790")) == 0));
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.taxMode() == PricingTaxMode.TAX_INCLUDED
&& rate.unitPrice().compareTo(new BigDecimal("10.7")) == 0));
PriceWorkbookParseResult.ParsedChannel parcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_PARCEL")).findFirst().orElseThrow();
assertEquals(18, parcel.rates().size());
assertTrue(parcel.rates().stream().allMatch(rate -> rate.taxMode() == PricingTaxMode.TAX_INCLUDED));
assertTrue(parcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.regionCode().equals("EAST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(101)) == 0), parcel.rates().toString());
PriceWorkbookParseResult.ParsedChannel hedeParcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("HEDE_PARCEL")).findFirst().orElseThrow();
assertTrue(hedeParcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.regionCode().equals("WEST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(12)) == 0
&& rate.unitPrice().compareTo(new BigDecimal("15.8")) == 0), hedeParcel.rates().toString());
}
@Test
void keepsUnknownDestinationAsBlockingMappingIssueInsteadOfDroppingTheRow() throws Exception {
Path workbook = minimalStandardWorkbook("未知仓组", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertTrue(result.hasBlockingErrors());
assertTrue(result.issues().stream().anyMatch(issue -> issue.code().equals("DESTINATION_MAPPING_REQUIRED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> rate.rawDestinationGroup().equals("未知仓组") && rate.destinationCode() == null));
}
@Test
void parsesPlatformOverseasWarehouseRowsWithoutGuessingWarehouseIdOrCode() throws Exception {
Path workbook = minimalStandardWorkbook("海外仓(洛杉矶仓)", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertFalse(result.hasBlockingErrors());
List<PriceWorkbookParseResult.ParsedRate> rates = result.channels().stream()
.flatMap(channel -> channel.rates().stream()).toList();
assertEquals(3, rates.size());
assertTrue(rates.stream().allMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null
&& rate.rawDestinationGroup().equals("海外仓(洛杉矶仓)")));
}
@Test
void parsesTheRealWorkbookWhenAnExternalPathIsProvided() throws Exception {
String configured = System.getProperty("price.workbook");
Assumptions.assumeTrue(configured != null && !configured.isBlank());
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(Path.of(configured));
System.out.printf("REAL_WORKBOOK channels=%d rates=%d issues=%d blocking=%d ignored=%s%n",
result.channels().size(), result.rateCount(), result.issues().size(),
result.issues().stream().filter(issue -> issue.severity() == PriceWorkbookParseResult.Severity.ERROR).count(),
result.ignoredSheets());
result.issues().forEach(issue -> System.out.printf("REAL_ISSUE %s %s sheet=%s row=%s raw=%s%n",
issue.severity(), issue.code(), issue.sheetName(), issue.rowNumber(), issue.rawValue()));
assertEquals(15, result.channels().size());
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "ONT8".equals(rate.destinationCode())));
assertTrue(result.channels().stream().anyMatch(channel -> channel.channelCode().equals("OA_PARCEL")));
assertFalse(result.hasBlockingErrors());
assertFalse(result.issues().stream().anyMatch(issue ->
issue.code().equals("OVERSEAS_WAREHOUSE_NOT_INCLUDED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "纽约仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
}
private static Path malformedWorkbook() throws IOException {
Path valid = minimalWorkbook();
Path malformed = Files.createTempFile("price-parser-malformed-", ".xlsx");
try (ZipInputStream input = new ZipInputStream(Files.newInputStream(valid));
OutputStream output = Files.newOutputStream(malformed);
ZipOutputStream zip = new ZipOutputStream(output)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
zip.putNextEntry(new ZipEntry(entry.getName()));
input.transferTo(zip);
zip.closeEntry();
}
zip.putNextEntry(new ZipEntry("xl/tables/table1.xml"));
zip.write("<table xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ref=\"5:31\"/>".getBytes());
zip.closeEntry();
} finally {
Files.deleteIfExists(valid);
}
return malformed;
}
private static Path minimalWorkbook() throws IOException {
Path file = Files.createTempFile("price-parser-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
for (int i = 0; i < STANDARD_SHEETS.size(); i++) {
String sheetName = STANDARD_SHEETS.get(i);
var sheet = workbook.createSheet(sheetName);
sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日");
boolean shifted = sheetName.equals("纽约EXX海铁");
int base = shifted ? 1 : 0;
sheet.createRow(3).createCell(base).setCellValue("FBA仓库");
sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)");
sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)");
var row = sheet.createRow(5);
row.createCell(base).setCellValue("ONT8/LGB8");
row.createCell(base + 1).setCellValue(1790);
row.createCell(base + 2).setCellValue(10.2);
row.createCell(base + 3).setCellValue(10.7);
row.createCell(base + 4).setCellValue("20天左右");
row.createCell(base + 5).setCellValue("周日全天");
row.createCell(base + 6).setCellValue("MATSON 上海周三开");
}
var parcel = workbook.createSheet("海派");
parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开");
parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开");
parcel.getRow(4).createCell(8).setCellValue("合德 周四开");
parcel.getRow(4).createCell(11).setCellValue("普船统配");
parcel.createRow(5).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(8).createCell(0).setCellValue("美西");
parcel.createRow(9).createCell(0).setCellValue("美中");
parcel.createRow(10).createCell(0).setCellValue("美东");
for (int row = 8; row <= 10; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0);
}
parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开");
parcel.createRow(14).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(17).createCell(0).setCellValue("美西");
parcel.createRow(18).createCell(0).setCellValue("美中");
parcel.createRow(19).createCell(0).setCellValue("美东");
for (int row = 17; row <= 19; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0);
}
var quick = workbook.createSheet("快速查询");
quick.createRow(4).createCell(0).setCellValue("列1");
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException {
Path file = Files.createTempFile("price-parser-unknown-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
var sheet = workbook.createSheet("美森正班海卡");
sheet.createRow(3).createCell(0).setCellValue("FBA仓库");
var row = sheet.createRow(5);
row.createCell(0).setCellValue(destination);
row.createCell(1).setCellValue(Double.parseDouble(east));
row.createCell(2).setCellValue(Double.parseDouble(yiwuTax));
row.createCell(3).setCellValue(Double.parseDouble(dongguanTax));
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表SQL契约")
class PriceBookSqlContractTest {
@Test
@DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细")
void shouldProvidePriceBookTables() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema)
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog")
.contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_version")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel")
.contains("price_book_version_id")
.contains("price_model VARCHAR(32)")
.contains("pricing_tax_mode VARCHAR(16)")
.contains("source_sheet_name VARCHAR(128)")
.contains("source_row_number INT UNSIGNED")
.contains("destination_type VARCHAR(16)")
.contains("destination_warehouse_id BIGINT UNSIGNED")
.contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'");
}
}
@@ -2,14 +2,16 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -24,7 +26,6 @@ import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,7 +34,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -48,6 +48,7 @@ class FbaPricingServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
@MockBean private FbaPricingRepository repository;
@MockBean private InboundFeeDetailService feeDetails;
@MockBean private ActivePriceBookEstimator priceBookEstimator;
@Resource private FbaPricingService service;
@Test
@@ -63,15 +64,15 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0001");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
inbound.setCarrierId(null);
inbound.setCarrierName(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
@@ -79,19 +80,26 @@ class FbaPricingServiceTest {
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
assertThat(response.priceBookVersionId()).isEqualTo(701L);
assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
}
@Test
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
void calculatesTaxExcludedCbmAtRoundingBoundary() {
@DisplayName("不包税CBM使用生效价格表返回的计费量")
void calculatesTaxExcludedCbmFromActivePriceBook() {
readyRepository();
InboundPricingEntity inbound = inbound("363.001", "0.5000");
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
inbound.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
@@ -106,11 +114,9 @@ class FbaPricingServiceTest {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
@@ -156,26 +162,21 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核通过");
verify(repository, never()).matchRules(any(), any());
verify(priceBookEstimator, never()).estimate(any());
}
@Test
@DisplayName("无客户规则和多客户规则都明确拒绝")
void rejectsMissingAndAmbiguousRules() {
@DisplayName("当前生效价格表无匹配价格时明确拒绝")
void rejectsMissingActivePriceBookRate() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of());
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
when(priceBookEstimator.estimate(any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
org.mockito.Mockito.reset(repository);
readyRepository();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格");
verify(repository, never()).insertFee(any());
}
@Test
@@ -194,7 +195,7 @@ class FbaPricingServiceTest {
assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository, never()).matchRules(any(), any());
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@@ -299,29 +300,25 @@ class FbaPricingServiceTest {
value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED");
value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A");
value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value;
}
private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) {
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1);
value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor));
value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD");
return value;
}
private CarrierFbaRateEntity rate(String unit, String price, String additional) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1);
value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price));
value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional));
value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1));
return value;
private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price,
String amount, String additional) {
RateRow rate = new RateRow();
rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1);
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA");
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit);
rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD");
rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1));
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE,
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit,
new BigDecimal(price), new BigDecimal(amount), "USD");
}
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -14,7 +16,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,14 +51,52 @@ class InboundOrderCreationServiceTest {
verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class));
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77");
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
assertThat(replayedId).isEqualTo(900L);
}
@Test
void shouldRejectReplayWithDifferentInboundCode() {
InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(existing);
assertThatThrownBy(() -> service.createFromApprovedOrder(approvedCommand("RK-NEW")))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(orders, never()).insert(any(InboundOrderEntity.class));
}
@Test
void shouldConvergeOnConcurrentDuplicateInsertWinner() {
InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner);
doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert"))
.when(orders).insert(any(InboundOrderEntity.class));
assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L);
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
}
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
Long carrierId, String carrierName) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(id);
inbound.setTenantId(10001L);
inbound.setOrderId(77L);
inbound.setInboundCode(code);
inbound.setFeeStatus(feeStatus);
inbound.setCarrierId(carrierId);
inbound.setCarrierName(carrierName);
return inbound;
}
private static OrderApprovedInboundCommand approvedCommand(String inboundCode) {
return new OrderApprovedInboundCommand(
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
null, null, null, "FBA", "AIR", "空运", null, null,
null, null, null, null, null, null, null, null, null, null);
"BUY_DECLARATION", "TAX_EXCLUDED", null, null, null, null, null, null, null, null, null);
}
}
@@ -0,0 +1,273 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver;
import com.baiyue.firstleg.pricing.PriceRegionResolver;
import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({MerchantPriceBookService.class, ActivePriceBookEstimator.class,
FbaWarehouseRateEstimator.class, RegionWeightTierEstimator.class, PriceRegionResolver.class,
PriceBookWarehouseRegionResolver.class})
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class MerchantPriceBookServiceTest {
@MockBean private PriceBookQueryRepository repository;
@MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service;
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
assertThat(service.channels(10001L, 10L)).isEmpty();
verify(repository, never()).channels(1L, 10L);
verify(repository, never()).channelTaxModes(1L, 10L);
}
@Test
void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() {
when(repository.activeVersionId()).thenReturn(1L, 2L);
when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE)));
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("OLD");
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("NEW");
}
@Test
void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE),
channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of(
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_EXCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("OA_PARCEL", PricingTaxMode.TAX_INCLUDED)));
var channels = service.channels(10001L, 10L);
assertThat(channels.get(0).pricingTaxModes())
.containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(channels.get(1).pricingTaxModes()).containsExactly(PricingTaxMode.TAX_INCLUDED);
}
@Test
void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of());
assertThat(service.channels(10001L, 10L)).singleElement()
.extracting(value -> value.pricingTaxModes())
.isEqualTo(List.of());
}
@Test
void shouldRejectForgedOrRemovedChannel() {
when(repository.activeVersionId()).thenReturn(2L);
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL",
PriceModel.FBA_WAREHOUSE_RATE)));
assertThatThrownBy(() -> service.estimate(10001L,
request("FORGED", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "10", "0.1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("已下线");
}
@Test
void shouldEstimateFbaDestinationWithIndependentPricingTaxMode() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.fbaRates(7L, 10L, "MATSON_TRUCK", "ONT8", "TAX_INCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "KG", null, "12.50")));
var result = service.estimate(10001L,
request(" matson_truck ", PricingDestinationType.FBA, "ont8", null, null,
PricingTaxMode.TAX_INCLUDED, "20", "0.2"));
assertThat(result.priceBookVersionId()).isEqualTo(7L);
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
assertThat(result.chargeableQuantity()).isEqualByComparingTo("33.400000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("417.50");
assertThat(result.disclaimer()).isEqualTo(MerchantPriceBookService.DISCLAIMER);
}
@Test
void shouldEstimateBoundOverseasWarehouse() {
stubChannel(7L, "EXX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.overseasRates(7L, 10L, "EXX_TRUCK", 901L, "TAX_EXCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "CBM", null, "880")));
var result = service.estimate(10001L,
request("EXX_TRUCK", PricingDestinationType.OVERSEAS, null, 901L, null,
PricingTaxMode.TAX_EXCLUDED, "363", "0.5"));
assertThat(result.chargeUnit()).isEqualTo("CBM");
assertThat(result.chargeableQuantity()).isEqualByComparingTo("1.000000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("880.00");
}
@Test
void shouldUseExplicitRegionForTierEstimate() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.priceModel()).isEqualTo(PriceModel.REGION_WEIGHT_TIER);
assertThat(result.unitPrice()).isEqualByComparingTo("13");
assertThat(result.estimatedAmount()).isEqualByComparingTo("663.00");
}
@Test
void shouldUseIndependentPricingTaxModeForTierEstimateWhenConfigured() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED");
}
@Test
void shouldResolveTierRegionFromPlatformFbaWarehousePostalCode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"))
.thenReturn(tiers());
var warehouse = new PriceBookWarehouseClient.Warehouse(501L, 0L, "FBA-NJ", "NJ FBA",
"FBA", "US", "ACTIVE");
var address = new PriceBookWarehouseClient.Address("US", "NJ", "Newark", null,
"07114", "address", null);
when(warehouses.active("internal-token", 0L, 501L))
.thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address))));
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, null, 501L, null,
PricingTaxMode.TAX_INCLUDED, "12", null));
assertThat(result.unitPrice()).isEqualByComparingTo("15");
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED");
}
@Test
void shouldRejectMissingRequiredEstimateFields() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_TRUCK", PricingDestinationType.FBA, "ONT8", null, null,
null, "10", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
@Test
void shouldNotGuessRegionWithoutExplicitRegionOrFbaWarehouse() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓区域或平台FBA仓");
}
@Test
void shouldRejectTierEstimateWithoutPricingTaxMode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
null, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
private void stubChannel(long versionId, String code, PriceModel model) {
when(repository.activeVersionId()).thenReturn(versionId);
when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model)));
}
private static EstimateRequest request(String channelCode, PricingDestinationType destinationType,
String destinationCode, Long destinationWarehouseId,
PriceRegion destinationRegion, PricingTaxMode pricingTaxMode,
String weight, String volume) {
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
decimal(weight), decimal(volume));
}
private static ChannelRow channel(long versionId, String code, PriceModel model) {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(versionId);
row.setChannelCode(code);
row.setChannelName(code);
row.setPriceModel(model.name());
return row;
}
private static ChannelTaxModeRow taxMode(String channelCode, PricingTaxMode pricingTaxMode) {
ChannelTaxModeRow row = new ChannelTaxModeRow();
row.setChannelCode(channelCode);
row.setPricingTaxMode(pricingTaxMode.name());
return row;
}
private static List<RateRow> tiers() {
return List.of(
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "12", "15"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "51", "13"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "101", "11"));
}
private static RateRow rate(PriceModel model, String unit, String from, String price) {
RateRow row = new RateRow();
row.setPriceModel(model.name());
row.setChargeUnit(unit);
row.setWeightFromKg(decimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
private static BigDecimal decimal(String value) {
return value == null ? null : new BigDecimal(value);
}
}
@@ -0,0 +1,285 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookFileClient;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.Arrays;
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.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PlatformPriceBookServiceTest {
@Mock private PriceBookRepository repository;
@Mock private PriceBookFileClient files;
@Mock private PriceBookWarehouseClient warehouses;
@Mock private TransactionTemplate transactions;
@Mock private TransactionStatus transactionStatus;
private PlatformPriceBookService service;
@BeforeEach
void setUp() {
service = new PlatformPriceBookService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "transactions", transactions);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void fileServiceFailureMarksAllocatedVersionFailed() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(9007199254740993L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(1);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(null);
var upload = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1});
assertThatThrownBy(() -> service.upload(7L, upload))
.isInstanceOf(BizException.class)
.hasMessageContaining("原文件保存失败");
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getId()).isEqualTo(9007199254740993L);
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getBlockingErrorCount()).isEqualTo(1);
}
@Test
void parseFailureKeepsImmutableSourceFileMetadataOnFailedVersion() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(88L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(2);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(ApiResponse.success(
new PriceBookFileClient.FileMetadata(501L, 88L, "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "abc123", "FBA_PRICE_BOOK_SOURCE")));
when(files.download(any(), any(Long.class), any(Long.class), any(Long.class)))
.thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
var invalid = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3});
assertThatThrownBy(() -> service.upload(7L, invalid))
.isInstanceOf(BizException.class);
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getSourceFileId()).isEqualTo(501L);
assertThat(saved.getValue().getSourceSha256()).isEqualTo("abc123");
assertThat(saved.getValue().getParseIssuesJson()).contains("UPLOAD_OR_PARSE_FAILED");
}
@Test
void exposesOnlyUploadConfirmationAndReadOperations() {
assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods())
.filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()))
.map(java.lang.reflect.Method::getName).toList())
.containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source");
}
@Test
void pendingDetailWarnsThatMissingActiveChannelWillBeRemoved() {
PriceBookVersionEntity pendingVersion = version(13L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(12L, PriceBookStatus.ACTIVE);
when(repository.findVersion(13L)).thenReturn(pendingVersion);
when(repository.findActive()).thenReturn(active);
when(repository.listChannels(13L)).thenReturn(List.of());
when(repository.listChannels(12L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(13L)).thenReturn(List.of());
var detail = service.detail(13L);
assertThat(detail.version().status()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION);
assertThat(detail.issues()).extracting(PriceBookDtos.ParseIssue::issueCode)
.containsExactly("CHANNEL_MISSING_FROM_PENDING_UPLOAD");
}
@Test
void sourceDownloadIsProxiedWithSafeAttachmentHeaders() {
PriceBookVersionEntity version = version(14L, PriceBookStatus.SUPERSEDED);
version.setSourceFileId(601L);
version.setOriginalFileName("历史价格.xlsx");
when(repository.findVersion(14L)).thenReturn(version);
when(files.download("internal-token", 7L, 14L, 601L))
.thenReturn(ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.body(new byte[]{9, 8, 7}));
ResponseEntity<byte[]> response = service.source(14L, 7L);
assertThat(response.getBody()).containsExactly(9, 8, 7);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.startsWith("attachment;").contains("filename*=");
assertThat(response.getHeaders().getFirst("X-Content-Type-Options")).isEqualTo("nosniff");
}
@Test
void confirmationMapsWarehousesBeforeReplacingActiveVersion() {
PriceBookVersionEntity pendingVersion = version(21L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(20L, PriceBookStatus.ACTIVE);
when(repository.findVersion(21L)).thenReturn(pendingVersion);
when(repository.listChannels(21L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(21L)).thenReturn(List.of(rate(77L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
when(repository.listActiveForUpdate()).thenReturn(List.of(active));
when(warehouses.active("internal-token", 0L, 501L)).thenReturn(ApiResponse.success(
new PriceBookWarehouseClient.Detail(new PriceBookWarehouseClient.Warehouse(501L, 0L,
"CN-YIWU-01", "义乌仓", "DOMESTIC", "CN", "ACTIVE"), List.of())));
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setStatus(PriceBookStatus.ACTIVE.name());
return 1;
}).when(repository).activate(any(), any(), any(Long.class));
service.publish(21L, 7L, confirmation("CN-YIWU-01", 501L));
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(21L);
verify(repository).deletePendingChildren(21L);
verify(repository).supersede(any(PriceBookVersionEntity.class), any(), any(Long.class));
verify(repository).activate(any(PriceBookVersionEntity.class), any(), any(Long.class));
ArgumentCaptor<CarrierFbaRateEntity> pendingRate = ArgumentCaptor.forClass(CarrierFbaRateEntity.class);
verify(repository).insertRate(pendingRate.capture());
assertThat(pendingRate.getValue().getStatus()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION.name());
}
@Test
void missingWarehouseMappingCannotPublish() {
PriceBookVersionEntity pendingVersion = version(22L, PriceBookStatus.PENDING_CONFIRMATION);
when(repository.findVersion(22L)).thenReturn(pendingVersion);
when(repository.listChannels(22L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(22L)).thenReturn(List.of(rate(78L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
assertThatThrownBy(() -> service.publish(22L, 7L,
new PriceBookConfirmationRequest(List.of(), List.of())))
.isInstanceOf(BizException.class)
.hasMessageContaining("阻断错误");
verify(repository, never()).activate(any(), any(), any(Long.class));
verify(repository, never()).deletePendingChildren(22L);
}
@Test
void duplicatePublishIsRejectedAfterPublishLock() {
when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE));
assertThatThrownBy(() -> service.publish(31L, 7L, confirmation("CN-YIWU-01", 501L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("待确认上传记录");
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(31L);
verify(repository, never()).activate(any(), any(), any(Long.class));
}
private static PriceBookVersionEntity version(long id, PriceBookStatus status) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setId(id);
value.setTenantId(0L);
value.setVersionNo((int) id);
value.setOriginalFileName("price.xlsx");
value.setStatus(status.name());
value.setChannelCount(0);
value.setRateCount(0);
value.setBlockingErrorCount(0);
value.setWarningCount(0);
value.setDeleted(false);
return value;
}
private static PriceBookChannelEntity channel(String code) {
PriceBookChannelEntity value = new PriceBookChannelEntity();
value.setChannelCode(code);
value.setChannelName(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setSourceSheetName(code);
return value;
}
private static PriceChannelCatalogEntity catalog(String code) {
PriceChannelCatalogEntity value = new PriceChannelCatalogEntity();
value.setChannelCode(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setStatus("ACTIVE");
return value;
}
private static CarrierFbaRateEntity rate(long id) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(id);
value.setChannelCode("MATSON_CLX_TRUCK");
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setOriginWarehouseCode("CN-YIWU-01");
value.setOriginWarehouseName("义乌仓");
value.setDestinationType(PricingDestinationType.FBA.name());
value.setDestinationCode("ONT8");
value.setPricingTaxMode(PricingTaxMode.TAX_INCLUDED.name());
value.setChargeUnit("KG");
value.setUnitPrice(new BigDecimal("10"));
value.setCurrency("CNY");
value.setSourceSheetName("美森正班海卡");
value.setSourceRowNumber(5);
value.setSourceDestinationText("ONT8");
return value;
}
private static PriceBookConfirmationRequest confirmation(String code, long id) {
return new PriceBookConfirmationRequest(
List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of());
}
private void executeTransactionsImmediately() {
when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
TransactionCallback<Object> callback = invocation.getArgument(0);
return callback.doInTransaction(transactionStatus);
});
}
}
@@ -12,6 +12,8 @@ import java.time.LocalDateTime;
public interface AuthAccountClient {
@PostMapping ApiResponse<AccountSummary> create(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody CreateAccountRequest request);
@GetMapping("/availability") ApiResponse<Boolean> availability(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam String username);
@GetMapping("/{id}") ApiResponse<AccountSummary> summary(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id);
@PutMapping("/{id}/status") ApiResponse<Void> status(@RequestHeader("X-Internal-Service-Token") String token,
@@ -73,6 +73,17 @@ public class PlatformTenantController {
return ApiResponse.success(service.merchant(tenantId));
}
@GetMapping("/tenants/contact-phone-availability")
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
@Operation(summary = "校验客户联系电话唯一性")
public ApiResponse<Boolean> contactPhoneAvailability(
@RequestParam String contactPhone,
@RequestParam(required = false) Long excludeTenantId,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId));
}
@GetMapping("/tenants/{tenantId:\\d+}/members")
@PreAuthorize("hasAuthority('platform:customer:view')")
@Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。")
@@ -27,7 +27,8 @@ public final class TenantDtos {
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
@Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
@NotBlank @Size(max = 64) String servicePackage,
@Size(max = 64) String contactWechat,
@@ -58,7 +59,7 @@ public final class TenantDtos {
@NotBlank String legalName,
@NotBlank @Size(max = 64) String legalRepresentative,
@NotBlank String contactName,
@NotBlank String contactPhone,
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@Email String contactEmail,
@NotBlank @Size(max = 64) String servicePackage,
@Size(max = 64) String contactWechat,
@@ -86,7 +87,7 @@ public final class TenantDtos {
public record ApprovalResponse(
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated,
@Schema(description = "Generated login username; null when an owner already exists / 自动生成的登录账号,已有所有者时为空", nullable = true) String administratorUsername,
@Schema(description = "Contact phone used as login username; null when an owner already exists / 使用联系电话作为登录账号,已有所有者时为空", nullable = true) String administratorUsername,
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
) { }
@@ -34,6 +34,19 @@ public class TenantRepository {
.ne(IamTenant::getId, tenantId)) > 0;
}
public boolean existsByContactPhone(String contactPhone) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(IamTenant::getContactPhone, contactPhone)) > 0;
}
public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(IamTenant::getContactPhone, contactPhone)
.ne(IamTenant::getId, tenantId)) > 0;
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
/**
* Serializes the draft qualification workflow for one enterprise. The file service and the
@@ -34,8 +34,6 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
private static final char[] DIGITS = "23456789".toCharArray();
private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray();
private static final int PASSWORD_LENGTH = 16;
private static final int USERNAME_SUFFIX_LENGTH = 8;
private static final int USERNAME_ATTEMPTS = 3;
@Resource
private MemberRepository members;
@@ -179,18 +177,9 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
private String createAuthAccountWithUniqueUsername(long authUserId, String password,
IamTenant tenant, long membershipId) {
for (int attempt = 0; attempt < USERNAME_ATTEMPTS; attempt++) {
String username = username(tenant.getId());
try {
createAuthAccount(authUserId, username, password, tenant, membershipId);
return username;
} catch (BizException exception) {
if (exception.getResultCode() != ResultCode.CONFLICT || attempt == USERNAME_ATTEMPTS - 1) {
throw exception;
}
}
}
throw new IllegalStateException("Username generation attempts exhausted");
String username = username(tenant.getContactPhone());
createAuthAccount(authUserId, username, password, tenant, membershipId);
return username;
}
private void createAuthAccount(long authUserId, String username, String password,
@@ -208,7 +197,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
if (response.data() == null) throw unavailable();
} catch (FeignException exception) {
if (exception.status() == 409) {
throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在");
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名");
}
throw unavailable();
} catch (BizException exception) {
@@ -244,10 +233,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
return new String(value);
}
private String username(long tenantId) {
char[] suffix = new char[USERNAME_SUFFIX_LENGTH];
for (int index = 0; index < suffix.length; index++) suffix[index] = pick(LOWER);
return "merchant_" + tenantId + "_" + new String(suffix);
private String username(String contactPhone) {
return contactPhone == null ? "" : contactPhone.trim();
}
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
@@ -3,6 +3,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamTenant;
@@ -10,6 +11,7 @@ import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
@@ -23,7 +25,9 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import feign.FeignException;
import java.time.LocalDateTime;
import java.util.List;
@@ -41,6 +45,10 @@ public class TenantService {
@Resource
private ProfileResourceRepository profileRepository;
@Resource
private AuthAccountClient authClient;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Resource
private TenantApprovalInitializationCoordinator approvalInitialization;
@@ -53,6 +61,8 @@ public class TenantService {
)
@Transactional
public TenantResponse register(RegisterRequest request, long operatorId) {
String contactPhone = normalizeContactPhone(request.contactPhone());
ensureContactPhoneAvailable(contactPhone, null);
IamTenant tenant = new IamTenant();
tenant.setId(IdWorker.getId());
tenant.setTenantCode("TEN" + tenant.getId());
@@ -63,7 +73,7 @@ public class TenantService {
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setUnifiedSocialCreditCode(null);
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactPhone(contactPhone);
tenant.setContactEmail(request.contactEmail());
tenant.setServicePackage(request.servicePackage());
tenant.setContactWechat(request.contactWechat());
@@ -208,16 +218,46 @@ public class TenantService {
return response(requireMerchant(tenantId));
}
public boolean contactPhoneAvailable(String contactPhone, Long tenantId) {
String normalized = normalizeContactPhone(contactPhone);
return tenantId == null
? !repository.existsByContactPhone(normalized)
: !repository.existsByContactPhoneForOtherTenant(normalized, tenantId);
}
public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) {
String normalized = normalizeContactPhone(contactPhone);
if (!contactPhoneAvailable(normalized, tenantId)) return false;
ApiResponse<Boolean> response;
try {
response = authClient.availability(internalToken, normalized);
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
}
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
}
return response.data();
}
@Transactional
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
description = "平台编辑企业客户档案")
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
IamTenant tenant = requireMerchant(tenantId);
String contactPhone = normalizeContactPhone(request.contactPhone());
String existingContactPhone = normalizeContactPhone(tenant.getContactPhone());
if (!contactPhone.equals(existingContactPhone)) {
if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "客户已开户,联系电话作为登录用户名不能直接修改");
}
ensureContactPhoneAvailable(contactPhone, tenantId);
}
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactPhone(contactPhone);
tenant.setContactEmail(request.contactEmail());
tenant.setServicePackage(request.servicePackage());
tenant.setContactWechat(request.contactWechat());
@@ -258,6 +298,7 @@ public class TenantService {
if (repository.findPendingReviewByTenant(tenantId) != null) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
}
ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId);
if (tenant.getUnifiedSocialCreditCode() == null
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
throw new BizException(ResultCode.BAD_REQUEST,
@@ -297,6 +338,16 @@ public class TenantService {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
private String normalizeContactPhone(String contactPhone) {
return contactPhone == null ? "" : contactPhone.trim();
}
private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) {
if (!contactPhoneAvailable(contactPhone, tenantId)) {
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名");
}
}
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
return hasAllQualificationsWithStatus(qualifications, "PENDING");
}
+20 -1
View File
@@ -17,6 +17,25 @@ tags:
- name: RBAC
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
paths:
/api/platform/v1/iam/tenants/contact-phone-availability:
get:
tags: [Platform Tenant / 平台租户]
summary: 校验客户联系电话唯一性
parameters:
- {name: contactPhone, in: query, required: true, schema: {type: string}}
- {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}}
responses:
'200':
description: 返回联系电话是否可作为登录用户名
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {type: boolean}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/tenants:
get:
tags: [Platform Tenant / 平台租户]
@@ -798,7 +817,7 @@ components:
properties:
tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'}
administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员}
administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs}
administratorUsername: {type: string, nullable: true, example: '13800138000'}
temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回}
TenantApiResponse:
allOf:
@@ -76,7 +76,7 @@ class TenantAdministratorProvisioningServiceTest {
var result = service.initialize(context());
assertTrue(result.created());
assertTrue(result.username().matches("^merchant_190000000000000001_[a-z]{8}$"));
assertEquals("13800138000", result.username());
assertNotNull(result.temporaryPassword());
assertEquals(16, result.temporaryPassword().length());
assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$"));
@@ -208,23 +208,16 @@ class TenantAdministratorProvisioningServiceTest {
}
@Test
void usernameConflictGeneratesAnotherUsernameBeforeCreatingOwner() {
void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() {
org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
"登录账号已存在", null, null))
.doAnswer(invocation -> {
AuthAccountClient.CreateAccountRequest request = invocation.getArgument(1);
return ApiResponse.success(new AuthAccountClient.AccountSummary(
request.accountId(), request.username(), request.displayName(), "ACTIVE"));
}).when(auth).create(anyString(), any());
"登录账号已存在", null, null)).when(auth).create(anyString(), any());
var result = service.initialize(context());
var exception = assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.initialize(context()));
assertTrue(result.created());
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> accounts =
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
verify(auth, times(2)).create(eq("internal-token"), accounts.capture());
assertFalse(accounts.getAllValues().get(0).username().equals(accounts.getAllValues().get(1).username()));
assertEquals(accounts.getAllValues().get(1).username(), result.username());
assertEquals(com.baiyue.common.core.api.ResultCode.CONFLICT, exception.getResultCode());
verify(auth).create(eq("internal-token"), any());
verify(members, never()).insertUser(any());
}
private TenantApprovalInitializer.ApprovalContext context() {
@@ -92,7 +92,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(tenant);
var updated = service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", "alice@example.com",
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com",
"专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2),
new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户"));
var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"));
@@ -113,6 +113,29 @@ class TenantServiceTest {
verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant);
}
@Test
void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() {
IamTenant tenant = merchant(10001L, "ACTIVE");
when(repository.findTenant(10001L)).thenReturn(tenant);
BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null,
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
assertEquals("13800138000", tenant.getContactPhone());
verify(repository, never()).saveTenant(tenant);
}
@Test
void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() {
when(authClient.availability("internal-token", "13800138000"))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(false));
assertEquals(false, service.contactPhoneAndUsernameAvailable("13800138000", null));
verify(authClient).availability("internal-token", "13800138000");
}
@Test
void platformCannotBypassReviewToActivatePendingTenant() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));
@@ -32,7 +32,7 @@ public interface FirstlegInboundClient {
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String containerMode,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo
@@ -0,0 +1,17 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegPricingClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface FirstlegPricingClient {
@PostMapping("/channels/validate")
ApiResponse<Void> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {}
}
@@ -146,7 +146,7 @@ public class PlatformFirstLegOrderController {
}
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order, advances it to measurement, and records its status history. / 审核通过已提交订单,进入待测量阶段并记录状态历史。")
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@@ -155,19 +155,6 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.APPROVED, null);
}
@BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商")
@Operation(summary = "指派承运商", description = "可选地为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填;该动作不是审核前置条件。")
@PutMapping("/{id}/carrier")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> assignCarrier(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.CarrierRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.assignCarrier(scopedTenant, user.userId(), id, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
@Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。")
@PostMapping("/{id}/reject")
@@ -56,11 +56,13 @@ public final class OrderRequests {
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传")
@Schema(description = "Legacy internal transport supplier snapshot; new order clients should omit it / 历史内部运输供应商快照,新订单调用方应留空")
@Min(1) Long carrierId,
@Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128)
@Schema(description = "Legacy internal transport supplier name snapshot; new order clients should omit it / 历史内部运输供应商名称快照,新订单调用方应留空", maxLength = 128)
@Size(max = 128) String carrierName,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"})
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@@ -74,13 +76,6 @@ public final class OrderRequests {
}
}
@Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商")
public record CarrierRequest(
@Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) Long carrierId
) {
}
@Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址")
public record Party(
@Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -21,6 +21,7 @@ public final class UnifiedOrderResponse {
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
@@ -42,6 +42,7 @@ public class OrderEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
@@ -219,6 +219,23 @@ public class OrderRepository {
.orderByDesc(OrderStatusHistoryEntity::getOccurredAt));
}
public List<OrderEntity> findByIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.in(OrderEntity::getId, orderIds));
}
public List<OrderInternalNoteEntity> findNotesByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
.eq(tenantId != null, OrderInternalNoteEntity::getTenantId, tenantId)
.in(OrderInternalNoteEntity::getOrderId, orderIds)
.orderByAsc(OrderInternalNoteEntity::getOrderId)
.orderByDesc(OrderInternalNoteEntity::getCreatedAt)
.orderByDesc(OrderInternalNoteEntity::getId));
}
public Map<Long, Long> countByTenantIds(List<Long> tenantIds) {
if (tenantIds.isEmpty()) return Map.of();
var rows = orderMapper.selectMaps(new QueryWrapper<OrderEntity>()
@@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@@ -31,6 +32,7 @@ public class OrderApplicationService {
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
@@ -48,6 +50,7 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
validatePrototypeFields(request);
var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT);
@@ -73,19 +76,6 @@ public class OrderApplicationService {
updateOrderData(tenantId, userId, id, request, true);
}
@Transactional
public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) {
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.SUBMITTED) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商");
}
var carrier = masterDataGuard.validateCarrier(request.carrierId());
order.setCarrierId(carrier.id());
order.setCarrierName(carrier.partnerName());
order.setUpdatedBy(userId);
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
@@ -96,6 +86,7 @@ public class OrderApplicationService {
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException("当前订单状态不允许编辑订单资料");
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.REJECTED) order.setInboundCode(null);
if (before == OrderStatus.SUBMITTED) {
@@ -118,10 +109,7 @@ public class OrderApplicationService {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
}
if (target == OrderStatus.APPROVED && order.getCarrierId() != null) {
var carrier = masterDataGuard.validateCarrier(order.getCarrierId());
order.setCarrierName(carrier.partnerName());
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required");
@@ -209,6 +197,10 @@ public class OrderApplicationService {
}
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
if (!"TAX_INCLUDED".equals(order.getPricingTaxMode())
&& !"TAX_EXCLUDED".equals(order.getPricingTaxMode())) {
throw new BizException("请选择包税或不包税报价");
}
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
@@ -347,12 +339,15 @@ public class OrderApplicationService {
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
// The price-book catalog is case-insensitive at the boundary, but orders
// keep one canonical code so later estimation and audit lookups are stable.
order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT));
order.setChannelName(request.channelName());
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
order.setTaxMode(request.taxMode());
order.setPricingTaxMode(request.pricingTaxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
@@ -422,7 +417,7 @@ public class OrderApplicationService {
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event);
}
private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
return new FirstlegInboundClient.OrderApprovedInboundCommand(
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(),
@@ -430,7 +425,7 @@ public class OrderApplicationService {
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
order.getCarrierId(), order.getCarrierName(),
"BUY_DECLARATION".equals(order.getTaxMode()) ? "TAX_INCLUDED" : "TAX_EXCLUDED",
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo());
@@ -39,12 +39,28 @@ public class OrderInboundOutboxPublisher {
failPermanently(event, "invalid event payload");
continue;
}
if (command == null) {
failPermanently(event, "empty event payload");
continue;
}
if (event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "event ownership does not match payload");
continue;
}
if (incomplete(command)) {
var order = repository.findOwned(event.getTenantId(), event.getAggregateId());
if (order == null) {
failPermanently(event, "order snapshot is unavailable");
continue;
}
command = OrderApplicationService.inboundCommand(order);
if (incomplete(command)) {
failPermanently(event, "rebuilt order snapshot is incomplete");
continue;
}
}
try {
ApiResponse<Long> response = client.create(internalToken, command);
if (response == null || !response.successful()) {
@@ -57,6 +73,13 @@ public class OrderInboundOutboxPublisher {
}
}
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0;
}
private void failPermanently(OutboxEventEntity event, String reason) {
log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}",
reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType());
@@ -0,0 +1,42 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class OrderPricingChannelGuard {
@Resource private FirstlegPricingClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) {
try {
ApiResponse<Void> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode));
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
}
}
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import feign.FeignException;
@@ -54,8 +55,9 @@ public class UnifiedOrderQueryService {
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -67,7 +69,8 @@ public class UnifiedOrderQueryService {
orders.findHistory(tenantId, orderId));
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId);
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote);
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -97,7 +100,13 @@ public class UnifiedOrderQueryService {
query.page(), query.size());
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
List<Long> orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList();
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -116,7 +125,12 @@ public class UnifiedOrderQueryService {
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) {
private Map<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap(
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
@@ -126,6 +140,7 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
@@ -135,10 +150,12 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt());
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) {
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()),
@@ -146,7 +163,8 @@ public class UnifiedOrderQueryService {
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt());
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
+1
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
@@ -33,6 +33,7 @@ CALL phase1_add_column('ord_order', 'receiving_time', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order', 'channel_code', "VARCHAR(64) NULL COMMENT 'Shipping channel code'");
CALL phase1_add_column('ord_order', 'channel_name', "VARCHAR(128) NULL COMMENT 'Shipping channel snapshot'");
CALL phase1_add_column('ord_order', 'tax_mode', "VARCHAR(24) NULL COMMENT 'BUY_DECLARATION or TAX_REFUND'");
CALL phase1_add_column('ord_order', 'pricing_tax_mode', "VARCHAR(16) NULL COMMENT 'TAX_INCLUDED or TAX_EXCLUDED; independent from tax_mode'");
CALL phase1_add_column('ord_order', 'container_mode', "VARCHAR(24) NULL COMMENT 'FULL_CONTAINER or LCL'");
CALL phase1_add_column('ord_order', 'customs_inspection', "TINYINT(1) NULL COMMENT 'Whether customs inspection is required'");
CALL phase1_add_column('ord_order', 'destination_type', "VARCHAR(24) NULL COMMENT 'FBA, OVERSEAS or PRIVATE'");
+32 -24
View File
@@ -16,6 +16,7 @@ paths:
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
@@ -112,7 +113,11 @@ paths:
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/FirstLegOrderSummary'}
'403': {description: 需要 merchant:order:view 权限}
/api/merchant/v1/orders/first-leg:
get:
@@ -138,7 +143,10 @@ paths:
description: 成功或业务失败,响应体 code 为字符串 200 时成功
content:
application/json:
schema: {type: object}
schema:
type: object
properties:
items: {type: array, items: {$ref: '#/components/schemas/FirstLegOrderSummary'}}
post:
summary: 创建订单草稿
parameters:
@@ -147,7 +155,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/{id}/submit:
@@ -180,7 +188,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
@@ -216,35 +224,35 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/carrier:
put:
summary: 为待审核订单指派承运商
description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [carrierId]
properties:
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200': {description: 指派成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:approve 权限}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
PricingTaxMode:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
required: [pricingTaxMode]
properties:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
FirstLegOrderSummary:
type: object
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
parameters:
UnifiedKeyword:
name: keyword
@@ -41,8 +41,6 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
AuthenticatedUser.class, long.class, long.class, String.class);
assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')",
AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class);
}
@Test
@@ -14,19 +14,28 @@ class OrderRequestsValidationTest {
@Test
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
assertThat(validator.validate(request(null, null))).isEmpty();
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
}
@Test
void carrierSnapshotMustBeBothPresentOrBothAbsent() {
assertThat(validator.validate(request(9L, null)))
assertThat(validator.validate(request(9L, null, "TAX_INCLUDED")))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(null, "船司")))
assertThat(validator.validate(request(null, "船司", "TAX_INCLUDED")))
.anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空"));
assertThat(validator.validate(request(9L, "船司"))).isEmpty();
assertThat(validator.validate(request(9L, "船司", "TAX_INCLUDED"))).isEmpty();
}
private OrderRequests.SaveRequest request(Long carrierId, String carrierName) {
@Test
void pricingTaxModeIsRequiredAndRejectsOrderTaxValues() {
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
assertThat(validator.validate(request(null, null, null)))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode"));
assertThat(validator.validate(request(null, null, "BUY_DECLARATION")))
.anyMatch(violation -> violation.getPropertyPath().toString().equals("pricingTaxMode"));
}
private OrderRequests.SaveRequest request(Long carrierId, String carrierName, String pricingTaxMode) {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
@@ -35,7 +44,7 @@ class OrderRequestsValidationTest {
null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓",
null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L,
"义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION",
"FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789");
pricingTaxMode, "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789");
return request;
}
}
@@ -34,6 +34,7 @@ class OrderApplicationServiceStateTest {
@MockBean private OrderRepository contextRepository;
@MockBean private OrderEligibilityGuard contextEligibility;
@MockBean private OrderMasterDataGuard contextMasterData;
@MockBean private OrderPricingChannelGuard contextPricingChannelGuard;
@Resource private OrderApplicationService springService;
@Configuration(proxyBeanMethods = false)
@@ -58,6 +59,54 @@ class OrderApplicationServiceStateTest {
&& order.getInboundCode() == null));
}
@Test
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest request = request();
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "validate-channel-on-create");
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "TAX_INCLUDED".equals(order.getPricingTaxMode())));
}
@Test
void orderStoresCanonicalChannelCodeWithoutChangingPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest source = request();
OrderRequests.SaveRequest request = new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(),
source.pickupMethod(), source.receivingWarehouseId(), source.receivingWarehouseName(),
" ch ", source.channelName(), source.carrierId(), source.carrierName(), source.taxMode(),
"TAX_EXCLUDED", source.containerMode(), source.customsInspection(), source.destinationType(),
source.amazonWarehouseCode(), source.shipmentId(), source.referenceId());
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.createDraft(10001L, 2L, request, "canonical-channel-code");
verify(pricing).requireAvailable(10001L, 8L, " ch ");
verify(repository).insertOrder(argThat(order ->
"CH".equals(order.getChannelCode())
&& "BUY_DECLARATION".equals(order.getTaxMode())
&& "TAX_EXCLUDED".equals(order.getPricingTaxMode())));
}
@Test
void unapprovedTenantCanUpdateDraft() {
OrderRepository repository = mock(OrderRepository.class);
@@ -79,6 +128,26 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
}
@Test
void updatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderRequests.SaveRequest request = request();
OrderEntity order = persistedOrder(OrderStatus.DRAFT);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.updateDraft(10001L, 2L, 1L, request);
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH");
ordered.verify(repository).saveOrder(10001L, order);
}
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
@@ -102,24 +171,6 @@ class OrderApplicationServiceStateTest {
&& "UPDATE".equals(history.getAction())));
}
@Test
void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L));
assertThat(order.getCarrierId()).isEqualTo(9L);
assertThat(order.getCarrierName()).isEqualTo("服务端船司名");
verify(repository).saveOrder(10001L, order);
}
@Test
void approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -137,26 +188,6 @@ class OrderApplicationServiceStateTest {
&& event.getPayloadJson().contains("\"carrierId\":null")));
}
@Test
void approvalRevalidatesCarrierAndRefreshesItsName() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
order.setCarrierId(9L);
order.setCarrierName("旧名称");
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(masterData.validateCarrier(9L)).thenReturn(
new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER")));
service(repository, mock(OrderEligibilityGuard.class), masterData)
.transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(order.getCarrierName()).isEqualTo("最新船司名");
verify(masterData).validateCarrier(9L);
}
@Test
void submittingDraftGeneratesAndPersistsInboundCode() {
OrderRepository repository = mock(OrderRepository.class);
@@ -176,6 +207,57 @@ class OrderApplicationServiceStateTest {
verify(repository).saveOrder(20002L, order);
}
@Test
void submittingLegacyDraftRequiresPricingTaxMode() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setPricingTaxMode(null);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).transit(
20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessage("请选择包税或不包税报价");
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void priceBookVersionSwitchBlocksSubmissionWhenDraftChannelWasRemoved() {
OrderRepository repository = mock(OrderRepository.class);
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity()));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
doThrow(new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"))
.when(pricing).requireAvailable(20002L, 8L, "CH");
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
assertThatThrownBy(() -> service.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
.isInstanceOf(BizException.class)
.hasMessage("所选渠道已下线,请重新选择");
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
verify(repository, never()).saveOrder(anyLong(), any());
}
@Test
void inboundCommandKeepsDeclarationAndPricingTaxModesIndependent() {
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setOrderNo("FL1");
order.setTaxMode("BUY_DECLARATION");
order.setPricingTaxMode("TAX_EXCLUDED");
var command = OrderApplicationService.inboundCommand(order);
assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED");
}
@Test
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
@@ -315,6 +397,7 @@ class OrderApplicationServiceStateTest {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "eligibilityGuard", eligibility);
ReflectionTestUtils.setField(service, "masterDataGuard", masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", mock(OrderPricingChannelGuard.class));
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
return service;
}
@@ -329,7 +412,7 @@ class OrderApplicationServiceStateTest {
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
party, party, List.of(cargo), "PICKUP", 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, "OVERSEAS", null, null, null);
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, "OVERSEAS", null, null, null);
}
private OrderRequests.SaveRequest request(Long customerId, String pickupMethod,
@@ -342,7 +425,7 @@ class OrderApplicationServiceStateTest {
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
sender, receiver, List.of(cargo), pickupMethod, 8L, "收货仓", "CH", "渠道", 9L, "船司",
"BUY_DECLARATION", "LCL", false, destinationType, null, null, null);
"BUY_DECLARATION", "TAX_INCLUDED", "LCL", false, destinationType, null, null, null);
}
private OrderMasterDataGuard.Snapshots snapshots() {
@@ -368,6 +451,7 @@ class OrderApplicationServiceStateTest {
order.setChannelCode("CH");
order.setChannelName("渠道");
order.setTaxMode("BUY_DECLARATION");
order.setPricingTaxMode("TAX_INCLUDED");
order.setCustomsInspection(false);
order.setDestinationType(destinationType);
return order;

Some files were not shown because too many files have changed in this diff Show More