diff --git a/AGENTS.md b/AGENTS.md index 6ddf0fe03..c8f8e2f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java index 70df7e8aa..86281d867 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java @@ -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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java index 1d8e12098..7a9f1f838 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -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) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java index 45a795675..401b753c3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerChargeRuleEntity.java @@ -38,4 +38,6 @@ public class CustomerChargeRuleEntity extends BaseEntity { private LocalDateTime effectiveTo; private String status; private String remark; + /** 客户创建执行器的幂等键;人工创建的规则为空。 */ + private String initializationKey; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java index 2f0c59d9e..76fa1538d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerChargeRuleMapper.java @@ -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 {} +public interface CustomerChargeRuleMapper extends BaseMapper { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 0bc2378ba..923b6fbbb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -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() .eq(CustomerChargeRuleEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationService.java new file mode 100644 index 000000000..373407fde --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationService.java @@ -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(); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java index dc594e632..c3eb59a90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -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 existing = repository.findAll(tenantId); if (!existing.isEmpty()) { List 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 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 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 values, + String initializationKey) { + return values.stream().allMatch(value -> initializationKey.equals(value.getInitializationKey())); + } + private static InitializeResponse initializationResponse(List 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 values) { 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 62d521929..2249b496b 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -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'); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index d6945fe11..8be10cda3 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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='客户头程计费规则版本'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index bf19f4436..6a77b5862 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java index 5bae64fbb..ed1490836 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/CustomerDimensionConfigControllerContractTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java new file mode 100644 index 000000000..527654467 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerChargeRuleInitializationServiceTest.java @@ -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 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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java index 6f605dee0..8809a8773 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -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 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> 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; + } } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ec959157e..ce4dddaca 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -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` | diff --git a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java index f02b8f982..d2eb9c2b8 100644 --- a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.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) { } } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java index 51b3aea9a..0d20384bb 100644 --- a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java @@ -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) { } - diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java index cc9d35ba8..358e728af 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -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); } diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java index 817d7eb81..ea43f1686 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -43,10 +43,11 @@ public class CustomerDimensionInitializationRelay { } ApiResponse 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) { diff --git a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java index 2375311f0..10db975cc 100644 --- a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java @@ -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"); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java index bdd692767..2fcc59dd1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -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 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); } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 8263d0e9b..b8e6b2fb1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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"' '海外费用事件类型'