diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index fad98d34f..55d31cb86 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -40,6 +40,11 @@ public class InternalAccountController { @Valid @RequestBody CreateAccountRequest request) { verify(token); return ApiResponse.success(service.create(request)); } + @GetMapping("/availability") + public ApiResponse 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 summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 5c4252efc..9305f96a7 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -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) { diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index a486c65c7..3cd148e1e 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -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); diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 4e086ad41..0771aee5c 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -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:} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 3ecdeaec1..eb233e21c 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -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:} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a64511126..69bfac2a6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 = '统一迁移失败:订单状态与入仓码数据不一致'; diff --git a/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java new file mode 100644 index 000000000..1952954f4 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/InternalPriceBookFileController.java @@ -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 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 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 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"); + } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java new file mode 100644 index 000000000..214c38c64 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileService.java @@ -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() + .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) {} +} diff --git a/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java new file mode 100644 index 000000000..df92dc5b6 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PriceBookFileStorage.java @@ -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 ACCEPTED_CONTENT_TYPES = Set.of( + XLSX_CONTENT_TYPE, "application/octet-stream", "application/zip"); + private static final Set 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 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) {} +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 88f3791af..79ac974ff 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -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 diff --git a/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java new file mode 100644 index 000000000..e7fde88d1 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/controller/InternalPriceBookFileControllerTest.java @@ -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); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java new file mode 100644 index 000000000..4c8637a86 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileServiceTest.java @@ -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; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java new file mode 100644 index 000000000..e07285030 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/PriceBookFileStorageTest.java @@ -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 entries = requiredEntries(); + entries.put("xl/tables/table1.xml", "".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 requiredEntries() { + Map entries = new LinkedHashMap<>(); + entries.put("[Content_Types].xml", "".getBytes()); + entries.put("xl/workbook.xml", "".getBytes()); + entries.put("xl/_rels/workbook.xml.rels", "".getBytes()); + return entries; + } + + private static byte[] zip(Map entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue()); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } +} diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 355127dd0..540bffc8e 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -18,6 +18,7 @@ org.springframework.cloudspring-cloud-starter-openfeign org.springframework.cloudspring-cloud-starter-loadbalancer com.alibaba.cloudspring-cloud-starter-stream-rocketmq + org.apache.poipoi-ooxml5.3.0 org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java new file mode 100644 index 000000000..803aa9d7d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java @@ -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 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 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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java new file mode 100644 index 000000000..708c9784d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java @@ -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 active(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("tenantId") long tenantId, + @PathVariable("id") long id); + + record Detail(Warehouse warehouse, List
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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java new file mode 100644 index 000000000..0743eb962 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -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 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, "内部服务凭证无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantPriceBookController.java new file mode 100644 index 000000000..2a7c9a62c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantPriceBookController.java @@ -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> 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 estimate( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody EstimateRequest request) { + return ApiResponse.success(service.estimate(FirstlegScopeGuard.merchant(user), request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java new file mode 100644 index 000000000..ecb4991ea --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java @@ -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> 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 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 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 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 source( + @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @PathVariable long versionId) { + FirstlegScopeGuard.platform(user); + return service.source(versionId, user.userId()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java new file mode 100644 index 000000000..6c14bd1ac --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookChannelCatalog.java @@ -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 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 aliases + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java new file mode 100644 index 000000000..717eefca6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceBookStatus.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.domain; + +/** 平台公共船期价格表版本状态;上传后待确认映射,确认即生效。 */ +public enum PriceBookStatus { + PENDING_CONFIRMATION, + ACTIVE, + SUPERSEDED, + FAILED +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceModel.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceModel.java new file mode 100644 index 000000000..4932177f9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceModel.java @@ -0,0 +1,7 @@ +package com.baiyue.firstleg.domain; + +/** 当前首版支持的两种明确价格模型,不扩展为通用规则引擎。 */ +public enum PriceModel { + FBA_WAREHOUSE_RATE, + REGION_WEIGHT_TIER +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceRegion.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceRegion.java new file mode 100644 index 000000000..7aeb751d7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PriceRegion.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.domain; + +/** 海派首版固定美国区域;优先由目的仓邮编或版本内区划数据解析。 */ +public enum PriceRegion { + WEST, + CENTRAL, + EAST +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingDestinationType.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingDestinationType.java new file mode 100644 index 000000000..14e07f63b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingDestinationType.java @@ -0,0 +1,7 @@ +package com.baiyue.firstleg.domain; + +/** 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价。 */ +public enum PricingDestinationType { + FBA, + OVERSEAS +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingTaxMode.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingTaxMode.java new file mode 100644 index 000000000..60705fc8d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/PricingTaxMode.java @@ -0,0 +1,7 @@ +package com.baiyue.firstleg.domain; + +/** 报价包税维度;与订单的买单/退税 taxMode 是两个独立概念。 */ +public enum PricingTaxMode { + TAX_INCLUDED, + TAX_EXCLUDED +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 528038655..b425c98b3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f4a66e077..f68fc7bc5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 8d81f3d8e..bac1cbc30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -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); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java new file mode 100644 index 000000000..10c337bc9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookChannelValidation.java @@ -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 + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookConfirmationRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookConfirmationRequest.java new file mode 100644 index 000000000..4851aa589 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookConfirmationRequest.java @@ -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 + ) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java new file mode 100644 index 000000000..cad3a507c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -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 channels, + List rates, + List 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 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 + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 892552ecc..16956b682 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java index 9ab280b6e..e88c7afc0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index cf80fc813..43290b179 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java index 396695c78..3796af551 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundPricingEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookChannelEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookChannelEntity.java new file mode 100644 index 000000000..79c36eb45 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookChannelEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java new file mode 100644 index 000000000..d4fa1f579 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceBookVersionEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java new file mode 100644 index 000000000..813ad1063 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceChannelCatalogEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceOriginAliasEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceOriginAliasEntity.java new file mode 100644 index 000000000..e76c5ecd0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/PriceOriginAliasEntity.java @@ -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; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java index 466b1293a..b0d32f7a4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CarrierFbaRateMapper.java @@ -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 {} +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +public interface CarrierFbaRateMapper extends BaseMapper { + @Delete("DELETE FROM fl_carrier_fba_rate WHERE price_book_version_id = #{versionId} AND status = 'PENDING_CONFIRMATION'") + int physicallyDeletePendingByPriceBookVersionId(@Param("versionId") long versionId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookChannelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookChannelMapper.java new file mode 100644 index 000000000..8840efd65 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookChannelMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java new file mode 100644 index 000000000..d6c69001c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java @@ -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 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 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 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 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 findRegionTiers(@Param("versionId") long versionId, + @Param("originWarehouseId") long originWarehouseId, + @Param("channelCode") String channelCode, + @Param("regionCode") String regionCode, + @Param("pricingTaxMode") String pricingTaxMode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookVersionMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookVersionMapper.java new file mode 100644 index 000000000..8c203058f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookVersionMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceChannelCatalogMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceChannelCatalogMapper.java new file mode 100644 index 000000000..6f8ec287b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceChannelCatalogMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceOriginAliasMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceOriginAliasMapper.java new file mode 100644 index 000000000..171803587 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceOriginAliasMapper.java @@ -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 {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java new file mode 100644 index 000000000..77a3b8a0c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -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 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 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 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 + ) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java new file mode 100644 index 000000000..463252d34 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java @@ -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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java new file mode 100644 index 000000000..7123a2a3c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceBookWarehouseRegionResolver.java @@ -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 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceRegionResolver.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceRegionResolver.java new file mode 100644 index 000000000..6f3203bc1 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/PriceRegionResolver.java @@ -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, "目的仓邮编无法确定海派区域"); + }; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java new file mode 100644 index 000000000..426593a10 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java @@ -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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParseResult.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParseResult.java new file mode 100644 index 000000000..e2a80ce2a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParseResult.java @@ -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 channels, + List issues, + List 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 sailings, + String scheduleText, + String cutoffText, + String transitTimeText, + String surchargeText, + String remarkText, + List 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) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParser.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParser.java new file mode 100644 index 000000000..917ff5f05 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParser.java @@ -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("(? 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 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 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 sheets = new LinkedHashMap<>(); + Set 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 channels = new ArrayList<>(); + List issues = new ArrayList<>(); + List 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 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 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 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 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 rates, List 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 parseParcel(SheetData data, List issues) { + Map 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 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 builders, List 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 extractCodes(String value) { + if (value == null) return List.of(); + Matcher matcher = CODE_PATTERN.matcher(value.toUpperCase(Locale.ROOT)); + Set 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 sailings(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + LocalDate date = expectedDate(raw); + List 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 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> cells = new LinkedHashMap<>(); + + private String value(int row, int column) { + Map 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 }; + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java new file mode 100644 index 000000000..d7c39b579 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java @@ -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 channels(long versionId, long originWarehouseId) { + return mapper.findChannels(versionId, originWarehouseId); + } + + public List channelTaxModes(long versionId, long originWarehouseId) { + return mapper.findChannelTaxModes(versionId, originWarehouseId); + } + + public List fbaRates(long versionId, long originWarehouseId, + String channelCode, String destinationCode, + String pricingTaxMode) { + return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); + } + + public List overseasRates(long versionId, long originWarehouseId, + String channelCode, long destinationWarehouseId, + String pricingTaxMode) { + return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, + pricingTaxMode); + } + + public List 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())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java new file mode 100644 index 000000000..916fe8869 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java @@ -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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java new file mode 100644 index 000000000..8f2ed640f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -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() + .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getId, value.getId())); + } + public PriceBookVersionEntity findVersion(long id) { + return versions.selectOne(new LambdaQueryWrapper() + .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getId, id) + .eq(PriceBookVersionEntity::getDeleted, false)); + } + public List listVersions(String status) { + return versions.selectList(new LambdaQueryWrapper() + .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() + .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getStatus, "ACTIVE") + .eq(PriceBookVersionEntity::getDeleted, false) + .last("FOR UPDATE")); + } + public List listActiveForUpdate() { + return versions.selectList(new LambdaQueryWrapper() + .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() + .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() + .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getDeleted, false) + .last("FOR UPDATE")); + } + public int nextVersionNo() { + PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper() + .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 listChannels(long versionId) { + return channels.selectList(new LambdaQueryWrapper() + .eq(PriceBookChannelEntity::getPriceBookVersionId, versionId) + .orderByAsc(PriceBookChannelEntity::getChannelCode)); + } + public List listRates(long versionId) { + return rates.selectList(new LambdaQueryWrapper() + .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().eq(PriceBookChannelEntity::getPriceBookVersionId, versionId)); + } + public List activeCatalog() { + return catalog.selectList(new LambdaQueryWrapper() + .eq(PriceChannelCatalogEntity::getStatus, "ACTIVE").orderByAsc(PriceChannelCatalogEntity::getId)); + } + public List activeAliases() { + return aliases.selectList(new LambdaQueryWrapper() + .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() + .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() + .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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 450a2d49a..6923e8d68 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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 matchedRules = repository.matchRules(inbound, pricingAt); - CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则"); - List 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 unique(List 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(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 619c0168d..c8ce95b51 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -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() - .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() + .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 findReferences(long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java new file mode 100644 index 000000000..09c2a5712 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -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 channels(long merchantTenantId, long originWarehouseId) { + requireMerchant(merchantTenantId); + if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); + Long versionId = repository.activeVersionId(); + if (versionId == null) return List.of(); + List channels = repository.channels(versionId, originWarehouseId); + if (channels.isEmpty()) return List.of(); + Map> 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> 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> pricingTaxModes(List rows) { + Map> 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> 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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java new file mode 100644 index 000000000..f1f53adf3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -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> ISSUE_LIST = new TypeReference<>() {}; + private static final Set 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 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 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 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 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 channelRows = repository.listChannels(versionId); + List 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 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 channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); + List rates = repository.listRates(versionId).stream().map(this::rateDto).toList(); + MappingResult mapped = applyMappings(rates, confirmation); + List 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 source(long versionId, long operatorId) { + PriceBookVersionEntity version = requireVersion(versionId); + if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在"); + try { + ResponseEntity 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 channels, + List rates, List 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 channels, + List 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 channels = new ArrayList<>(); + List 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 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 rates, + PriceBookConfirmationRequest confirmation) { + List issues = new ArrayList<>(); + if (confirmation == null) { + issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "请确认起运仓映射", null, null, null)); + return new MappingResult(rates, issues); + } + Map origins = mappingIndex(confirmation.originMappings(), + PriceBookConfirmationRequest.OriginMapping::originWarehouseCode, + PriceBookConfirmationRequest.OriginMapping::warehouseId, + "DUPLICATE_ORIGIN_MAPPING", "起运仓映射重复", issues); + Map overseas = mappingIndex(confirmation.overseasMappings(), + PriceBookConfirmationRequest.OverseasMapping::sourceDestinationName, + PriceBookConfirmationRequest.OverseasMapping::warehouseId, + "DUPLICATE_OVERSEAS_MAPPING", "海外仓映射重复", issues); + Set sourceOrigins = rates.stream().map(PriceBookDtos.ParsedRate::originWarehouseCode) + .filter(StringUtils::hasText).map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet()); + Set 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 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 Map mappingIndex(List mappings, + java.util.function.Function key, + java.util.function.Function warehouseId, + String duplicateCode, String duplicateMessage, + List issues) { + Map values = new HashMap<>(); + Map 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 preservedParseIssues(PriceBookVersionEntity version) { + return readIssues(version.getParseIssuesJson()).stream() + .filter(issue -> !REVALIDATED_ISSUE_CODES.contains(issue.issueCode())).toList(); + } + + private List validate(List channels, + List rates) { + List 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 catalog = new HashMap<>(); + repository.activeCatalog().forEach(value -> catalog.put(value.getChannelCode(), value)); + Set 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 unique = new HashSet<>(); + Map 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 cache, + List 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 cache, + List 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 cache, + List issues, + PriceBookDtos.ParsedRate rate, String label) { + if (cache.containsKey(id)) return cache.get(id); + try { + ApiResponse 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 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 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 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 mergeIssues(List first, + List second) { + Map 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 channels, List rates, + List issues) {} + private record MappingResult(List rates, List issues) {} +} diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5024450c5..1d16fbacb 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -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; -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 21c363eb4..e6546583f 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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 '换算后的重量或体积', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 796de5b83..72455cbc4 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f1b696cc2..4460ba26f 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java index 7ac0840c0..61f563119 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java new file mode 100644 index 000000000..1b68274c3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java new file mode 100644 index 000000000..c1b94e43d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java @@ -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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/PriceRegionResolverTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/PriceRegionResolverTest.java new file mode 100644 index 000000000..7573ef61b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/PriceRegionResolverTest.java @@ -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("无法确定海派区域"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java new file mode 100644 index 000000000..06fe39203 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java @@ -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 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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParserTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParserTest.java new file mode 100644 index 000000000..0b6666567 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/importer/PriceWorkbookParserTest.java @@ -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 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 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 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("
".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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java new file mode 100644 index 000000000..a397561f0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PriceBookSqlContractTest.java @@ -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'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 9ddda2d3c..78de2d6cb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 14e42fd17..7995676b0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -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); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java new file mode 100644 index 000000000..ad4d849dd --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -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 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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java new file mode 100644 index 000000000..ef24dc45f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -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 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 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 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 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 callback = invocation.getArgument(0); + return callback.doInTransaction(transactionStatus); + }); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java index 2f5e6808d..a204d907e 100644 --- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java @@ -12,6 +12,8 @@ import java.time.LocalDateTime; public interface AuthAccountClient { @PostMapping ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody CreateAccountRequest request); + @GetMapping("/availability") ApiResponse availability(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam String username); @GetMapping("/{id}") ApiResponse summary(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id); @PutMapping("/{id}/status") ApiResponse status(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index 373035190..b3c1c996d 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -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 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 企业管理员,并返回登录账号。") diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 04cdce90b..cac507ae6 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -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 ) { } diff --git a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java index aea4b7c84..f023f730b 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/TenantRepository.java @@ -34,6 +34,19 @@ public class TenantRepository { .ne(IamTenant::getId, tenantId)) > 0; } + public boolean existsByContactPhone(String contactPhone) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .eq(IamTenant::getTenantType, "MERCHANT") + .eq(IamTenant::getContactPhone, contactPhone)) > 0; + } + + public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) { + return tenantMapper.selectCount(new LambdaQueryWrapper() + .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 diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 56eb0053d..f0a8d1b8e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -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)]; } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 6c5e9aaf8..56ba60b3e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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 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 qualifications) { return hasAllQualificationsWithStatus(qualifications, "PENDING"); } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index cea557b7a..9d93691e1 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -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: diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index b0bd881f1..9f77673a7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -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 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() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 12d3ca2cf..3158de236 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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")); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 9a58afa5b..e0f6053fa 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -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 diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java new file mode 100644 index 000000000..3953bbc3d --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java @@ -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 validateChannel(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody ChannelValidationRequest request); + + record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {} +} diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 6a7b85119..3f9fcab56 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -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 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 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") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index ab1d9fc81..e455ac1b3 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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) diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 46544709c..5776b1f4d 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -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 ) {} diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 6f0262346..fbb773243 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index e1f3fd187..a8e1e885b 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -219,6 +219,23 @@ public class OrderRepository { .orderByDesc(OrderStatusHistoryEntity::getOccurredAt)); } + public List findByIds(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return orderMapper.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, OrderEntity::getTenantId, tenantId) + .in(OrderEntity::getId, orderIds)); + } + + public List findNotesByOrderIds(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return noteMapper.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, OrderInternalNoteEntity::getTenantId, tenantId) + .in(OrderInternalNoteEntity::getOrderId, orderIds) + .orderByAsc(OrderInternalNoteEntity::getOrderId) + .orderByDesc(OrderInternalNoteEntity::getCreatedAt) + .orderByDesc(OrderInternalNoteEntity::getId)); + } + public Map countByTenantIds(List tenantIds) { if (tenantIds.isEmpty()) return Map.of(); var rows = orderMapper.selectMaps(new QueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 90632951c..aff208993 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 5cc25cfd9..5c9f13331 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -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 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()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java new file mode 100644 index 000000000..4ccf3ed7c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderPricingChannelGuard.java @@ -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 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, "头程价格渠道校验服务暂时不可用"); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index ac48ef282..aa0695dea 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -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 inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus()) ? Map.of() : summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList()); + Map latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList()); List 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 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 source = require( call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); - List records = source.records().stream().map(this::row).toList(); + List orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList(); + Map orderById = orders.findByIds(tenantId, orderIds).stream() + .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); + Map latestNotes = latestNotes(tenantId, orderIds); + List 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 latestNotes(Long tenantId, List 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) { diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 2f8bae6d5..9c51385fa 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -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', diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql index 44cfc7162..62890d4ee 100644 --- a/order/src/main/resources/db/phase1-order-extension.sql +++ b/order/src/main/resources/db/phase1-order-extension.sql @@ -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'"); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index f3182588a..ab2b6ef44 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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 diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index f1716f81d..fd5d6eb22 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -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 diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index 308fee7fe..0e74ff3ba 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -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; } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 86d0cd632..7dfdd7856 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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; diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 8f53b4125..eb2e4cc6f 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -39,6 +39,7 @@ class OrderIdempotencyServiceTest { @MockBean private OrderRepository contextRepository; @MockBean private OrderEligibilityGuard contextEligibility; @MockBean private OrderMasterDataGuard contextMasterData; + @MockBean private OrderPricingChannelGuard contextPricingChannelGuard; @Resource private OrderApplicationService springService; @Configuration(proxyBeanMethods = false) @@ -151,6 +152,10 @@ class OrderIdempotencyServiceTest { .contains("platform:order:edit") .contains("platform:order:submit") .contains("name: Idempotency-Key") + .contains("pricingTaxMode") + .contains("PricingTaxMode") + .contains("FirstLegOrderDraft") + .contains("FirstLegOrderSummary") .contains("required: true") .contains("仅响应体 code 为字符串 \"200\" 时表示成功") .doesNotContain("'409':") @@ -164,7 +169,7 @@ class OrderIdempotencyServiceTest { .getContentAsString(StandardCharsets.UTF_8); for (String column : new String[] { - "receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no" + "receiving_warehouse_address", "receiving_time", "express_company", "express_tracking_no", "pricing_tax_mode" }) { assertThat(baseline).contains(column); assertThat(migration).contains(column); diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index 0aa72e870..332e3534a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -6,6 +6,7 @@ import com.baiyue.order.entity.OutboxEventEntity; import com.baiyue.order.repository.OrderRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; @@ -28,7 +29,13 @@ class OrderInboundOutboxPublisherTest { fixture.publisher.publishApprovedOrders(); - verify(fixture.client).create(eq("internal-token"), any()); + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class); + verify(fixture.client).create(eq("internal-token"), command.capture()); + org.assertj.core.api.Assertions.assertThat(command.getValue().inboundCode()).isEqualTo("RK-21"); + org.assertj.core.api.Assertions.assertThat(command.getValue().warehouseId()).isEqualTo(31L); + org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("BUY_DECLARATION"); + org.assertj.core.api.Assertions.assertThat(command.getValue().pricingTaxMode()).isEqualTo("TAX_EXCLUDED"); verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); verify(fixture.repository, never()).markOutboxRetry(anyLong(), anyLong(), any()); verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); @@ -93,6 +100,21 @@ class OrderInboundOutboxPublisherTest { OrderRepository repository = mock(OrderRepository.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); + if (event.getTenantId() != null && event.getAggregateId() != null) { + var order = new com.baiyue.order.entity.OrderEntity(); + order.setId(event.getAggregateId()); + order.setTenantId(event.getTenantId()); + order.setOrderNo("FL-" + event.getAggregateId()); + order.setInboundCode("RK-" + event.getAggregateId()); + order.setCustomerName("测试客户"); + order.setReceivingWarehouseId(31L); + order.setReceivingWarehouseName("测试仓"); + order.setDestinationType("OVERSEAS"); + order.setTransportMode("SEA"); + order.setTaxMode("BUY_DECLARATION"); + order.setPricingTaxMode("TAX_EXCLUDED"); + when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order); + } OrderInboundOutboxPublisher publisher = new OrderInboundOutboxPublisher(); ReflectionTestUtils.setField(publisher, "repository", repository); ReflectionTestUtils.setField(publisher, "client", client); diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 48e0a44c4..d74426d6a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -254,7 +254,7 @@ class OrderMasterDataGuardTest { var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null); var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE, null,null,null,null,null,false,null,null,null,null,null,null,null,null,null,null,null,null,null); - return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION","LCL",false,destinationType,null,null,null); + return new OrderRequests.SaveRequest(partner,"不可信客户名","SEA",null,"CN",destinationWarehouseId,"私人地址",null,null,party,party,List.of(cargo),"PICKUP",warehouse,"不可信收货仓","CH","渠道",9L,"船司","BUY_DECLARATION",null,"LCL",false,destinationType,null,null,null); } private WarehouseClient.Detail warehouse(long id,String type,boolean receiving) { var value=new WarehouseClient.Warehouse(id,PUBLIC_TENANT_ID,"WH-"+id,"仓库"+id,type,"CN","ACTIVE"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java new file mode 100644 index 000000000..a68617c16 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderPricingChannelGuardTest.java @@ -0,0 +1,85 @@ +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 feign.Request; +import feign.Response; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.DisplayName; +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.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("订单价格渠道校验") +@SpringJUnitConfig(OrderPricingChannelGuard.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=token") +class OrderPricingChannelGuardTest { + @MockBean private FirstlegPricingClient client; + @Resource private OrderPricingChannelGuard guard; + + @Test + void forwardsCustomerOriginAndChannelToFirstleg() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenReturn(ApiResponse.success(null)); + + guard.requireAvailable(20002L, 8L, "MATSON_TRUCK"); + + verify(client).validateChannel("token", request); + } + + @Test + void removedOrForgedChannelKeepsFirstlegBusinessError() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "FAKE"); + when(client.validateChannel("token", request)).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "所选渠道已下线,请重新选择", null, null)); + + assertThatThrownBy(() -> guard.requireAvailable(20002L, 8L, "FAKE")) + .isInstanceOf(BizException.class) + .hasMessage("所选渠道已下线,请重新选择") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test + void nullResponseMapsToServiceUnavailable() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenReturn(null); + + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + } + + @Test + void downstreamHttpFailureMapsToServiceUnavailable() { + var request = new FirstlegPricingClient.ChannelValidationRequest(20002L, 8L, "MATSON_TRUCK"); + when(client.validateChannel("token", request)).thenThrow(feign(403)); + + assertUnavailable(() -> guard.requireAvailable(20002L, 8L, "MATSON_TRUCK")); + } + + private void assertUnavailable(org.assertj.core.api.ThrowableAssert.ThrowingCallable invocation) { + assertThatThrownBy(invocation) + .isInstanceOf(BizException.class) + .hasMessage("头程价格渠道校验服务暂时不可用") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + private FeignException feign(int status) { + Request request = Request.create(Request.HttpMethod.POST, "http://firstleg-service/internal/test", + Map.of(), null, StandardCharsets.UTF_8, null); + Response response = Response.builder().status(status).reason("test").request(request) + .headers(Map.of()).build(); + return FeignException.errorStatus("pricingChannel", response); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index d11f28798..0604383e6 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -18,6 +18,7 @@ class PlatformOrderPageServiceTest { @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index 6de6404b8..bc1445931 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -21,6 +21,7 @@ class TenantOrderCountServiceTest { @MockBean private OrderRepository repository; @MockBean private OrderEligibilityGuard eligibilityGuard; @MockBean private OrderMasterDataGuard masterDataGuard; + @MockBean private OrderPricingChannelGuard pricingChannelGuard; @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 59df0cb15..a94cce672 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.dto.UnifiedOrderQuery; 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 jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -34,15 +36,26 @@ class UnifiedOrderQueryServiceTest { @MockBean private FirstlegUnifiedOrderClient firstleg; @Resource private UnifiedOrderQueryService service; + @BeforeEach + void stubListEnrichmentQueries() { + when(orders.findNotesByOrderIds(any(), any())).thenReturn(List.of()); + when(orders.findByIds(any(), any())).thenReturn(List.of()); + } + @Test void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { UnifiedOrderQuery query = query("PENDING_REVIEW"); OrderEntity order = order(OrderStatus.SUBMITTED); order.setInboundCode("RK-77"); + order.setCustomerRemark("客户要求提货前联系"); + OrderInternalNoteEntity note = new OrderInternalNoteEntity(); + note.setOrderId(77L); + note.setContent("客服已确认提货时间"); when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); when(firstleg.summaries(eq("internal-token"), any())) .thenReturn(ApiResponse.success(List.of())); + when(orders.findNotesByOrderIds(10001L, List.of(77L))).thenReturn(List.of(note)); var result = service.page(10001L, query); @@ -51,6 +64,8 @@ class UnifiedOrderQueryServiceTest { assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); assertThat(row.inboundCode()).isEqualTo("RK-77"); assertThat(row.inboundId()).isNull(); + assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系"); + assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); }); verify(firstleg, never()).summaries(any(), any()); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index d6b4ccfdd..4c16d9d87 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -15,6 +15,8 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/warehouses") public class InternalWarehouseController { + private static final long PLATFORM_TENANT_ID = 0L; + @Resource private WarehouseApplicationService service; @@ -30,8 +32,17 @@ public class InternalWarehouseController { public ApiResponse active( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable long tenantId, @PathVariable long id) { - if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) + verifyToken(token); + if (tenantId != PLATFORM_TENANT_ID) { + throw new BizException(ResultCode.BAD_REQUEST, "平台公共仓库作用域必须为0"); + } + return ApiResponse.success(service.activeDetail(PLATFORM_TENANT_ID, id)); + } + + private void verifyToken(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, supplied)) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); - return ApiResponse.success(service.activeDetail(tenantId, id)); + } } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java new file mode 100644 index 000000000..aa3ff07a4 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -0,0 +1,74 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InternalWarehouseControllerTest { + private static final String INTERNAL_TOKEN = "test-internal-service-token"; + + private final WarehouseApplicationService service = mock(WarehouseApplicationService.class); + private final InternalWarehouseController controller = new InternalWarehouseController(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(controller, "service", service); + ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN); + controller.initializeToken(); + } + + @Test + void returnsOnlyAnActivePlatformWarehouseToInternalCallers() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(200001L); + warehouse.setTenantId(0L); + warehouse.setWarehouseCode("CN-DONGGUAN-01"); + warehouse.setWarehouseName("东莞仓"); + warehouse.setWarehouseType("DOMESTIC"); + warehouse.setStatus("ACTIVE"); + when(service.activeDetail(0L, 200001L)) + .thenReturn(new WarehouseDetailResponse(warehouse, List.of(), List.of())); + + var response = controller.active(INTERNAL_TOKEN, 0L, 200001L); + + assertThat(response.data().warehouse().getTenantId()).isZero(); + assertThat(response.data().warehouse().getWarehouseCode()).isEqualTo("CN-DONGGUAN-01"); + assertThat(response.data().warehouse().getWarehouseType()).isEqualTo("DOMESTIC"); + assertThat(response.data().warehouse().getStatus()).isEqualTo("ACTIVE"); + verify(service).activeDetail(0L, 200001L); + } + + @Test + void rejectsCustomerScopedWarehouseLookups() { + assertThatThrownBy(() -> controller.active(INTERNAL_TOKEN, 10001L, 200001L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + + verifyNoInteractions(service); + } + + @Test + void rejectsAnInvalidInternalTokenBeforeQueryingWarehouseData() { + assertThatThrownBy(() -> controller.active("invalid-token", 0L, 200001L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.FORBIDDEN); + + verifyNoInteractions(service); + } +}