优化
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+67
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
+47
@@ -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, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
+84
@@ -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) {}
|
||||
}
|
||||
+54
@@ -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) {}
|
||||
}
|
||||
+90
@@ -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(),
|
||||
|
||||
+32
-5
@@ -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]
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
}
|
||||
+206
@@ -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) {
|
||||
|
||||
+44
-1
@@ -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
|
||||
|
||||
+6
-19
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+8
-15
@@ -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) {}
|
||||
}
|
||||
+1
-14
@@ -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) {
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-40
@@ -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
Reference in New Issue
Block a user