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 安全投影失败关闭校验 |
| 客户档案、成员、审核、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/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、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` |
@@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController {
@Valid @RequestBody InitializeRequest request) {
requireToken(token);
return ApiResponse.success(service.initializeDefault(
request.tenantId(), request.operatorId(), request.initializationKey()));
request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName()));
}
@DeleteMapping("/defaults")
@@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos {
public record InitializeRequest(
@NotNull @Min(1) Long tenantId,
@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 String status;
private String remark;
/** 客户创建执行器的幂等键;人工创建的规则为空。 */
private String initializationKey;
}
@@ -1,7 +1,50 @@
package com.baiyue.firstleg.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.baiyue.firstleg.entity.CustomerChargeRuleEntity;
@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 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) {
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
.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.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -32,6 +33,8 @@ public class CustomerDimensionConfigService {
@Resource
private CustomerDimensionConfigRepository repository;
@Resource
private CustomerChargeRuleInitializationService chargeRuleInitialization;
public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
@@ -78,18 +81,26 @@ public class CustomerDimensionConfigService {
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey,
String customerName) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) {
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);
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
@@ -99,6 +110,12 @@ public class CustomerDimensionConfigService {
List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey);
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) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
}
@@ -206,11 +223,16 @@ public class CustomerDimensionConfigService {
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,
String initializationKey) {
boolean ownedByKey = values.stream()
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
return new InitializeResponse(values.get(0).getId(), ownedByKey);
CustomerChargeRuleEntity chargeRule,
boolean ownedByKey) {
return new InitializeResponse(values.get(0).getId(),
chargeRule == null ? null : chargeRule.getId(), ownedByKey);
}
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', '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', '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_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");
@@ -67,6 +68,14 @@ 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_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
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 '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注',
initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
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 '乐观锁版本号',
PRIMARY KEY (id),
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)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
@@ -1750,16 +1750,18 @@ components:
updatedAt: {type: string, format: date-time, nullable: true}
CustomerDimensionConfigInitializeRequest:
type: object
required: [tenantId, operatorId, initializationKey]
required: [tenantId, operatorId, initializationKey, customerName]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorId: {type: string, pattern: '^[0-9]+$'}
initializationKey: {type: string, minLength: 1, maxLength: 128}
customerName: {type: string, minLength: 1, maxLength: 128}
CustomerDimensionConfigInitializeResponse:
type: object
required: [configId, createdByThisInitialization]
required: [configId, chargeRuleId, createdByThisInitialization]
properties:
configId: {type: string, pattern: '^[1-9][0-9]*$'}
chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$'}
createdByThisInitialization: {type: boolean}
WeightPalletSnapshot:
type: object
@@ -33,15 +33,15 @@ class CustomerDimensionConfigControllerContractTest {
@Test
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
when(service.initializeDefault(8L, 100L, "registration-1"))
.thenReturn(new InitializeResponse(91L, true));
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1", "柏悦物流");
when(service.initializeDefault(8L, 100L, "registration-1", "柏悦物流"))
.thenReturn(new InitializeResponse(91L, 191L, true));
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
.isInstanceOf(BizException.class);
assertThat(internalController.initializeDefault("test-token", request).data())
.isEqualTo(new InitializeResponse(91L, true));
verify(service).initializeDefault(8L, 100L, "registration-1");
.isEqualTo(new InitializeResponse(91L, 191L, true));
verify(service).initializeDefault(8L, 100L, "registration-1", "柏悦物流");
}
@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.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -32,6 +33,8 @@ import static org.mockito.Mockito.when;
class CustomerDimensionConfigServiceTest {
@MockBean
private CustomerDimensionConfigRepository repository;
@MockBean
private CustomerChargeRuleInitializationService chargeRuleInitialization;
@Resource
private CustomerDimensionConfigService service;
@@ -40,10 +43,12 @@ class CustomerDimensionConfigServiceTest {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted);
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")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L));
@@ -67,10 +72,12 @@ class CustomerDimensionConfigServiceTest {
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
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));
}
@@ -78,10 +85,11 @@ class CustomerDimensionConfigServiceTest {
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
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(chargeRuleInitialization, never()).initializeDefault(anyLong(), anyLong(), any(), any());
}
@Test
@@ -173,10 +181,14 @@ class CustomerDimensionConfigServiceTest {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
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");
verify(repository).deleteUnchangedDefaults(8L, "registration-1");
verify(chargeRuleInitialization).deleteUnchangedDefault(8L, "registration-1");
}
@Test
@@ -228,4 +240,11 @@ class CustomerDimensionConfigServiceTest {
}
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`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
| 客户默认尺寸初始化、重试与补偿 | `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、撤销该账号全部会话并审计,不与公司联系电话联动 |
| 平台用户与海外 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` |
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
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",
url = "${baiyue.clients.firstleg.url:}",
path = "/internal/firstleg/v1/customer-dimension-configs")
@@ -24,7 +24,8 @@ public interface CustomerDimensionRuleClient {
@RequestParam long tenantId,
@RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { }
/** configId is the first record ID retained only for contract compatibility. */
record InitializationResponse(Long configId, boolean createdByThisInitialization) { }
record InitializationRequest(long tenantId, long operatorId, String initializationKey,
String customerName) { }
record InitializationResponse(Long configId, Long chargeRuleId,
boolean createdByThisInitialization) { }
}
@@ -3,10 +3,10 @@ package com.baiyue.iam.model;
public record CustomerDimensionInitializationTask(
long tenantId,
long operatorId,
String customerName,
String initializationKey,
String action,
String status,
int attemptCount,
int taskVersion) {
}
@@ -73,11 +73,14 @@ public class CustomerDimensionInitializationRepository {
+ "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, tenantId, now);
if (claimed != 1) return null;
return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status,"
+ "attempt_count,task_version FROM iam_customer_dimension_initialization_task "
+ "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?",
return jdbc.queryForObject("SELECT task.tenant_id,task.operator_id,tenant.tenant_name,"
+ "task.initialization_key,task.action,task.status,task.attempt_count,task.task_version "
+ "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.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.getInt("task_version")), tenantId, workerId);
}
@@ -43,10 +43,11 @@ public class CustomerDimensionInitializationRelay {
}
ApiResponse<CustomerDimensionRuleClient.InitializationResponse> response = client.initialize(
internalToken, new CustomerDimensionRuleClient.InitializationRequest(
task.tenantId(), task.operatorId(), task.initializationKey()));
task.tenantId(), task.operatorId(), task.initializationKey(), task.customerName()));
requireSuccess(response, true);
if (response.data() == null || response.data().configId() == null) {
throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false);
if (response.data() == null || response.data().configId() == null
|| response.data().chargeRuleId() == null) {
throw new DeliveryFailure("S0503", "Firstleg 未完整返回默认尺寸和权重系数规则ID", false);
}
repository.markReady(task, workerId, response.data().configId());
} catch (Exception failure) {
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionRuleClientContractTest {
@Test
void usesOneAtomicCallForTheFourDefaultBoxDimensionRecords() throws Exception {
void usesOneAtomicCallForBoxDimensionsAndWeightCoefficientRule() throws Exception {
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
PostMapping initialize = CustomerDimensionRuleClient.class
.getMethod("initialize", String.class,
@@ -25,6 +25,6 @@ class CustomerDimensionRuleClientContractTest {
assertThat(compensate.value()).containsExactly("/defaults");
assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("configId", "createdByThisInitialization");
.containsExactly("configId", "chargeRuleId", "createdByThisInitialization");
}
}
@@ -43,15 +43,33 @@ class CustomerDimensionInitializationRelayTest {
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, true)));
new CustomerDimensionRuleClient.InitializationResponse(91L, 191L, true)));
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, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
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
void transientFailureStaysPendingWithDiagnosticAndBackoff() {
CustomerDimensionInitializationTask task = task("INITIALIZE", 0);
@@ -95,7 +113,7 @@ class CustomerDimensionInitializationRelayTest {
}
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);
}
+21 -14
View File
@@ -87,6 +87,12 @@ $overseasPdaAgentsPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/AGEN
$backendAgentsPath = Join-Path $backendRoot 'AGENTS.md'
$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
$clientAgents = Read-RequiredText $clientAgentsPath
$pdaAgents = Read-RequiredText $pdaAgentsPath
@@ -124,17 +130,11 @@ Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作
Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁'
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 '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控'
Assert-Contains $testTaskRegistry '| 【总控】测试环境全流程 |' '测试环境唯一总控名称'
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-Contains $testTaskRegistry '当前无登记任务' '测试登记空状态'
Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试历史台账授权边界'
Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工' '测试自动派工停用边界'
Assert-Matches $testTaskRegistry '(?s)\A(?!.*(?m:^\s*\|)).*\z' '测试空登记不得残留任务表'
Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控'
Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控'
Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId'
@@ -786,14 +786,21 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各分摊金额必须大于0且合计等于费用总额' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '单行接口拒绝处理分摊组' '柜费分摊整组操作契约'
Assert-Matches $containerFeeOpenApi '各行按\s+HALF_UP\s+保留两位后必须大于零,\s*落库金额合计必须等于两位小数的总额' '柜费舍入后正数与金额守恒'
Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
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 $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"' '海外费用事件类型'