diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 8d238f621..cb299bda9 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -94,12 +94,21 @@ public class GlobalExceptionHandler { private ResponseEntity>> validationResponse( Map errors, HttpServletRequest request) { ResultCode code = ResultCode.VALIDATION_FAILED; - ApiResponse> body = ApiResponse.of(code, code.message(), errors, + ApiResponse> body = ApiResponse.of(code, validationMessage(errors), errors, CorrelationContext.requestId(request), CorrelationContext.traceId(request)); recordFailure(code); return ResponseEntity.ok(body); } + private String validationMessage(Map errors) { + if (errors.isEmpty()) { + return ResultCode.VALIDATION_FAILED.message(); + } + return errors.entrySet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(java.util.stream.Collectors.joining(";")); + } + private ResponseEntity> failure(ResultCode code, String message, HttpServletRequest request) { ApiResponse body = ApiResponse.failure(code, message, diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index d2c6c808b..0df079856 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -7,6 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.test.util.ReflectionTestUtils; @@ -43,6 +46,21 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); } + @Test + void validationDetailsAreIncludedInMessageAndData() { + MockHttpServletRequest request = new MockHttpServletRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request"); + bindingResult.addError(new FieldError("request", "carrierId", "不能为null")); + bindingResult.addError(new FieldError("request", "carrierName", "不能为空")); + + var response = handler.handleBinding(new BindException(bindingResult), request); + + assertThat(response.getBody().code()).isEqualTo(ResultCode.VALIDATION_FAILED.code()); + assertThat(response.getBody().message()).isEqualTo("carrierId: 不能为null;carrierName: 不能为空"); + assertThat(response.getBody().data()).containsEntry("carrierId", "不能为null") + .containsEntry("carrierName", "不能为空"); + } + @Test void recordsBusinessFailureMetricByFiniteResultCode() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 2c11ba65a..3ebd532c5 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -186,6 +186,28 @@ SELECT 上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。 +## 船司 FBA 价格公共作用域迁移 + +- `fl_carrier_fba_rate` 的船司 FBA 价格固定归平台作用域 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。 +- 固定入口会先检查存量价格的 `(rate_code, rate_version)` 重复,以及相同匹配维度的有效期重叠;发现冲突时通过 `SQLSTATE 45000` 中止,不会自动覆盖价格。 +- 无冲突时,固定入口才会把存量船司价格迁移到 `tenant_id = 0`,并在末尾验证不存在非平台价格作用域。迁移完成后再启动或重启 `firstleg-service`。 + +执行前可只读盘点: + +```sql +SELECT tenant_id, COUNT(*) AS total + FROM baiyue_firstleg.fl_carrier_fba_rate + GROUP BY tenant_id + ORDER BY tenant_id; + +SELECT rate_code, rate_version, COUNT(*) AS total + FROM baiyue_firstleg.fl_carrier_fba_rate + GROUP BY rate_code, rate_version +HAVING COUNT(*) > 1; +``` + +如果固定入口因价格冲突中止,应先由业务确认保留版本、有效期和价格金额,再清理冲突后重新完整执行脚本;不要直接手工把冲突记录改为 `0`。 + ## 海外仓阶梯费用增量 - `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 6ff72d6a6..accdd4f6e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2527,8 +2527,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码', rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', @@ -3053,6 +3053,61 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; DROP PROCEDURE IF EXISTS phase2_fba_add_column; +-- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() +BEGIN + DECLARE v_conflicts INT DEFAULT 0; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT rate_code, rate_version + FROM fl_carrier_fba_rate + GROUP BY rate_code, rate_version + HAVING COUNT(*) > 1 + ) AS duplicate_versions; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; + END IF; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT first_rate.id + FROM fl_carrier_fba_rate first_rate + JOIN fl_carrier_fba_rate second_rate + ON first_rate.id < second_rate.id + AND first_rate.status = 'ACTIVE' + AND second_rate.status = 'ACTIVE' + AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id + AND first_rate.business_type <=> second_rate.business_type + AND first_rate.channel_code <=> second_rate.channel_code + AND first_rate.carrier_id <=> second_rate.carrier_id + AND first_rate.destination_code <=> second_rate.destination_code + AND first_rate.charge_unit <=> second_rate.charge_unit + AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') + AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') + LIMIT 1 + ) AS overlapping_rates; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; + END IF; + + UPDATE fl_carrier_fba_rate + SET tenant_id = 0 + WHERE tenant_id <> 0; + + IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; + END IF; +END$$ +DELIMITER ; +CALL phase2_fba_migrate_carrier_rate_scope(); +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; + -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 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/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index 4ad0fdf8f..80d64f61b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -75,19 +75,20 @@ public class PlatformFbaMasterDataController { @GetMapping("/carrier-rates") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')") - @Operation(summary = "查询船司 FBA 价格版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业") + @Operation(summary = "查询船司 FBA 价格版本", description = "查询平台公共价格;所有客户共享") public ApiResponse> rates( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) Long tenantId, @RequestParam(required = false) String rateCode) { - return ApiResponse.success(service.listRates(optionalPlatformTenant(user, tenantId), rateCode)); + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.listRates(rateCode)); } @GetMapping("/carrier-rates/{id}") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')") public ApiResponse rate( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) { - return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id)); + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.rate(id)); } @PostMapping("/carrier-rates/versions") @@ -95,10 +96,10 @@ public class PlatformFbaMasterDataController { @BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE", description = "创建船司FBA价格版本") public ApiResponse createRate( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody CarrierRateVersionRequest request) { - long scope = FirstlegScopeGuard.platform(user, tenantId); - return ApiResponse.success(service.createRateVersion(scope, user.userId(), request)); + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.createRateVersion(user.userId(), request)); } @DeleteMapping("/carrier-rates/{id}") @@ -106,8 +107,9 @@ public class PlatformFbaMasterDataController { @BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id", description = "停用船司FBA价格版本") public ApiResponse retireRate(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, @PathVariable long id) { - service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); + @PathVariable long id) { + FirstlegScopeGuard.platform(user); + service.retireRate(id, user.userId()); return ApiResponse.success(null); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 9dd81bd6a..892552ecc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -15,6 +15,9 @@ import java.time.LocalDateTime; @Setter @TableName("fl_carrier_fba_rate") public class CarrierFbaRateEntity extends BaseEntity { + /** 船司价格是平台公共主数据,不绑定任何客户。 */ + public static final long PLATFORM_SCOPE = 0L; + @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; 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 448445e87..82e14bcac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -50,9 +50,9 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getId, id)); } - public CarrierFbaRateEntity findRate(long tenantId, long id) { + public CarrierFbaRateEntity findRate(long id) { return rates.selectOne(new LambdaQueryWrapper() - .eq(CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getId, id)); } @@ -64,21 +64,34 @@ public class FbaPricingRepository { .orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); } - public List listRates(Long tenantId, String rateCode) { + public List listRates(String rateCode) { return rates.selectList(new LambdaQueryWrapper() - .eq(tenantId != null, CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode) .orderByAsc(CarrierFbaRateEntity::getRateCode) .orderByDesc(CarrierFbaRateEntity::getRateVersion)); } + /** + * 在创建平台公共价格版本时锁定当前公共价格集合,串行化版本号和有效期检查。 + * 调用方必须在事务中使用该方法。 + */ + public List listRatesForUpdate(String rateCode) { + return rates.selectList(new LambdaQueryWrapper() + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) + .eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode) + .orderByAsc(CarrierFbaRateEntity::getRateCode) + .orderByDesc(CarrierFbaRateEntity::getRateVersion) + .last("FOR UPDATE")); + } + public int nextRuleVersion(long tenantId, String ruleCode) { List values = listRules(tenantId, ruleCode); return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1; } - public int nextRateVersion(long tenantId, String rateCode) { - List values = listRates(tenantId, rateCode); + public int nextRateVersion(String rateCode) { + List values = listRatesForUpdate(rateCode); return values.isEmpty() ? 1 : values.get(0).getRateVersion() + 1; } @@ -91,9 +104,9 @@ public class FbaPricingRepository { .set(CustomerChargeRuleEntity::getUpdatedBy, operatorId)); } - public int retireRate(long tenantId, long id, long operatorId) { + public int retireRate(long id, long operatorId) { return rates.update(null, new LambdaUpdateWrapper() - .eq(CarrierFbaRateEntity::getTenantId, tenantId) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getId, id) .eq(CarrierFbaRateEntity::getStatus, "ACTIVE") .set(CarrierFbaRateEntity::getStatus, "INACTIVE") @@ -121,7 +134,7 @@ public class FbaPricingRepository { public List matchRates(InboundPricingEntity inbound, String chargeUnit, LocalDateTime at) { return rates.selectList(new LambdaQueryWrapper() - .eq(CarrierFbaRateEntity::getTenantId, inbound.getTenantId()) + .eq(CarrierFbaRateEntity::getTenantId, CarrierFbaRateEntity.PLATFORM_SCOPE) .eq(CarrierFbaRateEntity::getOriginWarehouseId, inbound.getWarehouseId()) .eq(CarrierFbaRateEntity::getBusinessType, inbound.getBusinessType()) .eq(CarrierFbaRateEntity::getChannelCode, inbound.getChannelCode()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java index 59b60be6a..4a9afefe0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -59,10 +59,9 @@ public class FbaMasterDataService { } @Transactional - public CarrierFbaRateEntity createRateVersion(long tenantId, long operatorId, - CarrierRateVersionRequest request) { + public CarrierFbaRateEntity createRateVersion(long operatorId, CarrierRateVersionRequest request) { validatePeriod(request.effectiveFrom(), request.effectiveTo()); - boolean overlaps = repository.listRates(tenantId, null).stream() + boolean overlaps = repository.listRatesForUpdate(null).stream() .filter(v -> "ACTIVE".equals(v.getStatus())) .filter(v -> Objects.equals(v.getOriginWarehouseId(), request.originWarehouseId())) .filter(v -> Objects.equals(v.getBusinessType(), request.businessType())) @@ -75,9 +74,9 @@ public class FbaMasterDataService { if (overlaps) throw conflict("船司 FBA 价格有效期重叠,无法保证唯一匹配"); CarrierFbaRateEntity value = new CarrierFbaRateEntity(); - value.setTenantId(tenantId); + value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setRateCode(request.rateCode()); - value.setRateVersion(repository.nextRateVersion(tenantId, request.rateCode())); + value.setRateVersion(repository.nextRateVersion(request.rateCode())); value.setOriginWarehouseId(request.originWarehouseId()); value.setOriginWarehouseName(request.originWarehouseName()); value.setBusinessType(request.businessType()); @@ -103,8 +102,8 @@ public class FbaMasterDataService { return repository.listRules(tenantId, code); } - public List listRates(Long tenantId, String code) { - return repository.listRates(tenantId, code); + public List listRates(String code) { + return repository.listRates(code); } public CustomerChargeRuleEntity rule(long tenantId, long id) { @@ -113,8 +112,8 @@ public class FbaMasterDataService { return value; } - public CarrierFbaRateEntity rate(long tenantId, long id) { - CarrierFbaRateEntity value = repository.findRate(tenantId, id); + public CarrierFbaRateEntity rate(long id) { + CarrierFbaRateEntity value = repository.findRate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司 FBA 价格版本不存在"); return value; } @@ -125,8 +124,8 @@ public class FbaMasterDataService { } @Transactional - public void retireRate(long tenantId, long id, long operatorId) { - if (repository.retireRate(tenantId, id, operatorId) != 1) throw conflict("价格已停用或不存在"); + public void retireRate(long id, long operatorId) { + if (repository.retireRate(id, operatorId) != 1) throw conflict("价格已停用或不存在"); } private static void validatePeriod(LocalDateTime from, LocalDateTime to) { diff --git a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql index 5024450c5..5c5cb23d0 100644 --- a/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql +++ b/firstleg/src/main/resources/db/phase2-fba-pricing-migration.sql @@ -56,6 +56,61 @@ SET @sql = IF(@resource_tenant_needs_modify > 0, 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; +-- 船司 FBA 价格是平台公共主数据;迁移前检查版本和有效期冲突,避免静默覆盖客户价格。 +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; +DELIMITER $$ +CREATE PROCEDURE phase2_fba_migrate_carrier_rate_scope() +BEGIN + DECLARE v_conflicts INT DEFAULT 0; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT rate_code, rate_version + FROM fl_carrier_fba_rate + GROUP BY rate_code, rate_version + HAVING COUNT(*) > 1 + ) AS duplicate_versions; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:不同客户存在重复价格编码版本,请先合并'; + END IF; + + SELECT COUNT(*) INTO v_conflicts + FROM ( + SELECT first_rate.id + FROM fl_carrier_fba_rate first_rate + JOIN fl_carrier_fba_rate second_rate + ON first_rate.id < second_rate.id + AND first_rate.status = 'ACTIVE' + AND second_rate.status = 'ACTIVE' + AND first_rate.origin_warehouse_id <=> second_rate.origin_warehouse_id + AND first_rate.business_type <=> second_rate.business_type + AND first_rate.channel_code <=> second_rate.channel_code + AND first_rate.carrier_id <=> second_rate.carrier_id + AND first_rate.destination_code <=> second_rate.destination_code + AND first_rate.charge_unit <=> second_rate.charge_unit + AND first_rate.effective_from < COALESCE(second_rate.effective_to, '9999-12-31 23:59:59.999') + AND second_rate.effective_from < COALESCE(first_rate.effective_to, '9999-12-31 23:59:59.999') + LIMIT 1 + ) AS overlapping_rates; + IF v_conflicts > 0 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:公共匹配维度存在有效期重叠,请先合并'; + END IF; + + UPDATE fl_carrier_fba_rate + SET tenant_id = 0 + WHERE tenant_id <> 0; + + IF EXISTS (SELECT 1 FROM fl_carrier_fba_rate WHERE tenant_id <> 0) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'FBA价格迁移失败:仍存在非平台价格作用域'; + END IF; +END$$ +DELIMITER ; +CALL phase2_fba_migrate_carrier_rate_scope(); +DROP PROCEDURE IF EXISTS phase2_fba_migrate_carrier_rate_scope; + DROP PROCEDURE IF EXISTS phase2_fba_add_column; -- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。 diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index aaf7ad059..21c363eb4 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -309,8 +309,8 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate ( id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', - rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码', rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号', origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID', origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c0038edae..cdf5048db 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -32,26 +32,24 @@ paths: responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates: get: - summary: 查询船司 FBA 价格版本 + summary: 查询平台公共船司 FBA 价格版本 description: 需要 firstleg:fba-pricing:rate:view 权限 parameters: - - {$ref: '#/components/parameters/OptionalTenantId'} - {name: rateCode, in: query, schema: {type: string, maxLength: 50}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates/versions: post: - summary: 新建船司 FBA 价格版本 - parameters: [{$ref: '#/components/parameters/TenantId'}] + summary: 新建平台公共船司 FBA 价格版本 requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CarrierRateVersionRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/carrier-rates/{id}: get: summary: 查询船司 FBA 价格版本详情 - parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + parameters: [{$ref: '#/components/parameters/Id'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} delete: summary: 停用船司 FBA 价格版本 - parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/Id'}] + parameters: [{$ref: '#/components/parameters/Id'}] responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/inbounds/{inboundId}/fees/calculate: post: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index cf912bf50..c689185fe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -13,20 +13,26 @@ import static org.assertj.core.api.Assertions.assertThat; @DisplayName("FBA计费OpenAPI契约") class FbaPricingContractTest { @Test - @DisplayName("只有基础资料列表允许省略企业参数") - void onlyMasterDataListsAllowOptionalTenant() { - for (String listMethod : new String[] {"rules", "rates"}) { - Method method = method(listMethod); - assertThat(method.getParameterTypes()[1]).isEqualTo(Long.class); - assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); - } + @DisplayName("客户规则保留客户参数,船司价格使用平台公共作用域") + void keepsCustomerRuleScopeAndRemovesCarrierRateTenantScope() { + Method rules = method("rules"); + assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class); + assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); - for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", - "rate", "createRate", "retireRate"}) { + Method rates = method("rates"); + assertThat(rates.getParameterTypes()).doesNotContain(Long.class); + + for (String scopedMethod : new String[] {"rule", "createRule", "retireRule"}) { Method method = method(scopedMethod); assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); } + + for (String publicMethod : new String[] {"rate", "createRate", "retireRate"}) { + assertThat(java.util.Arrays.stream(method(publicMethod).getParameters()) + .map(parameter -> parameter.getAnnotation(RequestParam.class))) + .allMatch(annotation -> annotation == null); + } } @Test @@ -62,7 +68,7 @@ class FbaPricingContractTest { openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates:"), openapi.indexOf("/api/platform/v1/fba-pricing/carrier-rates/versions:")); assertThat(carrierRates) - .contains("OptionalTenantId") + .doesNotContain("OptionalTenantId") .contains("name: rateCode"); assertThat(openapi) .contains("OptionalTenantId: {name: tenantId, in: query, required: false") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 653c42a8b..454cfe888 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; import com.baiyue.firstleg.mapper.InboundFeeItemMapper; @@ -48,6 +49,8 @@ class FbaPricingRepositoryRuleListTest { CustomerChargeRuleEntity.class); TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), CarrierFbaRateEntity.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + InboundPricingEntity.class); } @Test @@ -61,14 +64,52 @@ class FbaPricingRepositoryRuleListTest { } @Test - void platformRateListOmitsTenantConditionWhenTenantIsAbsent() { - assertThat(rateQueryValues(null, null)).isEmpty(); + void platformRateListAlwaysUsesPublicScope() { + assertThat(rateQueryValues(null)).containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); } @Test - void platformRateListKeepsTenantAndRateCodeFiltersWhenProvided() { - assertThat(rateQueryValues(10001L, "RATE-1")) - .containsExactlyInAnyOrder(10001L, "RATE-1"); + void platformRateListKeepsPublicScopeAndRateCodeFiltersWhenProvided() { + assertThat(rateQueryValues("RATE-1")) + .containsExactlyInAnyOrder(CarrierFbaRateEntity.PLATFORM_SCOPE, "RATE-1"); + } + + @Test + void platformRateCreateQueryLocksPublicScope() { + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.listRatesForUpdate(null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + assertThat(wrapper.getValue().getCustomSqlSegment()).contains("FOR UPDATE"); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .containsExactly(CarrierFbaRateEntity.PLATFORM_SCOPE); + } + + @Test + void carrierRateMatchingAlwaysUsesPlatformScope() { + InboundPricingEntity inbound = new InboundPricingEntity(); + inbound.setTenantId(10001L); + inbound.setWarehouseId(21L); + inbound.setBusinessType("FBA_SEA"); + inbound.setChannelCode("SEA-TAX"); + inbound.setCarrierId(31L); + inbound.setDestinationCode("ONT8"); + when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + + repository.matchRates(inbound, "KG", java.time.LocalDateTime.of(2026, 8, 1, 0, 0)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rates).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains(CarrierFbaRateEntity.PLATFORM_SCOPE) + .doesNotContain(10001L); } @Test @@ -122,10 +163,10 @@ class FbaPricingRepositoryRuleListTest { return wrapper.getValue().getParamNameValuePairs().values(); } - private Collection rateQueryValues(Long tenantId, String rateCode) { + private Collection rateQueryValues(String rateCode) { when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); - repository.listRates(tenantId, rateCode); + repository.listRates(rateCode); @SuppressWarnings("unchecked") ArgumentCaptor> wrapper = diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 36a47d521..fec46d05c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -38,4 +38,18 @@ class InboundWorkflowSqlContractTest { .contains("SET warehouse_resource_tenant_id = 0") .contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL"); } + + @Test + @DisplayName("船司价格存量迁移固定到平台作用域并拒绝冲突") + void shouldMigrateCarrierRatesToPublicScopeWithoutOverwritingConflicts() throws Exception { + String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(migration) + .contains("phase2_fba_migrate_carrier_rate_scope") + .contains("GROUP BY rate_code, rate_version") + .contains("公共匹配维度存在有效期重叠") + .contains("SET tenant_id = 0") + .contains("WHERE tenant_id <> 0"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java index b0e85db55..dec7edf1d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; +import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; +import com.baiyue.firstleg.entity.CarrierFbaRateEntity; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.repository.FbaPricingRepository; import jakarta.annotation.Resource; @@ -73,11 +75,33 @@ class FbaMasterDataServiceTest { .hasMessageContaining("有效期重叠"); } + @Test + @DisplayName("船司价格版本固定写入平台公共作用域") + void createsCarrierRateInPlatformScope() { + when(repository.listRatesForUpdate(null)).thenReturn(List.of()); + when(repository.nextRateVersion("RATE-1")).thenReturn(1); + doAnswer(invocation -> { ((CarrierFbaRateEntity) invocation.getArgument(0)).setId(20L); return null; }) + .when(repository).insertRate(any()); + + CarrierFbaRateEntity created = service.createRateVersion(2L, carrierRateRequest()); + + assertThat(created.getTenantId()).isEqualTo(CarrierFbaRateEntity.PLATFORM_SCOPE); + verify(repository).listRatesForUpdate(null); + verify(repository).nextRateVersion("RATE-1"); + verify(repository).insertRate(any()); + } + private CustomerRuleVersionRequest request(LocalDateTime from, LocalDateTime to) { return new CustomerRuleVersionRequest("CR-1", 100L, "客户A", "FBA_SEA", "KG", "TAX_INCLUDED", new BigDecimal("167"), 3, "CEILING", "USD", from, to, null); } + private CarrierRateVersionRequest carrierRateRequest() { + return new CarrierRateVersionRequest("RATE-1", 21L, "义乌仓", "FBA_SEA", "SEA-TAX", + 31L, "船司A", "ONT8", "KG", new BigDecimal("2.50"), "USD", new BigDecimal("10"), + AUGUST, null, null); + } + private CustomerChargeRuleEntity activeRule(LocalDateTime from, LocalDateTime to) { CustomerChargeRuleEntity value = new CustomerChargeRuleEntity(); value.setRuleCode("CR-1"); value.setRuleVersion(1); value.setCustomerId(100L); diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 481e3cc3b..06ec9cda8 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -155,6 +155,19 @@ public class PlatformFirstLegOrderController { return transit(user, tenantId, id, OrderStatus.APPROVED, null); } + @BusinessAudit(event = "ORDER_ASSIGN_CARRIER", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "指派订单承运商") + @Operation(summary = "指派承运商", description = "为待审核订单选择启用的公共承运商,名称快照由服务端主数据回填。") + @PutMapping("/{id}/carrier") + @PreAuthorize("hasAuthority('platform:order:approve')") + public ApiResponse assignCarrier(@AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, + @Parameter(description = "订单 ID", required = true) @PathVariable long id, + @Valid @RequestBody OrderRequests.CarrierRequest request) { + long scopedTenant = OrderScopeGuard.platform(user, tenantId); + service.assignCarrier(scopedTenant, user.userId(), id, request); + return ApiResponse.success(null); + } + @BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单") @Operation(summary = "Reject / 驳回", description = "Rejects a submitted order with a required reason. / 使用必填原因驳回已提交订单。") @PostMapping("/{id}/reject") diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 8e66b50ca..ab1d9fc81 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -3,6 +3,7 @@ package com.baiyue.order.dto; import com.baiyue.order.entity.OrderStatus; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; @@ -55,8 +56,10 @@ public final class OrderRequests { @NotBlank @Size(max = 128) String receivingWarehouseName, @NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 128) String channelName, - @NotNull Long carrierId, - @NotBlank @Size(max = 128) String carrierName, + @Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传") + @Min(1) Long carrierId, + @Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128) + @Size(max = 128) String carrierName, @NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode, @Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode, @NotNull Boolean customsInspection, @@ -64,6 +67,17 @@ public final class OrderRequests { @Size(max = 32) String amazonWarehouseCode, @Size(max = 64) String shipmentId, @Size(max = 64) String referenceId + ) { + @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") + public boolean isCarrierSnapshotComplete() { + return (carrierId == null) == (carrierName == null || carrierName.isBlank()); + } + } + + @Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商") + public record CarrierRequest( + @Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Min(1) Long carrierId ) { } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 9c036b27e..ed44baa9e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -73,6 +73,19 @@ public class OrderApplicationService { updateOrderData(tenantId, userId, id, request, true); } + @Transactional + public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) { + OrderEntity order = require(tenantId, id); + if (order.getStatus() != OrderStatus.SUBMITTED) { + throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商"); + } + var carrier = masterDataGuard.validateCarrier(request.carrierId()); + order.setCarrierId(carrier.id()); + order.setCarrierName(carrier.partnerName()); + order.setUpdatedBy(userId); + if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); + } + private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, boolean allowSubmitted) { OrderEntity order = require(tenantId, id); @@ -105,6 +118,14 @@ public class OrderApplicationService { masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(), order.getDestinationWarehouseId(), order.getDestinationType()); } + if (target == OrderStatus.APPROVED) { + if (order.getCarrierId() == null || !StringUtils.hasText(order.getCarrierName())) { + throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, + "请先选择有效承运商后再审核通过"); + } + var carrier = masterDataGuard.validateCarrier(order.getCarrierId()); + order.setCarrierName(carrier.partnerName()); + } if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) { throw new BizException("Reason is required"); } @@ -327,7 +348,9 @@ public class OrderApplicationService { order.setReceivingTime(formatReceivingTime(snapshots.receiving())); order.setChannelCode(request.channelCode()); order.setChannelName(request.channelName()); - order.setCarrierId(request.carrierId()); order.setCarrierName(request.carrierName()); + order.setCarrierId(request.carrierId()); + order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName()) + ? null : request.carrierName().trim()); order.setTaxMode(request.taxMode()); order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection()); order.setDestinationType(request.destinationType()); diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 3840cb691..3bee96ab3 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -78,6 +78,37 @@ public class OrderMasterDataGuard { throw downstreamFailure(exception); } } + public PartnerClient.Partner validateCarrier(long id) { + try { + ApiResponse response; + try { + response = partners.active(token, publicTenantId, id); + } catch (FeignException.NotFound exception) { + throw new BizException(ResultCode.NOT_FOUND, "承运商不存在或不可用"); + } + requireSuccessful(response, "承运商"); + if (response.data() == null) throw unavailable("承运商"); + var value = response.data(); + if (!Long.valueOf(id).equals(value.id())) throw unavailable("承运商"); + if (!Long.valueOf(publicTenantId).equals(value.tenantId())) + throw new BizException(ResultCode.FORBIDDEN, "承运商不属于平台公共主数据"); + if (!"ACTIVE".equals(value.status())) + throw new BizException(ResultCode.CONFLICT, "承运商已停用"); + boolean hasCarrierType = value.types() != null && value.types().contains("CARRIER"); + boolean legacyCarrierType = (value.types() == null || value.types().isEmpty()) + && "CARRIER".equals(value.partnerType()); + if (!hasCarrierType && !legacyCarrierType) + throw new BizException(ResultCode.VALIDATION_FAILED, "所选往来单位不具备承运商类型"); + if (!StringUtils.hasText(value.partnerName())) throw unavailable("承运商"); + return value; + } catch (FeignException exception) { + throw downstreamFailure(exception); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw downstreamFailure(exception); + } + } private PartnerClient.Partner loadPartner(long id) { ApiResponse response; try { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5be30c370..408cbd66d 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.3.0 + version: 1.4.0 security: - bearerAuth: [] paths: @@ -200,6 +200,25 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:edit 权限} + /api/platform/v1/orders/first-leg/{id}/carrier: + put: + summary: 为待审核订单指派承运商 + description: 只接收承运商 ID;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照。审核通过前必须完成指派。 + parameters: + - $ref: '#/components/parameters/OrderId' + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [carrierId] + properties: + carrierId: {type: string, pattern: '^[1-9][0-9]*$'} + responses: + '200': {description: 指派成功或业务失败,按响应体 code 判断} + '403': {description: 需要 platform:order:approve 权限} components: securitySchemes: bearerAuth: diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 03870fd24..80b72d7d4 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -41,6 +41,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", AuthenticatedUser.class, long.class, long.class, String.class); + assertAuthority(PlatformFirstLegOrderController.class, "assignCarrier", "hasAuthority('platform:order:approve')", + AuthenticatedUser.class, long.class, long.class, OrderRequests.CarrierRequest.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java new file mode 100644 index 000000000..308fee7fe --- /dev/null +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderRequestsValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() { + assertThat(validator.validate(request(null, null))).isEmpty(); + } + + @Test + void carrierSnapshotMustBeBothPresentOrBothAbsent() { + assertThat(validator.validate(request(9L, null))) + .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); + assertThat(validator.validate(request(null, "船司"))) + .anyMatch(violation -> violation.getMessage().contains("必须同时填写或同时留空")); + assertThat(validator.validate(request(9L, "船司"))).isEmpty(); + } + + private OrderRequests.SaveRequest request(Long carrierId, String carrierName) { + var cargo = new OrderRequests.Cargo( + "货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE, + null, null, null, null, null, false, null, null, null, null, + null, null, null, null, null, null, null, null, null); + var request = new OrderRequests.SaveRequest( + null, "当前企业", "SEA", "FOB", "US", 193000000000200003L, "洛杉矶仓", + null, null, null, null, List.of(cargo), "SELF_EXPRESS", 193000000000200001L, + "义乌仓", "SEA-US-GENERAL", "美线普货专线", carrierId, carrierName, "BUY_DECLARATION", + "FULL_CONTAINER", true, "OVERSEAS", null, null, "123456789"); + return request; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index d04e34ebc..9a24965f5 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -101,6 +101,58 @@ class OrderApplicationServiceStateTest { && "UPDATE".equals(history.getAction()))); } + @Test + void platformAssignsTrustedCarrierSnapshotToSubmittedOrder() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(masterData.validateCarrier(9L)).thenReturn( + new PartnerClient.Partner(9L, 0L, "服务端船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .assignCarrier(10001L, 2L, 1L, new OrderRequests.CarrierRequest(9L)); + + assertThat(order.getCarrierId()).isEqualTo(9L); + assertThat(order.getCarrierName()).isEqualTo("服务端船司名"); + verify(repository).saveOrder(10001L, order); + } + + @Test + void approvalRequiresCarrierAssignment() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("请先选择有效承运商"); + + verify(repository, never()).saveOrder(anyLong(), any()); + } + + @Test + void approvalRevalidatesCarrierAndRefreshesItsName() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + order.setCarrierId(9L); + order.setCarrierName("旧名称"); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(masterData.validateCarrier(9L)).thenReturn( + new PartnerClient.Partner(9L, 0L, "最新船司名", "ACTIVE", "CARRIER", List.of("CARRIER"))); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED); + assertThat(order.getCarrierName()).isEqualTo("最新船司名"); + verify(masterData).validateCarrier(9L); + } + @Test void merchantDraftEditorStillCannotEditSubmittedOrder() { OrderRepository repository = mock(OrderRepository.class); @@ -297,4 +349,12 @@ class OrderApplicationServiceStateTest { order.setDestinationType(destinationType); return order; } + + private OrderEntity persistedOrder(OrderStatus status) { + OrderEntity order = new OrderEntity(); + order.setId(1L); + order.setTenantId(10001L); + order.setStatus(status); + return order; + } } diff --git a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java index 1bf86be13..48e0a44c4 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderMasterDataGuardTest.java @@ -45,6 +45,31 @@ class OrderMasterDataGuardTest { assertThat(result.receiving().warehouse().warehouseName()).isEqualTo("深圳仓"); } @Test + void carrierValidationReturnsTrustedPublicCarrierSnapshot() { + var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "服务端船司名", "ACTIVE", "CARRIER", List.of("SUPPLIER", "CARRIER")); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier)); + + assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("服务端船司名"); + } + @Test + void carrierValidationSupportsLegacySingleTypeSnapshot() { + var carrier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "旧数据船司", "ACTIVE", "CARRIER", null); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(carrier)); + + assertThat(guard.validateCarrier(9L).partnerName()).isEqualTo("旧数据船司"); + } + @Test + void partnerWithoutCarrierTypeCannotBeAssignedToOrder() { + var supplier = new PartnerClient.Partner(9L, PUBLIC_TENANT_ID, "普通供应商", "ACTIVE", "SUPPLIER", List.of("SUPPLIER")); + when(partners.active("token", PUBLIC_TENANT_ID, 9L)).thenReturn(ApiResponse.success(supplier)); + + assertThatThrownBy(() -> guard.validateCarrier(9L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不具备承运商类型") + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.VALIDATION_FAILED); + } + @Test void inactiveOrCrossTenantMasterDataIsRejected() { when(partners.active("token",PUBLIC_TENANT_ID,7)).thenReturn(ApiResponse.success(null)); assertThatThrownBy(() -> guard.validate(10002,request(7L,8L)))