优化
This commit is contained in:
@@ -94,12 +94,21 @@ public class GlobalExceptionHandler {
|
||||
private ResponseEntity<ApiResponse<Map<String, String>>> validationResponse(
|
||||
Map<String, String> errors, HttpServletRequest request) {
|
||||
ResultCode code = ResultCode.VALIDATION_FAILED;
|
||||
ApiResponse<Map<String, String>> body = ApiResponse.of(code, code.message(), errors,
|
||||
ApiResponse<Map<String, String>> body = ApiResponse.of(code, validationMessage(errors), errors,
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||
recordFailure(code);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private String validationMessage(Map<String, String> 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<ApiResponse<Void>> failure(ResultCode code, String message,
|
||||
HttpServletRequest request) {
|
||||
ApiResponse<Void> body = ApiResponse.failure(code, message,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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` 增量已追加到固定入口末尾,不创建新的日期部署脚本。
|
||||
|
||||
@@ -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');
|
||||
|
||||
+12
-10
@@ -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<List<CarrierFbaRateEntity>> 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<CarrierFbaRateEntity> 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<CarrierFbaRateEntity> 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<Void> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CarrierFbaRateEntity>()
|
||||
.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<CarrierFbaRateEntity> listRates(Long tenantId, String rateCode) {
|
||||
public List<CarrierFbaRateEntity> listRates(String rateCode) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.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<CarrierFbaRateEntity> listRatesForUpdate(String rateCode) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.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<CustomerChargeRuleEntity> values = listRules(tenantId, ruleCode);
|
||||
return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1;
|
||||
}
|
||||
|
||||
public int nextRateVersion(long tenantId, String rateCode) {
|
||||
List<CarrierFbaRateEntity> values = listRates(tenantId, rateCode);
|
||||
public int nextRateVersion(String rateCode) {
|
||||
List<CarrierFbaRateEntity> 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<CarrierFbaRateEntity>()
|
||||
.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<CarrierFbaRateEntity> matchRates(InboundPricingEntity inbound, String chargeUnit,
|
||||
LocalDateTime at) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.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())
|
||||
|
||||
@@ -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<CarrierFbaRateEntity> listRates(Long tenantId, String code) {
|
||||
return repository.listRates(tenantId, code);
|
||||
public List<CarrierFbaRateEntity> 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- 旧索引可能缺少业务类型、船司和结束时间;用新索引名并存,避免破坏线上依赖。
|
||||
|
||||
@@ -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 '起运仓名称快照',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+48
-7
@@ -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<LambdaQueryWrapper<CarrierFbaRateEntity>> 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<LambdaQueryWrapper<CarrierFbaRateEntity>> 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<Object> rateQueryValues(Long tenantId, String rateCode) {
|
||||
private Collection<Object> rateQueryValues(String rateCode) {
|
||||
when(rates.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
|
||||
|
||||
repository.listRates(tenantId, rateCode);
|
||||
repository.listRates(rateCode);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<CarrierFbaRateEntity>> wrapper =
|
||||
|
||||
+14
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Void> 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")
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -78,6 +78,37 @@ public class OrderMasterDataGuard {
|
||||
throw downstreamFailure(exception);
|
||||
}
|
||||
}
|
||||
public PartnerClient.Partner validateCarrier(long id) {
|
||||
try {
|
||||
ApiResponse<PartnerClient.Partner> 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<PartnerClient.Partner> response;
|
||||
try {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user