This commit is contained in:
2026-09-10 12:18:33 +08:00
parent 443b01c8a8
commit 98f61ba3ed
22 changed files with 371 additions and 56 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
|---|---|---| |---|---|---|
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | | 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
@Valid @RequestBody InitializeRequest request) { @Valid @RequestBody InitializeRequest request) {
requireToken(token); requireToken(token);
return ApiResponse.success(service.initializeDefault( return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey())); request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
} }
@DeleteMapping("/defaults") @DeleteMapping("/defaults")
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
public record InitializeRequest( public record InitializeRequest(
@NotNull @Min(1) Long tenantId, @NotNull @Min(1) Long tenantId,
@NotNull @Min(0) Long operatorId, @NotNull @Min(0) Long operatorId,
@NotBlank @Size(max = 128) String initializationKey @NotBlank @Size(max = 128) String initializationKey,
@NotBlank @Size(max = 128) String customerName
) { } ) { }
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { } public record InitializeResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
} }
@@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity {
private LocalDateTime effectiveTo; private LocalDateTime effectiveTo;
private String status; private String status;
private String remark; private String remark;
/** 客户创建执行器的幂等键;人工创建的规则为空。 */
private String initializationKey;
} }
@@ -1,7 +1,50 @@
package com.baiyue.firstleg.mapper; package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
@Mapper @Mapper
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {} public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1
""")
CustomerChargeRuleEntity selectByInitializationKey(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Select("""
SELECT id, tenant_id, rule_code, rule_version, customer_id, customer_name, business_type,
charge_mode, tax_mode, conversion_factor, length_factor, width_factor, height_factor,
default_rule, rounding_scale, rounding_mode, currency, effective_from, effective_to,
status, remark, initialization_key, created_by, created_at, updated_by, updated_at,
deleted, version
FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey} AND deleted = 0
LIMIT 1 FOR UPDATE
""")
CustomerChargeRuleEntity selectByInitializationKeyForUpdate(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Delete("""
DELETE FROM fl_customer_charge_rule
WHERE tenant_id = #{tenantId} AND initialization_key = #{initializationKey}
AND rule_code = 'DEFAULT-DIMENSION-WEIGHT' AND rule_version = 1
AND customer_id = #{tenantId} AND business_type = 'FBA' AND charge_mode = 'CBM'
AND tax_mode = 'TAX_INCLUDED' AND conversion_factor = 167.000000
AND length_factor = 1.100000 AND width_factor = 1.100000 AND height_factor = 1.100000
AND default_rule = 1 AND rounding_scale = 2 AND rounding_mode = 'HALF_UP'
AND currency = 'CNY' AND effective_to IS NULL AND status = 'ACTIVE'
AND deleted = 0 AND version = 0
""")
int deleteUnchangedInitializedRule(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
}
@@ -55,6 +55,19 @@ public class FbaPricingRepository {
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
public CustomerChargeRuleEntity findRuleByInitializationKey(long tenantId, String initializationKey) {
return rules.selectByInitializationKey(tenantId, initializationKey);
}
public CustomerChargeRuleEntity findRuleByInitializationKeyForUpdate(
long tenantId, String initializationKey) {
return rules.selectByInitializationKeyForUpdate(tenantId, initializationKey);
}
public int deleteUnchangedInitializedRule(long tenantId, String initializationKey) {
return rules.deleteUnchangedInitializedRule(tenantId, initializationKey);
}
public int clearDefaultRule(long tenantId, long customerId, long operatorId) { public int clearDefaultRule(long tenantId, long customerId, long operatorId) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>() return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId) .eq(CustomerChargeRuleEntity::getTenantId, tenantId)
@@ -0,0 +1,95 @@
package com.baiyue.firstleg.service;
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.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 在客户创建执行器中幂等生成“箱规按权重系数计算”的默认规则。 */
@Service
public class CustomerChargeRuleInitializationService {
static final String DEFAULT_RULE_CODE = "DEFAULT-DIMENSION-WEIGHT";
static final BigDecimal DEFAULT_COEFFICIENT = new BigDecimal("1.100000");
static final BigDecimal DEFAULT_CONVERSION_FACTOR = new BigDecimal("167.000000");
static final String DEFAULT_REMARK = "SMALL:30,20,20;系数:1.1;重量:5;小箱:标准小箱;"
+ "MEDIUM:40,30,30;系数:1.1;重量:8;中箱:标准中箱;"
+ "LARGE:50,40,40;系数:1.1;重量:12;大箱:标准大箱;"
+ "EXTRA_LARGE:60,50,50;系数:1.1;重量:15;特大箱:特大箱";
@Resource private FbaPricingRepository repository;
public CustomerChargeRuleEntity initializeDefault(long tenantId, long operatorId,
String initializationKey, String customerName) {
CustomerChargeRuleEntity existing = repository.findRuleByInitializationKey(
tenantId, initializationKey);
if (existing != null) return existing;
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
value.setRuleCode(DEFAULT_RULE_CODE);
value.setRuleVersion(1);
value.setCustomerId(tenantId);
value.setCustomerName(requireCustomerName(customerName));
value.setBusinessType("FBA");
value.setChargeMode("CBM");
value.setTaxMode("TAX_INCLUDED");
value.setConversionFactor(DEFAULT_CONVERSION_FACTOR);
value.setLengthFactor(DEFAULT_COEFFICIENT);
value.setWidthFactor(DEFAULT_COEFFICIENT);
value.setHeightFactor(DEFAULT_COEFFICIENT);
value.setDefaultRule(true);
value.setRoundingScale(2);
value.setRoundingMode("HALF_UP");
value.setCurrency("CNY");
value.setEffectiveFrom(LocalDateTime.now());
value.setEffectiveTo(null);
value.setStatus("ACTIVE");
value.setRemark(DEFAULT_REMARK);
value.setInitializationKey(initializationKey);
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
repository.insertRule(value);
return value;
}
CustomerChargeRuleEntity lockForCompensation(long tenantId, String initializationKey) {
return repository.findRuleByInitializationKeyForUpdate(tenantId, initializationKey);
}
boolean isUnchangedDefault(CustomerChargeRuleEntity value) {
return value != null
&& value.getVersion() != null && value.getVersion() == 0
&& DEFAULT_RULE_CODE.equals(value.getRuleCode()) && Integer.valueOf(1).equals(value.getRuleVersion())
&& value.getTenantId().equals(value.getCustomerId())
&& "FBA".equals(value.getBusinessType()) && "CBM".equals(value.getChargeMode())
&& "TAX_INCLUDED".equals(value.getTaxMode())
&& DEFAULT_CONVERSION_FACTOR.compareTo(value.getConversionFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getLengthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getWidthFactor()) == 0
&& DEFAULT_COEFFICIENT.compareTo(value.getHeightFactor()) == 0
&& Boolean.TRUE.equals(value.getDefaultRule()) && Integer.valueOf(2).equals(value.getRoundingScale())
&& "HALF_UP".equals(value.getRoundingMode()) && "CNY".equals(value.getCurrency())
&& value.getEffectiveTo() == null && "ACTIVE".equals(value.getStatus())
&& DEFAULT_REMARK.equals(value.getRemark());
}
void deleteUnchangedDefault(long tenantId, String initializationKey) {
if (repository.deleteUnchangedInitializedRule(tenantId, initializationKey) != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户权重系数默认规则补偿删除失败");
}
}
private static String requireCustomerName(String value) {
if (value == null || value.isBlank() || value.trim().length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "客户名称无效");
}
return value.trim();
}
}
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest; import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
@Resource @Resource
private CustomerDimensionConfigRepository repository; private CustomerDimensionConfigRepository repository;
@Resource
private CustomerChargeRuleInitializationService chargeRuleInitialization;
public GroupResponse get(long tenantId) { public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId))); return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
} }
@Transactional @Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) { public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
String customerName) {
requireTenant(tenantId); requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey); String normalizedKey = requireInitializationKey(initializationKey);
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId); List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) { if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing); List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
return initializationResponse(complete, normalizedKey); boolean ownedByKey = ownedByInitialization(complete, normalizedKey);
CustomerChargeRuleEntity chargeRule = ownedByKey
? chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName)
: null;
return initializationResponse(complete, chargeRule, ownedByKey);
} }
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId); repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId)); List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
return initializationResponse(persisted, normalizedKey); CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.initializeDefault(
tenantId, operatorId, normalizedKey, customerName);
return initializationResponse(persisted, chargeRule, true);
} }
@Transactional @Transactional
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
List<CustomerDimensionConfigEntity> locked = List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey); repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return; if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
CustomerChargeRuleEntity chargeRule = chargeRuleInitialization.lockForCompensation(
tenantId, normalizedKey);
if (chargeRule != null && !chargeRuleInitialization.isUnchangedDefault(chargeRule)) return;
if (chargeRule != null) {
chargeRuleInitialization.deleteUnchangedDefault(tenantId, normalizedKey);
}
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) { if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败"); throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
} }
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
return true; return true;
} }
private static boolean ownedByInitialization(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
return values.stream().allMatch(value -> initializationKey.equals(value.getInitializationKey()));
}
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values, private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) { CustomerChargeRuleEntity chargeRule,
boolean ownedByKey = values.stream() boolean ownedByKey) {
.allMatch(value -> initializationKey.equals(value.getInitializationKey())); return new InitializeResponse(values.get(0).getId(),
return new InitializeResponse(values.get(0).getId(), ownedByKey); chargeRule == null ? null : chargeRule.getId(), ownedByKey);
} }
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) { private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
@@ -24,6 +24,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL(
CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status");
CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark");
CALL phase2_fba_add_column('fl_carrier_fba_rate', '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', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version");
CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id"); CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id");
@@ -67,6 +68,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
DROP PROCEDURE IF EXISTS phase2_fba_add_column; 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_customer_charge_rule'
AND INDEX_NAME = 'idx_charge_rule_initialization');
SET @sql = IF(@idx_exists = 0,
'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS 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'); WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_carrier_fba_rate' AND INDEX_NAME = 'idx_fba_rate_unique_match');
@@ -430,6 +430,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
effective_to DATETIME(3) NULL COMMENT '生效结束时间', effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注', remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -438,6 +439,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_initialization (tenant_id, initialization_key),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; ) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
@@ -1750,16 +1750,18 @@ components:
updatedAt: {type: string, format: date-time, nullable: true} updatedAt: {type: string, format: date-time, nullable: true}
CustomerDimensionConfigInitializeRequest: CustomerDimensionConfigInitializeRequest:
type: object type: object
required: [tenantId, operatorId, initializationKey] required: [tenantId, operatorId, initializationKey, customerName]
properties: properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorId: {type: string, pattern: '^[0-9]+$'} operatorId: {type: string, pattern: '^[0-9]+$'}
initializationKey: {type: string, minLength: 1, maxLength: 128} initializationKey: {type: string, minLength: 1, maxLength: 128}
customerName: {type: string, minLength: 1, maxLength: 128}
CustomerDimensionConfigInitializeResponse: CustomerDimensionConfigInitializeResponse:
type: object type: object
required: [configId, createdByThisInitialization] required: [configId, chargeRuleId, createdByThisInitialization]
properties: properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'} configId: {type: string, pattern: '^[1-9][0-9]*$'}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean} createdByThisInitialization: {type: boolean}
WeightPalletSnapshot: WeightPalletSnapshot:
type: object type: object
@@ -33,15 +33,15 @@ class CustomerDimensionConfigControllerContractTest {
@Test @Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() { void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1"); InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1", "柏悦物流");
when(service.initializeDefault(8L, 100L, "registration-1")) when(service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(new InitializeResponse(91L, true)); .thenReturn(new InitializeResponse(91L, 191L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request)) assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class); .isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data()) assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true)); .isEqualTo(new InitializeResponse(91L, 191L, true));
verify(service).initializeDefault(8L, 100L, "registration-1"); verify(service).initializeDefault(8L, 100L, "registration-1", "柏悦物流");
} }
@Test @Test
@@ -0,0 +1,76 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerChargeRuleInitializationService.class)
class CustomerChargeRuleInitializationServiceTest {
@MockBean private FbaPricingRepository repository;
@Resource private CustomerChargeRuleInitializationService service;
@Test
void initializesDefaultWeightCoefficientRuleForNewCustomer() {
CustomerChargeRuleEntity result = service.initializeDefault(
8L, 100L, "iam-customer-create:8", "柏悦物流");
ArgumentCaptor<CustomerChargeRuleEntity> captor =
ArgumentCaptor.forClass(CustomerChargeRuleEntity.class);
verify(repository).insertRule(captor.capture());
CustomerChargeRuleEntity saved = captor.getValue();
assertThat(result).isSameAs(saved);
assertThat(saved.getTenantId()).isEqualTo(8L);
assertThat(saved.getCustomerId()).isEqualTo(8L);
assertThat(saved.getCustomerName()).isEqualTo("柏悦物流");
assertThat(saved.getRuleCode()).isEqualTo("DEFAULT-DIMENSION-WEIGHT");
assertThat(saved.getRuleVersion()).isEqualTo(1);
assertThat(saved.getDefaultRule()).isTrue();
assertThat(saved.getLengthFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getWidthFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getHeightFactor()).isEqualByComparingTo("1.100000");
assertThat(saved.getConversionFactor()).isEqualByComparingTo("167.000000");
assertThat(saved.getRemark()).contains(
"SMALL:30,20,20;系数:1.1;重量:5",
"EXTRA_LARGE:60,50,50;系数:1.1;重量:15");
assertThat(saved.getInitializationKey()).isEqualTo("iam-customer-create:8");
}
@Test
void retryReturnsRuleOwnedBySameInitializationWithoutDuplicating() {
CustomerChargeRuleEntity existing = new CustomerChargeRuleEntity();
existing.setId(191L);
when(repository.findRuleByInitializationKey(8L, "iam-customer-create:8"))
.thenReturn(existing);
assertThat(service.initializeDefault(8L, 100L, "iam-customer-create:8", "柏悦物流"))
.isSameAs(existing);
verify(repository, never()).insertRule(any());
}
@Test
void compensationDeletesOnlyAnUnchangedInitializedRule() {
CustomerChargeRuleEntity value = service.initializeDefault(
8L, 100L, "iam-customer-create:8", "柏悦物流");
value.setVersion(0);
when(repository.findRuleByInitializationKeyForUpdate(8L, "iam-customer-create:8"))
.thenReturn(value);
when(repository.deleteUnchangedInitializedRule(8L, "iam-customer-create:8"))
.thenReturn(1);
CustomerChargeRuleEntity locked = service.lockForCompensation(8L, "iam-customer-create:8");
assertThat(service.isUnchangedDefault(locked)).isTrue();
service.deleteUnchangedDefault(8L, "iam-customer-create:8");
verify(repository).deleteUnchangedInitializedRule(8L, "iam-customer-create:8");
}
}
@@ -22,6 +22,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@@ -32,6 +33,8 @@ import static org.mockito.Mockito.when;
class CustomerDimensionConfigServiceTest { class CustomerDimensionConfigServiceTest {
@MockBean @MockBean
private CustomerDimensionConfigRepository repository; private CustomerDimensionConfigRepository repository;
@MockBean
private CustomerChargeRuleInitializationService chargeRuleInitialization;
@Resource @Resource
private CustomerDimensionConfigService service; private CustomerDimensionConfigService service;
@@ -40,10 +43,12 @@ class CustomerDimensionConfigServiceTest {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1"); List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted); when(repository.findAll(8L)).thenReturn(List.of(), persisted);
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4); when(repository.insertDefaults(any(), eq(100L))).thenReturn(4);
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(defaultChargeRule());
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1"); InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, true)); assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L)); verify(repository).insertDefaults(captor.capture(), eq(100L));
@@ -67,10 +72,12 @@ class CustomerDimensionConfigServiceTest {
@Test @Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() { void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
when(chargeRuleInitialization.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(defaultChargeRule());
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1"); InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, true)); assertThat(result).isEqualTo(new InitializeResponse(91L, 191L, true));
verify(repository, never()).insertDefaults(any(), eq(100L)); verify(repository, never()).insertDefaults(any(), eq(100L));
} }
@@ -78,10 +85,11 @@ class CustomerDimensionConfigServiceTest {
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() { void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1")); when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2"); InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2", "柏悦物流");
assertThat(result).isEqualTo(new InitializeResponse(91L, false)); assertThat(result).isEqualTo(new InitializeResponse(91L, null, false));
verify(repository, never()).insertDefaults(any(), eq(100L)); verify(repository, never()).insertDefaults(any(), eq(100L));
verify(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any());
} }
@Test @Test
@@ -173,10 +181,14 @@ class CustomerDimensionConfigServiceTest {
when(repository.findDefaultsForCompensation(8L, "registration-1")) when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1")); .thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4); when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4);
when(chargeRuleInitialization.lockForCompensation(8L, "registration-1"))
.thenReturn(defaultChargeRule());
when(chargeRuleInitialization.isUnchangedDefault(any())).thenReturn(true);
service.compensateDefault(8L, "registration-1"); service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefaults(8L, "registration-1"); verify(repository).deleteUnchangedDefaults(8L, "registration-1");
verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1");
} }
@Test @Test
@@ -228,4 +240,11 @@ class CustomerDimensionConfigServiceTest {
} }
return values; return values;
} }
private static com.baiyue.firstleg.entity.CustomerChargeRuleEntity defaultChargeRule() {
com.baiyue.firstleg.entity.CustomerChargeRuleEntity value =
new com.baiyue.firstleg.entity.CustomerChargeRuleEntity();
value.setId(191L);
return value;
}
} }
+1 -1
View File
@@ -13,7 +13,7 @@
| 目标 | 入口 | | 目标 | 入口 |
|---|---| |---|---|
| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | | 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` |
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 |
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` |
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
/** Firstleg internal contract for atomically provisioning four default box-size dimension records. */ /** Firstleg internal contract for atomically provisioning box-size and weight-coefficient defaults. */
@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient", @FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient",
url = "${baiyue.clients.firstleg.url:}", url = "${baiyue.clients.firstleg.url:}",
path = "/internal/firstleg/v1/customer-dimension-configs") path = "/internal/firstleg/v1/customer-dimension-configs")
@@ -24,7 +24,8 @@ public interface CustomerDimensionRuleClient {
@RequestParam long tenantId, @RequestParam long tenantId,
@RequestParam String initializationKey); @RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { } record InitializationRequest(long tenantId, long operatorId, String initializationKey,
/** configId is the first record ID retained only for contract compatibility. */ String customerName) { }
record InitializationResponse(Long configId, boolean createdByThisInitialization) { } record InitializationResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
} }
@@ -3,10 +3,10 @@ package com.baiyue.iam.model;
public record CustomerDimensionInitializationTask( public record CustomerDimensionInitializationTask(
long tenantId, long tenantId,
long operatorId, long operatorId,
String customerName,
String initializationKey, String initializationKey,
String action, String action,
String status, String status,
int attemptCount, int attemptCount,
int taskVersion) { int taskVersion) {
} }
@@ -73,11 +73,14 @@ public class CustomerDimensionInitializationRepository {
+ "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, tenantId, now); workerId, now.plusMinutes(2), now, tenantId, now);
if (claimed != 1) return null; if (claimed != 1) return null;
return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," return jdbc.queryForObject("SELECT task.tenant_id,task.operator_id,tenant.tenant_name,"
+ "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + "task.initialization_key,task.action,task.status,task.attempt_count,task.task_version "
+ "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + "FROM iam_customer_dimension_initialization_task task "
+ "JOIN iam_tenant tenant ON tenant.id=task.tenant_id "
+ "WHERE task.tenant_id=? AND task.status='SENDING' AND task.lease_owner=?",
(rs, row) -> new CustomerDimensionInitializationTask( (rs, row) -> new CustomerDimensionInitializationTask(
rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"), rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("tenant_name"),
rs.getString("initialization_key"),
rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"), rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"),
rs.getInt("task_version")), tenantId, workerId); rs.getInt("task_version")), tenantId, workerId);
} }
@@ -43,10 +43,11 @@ public class CustomerDimensionInitializationRelay {
} }
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize( ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken, new CustomerDimensionRuleClient.InitializationRequest( internalToken, new CustomerDimensionRuleClient.InitializationRequest(
task.tenantId(), task.operatorId(), task.initializationKey())); task.tenantId(), task.operatorId(), task.initializationKey(), task.customerName()));
requireSuccess(response, true); requireSuccess(response, true);
if (response.data() == null || response.data().configId() == null) { if (response.data() == null || response.data().configId() == null
throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); || response.data().chargeRuleId() == null) {
throw new DeliveryFailure("S0503", "Firstleg 未完整返回默认尺寸和权重系数规则ID", false);
} }
repository.markReady(task, workerId, response.data().configId()); repository.markReady(task, workerId, response.data().configId());
} catch (Exception failure) { } catch (Exception failure) {
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionRuleClientContractTest { class CustomerDimensionRuleClientContractTest {
@Test @Test
void usesOneAtomicCallForTheFourDefaultBoxDimensionRecords() throws Exception { void usesOneAtomicCallForBoxDimensionsAndWeightCoefficientRule() throws Exception {
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class); FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
PostMapping initialize = CustomerDimensionRuleClient.class PostMapping initialize = CustomerDimensionRuleClient.class
.getMethod("initialize", String.class, .getMethod("initialize", String.class,
@@ -25,6 +25,6 @@ class CustomerDimensionRuleClientContractTest {
assertThat(compensate.value()).containsExactly("/defaults"); assertThat(compensate.value()).containsExactly("/defaults");
assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents()) assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName) .extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("configId", "createdByThisInitialization"); .containsExactly("configId", "chargeRuleId", "createdByThisInitialization");
} }
} }
@@ -43,15 +43,33 @@ class CustomerDimensionInitializationRelayTest {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0); CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
new CustomerDimensionRuleClient.InitializationResponse(91L, true))); new CustomerDimensionRuleClient.InitializationResponse(91L, 191L, true)));
relay.deliver(10001L); relay.deliver(10001L);
ArgumentCaptor<CustomerDimensionRuleClient.InitializationRequest> request =
ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class);
verify(client).initialize(anyString(), request.capture());
assertTrue("柏悦物流".equals(request.getValue().customerName()));
verify(repository).markReady(task, workerId(), 91L); verify(repository).markReady(task, workerId(), 91L);
verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), any(Boolean.class)); anyString(), any(Boolean.class));
} }
@Test
void missingWeightCoefficientRuleKeepsInitializationFromBecomingReady() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task);
when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success(
new CustomerDimensionRuleClient.InitializationResponse(91L, null, true)));
relay.deliver(10001L);
verify(repository, never()).markReady(any(), anyString(), any());
verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), any(Boolean.class));
}
@Test @Test
void transientFailureStaysPendingWithDiagnosticAndBackoff() { void transientFailureStaysPendingWithDiagnosticAndBackoff() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0); CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
@@ -95,7 +113,7 @@ class CustomerDimensionInitializationRelayTest {
} }
private CustomerDimensionInitializationTask task(String action, int attempts) { private CustomerDimensionInitializationTask task(String action, int attempts) {
return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001", return new CustomerDimensionInitializationTask(10001L, 9001L, "柏悦物流", "iam-customer-create:10001",
action, "SENDING", attempts, 3); action, "SENDING", attempts, 3);
} }
+21 -14
View File
@@ -87,6 +87,12 @@ $overseasPdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/AGEN
$backendAgentsPath = Join-Path $backendRoot 'AGENTS.md' $backendAgentsPath = Join-Path $backendRoot 'AGENTS.md'
$rootAgents = Read-RequiredText $rootAgentsPath $rootAgents = Read-RequiredText $rootAgentsPath
$businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md'
$businessIndex = Read-RequiredText $businessIndexPath
Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口'
Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表'
# 既有业务语义断言覆盖根入口及其显式链接的详细索引;全局规则仍留在根文件。
$rootAgents = $rootAgents + "`n" + $businessIndex
$adminAgents = Read-RequiredText $adminAgentsPath $adminAgents = Read-RequiredText $adminAgentsPath
$clientAgents = Read-RequiredText $clientAgentsPath $clientAgents = Read-RequiredText $clientAgentsPath
$pdaAgents = Read-RequiredText $pdaAgentsPath $pdaAgents = Read-RequiredText $pdaAgentsPath
@@ -124,17 +130,11 @@ Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作
Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁' Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁'
Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口' Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口'
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目' # 测试登记已清空;保留链接与停用边界,不要求恢复历史任务。
Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录' Assert-Contains $testTaskRegistry '当前无登记任务' '测试登记空状态'
Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态' Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试历史台账授权边界'
Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控' Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工' '测试自动派工停用边界'
Assert-Contains $testTaskRegistry '| 【总控】测试环境全流程 |' '测试环境唯一总控名称' Assert-Matches $testTaskRegistry '(?s)\A(?!.*(?m:^\s*\|)).*\z' '测试空登记不得残留任务表'
Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外仓全流程测试专项'
Assert-Contains $testTaskRegistry '| 【测试】海外仓全流程 |' '测试环境海外仓全流程测试专项名称'
Assert-Contains $testTaskRegistry '| 【前端】Admin全流程 |' '测试环境 Admin 前端专项'
Assert-Contains $testTaskRegistry '| 【前端】Client全流程 |' '测试环境 Client 前端专项'
Assert-Contains $testTaskRegistry '| 【前端】海外PDA |' '测试环境海外 PDA 专项'
Assert-Contains $testTaskRegistry '| 【后端4】海外接柜 |' '测试环境海外接柜后端专项'
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
@@ -786,14 +786,21 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界' Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界' Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI' Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒' Assert-Matches $containerFeeOpenApi '各行按\s+HALF_UP\s+保留两位后必须大于零,\s*落库金额合计必须等于两位小数的总额' '柜费舍入后正数与金额守恒'
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约' Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口' Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口' Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验' Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验' Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属' Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属'
Assert-Contains $overseasFinanceOpenApi 'OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE' '海外五类费用 OpenAPI' # 在 BusinessType 枚举内逐项核对,新增类型或调整顺序不应使原有覆盖误报。
$businessTypeEnumMatch = [regex]::Match($overseasFinanceOpenApi, '(?s)BusinessType:\s*\{\s*type:\s*string,\s*enum:\s*\[([^\]]*)\]')
Assert-Matches $overseasFinanceOpenApi 'BusinessType:\s*\{\s*type:\s*string,\s*enum:' '海外业务类型枚举定义'
$businessTypeValues = @($businessTypeEnumMatch.Groups[1].Value.Split(',') | ForEach-Object { $_.Trim() })
foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCUREMENT_FEE', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE', 'OVERSEAS_CONTAINER', 'LASTLEG_ORDER', 'DELIVERY_ORDER', 'RELABEL_ORDER')) {
$checked++
if ($businessType -notin $businessTypeValues) { Add-Failure "海外 BusinessType 枚举缺少: $businessType" }
}
Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约' Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约'
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口' Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型' Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'