Merge branch 'test' into test-02huogui
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql # firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java # firstleg/src/main/resources/openapi/firstleg-v1.yaml
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,12 @@ SELECT
|
||||
|
||||
上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。
|
||||
|
||||
## 船司 FBA 价格公共作用域
|
||||
|
||||
- 当前测试环境 `fl_carrier_fba_rate` 为空表,不执行存量价格迁移。
|
||||
- 初始化表结构将船司 FBA 价格定义为平台公共价格,Firstleg 创建、查询和计费匹配固定使用 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。
|
||||
- 初始化或重建测试库后,再启动或重启 `firstleg-service`。
|
||||
|
||||
## 海外仓阶梯费用增量
|
||||
|
||||
- `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。
|
||||
|
||||
@@ -411,6 +411,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号',
|
||||
inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态',
|
||||
@@ -449,6 +450,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
|
||||
UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code),
|
||||
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
|
||||
KEY idx_order_tenant_customer (tenant_id, customer_id),
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
@@ -2213,6 +2215,30 @@ PREPARE carrier_name_statement FROM @carrier_name_sql;
|
||||
EXECUTE carrier_name_statement;
|
||||
DEALLOCATE PREPARE carrier_name_statement;
|
||||
|
||||
-- 客户提交时由 Order 固定入仓码;审核通过后 Firstleg 沿用该码创建正式入库单。
|
||||
SET @inbound_code_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ord_order' AND column_name = 'inbound_code'
|
||||
);
|
||||
SET @inbound_code_sql := IF(@inbound_code_exists = 0,
|
||||
'ALTER TABLE ord_order ADD COLUMN inbound_code VARCHAR(40) NULL COMMENT ''客户提交后生成的入仓码,草稿为空'' AFTER order_no',
|
||||
'SELECT 1');
|
||||
PREPARE inbound_code_statement FROM @inbound_code_sql;
|
||||
EXECUTE inbound_code_statement;
|
||||
DEALLOCATE PREPARE inbound_code_statement;
|
||||
|
||||
SET @inbound_code_index_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'ord_order'
|
||||
AND index_name = 'uk_order_tenant_inbound_code'
|
||||
);
|
||||
SET @inbound_code_index_sql := IF(@inbound_code_index_exists = 0,
|
||||
'ALTER TABLE ord_order ADD UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code)',
|
||||
'SELECT 1');
|
||||
PREPARE inbound_code_index_statement FROM @inbound_code_index_sql;
|
||||
EXECUTE inbound_code_index_statement;
|
||||
DEALLOCATE PREPARE inbound_code_index_statement;
|
||||
|
||||
-- ============================================================================
|
||||
-- 来源:firstleg\src\main\resources\db\phase2-inbound-schema.sql
|
||||
-- ============================================================================
|
||||
@@ -2294,6 +2320,12 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
|
||||
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
|
||||
|
||||
UPDATE baiyue_order.ord_order o
|
||||
LEFT JOIN baiyue_firstleg.fl_inbound_order i
|
||||
ON i.tenant_id = o.tenant_id AND i.order_id = o.id AND i.deleted = 0
|
||||
SET o.inbound_code = COALESCE(i.inbound_code, CONCAT('RK-', o.id))
|
||||
WHERE o.deleted = 0 AND o.status <> 'DRAFT' AND o.inbound_code IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
@@ -2527,8 +2559,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 '起运仓名称快照',
|
||||
@@ -3440,6 +3472,7 @@ CALL unified_assert_table('baiyue_audit', 'sys_audit_log');
|
||||
CALL unified_assert_table('baiyue_event_bridge', 'bridge_inbox');
|
||||
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id');
|
||||
CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name');
|
||||
CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code');
|
||||
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id');
|
||||
CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no');
|
||||
CALL unified_assert_column('baiyue_file', 'file_export_task', 'execution_deadline_at');
|
||||
@@ -4919,6 +4952,24 @@ JOIN wh_warehouse warehouse
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_id = VALUES(tenant_id);
|
||||
|
||||
-- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。
|
||||
INSERT INTO wh_service_capability (
|
||||
id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time,
|
||||
description, created_by, created_at, updated_by, updated_at, deleted, version
|
||||
)
|
||||
VALUES
|
||||
(193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00',
|
||||
'义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0),
|
||||
(193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00',
|
||||
'东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
capability_name = VALUES(capability_name),
|
||||
enabled = VALUES(enabled),
|
||||
cutoff_time = VALUES(cutoff_time),
|
||||
description = VALUES(description),
|
||||
deleted = 0,
|
||||
updated_at = CURRENT_TIMESTAMP(3);
|
||||
|
||||
-- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。
|
||||
INSERT INTO wh_pallet (
|
||||
id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm,
|
||||
@@ -5429,6 +5480,16 @@ BEGIN
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(*)
|
||||
FROM wh_service_capability capability
|
||||
WHERE capability.tenant_id = 0
|
||||
AND capability.warehouse_id IN (193000000000200001,193000000000200002)
|
||||
AND capability.capability_code = 'RECEIVING'
|
||||
AND capability.enabled = 1
|
||||
AND capability.deleted = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL verify_wh_fixed_platform_warehouse();
|
||||
@@ -5687,6 +5748,28 @@ BEGIN
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order'
|
||||
AND index_name = 'uk_order_tenant_inbound_code') <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:订单入仓码唯一索引未正确创建';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM baiyue_order.ord_order
|
||||
WHERE deleted = 0
|
||||
AND ((status = 'DRAFT' AND inbound_code IS NOT NULL)
|
||||
OR (status <> 'DRAFT' AND inbound_code IS NULL))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:订单状态与入仓码数据不一致';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name IN ('fl_preload_plan', 'fl_split_operation',
|
||||
'fl_loading_evidence', 'fl_loading_operation',
|
||||
'fl_loading_pick_task')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期分柜、预装或装柜表未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ public class InternalInboundOrderController {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
if (command.tenantId() <= 0 || command.orderId() <= 0 || command.warehouseId() == null
|
||||
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()) {
|
||||
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()
|
||||
|| command.inboundCode() == null || command.inboundCode().isBlank()
|
||||
|| command.inboundCode().length() > 40) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
|
||||
}
|
||||
return ApiResponse.success(service.createFromApprovedOrder(command));
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ public record OrderApprovedInboundCommand(
|
||||
long tenantId,
|
||||
long orderId,
|
||||
String orderNo,
|
||||
String inboundCode,
|
||||
Long customerId,
|
||||
String customerName,
|
||||
Long warehouseId,
|
||||
|
||||
@@ -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())
|
||||
@@ -188,15 +201,17 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String chargeMode,
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String processStatus, String chargeMode,
|
||||
java.math.BigDecimal chargeable) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus, List.of("WAIT_PRICING", "FEE_CONFIRMING"))
|
||||
.eq(InboundPricingEntity::getProcessStatus, processStatus)
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
|
||||
.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if (!"WAIT_SPLIT".equals(processStatus)) {
|
||||
update.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING");
|
||||
}
|
||||
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
|
||||
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
@@ -214,7 +229,7 @@ public class FbaPricingRepository {
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
|
||||
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -47,7 +47,7 @@ public class FbaPricingService {
|
||||
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
||||
|
||||
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
||||
List<CustomerChargeRuleEntity> matchedRules = repository.matchRules(inbound, pricingAt);
|
||||
@@ -88,11 +88,14 @@ public class FbaPricingService {
|
||||
repository.insertFee(fee);
|
||||
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
|
||||
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
|
||||
if (repository.markInboundCalculated(tenantId, inboundId, rule.getChargeMode(), chargeable) != 1) {
|
||||
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
|
||||
rule.getChargeMode(), chargeable) != 1) {
|
||||
throw conflict("入库单状态已变化,费用试算未保存");
|
||||
}
|
||||
complete(token, fee.getId(), now);
|
||||
return response(inbound, fee, rate.getUnitPrice(), "FEE_CONFIRMING");
|
||||
String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
|
||||
? "WAIT_SPLIT" : "FEE_CONFIRMING";
|
||||
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -103,14 +106,14 @@ public class FbaPricingService {
|
||||
String hash = hash("CONFIRM", inboundId, current.getId().toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash);
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1
|
||||
|| repository.markInboundConfirmed(tenantId, inboundId) != 1) {
|
||||
throw conflict("费用或入库单状态已变化,不能重复确认");
|
||||
}
|
||||
complete(token, current.getId(), now);
|
||||
return response(tenantId, current.getId(), "FEE_CONFIRMING");
|
||||
return response(tenantId, current.getId(), null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -232,7 +235,7 @@ public class FbaPricingService {
|
||||
|
||||
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING").contains(inbound.getProcessStatus())
|
||||
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING", "WAIT_SPLIT").contains(inbound.getProcessStatus())
|
||||
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
|
||||
throw conflict("当前入库单状态不允许费用试算");
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class InboundOrderCreationService {
|
||||
inbound.setTenantId(command.tenantId());
|
||||
inbound.setOrderId(command.orderId());
|
||||
inbound.setOrderNo(command.orderNo());
|
||||
inbound.setInboundCode("RK-" + command.orderId());
|
||||
inbound.setInboundCode(command.inboundCode());
|
||||
inbound.setProcessStatus("WAIT_MEASURE");
|
||||
inbound.setOrderStatusSnapshot("APPROVED");
|
||||
inbound.setMeasureReviewStatus("NOT_SUBMITTED");
|
||||
|
||||
@@ -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,43 +32,42 @@ 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:
|
||||
summary: 按审核通过测量快照试算 FBA 费用
|
||||
description: 精确匹配唯一客户规则和唯一船司价格;需要 firstleg:fba-pricing:calculate 权限。
|
||||
description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
|
||||
post:
|
||||
summary: 确认最新费用快照
|
||||
description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/send:
|
||||
post:
|
||||
summary: 手工恢复发送费用快照
|
||||
description: 仅作为异常恢复接口;正常业务由客服测量审核通过后自动计费、发布并完成入库。
|
||||
summary: 发送已确认费用快照
|
||||
description: 该动作涉及后续仓储流程,本轮二期验收不调用。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/latest:
|
||||
|
||||
@@ -6,6 +6,19 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/internal/firstleg/v1/inbounds/order-approved:
|
||||
post:
|
||||
summary: 订单审核通过后幂等创建入库单
|
||||
description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。承运商可在后续履约或计费前补充。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderApprovedInboundCommand'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败;相同客户和订单重放返回已有入库单 ID}
|
||||
/api/platform/v1/first-leg/tasks:
|
||||
get:
|
||||
summary: 分页查询履约任务
|
||||
@@ -308,6 +321,10 @@ components:
|
||||
in: header
|
||||
name: X-Signature
|
||||
description: HMAC-SHA256 小写十六进制签名
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
parameters:
|
||||
XTimestamp:
|
||||
name: X-Timestamp
|
||||
@@ -348,6 +365,36 @@ components:
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
schemas:
|
||||
OrderApprovedInboundCommand:
|
||||
type: object
|
||||
required: [tenantId, orderId, inboundCode, customerName, warehouseId]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string, nullable: true}
|
||||
inboundCode: {type: string, minLength: 1, maxLength: 40, example: RK-2085308606829346818}
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
customerName: {type: string, minLength: 1}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
warehouseName: {type: string, nullable: true}
|
||||
deliveryWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
deliveryWarehouseName: {type: string, nullable: true}
|
||||
expectedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
businessType: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 可选,可在后续履约或计费前补充}
|
||||
carrierName: {type: string, nullable: true}
|
||||
taxMode: {type: string, nullable: true}
|
||||
containerMode: {type: string, nullable: true}
|
||||
customsInspection: {type: boolean, nullable: true}
|
||||
destinationType: {type: string, nullable: true}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
destinationCode: {type: string, nullable: true}
|
||||
destinationName: {type: string, nullable: true}
|
||||
publicRemark: {type: string, nullable: true}
|
||||
expressCompanyName: {type: string, nullable: true}
|
||||
expressTrackingNo: {type: string, nullable: true}
|
||||
ContainerBookingPageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -13,24 +13,30 @@ 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
|
||||
@DisplayName("契约声明自动发布、已发布费用调整和幂等头")
|
||||
@DisplayName("契约声明二期边界计费、已发布费用调整和幂等头")
|
||||
void documentsPricingLifecycle() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
@@ -42,7 +48,8 @@ class FbaPricingContractTest {
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/send:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/fees/published:")
|
||||
.contains("Idempotency-Key")
|
||||
.contains("客服测量审核通过后自动计费、发布并完成入库")
|
||||
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
|
||||
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
|
||||
.contains("不回退 INBOUNDED 状态")
|
||||
.doesNotContain("应收")
|
||||
.doesNotContain("应付")
|
||||
@@ -62,7 +69,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")
|
||||
|
||||
+25
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.InboundOrderReference;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
import com.baiyue.firstleg.service.InboundOrderCreationService;
|
||||
import com.baiyue.firstleg.service.UnifiedInboundQueryService;
|
||||
import com.baiyue.firstleg.dto.UnifiedInboundQuery;
|
||||
@@ -63,4 +64,28 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
.isInstanceOf(BizException.class);
|
||||
verify(unifiedQueryService).page(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedOrderRequiresInboundCode() {
|
||||
assertThatThrownBy(() -> controller.createFromApprovedOrder(
|
||||
"internal-token", approvedCommand(" ", null)))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedOrderAllowsMissingCarrier() {
|
||||
OrderApprovedInboundCommand command = approvedCommand("RK-77", null);
|
||||
when(service.createFromApprovedOrder(command)).thenReturn(88L);
|
||||
|
||||
assertThat(controller.createFromApprovedOrder("internal-token", command).data()).isEqualTo(88L);
|
||||
verify(service).createFromApprovedOrder(command);
|
||||
}
|
||||
|
||||
private static OrderApprovedInboundCommand approvedCommand(String inboundCode, Long carrierId) {
|
||||
return new OrderApprovedInboundCommand(
|
||||
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
|
||||
null, null, null, "FBA", "AIR", "空运", carrierId,
|
||||
carrierId == null ? null : "承运商", null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -100,6 +100,51 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("100.01");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待分柜边界可独立计费且不回退主流程状态")
|
||||
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100.000", "1.0000");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
|
||||
|
||||
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
|
||||
new BigDecimal("167.000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
|
||||
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
|
||||
InboundPricingEntity inbound = inbound("10.000", "1.0000");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
inbound.setFeeStatus("CALCULATED");
|
||||
InboundFeeEntity fee = savedFee("CALCULATED");
|
||||
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-wait-split"))
|
||||
.thenReturn(null);
|
||||
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertIdempotency(any());
|
||||
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
|
||||
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
|
||||
FeeSnapshotResponse response = service.confirm(
|
||||
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
|
||||
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository).markInboundConfirmed(TENANT, INBOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核未通过的测量快照禁止计费")
|
||||
void rejectsUnapprovedMeasurementSnapshot() {
|
||||
@@ -137,15 +182,18 @@ class FbaPricingServiceTest {
|
||||
@DisplayName("同一幂等键重复试算直接返回原费用快照")
|
||||
void returnsOriginalSnapshotForRepeatedCalculation() {
|
||||
InboundPricingIdempotencyEntity completed = completed("FEE_CALCULATE", 501L);
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
inbound.setProcessStatus("WAIT_SPLIT");
|
||||
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CALCULATE", "same")).thenReturn(completed);
|
||||
when(repository.findFee(TENANT, 501L)).thenReturn(savedFee("CALCULATED"));
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound("10", "1"));
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity(); item.setUnitPrice(new BigDecimal("2.00"));
|
||||
when(repository.findBaseItem(TENANT, 501L)).thenReturn(item);
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "same");
|
||||
|
||||
assertThat(response.feeId()).isEqualTo(501L);
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
@@ -233,7 +281,7 @@ class FbaPricingServiceTest {
|
||||
doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(501L); return null; })
|
||||
.when(repository).insertFee(any());
|
||||
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(1);
|
||||
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any())).thenReturn(1);
|
||||
when(repository.markInboundCalculated(eq(TENANT), eq(INBOUND), any(), any(), any())).thenReturn(1);
|
||||
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
|
||||
}
|
||||
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundOrderCreationServiceTest {
|
||||
@Mock
|
||||
private InboundOrderMapper orders;
|
||||
@Mock
|
||||
private InboundStatusHistoryMapper histories;
|
||||
@InjectMocks
|
||||
private InboundOrderCreationService service;
|
||||
|
||||
@Test
|
||||
void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() {
|
||||
OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77");
|
||||
InboundOrderEntity existing = new InboundOrderEntity();
|
||||
existing.setId(900L);
|
||||
existing.setTenantId(10001L);
|
||||
existing.setOrderId(77L);
|
||||
existing.setInboundCode("RK-CUSTOM-77");
|
||||
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing);
|
||||
|
||||
long createdId = service.createFromApprovedOrder(command);
|
||||
long replayedId = service.createFromApprovedOrder(command);
|
||||
|
||||
ArgumentCaptor<InboundOrderEntity> inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class);
|
||||
verify(orders, times(1)).insert(inboundCaptor.capture());
|
||||
verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class));
|
||||
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77");
|
||||
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
|
||||
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
|
||||
assertThat(replayedId).isEqualTo(900L);
|
||||
}
|
||||
|
||||
private static OrderApprovedInboundCommand approvedCommand(String inboundCode) {
|
||||
return new OrderApprovedInboundCommand(
|
||||
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
|
||||
null, null, null, "FBA", "AIR", "空运", null, null,
|
||||
null, null, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
import com.baiyue.iam.service.MemberService;
|
||||
import com.baiyue.iam.dto.PlatformCustomerMemberDtos;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@@ -38,6 +40,8 @@ import java.util.List;
|
||||
public class PlatformTenantController {
|
||||
@Resource
|
||||
private TenantService service;
|
||||
@Resource
|
||||
private MemberService memberService;
|
||||
|
||||
@GetMapping("/tenants")
|
||||
@PreAuthorize("hasAuthority('platform:customer:list')")
|
||||
@@ -69,6 +73,15 @@ public class PlatformTenantController {
|
||||
return ApiResponse.success(service.merchant(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/{tenantId:\\d+}/members")
|
||||
@PreAuthorize("hasAuthority('platform:customer:view')")
|
||||
@Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。")
|
||||
public ApiResponse<List<PlatformCustomerMemberDtos.MemberResponse>> members(
|
||||
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(memberService.platformList(tenantId));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants")
|
||||
@PreAuthorize("hasAuthority('platform:customer:create')")
|
||||
@Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft only. Saving the customer does not create a review ticket. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。")
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.iam.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台查看客户企业成员时使用的只读契约,补充认证账号信息。 */
|
||||
public final class PlatformCustomerMemberDtos {
|
||||
private PlatformCustomerMemberDtos() { }
|
||||
|
||||
public record MemberResponse(Long membershipId, Long userId, Long authUserId,
|
||||
String username, String displayName, String phone, String email,
|
||||
String memberType, String memberNo, String status,
|
||||
List<Long> roleIds, List<String> roleNames) { }
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import com.baiyue.iam.dto.PlatformCustomerMemberDtos;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -33,6 +35,8 @@ public class MemberService {
|
||||
@Resource
|
||||
private MemberRepository repository;
|
||||
@Resource
|
||||
private TenantRepository tenantRepository;
|
||||
@Resource
|
||||
private IamRoleMapper roleMapper;
|
||||
@Resource
|
||||
private AuthAccountClient authClient;
|
||||
@@ -43,6 +47,34 @@ public class MemberService {
|
||||
return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList();
|
||||
}
|
||||
|
||||
/** 平台只读查询客户成员;显式校验客户主档,并包含 OWNER 管理员。 */
|
||||
public List<PlatformCustomerMemberDtos.MemberResponse> platformList(long tenantId) {
|
||||
var tenant = tenantRepository.findTenant(tenantId);
|
||||
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "企业客户不存在");
|
||||
}
|
||||
return repository.findByTenant(tenantId).stream().map(m -> platformResponse(tenantId, m)).toList();
|
||||
}
|
||||
|
||||
private PlatformCustomerMemberDtos.MemberResponse platformResponse(long tenantId, IamMembership membership) {
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
AuthAccountClient.AccountSummary account = accountSummary(user.getAuthUserId());
|
||||
List<Long> roleIds = repository.findRoleIds(tenantId, membership.getId());
|
||||
List<String> roleNames = roleIds.stream()
|
||||
.map(roleMapper::selectById)
|
||||
.filter(role -> role != null && Long.valueOf(tenantId).equals(role.getTenantId()))
|
||||
.map(IamRole::getRoleName)
|
||||
.toList();
|
||||
return new PlatformCustomerMemberDtos.MemberResponse(membership.getId(), user.getId(),
|
||||
user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(),
|
||||
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(),
|
||||
roleIds, roleNames);
|
||||
}
|
||||
|
||||
private AuthAccountClient.AccountSummary accountSummary(Long authUserId) {
|
||||
return data(call(() -> authClient.summary(internalToken, authUserId)));
|
||||
}
|
||||
|
||||
public MemberResponse detail(long tenantId, Long membershipId) {
|
||||
return response(tenantId, required(tenantId, membershipId));
|
||||
}
|
||||
|
||||
@@ -88,6 +88,22 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/api/platform/v1/iam/tenants/{tenantId}/members:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 查询企业客户成员及账号
|
||||
description: 平台只读查询指定客户的全部企业成员,包含 OWNER 企业管理员并返回登录账号;需要 `platform:customer:view` 权限。
|
||||
responses:
|
||||
'200':
|
||||
description: 企业成员查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlatformCustomerMembersApiResponse'
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/api/platform/v1/iam/tenants/{tenantId}/submit-review:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
@@ -802,6 +818,28 @@ components:
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/InternalUserResponse'}
|
||||
PlatformCustomerMemberResponse:
|
||||
type: object
|
||||
required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds, roleNames]
|
||||
properties:
|
||||
membershipId: {type: string, pattern: '^\d+$'}
|
||||
userId: {type: string, pattern: '^\d+$'}
|
||||
authUserId: {type: string, pattern: '^\d+$'}
|
||||
username: {type: string}
|
||||
displayName: {type: string}
|
||||
phone: {type: string}
|
||||
email: {type: string, format: email}
|
||||
memberType: {type: string, enum: [OWNER, EMPLOYEE]}
|
||||
memberNo: {type: string}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
roleIds: {type: array, items: {type: string, pattern: '^\d+$'}}
|
||||
roleNames: {type: array, items: {type: string}}
|
||||
PlatformCustomerMembersApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/PlatformCustomerMemberResponse'}}
|
||||
PlatformManagedUserPageApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
|
||||
@@ -7,9 +7,11 @@ import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.dto.MemberDtos.*;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -28,6 +30,7 @@ import static org.mockito.Mockito.*;
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||
class MemberServiceTest {
|
||||
@MockBean private MemberRepository repository;
|
||||
@MockBean private TenantRepository tenantRepository;
|
||||
@MockBean private IamRoleMapper roleMapper;
|
||||
@MockBean private AuthAccountClient auth;
|
||||
@Resource private MemberService service;
|
||||
@@ -52,6 +55,28 @@ class MemberServiceTest {
|
||||
verifyNoInteractions(auth);
|
||||
}
|
||||
|
||||
@Test void shouldReturnOwnerAccountForPlatformCustomerDetail() {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(100L); tenant.setTenantType("MERCHANT");
|
||||
member.setMemberType("OWNER");
|
||||
user.setPhone("13800138000"); user.setEmail("owner@example.com");
|
||||
when(tenantRepository.findTenant(100L)).thenReturn(tenant);
|
||||
when(repository.findByTenant(100L)).thenReturn(List.of(member));
|
||||
IamRole ownerRole = new IamRole();
|
||||
ownerRole.setId(50L); ownerRole.setTenantId(100L); ownerRole.setRoleName("企业管理员");
|
||||
when(roleMapper.selectById(50L)).thenReturn(ownerRole);
|
||||
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(40L, "merchant_owner", "企业管理员", "ACTIVE")));
|
||||
|
||||
var result = service.platformList(100L);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("merchant_owner", result.get(0).username());
|
||||
assertEquals("OWNER", result.get(0).memberType());
|
||||
assertEquals("ACTIVE", result.get(0).status());
|
||||
assertEquals(List.of("企业管理员"), result.get(0).roleNames());
|
||||
}
|
||||
|
||||
@Test void shouldHandleCase02() {
|
||||
when(repository.findByTenantAndId(200L, 20L)).thenReturn(null);
|
||||
assertThrows(BizException.class, () -> service.detail(200L, 20L));
|
||||
|
||||
@@ -29,7 +29,7 @@ public interface FirstlegInboundClient {
|
||||
}
|
||||
|
||||
record OrderApprovedInboundCommand(
|
||||
long tenantId, long orderId, String orderNo, Long customerId, String customerName,
|
||||
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
|
||||
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
|
||||
Long carrierId, String carrierName, String taxMode, String containerMode,
|
||||
|
||||
+14
-1
@@ -146,7 +146,7 @@ public class PlatformFirstLegOrderController {
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
|
||||
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order and records its status history. / 审核通过已提交订单,并记录状态历史。")
|
||||
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。")
|
||||
@PostMapping("/{id}/approve")
|
||||
@PreAuthorize("hasAuthority('platform:order:approve')")
|
||||
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -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")
|
||||
|
||||
@@ -2,9 +2,12 @@ package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.service.OrderApplicationService;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import com.baiyue.order.service.UnifiedOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Platform product-level order query. Legacy first-leg paths remain available. */
|
||||
@@ -25,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/platform/v1/orders")
|
||||
public class PlatformUnifiedOrderController {
|
||||
@Resource private UnifiedOrderQueryService service;
|
||||
@Resource private OrderApplicationService orderService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:order:list')")
|
||||
@@ -47,4 +53,17 @@ public class PlatformUnifiedOrderController {
|
||||
@PathVariable long orderId) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/internal-notes")
|
||||
@BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注")
|
||||
@PreAuthorize("hasAuthority('platform:order:note')")
|
||||
@Operation(summary = "添加订单内部备注", description = "添加仅平台内部可见的订单备注。")
|
||||
public ApiResponse<Long> addInternalNote(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long orderId,
|
||||
@Valid @RequestBody OrderRequests.NoteRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(orderService.addNote(scopedTenant, user.userId(), orderId, request.content()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class OrderEntity extends BaseEntity {
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String orderNo;
|
||||
private String inboundCode;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private OrderStatus status;
|
||||
|
||||
@@ -161,6 +161,7 @@ public class OrderRepository {
|
||||
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
|
||||
.in(OrderEntity::getStatus, statuses)
|
||||
.like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo())
|
||||
.like(StringUtils.hasText(query.inboundCode()), OrderEntity::getInboundCode, query.inboundCode())
|
||||
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
|
||||
.eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId,
|
||||
query.receivingWarehouseId())
|
||||
|
||||
@@ -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);
|
||||
@@ -84,6 +97,7 @@ public class OrderApplicationService {
|
||||
throw new BizException("当前订单状态不允许编辑订单资料");
|
||||
}
|
||||
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (before == OrderStatus.REJECTED) order.setInboundCode(null);
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
}
|
||||
@@ -105,11 +119,19 @@ public class OrderApplicationService {
|
||||
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationType());
|
||||
}
|
||||
if (target == OrderStatus.APPROVED && order.getCarrierId() != null) {
|
||||
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");
|
||||
}
|
||||
order.setStatus(target); order.setUpdatedBy(userId);
|
||||
if (target == OrderStatus.SUBMITTED) order.setSubmittedAt(LocalDateTime.now());
|
||||
if (target == OrderStatus.SUBMITTED) {
|
||||
if (!StringUtils.hasText(order.getInboundCode())) order.setInboundCode("RK-" + order.getId());
|
||||
order.setSubmittedAt(LocalDateTime.now());
|
||||
}
|
||||
if (target == OrderStatus.DRAFT) order.setInboundCode(null);
|
||||
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
|
||||
record(tenantId, id, from, target, target.name(), reason, userId, username);
|
||||
@@ -327,7 +349,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());
|
||||
@@ -400,7 +424,8 @@ public class OrderApplicationService {
|
||||
}
|
||||
private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
|
||||
return new FirstlegInboundClient.OrderApprovedInboundCommand(
|
||||
order.getTenantId(), order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(),
|
||||
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
|
||||
order.getCustomerId(), order.getCustomerName(),
|
||||
order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(),
|
||||
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
|
||||
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,8 +51,9 @@ public class UnifiedOrderQueryService {
|
||||
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
|
||||
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
|
||||
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(
|
||||
tenantId, source.records().stream().map(OrderEntity::getId).toList());
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
|
||||
? Map.of()
|
||||
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
|
||||
List<UnifiedOrderResponse.Row> rows = source.records().stream()
|
||||
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
|
||||
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
|
||||
@@ -64,7 +65,8 @@ public class UnifiedOrderQueryService {
|
||||
OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId),
|
||||
orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(),
|
||||
orders.findHistory(tenantId, orderId));
|
||||
FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId);
|
||||
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
|
||||
? null : firstlegDetail(tenantId, orderId);
|
||||
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
|
||||
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
|
||||
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
|
||||
@@ -121,7 +123,8 @@ public class UnifiedOrderQueryService {
|
||||
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
|
||||
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
|
||||
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null,
|
||||
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
|
||||
order.getInboundCode(),
|
||||
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
|
||||
order.getCreatedAt(), order.getUpdatedAt());
|
||||
}
|
||||
@@ -213,7 +216,7 @@ public class UnifiedOrderQueryService {
|
||||
|
||||
private boolean requiresFirstlegQuery(UnifiedOrderQuery query) {
|
||||
return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus()))
|
||||
|| StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null
|
||||
|| query.expectedArrivalDate() != null
|
||||
|| StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo())
|
||||
|| StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null
|
||||
|| StringUtils.hasText(query.measurementReviewStatus());
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
order_no VARCHAR(32) NOT NULL COMMENT '租户内唯一订单号',
|
||||
inbound_code VARCHAR(40) NULL COMMENT '客户提交后生成的入仓码,草稿为空',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态',
|
||||
@@ -47,6 +48,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_tenant_no (tenant_id, order_no),
|
||||
UNIQUE KEY uk_order_tenant_inbound_code (tenant_id, inbound_code),
|
||||
KEY idx_order_tenant_status_time (tenant_id, status, created_at),
|
||||
KEY idx_order_tenant_customer (tenant_id, customer_id),
|
||||
KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id)
|
||||
|
||||
@@ -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:
|
||||
@@ -11,7 +11,8 @@ paths:
|
||||
summary: 平台统一订单列表
|
||||
description: >-
|
||||
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
|
||||
有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
|
||||
草稿不生成 inboundCode,客户提交进入待审核后即返回固定 inboundCode;有入库单时同时返回
|
||||
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
|
||||
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
|
||||
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
|
||||
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
|
||||
@@ -45,13 +46,32 @@ paths:
|
||||
/api/platform/v1/orders/{orderId}:
|
||||
get:
|
||||
summary: 平台统一订单详情
|
||||
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。
|
||||
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;待审核订单直接返回 Order 在提交时生成的 inboundCode;平台可见内部备注。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要平台身份和 platform:order:list 权限}
|
||||
/api/platform/v1/orders/{orderId}/internal-notes:
|
||||
post:
|
||||
summary: 添加订单内部备注
|
||||
description: 添加仅平台内部可见的订单备注,需要 platform:order:note 权限。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [content]
|
||||
properties:
|
||||
content: {type: string, minLength: 1, maxLength: 2000}
|
||||
responses:
|
||||
'200': {description: 备注添加成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:note 权限}
|
||||
/api/merchant/v1/orders:
|
||||
get:
|
||||
summary: 客户统一订单列表
|
||||
@@ -200,6 +220,25 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:edit 权限}
|
||||
/api/platform/v1/orders/first-leg/{id}/carrier:
|
||||
put:
|
||||
summary: 为待审核订单指派承运商
|
||||
description: 可选的独立指派动作;服务端从公共往来单位主数据校验 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:
|
||||
@@ -225,7 +264,7 @@ components:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
|
||||
UnifiedInboundCode:
|
||||
{name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}}
|
||||
{name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}}
|
||||
UnifiedInternalOrderStatus:
|
||||
name: internalOrderStatus
|
||||
in: query
|
||||
|
||||
@@ -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
|
||||
@@ -55,6 +57,12 @@ class OrderControllerPermissionTest {
|
||||
AuthenticatedUser.class, long.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedPlatformInternalNoteRequiresNotePermission() throws Exception {
|
||||
assertAuthority(PlatformUnifiedOrderController.class, "addInternalNote", "hasAuthority('platform:order:note')",
|
||||
AuthenticatedUser.class, long.class, long.class, OrderRequests.NoteRequest.class);
|
||||
}
|
||||
|
||||
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ class OrderApplicationServiceStateTest {
|
||||
|
||||
assertThat(orderId).isNotNull();
|
||||
verify(eligibility, never()).requireApproved(anyLong());
|
||||
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT));
|
||||
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT
|
||||
&& order.getInboundCode() == null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -101,6 +102,80 @@ 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 approvalDoesNotRequireCarrierAssignmentAndPublishesInboundCode() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
|
||||
order.setOrderNo("FL1");
|
||||
order.setInboundCode("RK-1");
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
when(repository.saveOrder(10001L, order)).thenReturn(1);
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.transit(10001L, 2L, "u", 1L, OrderStatus.APPROVED, null);
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.APPROVED);
|
||||
verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"inboundCode\":\"RK-1\"")
|
||||
&& event.getPayloadJson().contains("\"carrierId\":null")));
|
||||
}
|
||||
|
||||
@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 submittingDraftGeneratesAndPersistsInboundCode() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
|
||||
when(repository.findOwned(20002L, 1L)).thenReturn(order);
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(new OrderCargoEntity()));
|
||||
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
|
||||
when(repository.saveOrder(20002L, order)).thenReturn(1);
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
|
||||
assertThat(order.getInboundCode()).isEqualTo("RK-1");
|
||||
assertThat(order.getSubmittedAt()).isNotNull();
|
||||
verify(repository).saveOrder(20002L, order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantDraftEditorStillCannotEditSubmittedOrder() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
@@ -297,4 +372,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)))
|
||||
|
||||
@@ -38,6 +38,7 @@ class UnifiedOrderQueryServiceTest {
|
||||
void pendingReviewIsPagedFromOrderAndMappedToApproveAction() {
|
||||
UnifiedOrderQuery query = query("PENDING_REVIEW");
|
||||
OrderEntity order = order(OrderStatus.SUBMITTED);
|
||||
order.setInboundCode("RK-77");
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
@@ -48,7 +49,58 @@ class UnifiedOrderQueryServiceTest {
|
||||
assertThat(result.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
|
||||
assertThat(row.inboundCode()).isEqualTo("RK-77");
|
||||
assertThat(row.inboundId()).isNull();
|
||||
});
|
||||
verify(firstleg, never()).summaries(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewInboundCodeFilterStaysInOrderQuery() {
|
||||
UnifiedOrderQuery query = new UnifiedOrderQuery(
|
||||
null, null, "PENDING_REVIEW", null, null, null, "RK-77", null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, 1, 20);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
|
||||
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
service.page(10001L, query);
|
||||
|
||||
verify(orders).unifiedPage(10001L, query, List.of(OrderStatus.SUBMITTED));
|
||||
verify(firstleg, never()).page(any(), any());
|
||||
verify(firstleg, never()).summaries(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() {
|
||||
UnifiedOrderQuery query = new UnifiedOrderQuery(
|
||||
null, null, null, null, null, null, "RK-77", null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, 1, 20);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), any()))
|
||||
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
service.page(10001L, query);
|
||||
|
||||
verify(orders).unifiedPage(eq(10001L), eq(query), any());
|
||||
verify(firstleg, never()).page(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewDetailUsesOrderInboundCodeWithoutCallingFirstleg() {
|
||||
OrderEntity order = order(OrderStatus.SUBMITTED);
|
||||
order.setInboundCode("RK-77");
|
||||
when(orders.findOwned(10001L, 77L)).thenReturn(order);
|
||||
when(orders.findCargo(10001L, 77L)).thenReturn(List.of());
|
||||
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
|
||||
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
|
||||
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
|
||||
|
||||
var result = service.detail(10001L, 77L, true);
|
||||
|
||||
assertThat(result.summary().inboundCode()).isEqualTo("RK-77");
|
||||
assertThat(result.order().order().getInboundCode()).isEqualTo("RK-77");
|
||||
verify(firstleg, never()).detail(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806180457",
|
||||
"startedAt": "2026-08-06T18:04:57.0000000",
|
||||
"finishedAt": "2026-08-06T18:04:59.2138202+08:00",
|
||||
"success": false,
|
||||
"passedCount": 4,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"carrierId": "2085306128758538241",
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "创建头程订单草稿 失败:HTTP=200 code=A0422 message=Validation failed",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"name": "创建公共测试承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"name": "创建头程订单草稿",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0422",
|
||||
"message": "Validation failed",
|
||||
"requestId": "347014a0-f9e9-466a-8cc2-ff947cf946d3",
|
||||
"traceId": "946f25aaf3824d029c5228b1fd4b52f8",
|
||||
"evidence": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806180542",
|
||||
"startedAt": "2026-08-06T18:05:42.0000000",
|
||||
"finishedAt": "2026-08-06T18:05:42.1924377+08:00",
|
||||
"success": false,
|
||||
"passedCount": 0,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"carrierId": null,
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "平台登录 失败:HTTP=503 code= message=",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": false,
|
||||
"http": 503,
|
||||
"code": "",
|
||||
"message": "",
|
||||
"requestId": "93c3a0a1-684",
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806180635",
|
||||
"startedAt": "2026-08-06T18:06:35.0000000",
|
||||
"finishedAt": "2026-08-06T18:06:37.8507638+08:00",
|
||||
"success": false,
|
||||
"passedCount": 0,
|
||||
"failedCount": 0,
|
||||
"ids": {
|
||||
"carrierId": null,
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "由于目标计算机积极拒绝,无法连接。 (8.163.30.128:9000)",
|
||||
"billingDefect": null,
|
||||
"steps": []
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806180746",
|
||||
"startedAt": "2026-08-06T18:07:46.0000000",
|
||||
"finishedAt": "2026-08-06T18:07:49.7438673+08:00",
|
||||
"success": false,
|
||||
"passedCount": 4,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"carrierId": "2085306838682238977",
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "创建头程订单草稿 失败:HTTP=200 code=A0422 message=仓库未启用收货能力",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建公共测试承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建头程订单草稿",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0422",
|
||||
"message": "仓库未启用收货能力",
|
||||
"requestId": "879f056b-40c2-486b-b643-c6ff7ab1a224",
|
||||
"traceId": "282c6a4652d649608deaa0989f1109f8",
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806180915",
|
||||
"startedAt": "2026-08-06T18:09:15.0000000",
|
||||
"finishedAt": "2026-08-06T18:09:18.4509682+08:00",
|
||||
"success": false,
|
||||
"passedCount": 13,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"warehouseId": "2085307213141311489",
|
||||
"carrierId": "2085307212440862722",
|
||||
"orderId": "2085307214373949442",
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "商户提交订单 失败:HTTP=200 code=A0403 message=All required enterprise qualifications must be approved",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建公共测试承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建启用收货能力的公共测试仓",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "重放创建订单幂等请求",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建订单幂等结果一致",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "orderId=2085307214373949442",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单初始状态为 DRAFT",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "status=DRAFT",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "编辑头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "回查编辑后的订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单编辑内容已保存",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "customerRemark=API-E2E-20260806180915 编辑后草稿",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户提交订单",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0403",
|
||||
"message": "All required enterprise qualifications must be approved",
|
||||
"requestId": "43731de1-a837-47ed-944e-74a52c778c0d",
|
||||
"traceId": "d65dce17b1974c0e85987a1f5b7b38c0",
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806181135",
|
||||
"startedAt": "2026-08-06T18:11:35.0000000",
|
||||
"finishedAt": "2026-08-06T18:11:36.8065276+08:00",
|
||||
"success": false,
|
||||
"passedCount": 4,
|
||||
"failedCount": 0,
|
||||
"ids": {
|
||||
"qualificationReviewId": null,
|
||||
"warehouseId": null,
|
||||
"carrierId": null,
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "The process cannot access the file 'C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global\\apps\\pda-uniapp\\static\\c7.png' because it is being used by another process.",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询商户必备资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806181211",
|
||||
"startedAt": "2026-08-06T18:12:11.0000000",
|
||||
"finishedAt": "2026-08-06T18:12:12.6225155+08:00",
|
||||
"success": false,
|
||||
"passedCount": 4,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"qualificationReviewId": null,
|
||||
"warehouseId": null,
|
||||
"carrierId": null,
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "补齐商户营业执照与法人身份证测试包 失败:HTTP=200 code=B0001 message=Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询商户必备资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "补齐商户营业执照与法人身份证测试包",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "B0001",
|
||||
"message": "Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件",
|
||||
"requestId": "a3322a13-862f-4176-b36a-7f5bf48dabbc",
|
||||
"traceId": "158c6783070f4605b966a8bbb5fd624d",
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806181252",
|
||||
"startedAt": "2026-08-06T18:12:52.0000000",
|
||||
"finishedAt": "2026-08-06T18:12:53.8062380+08:00",
|
||||
"success": false,
|
||||
"passedCount": 4,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"qualificationReviewId": null,
|
||||
"warehouseId": null,
|
||||
"carrierId": null,
|
||||
"orderId": null,
|
||||
"inboundId": null,
|
||||
"measurementId": null,
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": null,
|
||||
"measurement": null,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "补齐商户营业执照与法人身份证测试包 失败:HTTP=200 code=A0409 message=Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询商户必备资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "补齐商户营业执照与法人身份证测试包",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0409",
|
||||
"message": "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质",
|
||||
"requestId": "c3dcb898-0edd-4244-97a0-e3b399a334ed",
|
||||
"traceId": "18334f1be9ca4ee8ae0a88f98724cc51",
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806181400",
|
||||
"startedAt": "2026-08-06T18:14:00.0000000",
|
||||
"finishedAt": "2026-08-06T18:14:07.3773798+08:00",
|
||||
"success": false,
|
||||
"passedCount": 30,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"tenantId": "2085308406756851714",
|
||||
"qualificationReviewId": "2085308409122439169",
|
||||
"warehouseId": "2085308410753515522",
|
||||
"carrierId": "2085308410510245890",
|
||||
"orderId": "2085308411180847106",
|
||||
"inboundId": "2085308426690875393",
|
||||
"measurementId": "2085308428624449538",
|
||||
"weightResultId": null,
|
||||
"fileId": null
|
||||
},
|
||||
"versions": {
|
||||
"inbound": 0,
|
||||
"measurement": 0,
|
||||
"weightResult": null
|
||||
},
|
||||
"fatal": "上传称重图片并触发 OCR 失败:HTTP=200 code=A0400 message=称重图片仅支持JPG、PNG、BMP",
|
||||
"billingDefect": null,
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询商户必备资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "注册隔离的 API E2E 测试企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "登录隔离的 API E2E 测试企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "补齐商户营业执照与法人身份证测试包",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "提交商户资质审核",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询平台待审核企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台审核通过商户资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建公共测试承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建启用收货能力的公共测试仓",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "重放创建订单幂等请求",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建订单幂等结果一致",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "orderId=2085308411180847106",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单初始状态为 DRAFT",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "status=DRAFT",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "编辑头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "回查编辑后的订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单编辑内容已保存",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "customerRemark=API-E2E-20260806181400 编辑后草稿",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户提交订单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "统一订单详情查询待审核状态",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "统一订单主状态为 PENDING_REVIEW",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "mainStatus=PENDING_REVIEW",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台指派承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台审核通过订单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "异步生成入库单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "inboundId=2085308426690875393",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询入库单详情",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "入库单进入待测量状态",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "processStatus=WAIT_MEASURE, version=0",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询初始测量详情",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "保存测量草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "上传称重图片并触发 OCR",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0400",
|
||||
"message": "称重图片仅支持JPG、PNG、BMP",
|
||||
"requestId": "b527475c-3624-4050-8cb1-13ec7ec48fa5",
|
||||
"traceId": "81f92a40cd554334a987d4c2ff4a8679",
|
||||
"evidence": "",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
{
|
||||
"reportName": "二期测试环境 API 验收",
|
||||
"scope": [
|
||||
"头程下单",
|
||||
"商户提交",
|
||||
"平台审核",
|
||||
"异步生成入库单",
|
||||
"测量草稿",
|
||||
"称重图片与结果",
|
||||
"人工修正与确认",
|
||||
"测量提交与审核",
|
||||
"计费试算"
|
||||
],
|
||||
"excluded": [
|
||||
"分柜",
|
||||
"预装",
|
||||
"PDA 拣货",
|
||||
"装柜证据",
|
||||
"装柜完成",
|
||||
"派送"
|
||||
],
|
||||
"gateway": "http://8.163.30.128:9000",
|
||||
"marker": "API-E2E-20260806181448",
|
||||
"startedAt": "2026-08-06T18:14:48.0000000",
|
||||
"finishedAt": "2026-08-06T18:14:56.1155716+08:00",
|
||||
"success": false,
|
||||
"passedCount": 42,
|
||||
"failedCount": 1,
|
||||
"ids": {
|
||||
"tenantId": "2085308606829346818",
|
||||
"qualificationReviewId": "2085308609115242497",
|
||||
"warehouseId": "2085308610297528322",
|
||||
"carrierId": "2085308609974566913",
|
||||
"orderId": "2085308610682916865",
|
||||
"inboundId": "2085308616747372546",
|
||||
"measurementId": "2085308626759176194",
|
||||
"weightResultId": "2085308631419047938",
|
||||
"fileId": "2085308627513511938"
|
||||
},
|
||||
"versions": {
|
||||
"inbound": 3,
|
||||
"measurement": 1,
|
||||
"weightResult": 2
|
||||
},
|
||||
"fatal": null,
|
||||
"billingDefect": {
|
||||
"severity": "P0",
|
||||
"summary": "测量审核后进入 WAIT_SPLIT,但计费接口不接受该状态,二期正常链路无法计费",
|
||||
"http": 200,
|
||||
"code": "A0409",
|
||||
"message": "当前入库单状态不允许费用试算",
|
||||
"requestId": "b55826a9-c964-4bb5-91c6-1b862a469eee",
|
||||
"traceId": "44c08299046a4c5ab095b39463091218"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"name": "平台登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户登录",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "Firstleg 服务健康检查",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询商户必备资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "注册隔离的 API E2E 测试企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "登录隔离的 API E2E 测试企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "补齐商户营业执照与法人身份证测试包",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "提交商户资质审核",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询平台待审核企业",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台审核通过商户资质",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建公共测试承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建启用收货能力的公共测试仓",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "重放创建订单幂等请求",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "创建订单幂等结果一致",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "orderId=2085308610682916865",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单初始状态为 DRAFT",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "status=DRAFT",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "编辑头程订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "回查编辑后的订单草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "订单编辑内容已保存",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "customerRemark=API-E2E-20260806181448 编辑后草稿",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "商户提交订单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "统一订单详情查询待审核状态",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "统一订单主状态为 PENDING_REVIEW",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "mainStatus=PENDING_REVIEW",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台指派承运商",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台审核通过订单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "异步生成入库单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "inboundId=2085308616747372546",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询入库单详情",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "入库单进入待测量状态",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "processStatus=WAIT_MEASURE, version=0",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "查询初始测量详情",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "保存测量草稿",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "上传称重图片并触发 OCR",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "登记称重结果",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "人工修正称重结果",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "确认称重结果",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "提交前回查测量详情",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "确认重量汇总为 10KG",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "actualGrossWeightKg=10",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "提交测量审核",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "测量进入待审核状态",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "processStatus=MEASURE_PENDING, reviewStatus=PENDING",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "审核前回查入库单版本",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "平台审核通过测量",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "审核后回查入库单",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "二期边界停在 WAIT_SPLIT",
|
||||
"passed": true,
|
||||
"http": 200,
|
||||
"code": "200",
|
||||
"message": "success",
|
||||
"requestId": null,
|
||||
"traceId": null,
|
||||
"evidence": "processStatus=WAIT_SPLIT, reviewStatus=APPROVED",
|
||||
"details": null
|
||||
},
|
||||
{
|
||||
"name": "二期计费接口试算",
|
||||
"passed": false,
|
||||
"http": 200,
|
||||
"code": "A0409",
|
||||
"message": "当前入库单状态不允许费用试算",
|
||||
"requestId": "b55826a9-c964-4bb5-91c6-1b862a469eee",
|
||||
"traceId": "44c08299046a4c5ab095b39463091218",
|
||||
"evidence": "inboundStatus=WAIT_SPLIT",
|
||||
"details": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
param(
|
||||
[string]$GatewayBaseUrl = 'http://8.163.30.128:9000',
|
||||
[string]$PlatformUsername = 'platform_admin',
|
||||
[string]$MerchantUsername = 'merchant_admin',
|
||||
[string]$PlatformPassword = 'ChangeMe123!',
|
||||
[string]$MerchantPassword = 'ChangeMe123!',
|
||||
[long]$TenantId = 10001,
|
||||
[string]$OutputPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$stamp = Get-Date -Format 'yyyyMMddHHmmss'
|
||||
$marker = "API-E2E-$stamp"
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path $repoRoot "reports\二期测试环境API验收-$stamp.json"
|
||||
}
|
||||
|
||||
$steps = [System.Collections.Generic.List[object]]::new()
|
||||
$ids = [ordered]@{ tenantId = [string]$TenantId; qualificationReviewId = $null; warehouseId = $null; carrierId = $null; customerRuleId = $null; carrierRateId = $null; orderId = $null; inboundId = $null; measurementId = $null; weightResultId = $null; fileId = $null; feeId = $null }
|
||||
$versions = [ordered]@{ inbound = $null; measurement = $null; weightResult = $null }
|
||||
$fatal = $null
|
||||
$billingDefect = $null
|
||||
|
||||
function New-Key([string]$Action) {
|
||||
return "$marker-$Action-$([guid]::NewGuid().ToString('N'))"
|
||||
}
|
||||
|
||||
function Invoke-Api(
|
||||
[string]$Method,
|
||||
[string]$Path,
|
||||
[string]$Token = '',
|
||||
[object]$Body = $null,
|
||||
[hashtable]$ExtraHeaders = @{}
|
||||
) {
|
||||
$headers = @{}
|
||||
if (-not [string]::IsNullOrWhiteSpace($Token)) { $headers.Authorization = "Bearer $Token" }
|
||||
foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value }
|
||||
$args = @{ Method = $Method; Uri = "$GatewayBaseUrl$Path"; Headers = $headers; TimeoutSec = 30; SkipHttpErrorCheck = $true }
|
||||
if ($null -ne $Body) {
|
||||
$args.ContentType = 'application/json; charset=utf-8'
|
||||
$jsonText = $Body | ConvertTo-Json -Depth 20 -Compress
|
||||
$args.Body = [Text.Encoding]::UTF8.GetBytes($jsonText)
|
||||
}
|
||||
$response = Invoke-WebRequest @args
|
||||
$json = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($response.Content)) {
|
||||
try { $json = $response.Content | ConvertFrom-Json } catch { }
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
http = [int]$response.StatusCode
|
||||
body = $json
|
||||
raw = if ($null -eq $json) { $response.Content } else { $null }
|
||||
requestId = if ($null -ne $json) { $json.requestId } else { $response.Headers['X-Request-Id'] }
|
||||
traceId = if ($null -ne $json) { $json.traceId } else { $response.Headers['X-Trace-Id'] }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Upload([string]$Path, [string]$Token, [string]$FilePath) {
|
||||
$file = Get-Item -LiteralPath $FilePath
|
||||
$content = & curl.exe -sS -X POST "$GatewayBaseUrl$Path" `
|
||||
-H "Authorization: Bearer $Token" -F "file=@$($file.FullName);type=image/png"
|
||||
if ($LASTEXITCODE -ne 0) { throw '称重图片上传发生传输错误' }
|
||||
$json = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($content)) {
|
||||
try { $json = $content | ConvertFrom-Json } catch { }
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
http = 200
|
||||
body = $json
|
||||
raw = if ($null -eq $json) { [string]$content } else { $null }
|
||||
requestId = if ($null -ne $json) { $json.requestId } else { $null }
|
||||
traceId = if ($null -ne $json) { $json.traceId } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RequiredQualificationUpload([string]$Token, [string]$FilePath, [string]$CreditCode) {
|
||||
$query = "unifiedSocialCreditCode=$CreditCode&replaceBusinessLicense=true&replaceLegalPersonId=false"
|
||||
$businessFile = Get-Item -LiteralPath $FilePath
|
||||
$legalPersonFile = Get-Item -LiteralPath (Join-Path $businessFile.DirectoryName 'c8.png')
|
||||
$content = & curl.exe -sS -X POST "$GatewayBaseUrl/api/merchant/v1/files/qualifications/required-package?$query" `
|
||||
-H "Authorization: Bearer $Token" `
|
||||
-F "businessLicense=@$($businessFile.FullName);type=image/png" `
|
||||
-F "legalPersonId=@$($legalPersonFile.FullName);type=image/png"
|
||||
if ($LASTEXITCODE -ne 0) { throw '必备资质包上传发生传输错误' }
|
||||
$json = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($content)) {
|
||||
try { $json = $content | ConvertFrom-Json } catch { }
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
http = 200
|
||||
body = $json
|
||||
raw = if ($null -eq $json) { [string]$content } else { $null }
|
||||
requestId = if ($null -ne $json) { $json.requestId } else { $null }
|
||||
traceId = if ($null -ne $json) { $json.traceId } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Add-Step([string]$Name, $Response, [bool]$Passed, [string]$Evidence = '') {
|
||||
$code = if ($null -ne $Response.body) { [string]$Response.body.code } else { $null }
|
||||
$message = if ($null -ne $Response.body) { [string]$Response.body.message } else { [string]$Response.raw }
|
||||
$steps.Add([pscustomobject]@{
|
||||
name = $Name
|
||||
passed = $Passed
|
||||
http = $Response.http
|
||||
code = $code
|
||||
message = $message
|
||||
requestId = $Response.requestId
|
||||
traceId = $Response.traceId
|
||||
evidence = $Evidence
|
||||
details = if ($Passed -or $null -eq $Response.body) { $null } else { $Response.body.data }
|
||||
})
|
||||
Write-Host "[$(if ($Passed) { '通过' } else { '失败' })] $Name HTTP=$($Response.http) code=$code $Evidence"
|
||||
}
|
||||
|
||||
function Require-Success([string]$Name, $Response, [string]$Evidence = '') {
|
||||
$passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200'
|
||||
Add-Step $Name $Response $passed $Evidence
|
||||
if (-not $passed) { throw "$Name 失败:HTTP=$($Response.http) code=$($Response.body.code) message=$($Response.body.message)" }
|
||||
return $Response.body.data
|
||||
}
|
||||
|
||||
function Require-Condition([string]$Name, $Response, [bool]$Condition, [string]$Evidence) {
|
||||
$passed = $Response.http -eq 200 -and [string]$Response.body.code -eq '200' -and $Condition
|
||||
Add-Step $Name $Response $passed $Evidence
|
||||
if (-not $passed) { throw "$Name 断言失败:$Evidence" }
|
||||
}
|
||||
|
||||
try {
|
||||
$platformLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $PlatformUsername; password = $PlatformPassword }
|
||||
$platformLogin = Require-Success '平台登录' $platformLoginResponse
|
||||
$platformToken = $platformLogin.accessToken
|
||||
|
||||
$merchantLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $MerchantUsername; password = $MerchantPassword }
|
||||
$merchantLogin = Require-Success '商户登录' $merchantLoginResponse
|
||||
$merchantToken = $merchantLogin.accessToken
|
||||
|
||||
$healthResponse = Invoke-Api GET '/api/platform/v1/first-leg/status' $platformToken
|
||||
$null = Require-Success 'Firstleg 服务健康检查' $healthResponse
|
||||
|
||||
$qualificationsResponse = Invoke-Api GET '/api/merchant/v1/iam/tenant/qualifications' $merchantToken
|
||||
$qualifications = @(Require-Success '查询商户必备资质' $qualificationsResponse)
|
||||
$approvedTypes = @($qualifications | Where-Object { $_.status -eq 'APPROVED' } | ForEach-Object { $_.qualificationType })
|
||||
if ('BUSINESS_LICENSE' -notin $approvedTypes -or 'LEGAL_PERSON_ID' -notin $approvedTypes) {
|
||||
$e2eUsername = "api_e2e_$stamp"
|
||||
$e2ePassword = 'ApiE2e123!'
|
||||
$registerResponse = Invoke-Api POST '/api/auth/register' '' @{
|
||||
username = $e2eUsername; password = $e2ePassword; displayName = 'API E2E Tester'
|
||||
companyName = "API E2E 测试企业 $stamp"; legalPerson = 'API Tester'; legalPhone = '13800138000'
|
||||
contactEmail = "api-e2e-$stamp@example.com"; countryCode = 'CN'; address = 'API E2E 测试地址'
|
||||
}
|
||||
$registration = Require-Success '注册隔离的 API E2E 测试企业' $registerResponse
|
||||
$TenantId = [long]$registration.tenantId
|
||||
$ids.tenantId = [string]$TenantId
|
||||
|
||||
$e2eLoginResponse = Invoke-Api POST '/api/auth/login' '' @{ username = $e2eUsername; password = $e2ePassword }
|
||||
$e2eLogin = Require-Success '登录隔离的 API E2E 测试企业' $e2eLoginResponse
|
||||
$merchantToken = $e2eLogin.accessToken
|
||||
|
||||
$qualificationImage = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png'
|
||||
$creditCode = "91${stamp}A1"
|
||||
$qualificationUploadResponse = Invoke-RequiredQualificationUpload $merchantToken $qualificationImage $creditCode
|
||||
$null = Require-Success '补齐商户营业执照与法人身份证测试包' $qualificationUploadResponse
|
||||
|
||||
$reviewSubmitResponse = Invoke-Api POST '/api/merchant/v1/iam/tenant/qualifications/submit-review' $merchantToken
|
||||
$null = Require-Success '提交商户资质审核' $reviewSubmitResponse
|
||||
$reviewsResponse = Invoke-Api GET '/api/platform/v1/iam/reviews' $platformToken
|
||||
$reviews = @(Require-Success '查询平台待审核企业' $reviewsResponse)
|
||||
$review = @($reviews | Where-Object { [string]$_.tenantId -eq [string]$TenantId } | Select-Object -First 1)
|
||||
if ($review.Count -eq 0) { throw "未找到 tenantId=$TenantId 的资质审核单" }
|
||||
$ids.qualificationReviewId = [string]$review[0].id
|
||||
$reviewApproveResponse = Invoke-Api POST "/api/platform/v1/iam/reviews/$($ids.qualificationReviewId)/approve" $platformToken @{ comment = "$marker 测试环境验收补齐" }
|
||||
$null = Require-Success '平台审核通过商户资质' $reviewApproveResponse
|
||||
}
|
||||
|
||||
$carrierPayload = [ordered]@{
|
||||
partnerCode = "API-E2E-CARRIER-$stamp"
|
||||
partnerName = "API E2E 测试承运商 $stamp"
|
||||
partnerType = 'CARRIER'
|
||||
types = @('CARRIER')
|
||||
contactName = 'API Tester'
|
||||
contactPhone = '13800138000'
|
||||
contactEmail = 'api-e2e@example.com'
|
||||
countryCode = 'CN'
|
||||
province = '广东省'
|
||||
city = '深圳市'
|
||||
district = '南山区'
|
||||
addressLine = 'API E2E 测试地址'
|
||||
postalCode = '518000'
|
||||
status = 'ACTIVE'
|
||||
remark = $marker
|
||||
}
|
||||
$carrierResponse = Invoke-Api POST '/api/platform/v1/partners?tenantId=0' $platformToken $carrierPayload
|
||||
$ids.carrierId = [string](Require-Success '创建公共测试承运商' $carrierResponse)
|
||||
|
||||
$warehousePayload = [ordered]@{
|
||||
tenantId = 0
|
||||
warehouseCode = "API-E2E-WH-$stamp"
|
||||
warehouseName = "API E2E 收货仓 $stamp"
|
||||
warehouseType = 'DOMESTIC'
|
||||
countryCode = 'CN'
|
||||
timezone = 'UTC+8'
|
||||
contactName = 'API Tester'
|
||||
contactPhone = '13800138000'
|
||||
contactEmail = 'api-e2e@example.com'
|
||||
status = 'ACTIVE'
|
||||
remark = $marker
|
||||
address = [ordered]@{
|
||||
addressType = 'WAREHOUSE'; countryCode = 'CN'; stateProvince = '广东省'; city = '深圳市'
|
||||
district = '南山区'; postalCode = '518000'; addressLine1 = 'API E2E 测试收货仓地址'
|
||||
addressLine2 = $null; latitude = $null; longitude = $null
|
||||
}
|
||||
capabilities = @([ordered]@{
|
||||
capabilityCode = 'RECEIVING'; capabilityName = '头程收货'; enabled = $true
|
||||
cutoffTime = '16:00:00'; description = $marker
|
||||
})
|
||||
}
|
||||
$warehouseResponse = Invoke-Api POST '/api/platform/v1/warehouse-admin' $platformToken $warehousePayload
|
||||
$ids.warehouseId = [string](Require-Success '创建启用收货能力的公共测试仓' $warehouseResponse)
|
||||
|
||||
$effectiveFrom = (Get-Date).AddDays(-1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$customerRuleResponse = Invoke-Api POST "/api/platform/v1/fba-pricing/customer-rules/versions?tenantId=$TenantId" $platformToken @{
|
||||
ruleCode = "API-E2E-RULE-$stamp"; customerId = '191000000000000001'
|
||||
customerName = '深圳示例贸易有限公司'; businessType = 'FBA_SEA'; chargeMode = 'KG'
|
||||
taxMode = 'TAX_INCLUDED'; conversionFactor = 167; roundingScale = 3
|
||||
roundingMode = 'HALF_UP'; currency = 'CNY'; effectiveFrom = $effectiveFrom
|
||||
effectiveTo = $null; remark = $marker
|
||||
}
|
||||
$customerRule = Require-Success '创建客户计费规则版本' $customerRuleResponse
|
||||
$ids.customerRuleId = [string]$customerRule.id
|
||||
|
||||
$carrierRateResponse = Invoke-Api POST '/api/platform/v1/fba-pricing/carrier-rates/versions' $platformToken @{
|
||||
rateCode = "API-E2E-RATE-$stamp"; originWarehouseId = $ids.warehouseId
|
||||
originWarehouseName = $warehousePayload.warehouseName; businessType = 'FBA_SEA'
|
||||
channelCode = 'API-E2E-SEA'; carrierId = $ids.carrierId
|
||||
carrierName = $carrierPayload.partnerName; destinationCode = 'ABE2'; chargeUnit = 'KG'
|
||||
unitPrice = 2; currency = 'CNY'; additionalFee = 10; effectiveFrom = $effectiveFrom
|
||||
effectiveTo = $null; remark = $marker
|
||||
}
|
||||
$carrierRate = Require-Success '创建承运商 FBA 费率版本' $carrierRateResponse
|
||||
$ids.carrierRateId = [string]$carrierRate.id
|
||||
|
||||
$sender = [ordered]@{
|
||||
companyName = '深圳示例贸易有限公司'; contactName = 'API Tester'; phone = '13800138000'
|
||||
email = 'api-e2e@example.com'; countryCode = 'CN'; province = '广东省'; city = '深圳市'
|
||||
district = '南山区'; addressLine = 'API E2E 测试地址'; postalCode = '518000'
|
||||
}
|
||||
$orderPayload = [ordered]@{
|
||||
customerId = '191000000000000001'
|
||||
customerName = '深圳示例贸易有限公司'
|
||||
transportMode = 'SEA'
|
||||
tradeTerm = 'FOB'
|
||||
destinationCountryCode = 'US'
|
||||
destinationWarehouseId = '193000000000000001'
|
||||
destinationWarehouseName = 'ABE2'
|
||||
expectedShipDate = (Get-Date).AddDays(10).ToString('yyyy-MM-dd')
|
||||
customerRemark = "$marker 初始草稿"
|
||||
sender = $sender
|
||||
receiver = $null
|
||||
cargoes = @([ordered]@{
|
||||
cargoName = 'API E2E 测试货物'; cargoNameEn = 'API E2E Test Cargo'; hsCode = '8518210000'
|
||||
customsHsCode = '8518210000'; packageType = 'CARTON'; packageCount = 1
|
||||
grossWeightKg = 10; netWeightKg = 9; volumeCbm = 0.06
|
||||
lengthCm = 50; widthCm = 40; heightCm = 30; declaredValue = 100; unitDeclaredValue = 100
|
||||
currency = 'USD'; dangerousGoods = $false; cargoRemark = $marker
|
||||
skuNo = "SKU-$stamp"; upcNo = "UPC-$stamp"; unitsPerPackage = 1; totalQuantity = 1
|
||||
material = '塑料'; usage = '测试'; model = 'E2E'; brand = 'API-E2E'
|
||||
})
|
||||
pickupMethod = 'PICKUP'
|
||||
receivingWarehouseId = $ids.warehouseId
|
||||
receivingWarehouseName = $warehousePayload.warehouseName
|
||||
channelCode = 'API-E2E-SEA'
|
||||
channelName = 'API E2E 海运渠道'
|
||||
carrierId = $null
|
||||
carrierName = $null
|
||||
taxMode = 'BUY_DECLARATION'
|
||||
containerMode = 'LCL'
|
||||
customsInspection = $false
|
||||
destinationType = 'FBA'
|
||||
amazonWarehouseCode = 'ABE2'
|
||||
shipmentId = "SHIP-$stamp"
|
||||
referenceId = $marker
|
||||
}
|
||||
|
||||
$createKey = New-Key 'order-create'
|
||||
$createResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey }
|
||||
$ids.orderId = [string](Require-Success '创建头程订单草稿' $createResponse)
|
||||
|
||||
$replayResponse = Invoke-Api POST '/api/merchant/v1/orders/first-leg' $merchantToken $orderPayload @{ 'Idempotency-Key' = $createKey }
|
||||
$replayedOrderId = [string](Require-Success '重放创建订单幂等请求' $replayResponse)
|
||||
Require-Condition '创建订单幂等结果一致' $replayResponse ($replayedOrderId -eq $ids.orderId) "orderId=$replayedOrderId"
|
||||
|
||||
$draftDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken
|
||||
$draftDetail = Require-Success '查询订单草稿' $draftDetailResponse
|
||||
Require-Condition '订单初始状态为 DRAFT' $draftDetailResponse ($draftDetail.order.status -eq 'DRAFT') "status=$($draftDetail.order.status)"
|
||||
|
||||
$orderPayload.customerRemark = "$marker 编辑后草稿"
|
||||
$updateResponse = Invoke-Api PUT "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken $orderPayload
|
||||
$null = Require-Success '编辑头程订单草稿' $updateResponse
|
||||
$editedDetailResponse = Invoke-Api GET "/api/merchant/v1/orders/first-leg/$($ids.orderId)" $merchantToken
|
||||
$editedDetail = Require-Success '回查编辑后的订单草稿' $editedDetailResponse
|
||||
Require-Condition '订单编辑内容已保存' $editedDetailResponse ($editedDetail.order.customerRemark -eq $orderPayload.customerRemark) "customerRemark=$($editedDetail.order.customerRemark)"
|
||||
|
||||
$submitResponse = Invoke-Api POST "/api/merchant/v1/orders/first-leg/$($ids.orderId)/submit" $merchantToken $null @{ 'Idempotency-Key' = (New-Key 'order-submit') }
|
||||
$null = Require-Success '商户提交订单' $submitResponse
|
||||
|
||||
$unifiedPendingResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken
|
||||
$unifiedPending = Require-Success '统一订单详情查询待审核状态' $unifiedPendingResponse
|
||||
Require-Condition '统一订单主状态为 PENDING_REVIEW' $unifiedPendingResponse ($unifiedPending.summary.mainStatus -eq 'PENDING_REVIEW') "mainStatus=$($unifiedPending.summary.mainStatus)"
|
||||
|
||||
$carrierAssignResponse = Invoke-Api PUT "/api/platform/v1/orders/first-leg/$($ids.orderId)/carrier?tenantId=$TenantId" $platformToken @{ carrierId = $ids.carrierId }
|
||||
$null = Require-Success '平台指派承运商' $carrierAssignResponse
|
||||
|
||||
$approveOrderResponse = Invoke-Api POST "/api/platform/v1/orders/first-leg/$($ids.orderId)/approve?tenantId=$TenantId" $platformToken
|
||||
$null = Require-Success '平台审核通过订单' $approveOrderResponse
|
||||
|
||||
$unifiedApprovedResponse = $null
|
||||
$unifiedApproved = $null
|
||||
for ($attempt = 1; $attempt -le 20; $attempt++) {
|
||||
$unifiedApprovedResponse = Invoke-Api GET "/api/platform/v1/orders/$($ids.orderId)?tenantId=$TenantId" $platformToken
|
||||
if ($unifiedApprovedResponse.http -eq 200 -and [string]$unifiedApprovedResponse.body.code -eq '200' -and $null -ne $unifiedApprovedResponse.body.data.summary.inboundId) {
|
||||
$unifiedApproved = $unifiedApprovedResponse.body.data
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
$inboundReady = $null -ne $unifiedApproved -and $null -ne $unifiedApproved.summary.inboundId
|
||||
Add-Step '异步生成入库单' $unifiedApprovedResponse $inboundReady $(if ($inboundReady) { "inboundId=$($unifiedApproved.summary.inboundId)" } else { '60 秒内未取得 inboundId' })
|
||||
if (-not $inboundReady) { throw '订单审核后 60 秒内未异步生成入库单' }
|
||||
$ids.inboundId = [string]$unifiedApproved.summary.inboundId
|
||||
$versions.inbound = [int]$unifiedApproved.summary.inboundVersion
|
||||
|
||||
$inboundDetailResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken
|
||||
$inboundDetail = Require-Success '查询入库单详情' $inboundDetailResponse
|
||||
$versions.inbound = [int]$inboundDetail.summary.version
|
||||
Require-Condition '入库单进入待测量状态' $inboundDetailResponse ($inboundDetail.summary.processStatus -eq 'WAIT_MEASURE') "processStatus=$($inboundDetail.summary.processStatus), version=$($versions.inbound)"
|
||||
|
||||
$measurementDetailResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken
|
||||
$null = Require-Success '查询初始测量详情' $measurementDetailResponse
|
||||
|
||||
$draftPayload = [ordered]@{
|
||||
measurementId = $null; version = 0; originalDocumentNo = $marker
|
||||
originalGrossWeightKg = 10; originalVolumeCbm = 0.06; actualGoodsName = 'API E2E 测试货物'
|
||||
items = @([ordered]@{
|
||||
itemNo = 'ITEM-1'; boxSpecification = '50x40x30cm'; packageCount = 1
|
||||
lengthCm = 50; widthCm = 40; heightCm = 30; unitWeightKg = 10
|
||||
intelligentImageFileId = $null; overLimitWarning = $null
|
||||
outerLengthCm = 50; outerWidthCm = 40; outerHeightCm = 30; remark = $marker
|
||||
})
|
||||
}
|
||||
$saveDraftResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/draft?tenantId=$TenantId" $platformToken $draftPayload
|
||||
$savedDraft = Require-Success '保存测量草稿' $saveDraftResponse
|
||||
$ids.measurementId = [string]$savedDraft.measurementId
|
||||
$versions.measurement = [int]$savedDraft.version
|
||||
|
||||
$imagePath = Join-Path (Split-Path -Parent $repoRoot) 'apps\pda-uniapp\static\c7.png'
|
||||
if (-not (Test-Path -LiteralPath $imagePath)) { throw "找不到测试称重图片:$imagePath" }
|
||||
$uploadResponse = Invoke-Upload "/api/platform/v1/files/measurements/$TenantId/$($ids.inboundId)" $platformToken $imagePath
|
||||
$uploaded = Require-Success '上传称重图片并触发 OCR' $uploadResponse
|
||||
$ids.fileId = [string]$uploaded.fileId
|
||||
|
||||
$weightPayload = [ordered]@{
|
||||
fileId = $ids.fileId
|
||||
ocrProvider = 'API_E2E_MANUAL'
|
||||
ocrRawText = if ($uploaded.weightValue) { [string]$uploaded.weightValue } else { 'OCR unavailable; manual verification required' }
|
||||
ocrWeightValue = $uploaded.weightValue
|
||||
ocrWeightUnit = if ($uploaded.weightValue) { 'KG' } else { $null }
|
||||
confidence = $uploaded.confidence
|
||||
}
|
||||
$weightCreateResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results?tenantId=$TenantId" $platformToken $weightPayload
|
||||
$weightResult = Require-Success '登记称重结果' $weightCreateResponse
|
||||
$ids.weightResultId = [string]$weightResult.id
|
||||
$versions.weightResult = [int]$weightResult.version
|
||||
|
||||
$correctResponse = Invoke-Api PUT "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)?tenantId=$TenantId" $platformToken @{
|
||||
version = $versions.weightResult; adjustedWeightValue = 10; adjustedWeightUnit = 'KG'; adjustmentReason = "$marker 人工确认"
|
||||
}
|
||||
$corrected = Require-Success '人工修正称重结果' $correctResponse
|
||||
$versions.weightResult = [int]$corrected.version
|
||||
|
||||
$confirmResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/weight-results/$($ids.weightResultId)/confirm?tenantId=$TenantId" $platformToken @{
|
||||
version = $versions.weightResult
|
||||
}
|
||||
$confirmed = Require-Success '确认称重结果' $confirmResponse
|
||||
$versions.weightResult = [int]$confirmed.version
|
||||
|
||||
$measurementBeforeSubmitResponse = Invoke-Api GET "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement?tenantId=$TenantId" $platformToken
|
||||
$measurementBeforeSubmit = Require-Success '提交前回查测量详情' $measurementBeforeSubmitResponse
|
||||
$versions.measurement = [int]$measurementBeforeSubmit.version
|
||||
Require-Condition '确认重量汇总为 10KG' $measurementBeforeSubmitResponse ([decimal]$measurementBeforeSubmit.actualGrossWeightKg -eq 10) "actualGrossWeightKg=$($measurementBeforeSubmit.actualGrossWeightKg)"
|
||||
|
||||
$measurementSubmitResponse = Invoke-Api POST "/api/platform/v1/first-leg/inbounds/$($ids.inboundId)/measurement/submit?tenantId=$TenantId" $platformToken @{
|
||||
measurementId = $ids.measurementId; version = $versions.measurement
|
||||
}
|
||||
$measurementSubmitted = Require-Success '提交测量审核' $measurementSubmitResponse
|
||||
$versions.measurement = [int]$measurementSubmitted.version
|
||||
Require-Condition '测量进入待审核状态' $measurementSubmitResponse ($measurementSubmitted.processStatus -eq 'MEASURE_PENDING' -and $measurementSubmitted.measureReviewStatus -eq 'PENDING') "processStatus=$($measurementSubmitted.processStatus), reviewStatus=$($measurementSubmitted.measureReviewStatus)"
|
||||
|
||||
$pendingInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken
|
||||
$pendingInbound = Require-Success '审核前回查入库单版本' $pendingInboundResponse
|
||||
$versions.inbound = [int]$pendingInbound.summary.version
|
||||
|
||||
$measurementApproveResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/measurements/approve?tenantId=$TenantId" $platformToken @{
|
||||
measurementId = $ids.measurementId; expectedVersion = $versions.inbound; reason = $null
|
||||
} @{ 'Idempotency-Key' = (New-Key 'measurement-approve') }
|
||||
$null = Require-Success '平台审核通过测量' $measurementApproveResponse
|
||||
|
||||
$approvedInboundResponse = Invoke-Api GET "/api/platform/inbounds/$($ids.inboundId)?tenantId=$TenantId" $platformToken
|
||||
$approvedInbound = Require-Success '审核后回查入库单' $approvedInboundResponse
|
||||
$versions.inbound = [int]$approvedInbound.summary.version
|
||||
Require-Condition '二期边界停在 WAIT_SPLIT' $approvedInboundResponse ($approvedInbound.summary.processStatus -eq 'WAIT_SPLIT' -and $approvedInbound.summary.measureReviewStatus -eq 'APPROVED') "processStatus=$($approvedInbound.summary.processStatus), reviewStatus=$($approvedInbound.summary.measureReviewStatus)"
|
||||
|
||||
$feeResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/calculate?tenantId=$TenantId" $platformToken @{} @{ 'Idempotency-Key' = (New-Key 'fee-calculate') }
|
||||
$calculatedFee = Require-Success '二期计费接口试算' $feeResponse
|
||||
$ids.feeId = [string]$calculatedFee.feeId
|
||||
Require-Condition '计费试算保持 WAIT_SPLIT 主流程边界' $feeResponse ($calculatedFee.nextProcessStatus -eq 'WAIT_SPLIT') "nextProcessStatus=$($calculatedFee.nextProcessStatus)"
|
||||
|
||||
$feeConfirmResponse = Invoke-Api POST "/api/platform/v1/inbounds/$($ids.inboundId)/fees/confirm?tenantId=$TenantId" $platformToken $null @{ 'Idempotency-Key' = (New-Key 'fee-confirm') }
|
||||
$confirmedFee = Require-Success '确认二期费用快照' $feeConfirmResponse
|
||||
Require-Condition '费用确认后仍保持 WAIT_SPLIT 主流程边界' $feeConfirmResponse ($confirmedFee.feeStatus -eq 'CONFIRMED' -and $confirmedFee.nextProcessStatus -eq 'WAIT_SPLIT') "feeStatus=$($confirmedFee.feeStatus), nextProcessStatus=$($confirmedFee.nextProcessStatus)"
|
||||
}
|
||||
catch {
|
||||
$fatal = $_.Exception.Message
|
||||
Write-Host "[中止] $fatal"
|
||||
}
|
||||
finally {
|
||||
$passedCount = @($steps | Where-Object passed).Count
|
||||
$failedCount = @($steps | Where-Object { -not $_.passed }).Count
|
||||
$report = [ordered]@{
|
||||
reportName = '二期测试环境 API 验收'
|
||||
scope = @('头程下单', '商户提交', '平台审核', '异步生成入库单', '测量草稿', '称重图片与结果', '人工修正与确认', '测量提交与审核', '计费试算')
|
||||
excluded = @('分柜', '预装', 'PDA 拣货', '装柜证据', '装柜完成', '派送')
|
||||
gateway = $GatewayBaseUrl
|
||||
marker = $marker
|
||||
startedAt = [datetime]::ParseExact($stamp, 'yyyyMMddHHmmss', $null).ToString('o')
|
||||
finishedAt = (Get-Date).ToString('o')
|
||||
success = $null -eq $fatal -and $null -eq $billingDefect -and $failedCount -eq 0
|
||||
passedCount = $passedCount
|
||||
failedCount = $failedCount
|
||||
ids = $ids
|
||||
versions = $versions
|
||||
fatal = $fatal
|
||||
billingDefect = $billingDefect
|
||||
steps = $steps
|
||||
}
|
||||
$report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8
|
||||
Write-Host "报告:$OutputPath"
|
||||
$report | ConvertTo-Json -Depth 20
|
||||
}
|
||||
@@ -162,6 +162,24 @@ JOIN wh_warehouse warehouse
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_id = VALUES(tenant_id);
|
||||
|
||||
-- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。
|
||||
INSERT INTO wh_service_capability (
|
||||
id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time,
|
||||
description, created_by, created_at, updated_by, updated_at, deleted, version
|
||||
)
|
||||
VALUES
|
||||
(193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00',
|
||||
'义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0),
|
||||
(193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00',
|
||||
'东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
capability_name = VALUES(capability_name),
|
||||
enabled = VALUES(enabled),
|
||||
cutoff_time = VALUES(cutoff_time),
|
||||
description = VALUES(description),
|
||||
deleted = 0,
|
||||
updated_at = CURRENT_TIMESTAMP(3);
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_wh_fba_public_master_data;
|
||||
CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data (
|
||||
warehouse_id_seed BIGINT UNSIGNED NOT NULL,
|
||||
@@ -655,6 +673,16 @@ BEGIN
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台仓地址父子解析失败';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(*)
|
||||
FROM wh_service_capability capability
|
||||
WHERE capability.tenant_id = 0
|
||||
AND capability.warehouse_id IN (193000000000200001,193000000000200002)
|
||||
AND capability.capability_code = 'RECEIVING'
|
||||
AND capability.enabled = 1
|
||||
AND capability.deleted = 0) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL verify_wh_fixed_platform_warehouse();
|
||||
|
||||
+15
@@ -97,6 +97,21 @@ class WarehouseSchemaContractTest {
|
||||
.contains("idx_wh_express_tenant_status");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSeedReceivingCapabilityForDomesticOperatingWarehouses() throws IOException {
|
||||
String masterData = read("warehouse-public-master-data.sql");
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
for (String sql : java.util.List.of(masterData, unified)) {
|
||||
assertThat(sql)
|
||||
.contains("193000000000200001,'RECEIVING','头程收货',1")
|
||||
.contains("193000000000200002,'RECEIVING','头程收货',1")
|
||||
.contains("国内固定作业仓收货能力写入校验失败")
|
||||
.contains("warehouse.warehouse_type = 'FBA'")
|
||||
.contains("FBA公共地址不得创建平台作业能力");
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(String file) throws IOException {
|
||||
return Files.readString(Path.of("src/main/resources/db", file));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user