fix: close phase4 overseas acceptance gaps
This commit is contained in:
@@ -10908,6 +10908,7 @@ DELETE role_permission
|
|||||||
WHERE role.tenant_id = 0
|
WHERE role.tenant_id = 0
|
||||||
AND role.app_code = 'PLATFORM'
|
AND role.app_code = 'PLATFORM'
|
||||||
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
|
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
|
||||||
|
AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate')
|
||||||
AND expected.permission_code IS NULL;
|
AND expected.permission_code IS NULL;
|
||||||
|
|
||||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||||
@@ -16265,10 +16266,47 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio
|
|||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM baiyue_iam.iam_role role
|
FROM baiyue_iam.iam_role role
|
||||||
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
AND permission.id BETWEEN 5107000 AND 5107299
|
AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate'
|
||||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission
|
||||||
|
JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
|
FROM baiyue_iam.iam_role role
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code='lastleg:pda:operate'
|
||||||
|
WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'
|
||||||
|
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission()
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission grant_row
|
||||||
|
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM baiyue_iam.iam_role_permission grant_row
|
||||||
|
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL baiyue_iam.assert_overseas_pda_picker_permission();
|
||||||
|
DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission;
|
||||||
|
|
||||||
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM baiyue_iam.iam_role role
|
FROM baiyue_iam.iam_role role
|
||||||
|
|||||||
@@ -387,9 +387,9 @@ SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l
|
|||||||
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键',
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
||||||
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||||
@@ -405,6 +405,31 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1'));
|
'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1'));
|
||||||
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
|
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE assert_receiving_global_locks_migratable()
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT operator_key FROM ll_receiving_container_session
|
||||||
|
WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session
|
||||||
|
WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL assert_receiving_global_locks_migratable();
|
||||||
|
DROP PROCEDURE assert_receiving_global_locks_migratable;
|
||||||
|
UPDATE ll_receiving_container_session
|
||||||
|
SET active_operator_key=CONCAT('OPERATOR:',operator_key),
|
||||||
|
active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no)))
|
||||||
|
WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL;
|
||||||
|
ALTER TABLE ll_receiving_container_session
|
||||||
|
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||||
|
MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
|
MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
|
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
|
||||||
id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||||
session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
||||||
@@ -424,8 +449,39 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
|||||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||||
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||||
|
DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE assert_receiving_operation_global_key_migratable()
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation
|
||||||
|
GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL assert_receiving_operation_global_key_migratable();
|
||||||
|
DROP PROCEDURE assert_receiving_operation_global_key_migratable;
|
||||||
|
SET @receiving_operation_global_index = (
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT index_name FROM information_schema.statistics
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
|
||||||
|
AND index_name='uk_ll_receiving_operation'
|
||||||
|
GROUP BY index_name
|
||||||
|
HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key'
|
||||||
|
) ready_index
|
||||||
|
);
|
||||||
|
SET @receiving_operation_named_index = (
|
||||||
|
SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
|
||||||
|
AND index_name='uk_ll_receiving_operation'
|
||||||
|
);
|
||||||
|
SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1',
|
||||||
|
IF(@receiving_operation_named_index>0,
|
||||||
|
'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)',
|
||||||
|
'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)'));
|
||||||
|
PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt;
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
|
||||||
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||||
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.baiyue.gateway.config;
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import com.baiyue.gateway.filter.CorrelationIdWebFilter;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -66,11 +67,16 @@ public class GatewaySecurityConfig {
|
|||||||
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
||||||
exchange.getResponse().setStatusCode(status);
|
exchange.getResponse().setStatusCode(status);
|
||||||
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
String requestId = CorrelationIdWebFilter.ensureRequestId(exchange);
|
||||||
|
String traceId = CorrelationIdWebFilter.ensureTraceId(exchange);
|
||||||
|
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId);
|
||||||
|
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId);
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("code", code);
|
body.put("code", code);
|
||||||
body.put("message", message);
|
body.put("message", message);
|
||||||
body.put("data", null);
|
body.put("data", null);
|
||||||
body.put("requestId", exchange.getRequest().getId());
|
body.put("requestId", requestId);
|
||||||
|
body.put("traceId", traceId);
|
||||||
body.put("timestamp", Instant.now().toString());
|
body.put("timestamp", Instant.now().toString());
|
||||||
try {
|
try {
|
||||||
byte[] bytes = mapper.writeValueAsBytes(body);
|
byte[] bytes = mapper.writeValueAsBytes(body);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilter;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Ensures overseas security failures carry the same correlation contract as normal API responses. */
|
||||||
|
@Component
|
||||||
|
public class CorrelationIdWebFilter implements WebFilter, Ordered {
|
||||||
|
public static final String REQUEST_HEADER = "X-Request-Id";
|
||||||
|
public static final String TRACE_HEADER = "X-Trace-Id";
|
||||||
|
private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId";
|
||||||
|
private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||||
|
exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange));
|
||||||
|
exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange));
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String ensureRequestId(ServerWebExchange exchange) {
|
||||||
|
return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE,
|
||||||
|
ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String ensureTraceId(ServerWebExchange exchange) {
|
||||||
|
return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE,
|
||||||
|
ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String accepted(HttpHeaders headers, String name) {
|
||||||
|
String incoming = headers.getFirst(name);
|
||||||
|
return valid(incoming) ? incoming : UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean valid(String value) {
|
||||||
|
return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128
|
||||||
|
&& value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return Ordered.HIGHEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
class GatewaySecurityErrorContractTest {
|
||||||
|
@Test
|
||||||
|
void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception {
|
||||||
|
var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test")
|
||||||
|
.header("X-Request-Id", "request-1234567890")
|
||||||
|
.header("X-Trace-Id", "trace-123456789012")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED,
|
||||||
|
"A0401", "未登录或令牌已失效").block();
|
||||||
|
|
||||||
|
var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block());
|
||||||
|
assertEquals("request-1234567890", body.path("requestId").asText());
|
||||||
|
assertEquals("trace-123456789012", body.path("traceId").asText());
|
||||||
|
assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id"));
|
||||||
|
assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id"));
|
||||||
|
assertFalse(body.path("timestamp").asText().isBlank());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,10 +72,47 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
|
|||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM iam_role role
|
FROM iam_role role
|
||||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
AND permission.id BETWEEN 5107000 AND 5107299
|
AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate'
|
||||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DELETE role_permission FROM iam_role_permission role_permission
|
||||||
|
JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id
|
||||||
|
JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
|
FROM iam_role role
|
||||||
|
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code='lastleg:pda:operate'
|
||||||
|
WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'
|
||||||
|
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE assert_overseas_pda_picker_permission()
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||||
|
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM iam_role_permission grant_row
|
||||||
|
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
WHERE permission.permission_code='lastleg:pda:operate'
|
||||||
|
AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL assert_overseas_pda_picker_permission();
|
||||||
|
DROP PROCEDURE assert_overseas_pda_picker_permission;
|
||||||
|
|
||||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM iam_role role
|
FROM iam_role role
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ DELETE role_permission
|
|||||||
WHERE role.tenant_id = 0
|
WHERE role.tenant_id = 0
|
||||||
AND role.app_code = 'PLATFORM'
|
AND role.app_code = 'PLATFORM'
|
||||||
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
|
AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY')
|
||||||
|
AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate')
|
||||||
AND expected.permission_code IS NULL;
|
AND expected.permission_code IS NULL;
|
||||||
|
|
||||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||||
|
|||||||
+20
@@ -40,6 +40,7 @@ class OverseasPhase4PermissionSqlContractTest {
|
|||||||
Set<String> catalog = codes(sql);
|
Set<String> catalog = codes(sql);
|
||||||
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
|
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
|
||||||
assertThat(sql).contains("'lastleg:pda:operate'");
|
assertThat(sql).contains("'lastleg:pda:operate'");
|
||||||
|
assertPdaPickerGrant(sql);
|
||||||
assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view"));
|
assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view"));
|
||||||
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
|
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
|
||||||
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
|
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
|
||||||
@@ -55,6 +56,7 @@ class OverseasPhase4PermissionSqlContractTest {
|
|||||||
.getContentAsString(StandardCharsets.UTF_8));
|
.getContentAsString(StandardCharsets.UTF_8));
|
||||||
assertThat(codes(sql)).containsAll(moduleCatalog);
|
assertThat(codes(sql)).containsAll(moduleCatalog);
|
||||||
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'");
|
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'");
|
||||||
|
assertPdaPickerGrant(sql);
|
||||||
assertCustomAdminGrant(sql);
|
assertCustomAdminGrant(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,24 @@ class OverseasPhase4PermissionSqlContractTest {
|
|||||||
.containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS);
|
.containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void assertPdaPickerGrant(String sql) {
|
||||||
|
assertThat(sql).contains(
|
||||||
|
"permission.permission_code<>'lastleg:pda:operate'",
|
||||||
|
"role.id=4000005",
|
||||||
|
"assert_overseas_pda_picker_permission",
|
||||||
|
"存在非仓库拣货员授权",
|
||||||
|
"role.role_code='WAREHOUSE_PICKER'",
|
||||||
|
"permission.permission_code='lastleg:pda:operate'",
|
||||||
|
"role.role_code='PLATFORM_ADMIN'",
|
||||||
|
"DELETE role_permission FROM");
|
||||||
|
int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'");
|
||||||
|
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker);
|
||||||
|
int grantEnd = sql.indexOf(';', picker);
|
||||||
|
assertThat(grantStart).isGreaterThanOrEqualTo(0);
|
||||||
|
assertThat(grantEnd).isGreaterThan(picker);
|
||||||
|
assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate");
|
||||||
|
}
|
||||||
|
|
||||||
private static String repositoryFile(String relativePath) throws Exception {
|
private static String repositoryFile(String relativePath) throws Exception {
|
||||||
Path path = Path.of(relativePath);
|
Path path = Path.of(relativePath);
|
||||||
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest {
|
|||||||
.contains("依赖的三期平台权限尚未完整部署")
|
.contains("依赖的三期平台权限尚未完整部署")
|
||||||
.contains("DELETE role_permission")
|
.contains("DELETE role_permission")
|
||||||
.contains("expected.permission_code IS NULL")
|
.contains("expected.permission_code IS NULL")
|
||||||
|
.contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'")
|
||||||
.contains("最小权限授权未完整写入")
|
.contains("最小权限授权未完整写入")
|
||||||
.contains("grant_row.tenant_id = 0) <> 19")
|
.contains("grant_row.tenant_id = 0) <> 19")
|
||||||
.contains("(6000004, 4000004, 0, 'TENANT', NULL)")
|
.contains("(6000004, 4000004, 0, 'TENANT', NULL)")
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
||||||
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
||||||
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
|
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
|
||||||
|
- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。
|
||||||
|
|
||||||
## 查询与租户约束
|
## 查询与租户约束
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -34,11 +34,11 @@ public class ReceivingContainerController {
|
|||||||
@Resource private ReceivingContainerService service;
|
@Resource private ReceivingContainerService service;
|
||||||
|
|
||||||
@GetMapping("/candidates")
|
@GetMapping("/candidates")
|
||||||
@Operation(summary = "List pending receiving containers for the authenticated customer")
|
@Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker")
|
||||||
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey()));
|
return ApiResponse.success(service.candidates(identity.operatorKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{containerNo}")
|
@GetMapping("/{containerNo}")
|
||||||
@@ -46,14 +46,14 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo) {
|
@PathVariable String containerNo) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo));
|
return ApiResponse.success(service.detail(identity.operatorKey(), containerNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{containerNo}/prepare")
|
@PostMapping("/{containerNo}/prepare")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(),
|
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(),
|
||||||
containerNo, request));
|
containerNo, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,20 +62,20 @@ public class ReceivingContainerController {
|
|||||||
@RequestPart("file") MultipartFile file) {
|
@RequestPart("file") MultipartFile file) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
||||||
return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file));
|
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/sign")
|
@PostMapping("/current/sign")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey()));
|
return ApiResponse.success(service.current(identity.operatorKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/cartons/resolve")
|
@PostMapping("/current/cartons/resolve")
|
||||||
@@ -83,7 +83,7 @@ public class ReceivingContainerController {
|
|||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/skus/resolve")
|
@PostMapping("/current/skus/resolve")
|
||||||
@@ -91,7 +91,7 @@ public class ReceivingContainerController {
|
|||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/pallets/resolve")
|
@PostMapping("/current/pallets/resolve")
|
||||||
@@ -99,41 +99,42 @@ public class ReceivingContainerController {
|
|||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request));
|
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/bulk")
|
@PostMapping("/current/receipts/bulk")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/carton")
|
@PostMapping("/current/receipts/carton")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/complete")
|
@PostMapping("/current/complete")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request));
|
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Identity identity(AuthenticatedUser user) {
|
private static Identity identity(AuthenticatedUser user) {
|
||||||
if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) {
|
if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required");
|
|| !"PLATFORM".equals(user.appCode())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required");
|
||||||
}
|
}
|
||||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||||
}
|
}
|
||||||
String operator = String.valueOf(user.userId());
|
String operator = String.valueOf(user.userId());
|
||||||
return new Identity(String.valueOf(user.tenantId()), operator,
|
return new Identity(operator,
|
||||||
user.username() == null || user.username().isBlank() ? operator : user.username());
|
user.username() == null || user.username().isBlank() ? operator : user.username());
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Identity(String customerId, String operatorKey, String operatorName) { }
|
private record Identity(String operatorKey, String operatorName) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-4
@@ -8,9 +8,8 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
|
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
|
||||||
@Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " +
|
@Select("SELECT * FROM ll_receiving_container_operation " +
|
||||||
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
"WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||||
ReceivingContainerOperation find(@Param("customerId") String customerId,
|
ReceivingContainerOperation find(@Param("operatorKey") String operatorKey,
|
||||||
@Param("operatorKey") String operatorKey,
|
|
||||||
@Param("key") String key);
|
@Param("key") String key);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-12
@@ -8,18 +8,15 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
|
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
|
||||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
@Select("SELECT * FROM ll_receiving_container_session " +
|
||||||
"AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
|
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
|
||||||
ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId,
|
ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
|
||||||
@Param("activeOperatorKey") String activeOperatorKey);
|
|
||||||
|
|
||||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
@Select("SELECT * FROM ll_receiving_container_session " +
|
||||||
"AND active_operator_key=#{activeOperatorKey} LIMIT 1")
|
"WHERE active_operator_key=#{activeOperatorKey} LIMIT 1")
|
||||||
ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId,
|
ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey);
|
||||||
@Param("activeOperatorKey") String activeOperatorKey);
|
|
||||||
|
|
||||||
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
@Select("SELECT * FROM ll_receiving_container_session " +
|
||||||
"AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
|
"WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
|
||||||
ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId,
|
ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
|
||||||
@Param("activeContainerKey") String activeContainerKey);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-48
@@ -80,16 +80,17 @@ public class ReceivingContainerService {
|
|||||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ReceivingContainerResponses.Candidate> candidates(String customerId, String operatorKey) {
|
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey) {
|
||||||
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||||
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
||||||
plans.selectList(new LambdaQueryWrapper<InboundPlan>().eq(InboundPlan::getCustomerId, customerId)
|
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||||
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
|
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
|
||||||
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
|
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
|
||||||
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
|
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return grouped.entrySet().stream().map(entry -> {
|
return grouped.entrySet().stream().map(entry -> {
|
||||||
|
requireSingleCustomer(entry.getValue(), entry.getKey());
|
||||||
Totals totals = totals(entry.getValue());
|
Totals totals = totals(entry.getValue());
|
||||||
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
||||||
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
|
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
|
||||||
@@ -98,9 +99,10 @@ public class ReceivingContainerService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) {
|
public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) {
|
||||||
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||||
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||||
|
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||||
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
||||||
throw conflict("Current account is locked to another selected container");
|
throw conflict("Current account is locked to another selected container");
|
||||||
}
|
}
|
||||||
@@ -109,27 +111,28 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName,
|
public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo,
|
||||||
String containerNo, ReceivingContainerRequests.Action request) {
|
ReceivingContainerRequests.Action request) {
|
||||||
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
|
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
|
||||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), command);
|
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||||
String activeOperatorKey = activeOperatorKey(customerId, operatorKey);
|
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||||
ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey);
|
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||||
|
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
||||||
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||||
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
|
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
|
||||||
}
|
}
|
||||||
if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) {
|
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
|
||||||
throw conflict("Container is already signed or prepared by another account");
|
throw conflict("Container is already signed or prepared by another account");
|
||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
ReceivingContainerSession session = new ReceivingContainerSession();
|
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||||
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||||
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
||||||
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo));
|
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
|
||||||
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
||||||
session.setVersion(0);
|
session.setVersion(0);
|
||||||
try { sessions.insert(session); }
|
try { sessions.insert(session); }
|
||||||
@@ -138,20 +141,21 @@ public class ReceivingContainerService {
|
|||||||
save(session, "PREPARE", request.idempotencyKey(), command, result); return result;
|
save(session, "PREPARE", request.idempotencyKey(), command, result); return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId,
|
public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) {
|
||||||
MultipartFile file) {
|
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
String customerId = session.getCustomerId();
|
||||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||||
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName,
|
public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName,
|
||||||
ReceivingContainerRequests.Sign request) {
|
ReceivingContainerRequests.Sign request) {
|
||||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request);
|
ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||||
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
|
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
|
||||||
@@ -166,14 +170,15 @@ public class ReceivingContainerService {
|
|||||||
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
|
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.Session current(String customerId, String operatorKey) {
|
public ReceivingContainerResponses.Session current(String operatorKey) {
|
||||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false);
|
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||||
return snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey,
|
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
|
||||||
ReceivingContainerRequests.CartonResolve request) {
|
ReceivingContainerRequests.CartonResolve request) {
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||||
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||||
if (carton == null) throw notFound("Carton mark was not found");
|
if (carton == null) throw notFound("Carton mark was not found");
|
||||||
@@ -190,9 +195,10 @@ public class ReceivingContainerService {
|
|||||||
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey,
|
public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey,
|
||||||
ReceivingContainerRequests.SkuResolve request) {
|
ReceivingContainerRequests.SkuResolve request) {
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
|
List<SkuRef> matches = refs(customerId, session.getContainerNo()).stream()
|
||||||
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
||||||
.map(ref -> skuRef(ref, request.skuCode()))
|
.map(ref -> skuRef(ref, request.skuCode()))
|
||||||
@@ -203,9 +209,10 @@ public class ReceivingContainerService {
|
|||||||
return skuResponse(matches.get(0));
|
return skuResponse(matches.get(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey,
|
public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
|
||||||
ReceivingContainerRequests.PalletResolve request) {
|
ReceivingContainerRequests.PalletResolve request) {
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||||
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
|
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
|
||||||
validatePallet(ref, pallet);
|
validatePallet(ref, pallet);
|
||||||
@@ -214,11 +221,12 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName,
|
public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName,
|
||||||
ReceivingContainerRequests.BulkReceive request) {
|
ReceivingContainerRequests.BulkReceive request) {
|
||||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||||
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
|
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
|
||||||
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
||||||
@@ -248,11 +256,12 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName,
|
public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName,
|
||||||
ReceivingContainerRequests.CartonReceive request) {
|
ReceivingContainerRequests.CartonReceive request) {
|
||||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||||
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||||
if (carton == null) throw notFound("Carton mark was not found");
|
if (carton == null) throw notFound("Carton mark was not found");
|
||||||
@@ -284,11 +293,12 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReceivingContainerResponses.Session complete(String customerId, String operatorKey,
|
public ReceivingContainerResponses.Session complete(String operatorKey,
|
||||||
ReceivingContainerRequests.Action request) {
|
ReceivingContainerRequests.Action request) {
|
||||||
ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
|
String customerId = session.getCustomerId();
|
||||||
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
|
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
|
||||||
Totals totals = totals(containerPlans);
|
Totals totals = totals(containerPlans);
|
||||||
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
||||||
@@ -301,22 +311,33 @@ public class ReceivingContainerService {
|
|||||||
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
|
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) {
|
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
|
||||||
String key = activeOperatorKey(customerId, operatorKey);
|
String key = activeOperatorKey(operatorKey);
|
||||||
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key)
|
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
|
||||||
: sessions.findActiveByOperator(customerId, key);
|
: sessions.findActiveByOperator(key);
|
||||||
if (session == null) throw notFound("Current receiving container session was not found");
|
if (session == null) throw notFound("Current receiving container session was not found");
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReceivingContainerSession requireSigned(String customerId, String operatorKey) {
|
private ReceivingContainerSession requireSigned(String operatorKey) {
|
||||||
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
ReceivingContainerSession session = requireCurrent(operatorKey, true);
|
||||||
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
|
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
|
||||||
throw conflict("Sign the receiving container before receiving cargo");
|
throw conflict("Sign the receiving container before receiving cargo");
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<InboundPlan> requirePlans(String containerNo) {
|
||||||
|
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||||
|
.eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId));
|
||||||
|
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||||
|
requireSingleCustomer(values, containerNo);
|
||||||
|
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||||
|
throw conflict("Receiving container is already completed");
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
||||||
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
||||||
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||||
@@ -334,6 +355,15 @@ public class ReceivingContainerService {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String requireSingleCustomer(List<InboundPlan> values, String containerNo) {
|
||||||
|
Set<String> customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim)
|
||||||
|
.filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
|
||||||
|
if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||||
|
throw conflict("Receiving container must resolve to exactly one customer: " + containerNo);
|
||||||
|
}
|
||||||
|
return customers.iterator().next();
|
||||||
|
}
|
||||||
|
|
||||||
private List<InboundRef> refs(String customerId, String containerNo) {
|
private List<InboundRef> refs(String customerId, String containerNo) {
|
||||||
List<InboundRef> result = new ArrayList<>();
|
List<InboundRef> result = new ArrayList<>();
|
||||||
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
||||||
@@ -576,9 +606,9 @@ public class ReceivingContainerService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) {
|
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
|
||||||
key = idempotencyKey(key);
|
key = idempotencyKey(key);
|
||||||
ReceivingContainerOperation value = operations.find(customerId, operatorKey, key);
|
ReceivingContainerOperation value = operations.find(operatorKey, key);
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
|
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
|
||||||
throw conflict("Idempotency key was already used with a different request");
|
throw conflict("Idempotency key was already used with a different request");
|
||||||
@@ -618,8 +648,8 @@ public class ReceivingContainerService {
|
|||||||
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; }
|
private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; }
|
||||||
private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; }
|
private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); }
|
||||||
private static String idempotencyKey(String value) {
|
private static String idempotencyKey(String value) {
|
||||||
if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required");
|
if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required");
|
||||||
value = value.trim();
|
value = value.trim();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.lastleg.inventory.entity;
|
package com.baiyue.lastleg.inventory.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -40,8 +41,11 @@ public class InventoryLedger {
|
|||||||
private Long deltaPackages;
|
private Long deltaPackages;
|
||||||
private Long afterPackages;
|
private Long afterPackages;
|
||||||
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
|
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
|
||||||
|
@TableField(exist = false)
|
||||||
private Long beforeQuantity;
|
private Long beforeQuantity;
|
||||||
|
@TableField(exist = false)
|
||||||
private Long deltaQuantity;
|
private Long deltaQuantity;
|
||||||
|
@TableField(exist = false)
|
||||||
private Long afterQuantity;
|
private Long afterQuantity;
|
||||||
private String operatorId;
|
private String operatorId;
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|||||||
+12
-10
@@ -1,6 +1,8 @@
|
|||||||
package com.baiyue.lastleg.procurement.entity;
|
package com.baiyue.lastleg.procurement.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
import com.baomidou.mybatisplus.annotation.TableName;
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -17,24 +19,24 @@ public class OverseasProcurementOrder {
|
|||||||
private String orderNo;
|
private String orderNo;
|
||||||
private String purchaseCategory;
|
private String purchaseCategory;
|
||||||
private String productName;
|
private String productName;
|
||||||
private String productSpecification;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification;
|
||||||
private String productUrl;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl;
|
||||||
private Long purchaseQuantity;
|
private Long purchaseQuantity;
|
||||||
private BigDecimal budgetUnitPrice;
|
private BigDecimal budgetUnitPrice;
|
||||||
private String currency;
|
private String currency;
|
||||||
private String receivingAddress;
|
private String receivingAddress;
|
||||||
private String contactName;
|
private String contactName;
|
||||||
private String contactPhone;
|
private String contactPhone;
|
||||||
private String purchaseWarehouseId;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId;
|
||||||
private String purchaseWarehouseName;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName;
|
||||||
private Long inboundQuantity;
|
private Long inboundQuantity;
|
||||||
private String domesticCarrier;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier;
|
||||||
private String domesticTrackingNo;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo;
|
||||||
private String overseasCarrier;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier;
|
||||||
private String overseasTrackingNo;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo;
|
||||||
private LocalDateTime estimatedArrivalAt;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt;
|
||||||
private String status;
|
private String status;
|
||||||
private String remark;
|
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
|
||||||
private String idempotencyKey;
|
private String idempotencyKey;
|
||||||
private String requestHash;
|
private String requestHash;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
|
|||||||
@@ -678,10 +678,10 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
|
|||||||
OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键',
|
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||||
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
|
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
|
||||||
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
@@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
|||||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||||
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ paths:
|
|||||||
/api/pda/v1/last-leg/receiving-containers/candidates:
|
/api/pda/v1/last-leg/receiving-containers/candidates:
|
||||||
get:
|
get:
|
||||||
summary: 查询当前客户待签收柜号
|
summary: 查询当前客户待签收柜号
|
||||||
description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。
|
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
||||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
||||||
get:
|
get:
|
||||||
summary: 查询待签收柜号详情
|
summary: 查询待签收柜号详情
|
||||||
description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。
|
description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||||
responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } }
|
responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } }
|
||||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
||||||
post:
|
post:
|
||||||
summary: 选择当前待签收柜号
|
summary: 选择当前待签收柜号
|
||||||
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。
|
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||||
|
|||||||
+11
-1
@@ -47,7 +47,7 @@ class ReceivingContainerContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException {
|
void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException {
|
||||||
String schema = resource("db/lastleg.sql").toLowerCase();
|
String schema = resource("db/lastleg.sql").toLowerCase();
|
||||||
String unified = java.nio.file.Files.readString(
|
String unified = java.nio.file.Files.readString(
|
||||||
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
|
java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||||
@@ -62,7 +62,10 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
|
assertTrue(schema.contains("create table if not exists ll_receiving_container_session"));
|
||||||
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
|
assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)"));
|
||||||
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
|
assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)"));
|
||||||
|
assertTrue(schema.contains("平台操作账号全局唯一锁键"));
|
||||||
|
assertTrue(schema.contains("柜号全局唯一锁键"));
|
||||||
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
|
assertTrue(schema.contains("create table if not exists ll_receiving_container_operation"));
|
||||||
|
assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)"));
|
||||||
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
|
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
|
||||||
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
|
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
|
||||||
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
|
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
|
||||||
@@ -75,6 +78,11 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
|
assertTrue(unified.contains("customer_id is null or v.customer_id=''"));
|
||||||
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
|
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
|
||||||
assertTrue(unified.contains("signal sqlstate '45000'"));
|
assertTrue(unified.contains("signal sqlstate '45000'"));
|
||||||
|
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
|
||||||
|
assertTrue(unified.contains("concat('operator:',operator_key)"));
|
||||||
|
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
|
||||||
|
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
|
||||||
|
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -94,6 +102,8 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(successResponses.stream().allMatch(line ->
|
assertTrue(successResponses.stream().allMatch(line ->
|
||||||
line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving")));
|
line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving")));
|
||||||
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
||||||
|
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||||
|
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||||
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
||||||
assertFalse(receivingPaths.contains("photoFileIds"));
|
assertFalse(receivingPaths.contains("photoFileIds"));
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-11
@@ -30,25 +30,32 @@ class ReceivingContainerControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() {
|
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
|
||||||
when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
|
when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
|
||||||
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
|
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
|
||||||
|
|
||||||
controller.candidates(pda());
|
controller.candidates(picker());
|
||||||
|
|
||||||
verify(service).candidates("100", "42");
|
verify(service).candidates("42");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformIdentityCannotEnterPdaReceivingApi() {
|
void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() {
|
||||||
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB",
|
||||||
Set.of("lastleg:pda:operate"));
|
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
|
||||||
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
|
assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("401");
|
.hasMessageContaining("401");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthenticatedUser pda() {
|
@Test
|
||||||
return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA",
|
void platformIdentityWithoutPickerAuthorityIsForbidden() {
|
||||||
Set.of("lastleg:pda:operate"));
|
AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of());
|
||||||
|
assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("403");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthenticatedUser picker() {
|
||||||
|
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
||||||
|
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-25
@@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
@@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
when(lines.selectList(any())).thenReturn(List.of());
|
when(lines.selectList(any())).thenReturn(List.of());
|
||||||
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
|
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||||
service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
|
service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
|
||||||
|
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
|
||||||
|
verify(sessions).insert(selected.capture());
|
||||||
|
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
|
||||||
|
assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7");
|
||||||
|
assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A");
|
||||||
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
|
ArgumentCaptor<ReceivingContainerOperation> saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class);
|
||||||
verify(operations).insert(saved.capture());
|
verify(operations).insert(saved.capture());
|
||||||
when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue());
|
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||||
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
|
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("409");
|
.hasMessageContaining("409");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() {
|
||||||
|
InboundPlan first = plan("CONT-A");
|
||||||
|
InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200");
|
||||||
|
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
|
||||||
|
when(lines.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
|
||||||
|
|
||||||
|
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
|
||||||
|
org.mockito.Mockito.reset(plans, sessions);
|
||||||
|
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
|
||||||
|
assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class)
|
||||||
|
.hasMessageContaining("exactly one customer");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
|
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||||
when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
assertThatThrownBy(() -> service.detail("100", "7", "CONT-B"))
|
assertThatThrownBy(() -> service.detail("7", "CONT-B"))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
|
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||||
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
|
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||||
|
|
||||||
org.mockito.Mockito.reset(sessions, operations);
|
org.mockito.Mockito.reset(sessions, operations);
|
||||||
when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
|
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
|
||||||
assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B",
|
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
|
||||||
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
|
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||||
}
|
}
|
||||||
@@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
@Test
|
@Test
|
||||||
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
|
void destinationModesAreHardRejectedBeforeWarehouseCalls() {
|
||||||
stubCurrent("OVERSEAS", "CONT-A");
|
stubCurrent("OVERSEAS", "CONT-A");
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(warehouse);
|
verifyNoInteractions(warehouse);
|
||||||
@@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
InboundPlanCarton carton = carton(1L, "BOX-1");
|
InboundPlanCarton carton = carton(1L, "BOX-1");
|
||||||
when(cartons.selectOne(any())).thenReturn(carton);
|
when(cartons.selectOne(any())).thenReturn(carton);
|
||||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||||
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
|
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(warehouse);
|
verifyNoInteractions(warehouse);
|
||||||
@@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
|
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
|
||||||
stubCurrent("OVERSEAS", "CONT-A");
|
stubCurrent("OVERSEAS", "CONT-A");
|
||||||
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
|
when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X"));
|
||||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||||
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
|
new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(warehouse);
|
verifyNoInteractions(warehouse);
|
||||||
@@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
stubSku();
|
stubSku();
|
||||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
|
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||||
@@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
||||||
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class);
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||||
@@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
|
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton"))
|
||||||
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
|
.when(workflow).validateCartonResolved(any(), any(), any(), any(), any());
|
||||||
assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
|
||||||
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
|
new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class);
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||||
@@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
||||||
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
||||||
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
||||||
assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key")))
|
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verify(workflow, never()).completeContainer(any(), any());
|
verify(workflow, never()).completeContainer(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedRequestReplaysWithoutAnActiveCustomerSession() {
|
||||||
|
ReceivingContainerOperation operation = new ReceivingContainerOperation();
|
||||||
|
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
|
||||||
|
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
|
||||||
|
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
|
||||||
|
when(operations.find("7", "complete-replay")).thenReturn(operation);
|
||||||
|
|
||||||
|
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
|
||||||
|
.isEqualTo("COMPLETED");
|
||||||
|
verifyNoInteractions(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
|
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
|
||||||
stubCurrent("FBA", "CONT-A");
|
stubCurrent("FBA", "CONT-A");
|
||||||
@@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
|
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
|
||||||
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
|
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key")))
|
||||||
.isSameAs(failure);
|
.isSameAs(failure);
|
||||||
verify(workflow).compensateReservation("100", 55L, failure);
|
verify(workflow).compensateReservation("100", 55L, failure);
|
||||||
@@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
|
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
|
||||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
|
||||||
when(lines.selectList(any())).thenReturn(List.of());
|
when(lines.selectList(any())).thenReturn(List.of());
|
||||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||||
@@ -222,7 +257,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
|
||||||
|
|
||||||
var response = service.sign("100", "7", "receiver", request);
|
var response = service.sign("7", "receiver", request);
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
|
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
|
||||||
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
|
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
|
||||||
@@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signRejectsDuplicateRolesBeforeFileValidation() {
|
void signRejectsDuplicateRolesBeforeFileValidation() {
|
||||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
|
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(unloadPhotos);
|
verifyNoInteractions(unloadPhotos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
||||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
|
||||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
|
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.sign("100", "7", "receiver", request))
|
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(unloadPhotos);
|
verifyNoInteractions(unloadPhotos);
|
||||||
}
|
}
|
||||||
@@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
received.setReceivedPackages(2);
|
received.setReceivedPackages(2);
|
||||||
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
|
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver",
|
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||||
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
|
new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over")))
|
||||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
verifyNoInteractions(warehouse);
|
verifyNoInteractions(warehouse);
|
||||||
@@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
InboundPlan plan = plan(containerNo);
|
InboundPlan plan = plan(containerNo);
|
||||||
InboundPlanLine line = line(destinationType);
|
InboundPlanLine line = line(destinationType);
|
||||||
LastlegOrder order = order(destinationType);
|
LastlegOrder order = order(destinationType);
|
||||||
when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED"));
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan));
|
when(plans.selectList(any())).thenReturn(List.of(plan));
|
||||||
when(lines.selectList(any())).thenReturn(List.of(line));
|
when(lines.selectList(any())).thenReturn(List.of(line));
|
||||||
when(orders.selectById(1L)).thenReturn(order);
|
when(orders.selectById(1L)).thenReturn(order);
|
||||||
@@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
|
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
|
||||||
"AVAILABLE", fbaCode, 0);
|
"AVAILABLE", fbaCode, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String hash(Object value) {
|
||||||
|
try {
|
||||||
|
String json = new ObjectMapper().findAndRegisterModules().copy()
|
||||||
|
.configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||||
|
.configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
|
||||||
|
.writeValueAsString(value);
|
||||||
|
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AssertionError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -12,6 +12,7 @@ import java.io.IOException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
@@ -108,9 +109,12 @@ class InventorySchemaContractTest {
|
|||||||
assertThrowsNoField(InventoryPosition.class, "productQuantity");
|
assertThrowsNoField(InventoryPosition.class, "productQuantity");
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
|
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
|
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
|
for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) {
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
|
var annotation = InventoryLedger.class.getDeclaredField(field)
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
|
.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class);
|
||||||
|
assertNotNull(annotation, field);
|
||||||
|
assertFalse(annotation.exist(), field);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertThrowsNoField(Class<?> type, String fieldName) {
|
private static void assertThrowsNoField(Class<?> type, String fieldName) {
|
||||||
|
|||||||
+14
@@ -1,5 +1,7 @@
|
|||||||
package com.baiyue.lastleg.procurement.service;
|
package com.baiyue.lastleg.procurement.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||||
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
|
||||||
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
|
||||||
@@ -21,6 +23,7 @@ import java.util.List;
|
|||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
@@ -155,4 +158,15 @@ class OverseasProcurementServiceTest {
|
|||||||
order.setUpdatedAt(order.getCreatedAt());
|
order.setUpdatedAt(order.getCreatedAt());
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception {
|
||||||
|
for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId",
|
||||||
|
"purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier",
|
||||||
|
"overseasTrackingNo", "estimatedArrivalAt", "remark")) {
|
||||||
|
TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class);
|
||||||
|
assertNotNull(annotation, field);
|
||||||
|
assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,49 @@ import process from 'node:process'
|
|||||||
|
|
||||||
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
|
||||||
|
|
||||||
|
const MODULE_DEPENDENCIES = new Map([
|
||||||
|
['orders', ['merchant-qualification-fixture']],
|
||||||
|
['inbound-measurement', ['orders']],
|
||||||
|
['phase3-container-loading', ['inbound-measurement']],
|
||||||
|
['phase3-loading', ['phase3-container-loading']],
|
||||||
|
['phase3-container-overview', ['phase3-loading']],
|
||||||
|
['phase3-loading-model-bridge', ['phase3-loading']],
|
||||||
|
['phase3-pda-convergence', ['phase3-loading']],
|
||||||
|
['phase3-evidence-gate', ['phase3-pda-convergence']],
|
||||||
|
['ocean-bookings', ['phase3-loading']],
|
||||||
|
['truck-appointments', ['merchant-qualification-fixture']],
|
||||||
|
['service-center-flow', ['ocean-bookings', 'truck-appointments']],
|
||||||
|
['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']],
|
||||||
|
['phase3-customs', ['phase3-loading-finalize']],
|
||||||
|
['phase3-port-schedules', ['ocean-bookings']],
|
||||||
|
['phase3-outbound-dispatch', ['phase3-customs']],
|
||||||
|
['phase3-outbound-aggregate', ['phase3-outbound-dispatch']],
|
||||||
|
['phase3-dispatch-gate', ['phase3-outbound-dispatch']],
|
||||||
|
['phase3-waiting-for-loading', ['phase3-outbound-dispatch']],
|
||||||
|
['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']],
|
||||||
|
['phase3-order-splitting', ['phase3-loading-finalize']],
|
||||||
|
['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']],
|
||||||
|
['phase4-overseas-pda', ['phase3-loading-finalize']],
|
||||||
|
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']],
|
||||||
|
['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']],
|
||||||
|
])
|
||||||
|
|
||||||
|
function stableTopologicalOrder(modules) {
|
||||||
|
const selected = new Set(modules)
|
||||||
|
const pending = [...modules]
|
||||||
|
const result = []
|
||||||
|
const completed = new Set()
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const readyIndex = pending.findIndex((module) => (MODULE_DEPENDENCIES.get(module) || [])
|
||||||
|
.every((dependency) => !selected.has(dependency) || completed.has(dependency)))
|
||||||
|
if (readyIndex < 0) throw new Error(`HTTP 验收模块依赖存在循环: ${pending.join(',')}`)
|
||||||
|
const [module] = pending.splice(readyIndex, 1)
|
||||||
|
result.push(module)
|
||||||
|
completed.add(module)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeModuleOrder(modules) {
|
export function normalizeModuleOrder(modules) {
|
||||||
const normalized = [...modules]
|
const normalized = [...modules]
|
||||||
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
|
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
|
||||||
@@ -68,9 +111,9 @@ export function normalizeModuleOrder(modules) {
|
|||||||
const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement',
|
||||||
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
|
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
|
||||||
'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda']
|
'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda']
|
||||||
const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module))
|
const targetIndex = normalized.indexOf('phase4-cross-region-events')
|
||||||
withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites)
|
const missing = prerequisites.filter((name) => !normalized.includes(name))
|
||||||
normalized.splice(0, normalized.length, ...withoutPrerequisites)
|
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
|
||||||
}
|
}
|
||||||
const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']
|
||||||
const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name))
|
const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name))
|
||||||
@@ -122,10 +165,11 @@ export function normalizeModuleOrder(modules) {
|
|||||||
}
|
}
|
||||||
const loadingIndex = normalized.indexOf('phase3-loading')
|
const loadingIndex = normalized.indexOf('phase3-loading')
|
||||||
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
const overviewIndex = normalized.indexOf('phase3-container-overview')
|
||||||
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
|
if (loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex) {
|
||||||
normalized.splice(overviewIndex, 1)
|
normalized.splice(overviewIndex, 1)
|
||||||
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
||||||
return normalized
|
}
|
||||||
|
return stableTopologicalOrder(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
|
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ function assertCargoContract(context, response, cargoes, payload) {
|
|||||||
&& Number(actual?.productLengthCm) === expected.productLengthCm
|
&& Number(actual?.productLengthCm) === expected.productLengthCm
|
||||||
&& Number(actual?.productWidthCm) === expected.productWidthCm
|
&& Number(actual?.productWidthCm) === expected.productWidthCm
|
||||||
&& Number(actual?.productHeightCm) === expected.productHeightCm
|
&& Number(actual?.productHeightCm) === expected.productHeightCm
|
||||||
&& actual?.shipmentId === expected.shipmentId && actual?.referenceId === expected.referenceId
|
&& (actual?.shipmentId ?? null) === (expected.shipmentId ?? null)
|
||||||
|
&& (actual?.referenceId ?? null) === (expected.referenceId ?? null)
|
||||||
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
|
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
|
||||||
})
|
})
|
||||||
context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete,
|
context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { markerSuffix } from './phase3-loading.mjs'
|
||||||
|
|
||||||
|
test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => {
|
||||||
|
const base = 'ADMIN-API-20260824123024536'
|
||||||
|
const primary = markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`)
|
||||||
|
const secondary = markerSuffix(`${base}-P4-SECONDARY`)
|
||||||
|
|
||||||
|
assert.equal(primary, markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`))
|
||||||
|
assert.notEqual(primary, secondary)
|
||||||
|
assert.match(primary, /^\d{8}-[0-9A-F]{8}$/)
|
||||||
|
assert.ok(`P3B-${primary}`.length <= 64)
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
const MODULE = 'phase3-loading'
|
const MODULE = 'phase3-loading'
|
||||||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||||||
@@ -6,6 +7,12 @@ const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
|
|||||||
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
|
export function markerSuffix(marker) {
|
||||||
|
const value = String(marker || '')
|
||||||
|
const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0')
|
||||||
|
const digest = createHash('sha256').update(value).digest('hex').slice(0, 8).toUpperCase()
|
||||||
|
return `${digits}-${digest}`
|
||||||
|
}
|
||||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||||
function responseEvidence(response) {
|
function responseEvidence(response) {
|
||||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||||
@@ -119,7 +126,7 @@ export async function run(context) {
|
|||||||
if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return
|
if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return
|
||||||
|
|
||||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
const suffix = markerSuffix(context.marker)
|
||||||
const warehouseId = id(initial?.summary?.warehouseId)
|
const warehouseId = id(initial?.summary?.warehouseId)
|
||||||
const containerNo = `P3B-${suffix}`.slice(0, 64)
|
const containerNo = `P3B-${suffix}`.slice(0, 64)
|
||||||
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ export async function run(context) {
|
|||||||
const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || [])
|
const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || [])
|
||||||
.map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean))
|
.map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean))
|
||||||
const pickerCodes = roleCodes('WAREHOUSE_PICKER')
|
const pickerCodes = roleCodes('WAREHOUSE_PICKER')
|
||||||
context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse,
|
context.assert(MODULE, '拣货员只具备国内PDA查询作业与海外PDA入仓操作权限', rolesResponse,
|
||||||
pickerCodes.size === 2
|
pickerCodes.size === 3
|
||||||
&& pickerCodes.has('platform:firstleg:pda:view')
|
&& pickerCodes.has('platform:firstleg:pda:view')
|
||||||
&& pickerCodes.has('platform:firstleg:pda:work'),
|
&& pickerCodes.has('platform:firstleg:pda:work')
|
||||||
|
&& pickerCodes.has('lastleg:pda:operate'),
|
||||||
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
|
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
|
||||||
|
|
||||||
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
||||||
|
|||||||
@@ -58,6 +58,25 @@ test('entry orders marker-owned prerequisites before cross-region acceptance', (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('cross-region normalization keeps existing producers ahead of every phase-three consumer', () => {
|
||||||
|
const requested = [
|
||||||
|
'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading',
|
||||||
|
'phase3-loading-model-bridge', 'phase3-loading', 'ocean-bookings', 'phase3-loading-finalize',
|
||||||
|
'phase3-customs', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate',
|
||||||
|
'phase4-overseas-pda', 'phase4-cross-region-events',
|
||||||
|
]
|
||||||
|
const modules = normalizeModuleOrder(requested)
|
||||||
|
for (const [producer, consumer] of [
|
||||||
|
['merchant-qualification-fixture', 'orders'], ['orders', 'inbound-measurement'],
|
||||||
|
['inbound-measurement', 'phase3-container-loading'], ['phase3-container-loading', 'phase3-loading'],
|
||||||
|
['phase3-loading', 'phase3-loading-model-bridge'], ['phase3-loading', 'ocean-bookings'],
|
||||||
|
['phase3-loading-finalize', 'phase3-customs'], ['phase3-customs', 'phase3-outbound-dispatch'],
|
||||||
|
['phase3-outbound-dispatch', 'phase3-outbound-aggregate'],
|
||||||
|
['phase4-overseas-pda', 'phase4-cross-region-events'],
|
||||||
|
]) assert.ok(modules.indexOf(producer) < modules.indexOf(consumer), `${producer} -> ${consumer}`)
|
||||||
|
assert.deepEqual(normalizeModuleOrder(modules), modules)
|
||||||
|
})
|
||||||
|
|
||||||
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||||
for (const token of [
|
for (const token of [
|
||||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ test('PDA module covers the container-number receiving contract and excludes out
|
|||||||
assert.match(source, /同键同载荷重放/)
|
assert.match(source, /同键同载荷重放/)
|
||||||
assert.match(source, /同键异载荷冲突/)
|
assert.match(source, /同键异载荷冲突/)
|
||||||
assert.match(source, /跨柜切换锁返回409/)
|
assert.match(source, /跨柜切换锁返回409/)
|
||||||
|
assert.match(source, /第二仓库操作员抢占同柜返回409/)
|
||||||
|
assert.match(source, /WAREHOUSE_PICKER/)
|
||||||
|
assert.match(source, /lastleg:pda:operate/)
|
||||||
assert.match(source, /超收被拒绝/)
|
assert.match(source, /超收被拒绝/)
|
||||||
assert.match(source, /重复箱新键被拒绝/)
|
assert.match(source, /重复箱新键被拒绝/)
|
||||||
assert.match(source, /模式硬拒绝/)
|
assert.match(source, /模式硬拒绝/)
|
||||||
@@ -37,43 +40,60 @@ test('PDA module covers the container-number receiving contract and excludes out
|
|||||||
assert.match(source, /retained as acceptance evidence/)
|
assert.match(source, /retained as acceptance evidence/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => {
|
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
|
||||||
const calls = []; const gaps = []; const steps = []
|
const calls = []; const gaps = []; const steps = []
|
||||||
const context = {
|
const context = {
|
||||||
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
marker: 'PDA-CONTRACT', context: {}, createdResources: {},
|
||||||
async request(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
async request(method, requestPath) {
|
||||||
|
calls.push({ method, requestPath })
|
||||||
|
return { method, path: requestPath, http: 200, body: { code: '200', data: [] },
|
||||||
|
requestId: 'request-contract-1', traceId: 'trace-contract-0001' }
|
||||||
|
},
|
||||||
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||||
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') },
|
||||||
|
apiSuccess(module, name, response) { return response.body.data },
|
||||||
addStep(...args) { steps.push(args) },
|
addStep(...args) { steps.push(args) },
|
||||||
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) },
|
||||||
addCoverageGap(...args) { gaps.push(args) },
|
addCoverageGap(...args) { gaps.push(args) },
|
||||||
}
|
}
|
||||||
await run(context)
|
await run(context)
|
||||||
assert.equal(calls.length, 0)
|
assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/roles'))
|
||||||
assert.equal(gaps.length, 1)
|
assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/permissions'))
|
||||||
assert.equal(gaps[0][5].deploymentBlocker, true)
|
assert.ok(gaps.some((gap) => gap[2] === '海外PDA仓库操作员权限'))
|
||||||
assert.equal(steps.length, 1)
|
assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker))
|
||||||
assert.equal(steps[0].passed, false)
|
|
||||||
assert.equal(steps[0].options.deploymentBlocker, true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('empty candidate response cannot become a zero-business-request pass', async () => {
|
test('empty candidate response cannot become a zero-business-request pass', async () => {
|
||||||
const calls = []; const gaps = []; const steps = []
|
const calls = []; const gaps = []; const steps = []
|
||||||
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
|
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
|
||||||
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
|
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
|
||||||
|
let userSequence = 0
|
||||||
|
const roleId = '4000005'; const permissionId = '5107015'
|
||||||
const context = {
|
const context = {
|
||||||
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {},
|
marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {},
|
||||||
context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } },
|
async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` },
|
||||||
async request(method, requestPath) {
|
async request(method, requestPath, body) {
|
||||||
calls.push({ channel: 'domestic', method, requestPath })
|
calls.push({ channel: 'domestic', method, requestPath })
|
||||||
return response({ overseasAccessToken: 'pda-token' }, requestPath, method)
|
if (method === 'GET' && requestPath.endsWith('/roles')) return response([
|
||||||
|
{ id: roleId, roleCode: 'WAREHOUSE_PICKER', status: 'ACTIVE', permissionIds: [permissionId] },
|
||||||
|
], requestPath, method)
|
||||||
|
if (method === 'GET' && requestPath.endsWith('/permissions')) return response([
|
||||||
|
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
|
||||||
|
], requestPath, method)
|
||||||
|
if (method === 'POST' && requestPath.endsWith('/users')) return response({
|
||||||
|
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], status: 'ACTIVE',
|
||||||
|
}, requestPath, method)
|
||||||
|
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method)
|
||||||
|
if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method)
|
||||||
|
throw new Error(`unexpected domestic request ${method} ${requestPath}`)
|
||||||
},
|
},
|
||||||
async requestOverseas(method, requestPath) {
|
async requestOverseas(method, requestPath, body, headers, token) {
|
||||||
calls.push({ channel: 'overseas', method, requestPath })
|
calls.push({ channel: 'overseas', method, requestPath })
|
||||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||||
return response([], requestPath, method)
|
return response([], requestPath, method)
|
||||||
},
|
},
|
||||||
|
apiSuccess(module, name, result) { return result.body.data },
|
||||||
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||||
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) },
|
||||||
addCoverageGap(...args) { gaps.push(args) },
|
addCoverageGap(...args) { gaps.push(args) },
|
||||||
@@ -84,4 +104,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
|||||||
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
|
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
|
||||||
assert.equal(gaps.length, 1)
|
assert.equal(gaps.length, 1)
|
||||||
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
|
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
|
||||||
|
assert.equal(context.createdResources.phase4OverseasPda.pickerMembershipIds.length, 2)
|
||||||
|
assert.equal(calls.filter((call) => /\/users\/\d+\/status$/.test(call.requestPath)).length, 2)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,20 +35,58 @@ async function expected(context, token, method, name, path, body, predicate) {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginPda(context) {
|
async function loginMerchantForNegativeProbe(context) {
|
||||||
const fixture = context.context?.merchantQualificationFixture
|
const fixture = context.context?.merchantQualificationFixture
|
||||||
if (!fixture?.username || !fixture?.password) {
|
if (!fixture?.username || !fixture?.password) return null
|
||||||
blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '')
|
const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '')
|
||||||
const data = success(response) ? response.body?.data : null
|
const data = success(response) ? response.body?.data : null
|
||||||
context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response,
|
context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response,
|
||||||
success(response) && nonBlank(data?.overseasAccessToken),
|
success(response) && nonBlank(data?.overseasAccessToken),
|
||||||
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
`overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS)
|
||||||
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createPicker(context, index) {
|
||||||
|
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
|
||||||
|
const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS)
|
||||||
|
const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE')
|
||||||
|
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
|
||||||
|
const permissions = context.apiSuccess(MODULE, `查询海外PDA权限资源${index}`, permissionsResponse, '', OPTIONS)
|
||||||
|
const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate')
|
||||||
|
const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id)))
|
||||||
|
context.assert(MODULE, `WAREHOUSE_PICKER精确具备海外PDA权限${index}`, rolesResponse, assigned,
|
||||||
|
`roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS)
|
||||||
|
if (!assigned) {
|
||||||
|
blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const suffix = markerSuffix(context)
|
||||||
|
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
||||||
|
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
|
||||||
|
const phone = await context.findAvailablePhone(MODULE)
|
||||||
|
const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone,
|
||||||
|
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], password, status: 'ACTIVE' }
|
||||||
|
const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload)
|
||||||
|
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
|
||||||
|
const membershipId = id(created?.membershipId)
|
||||||
|
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
|
||||||
|
longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)),
|
||||||
|
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS)
|
||||||
|
if (!longId(membershipId)) return null
|
||||||
|
const login = await context.request('POST', '/api/auth/login', { username, password }, '')
|
||||||
|
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
||||||
|
context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token),
|
||||||
|
`overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS)
|
||||||
|
return { membershipId, username, token: nonBlank(token) ? token : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePicker(context, picker, index) {
|
||||||
|
if (!longId(picker?.membershipId)) return
|
||||||
|
const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' })
|
||||||
|
const disabled = success(response) && response.body?.data?.status === 'DISABLED'
|
||||||
|
context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
async function createPallet(context, inbound, index) {
|
async function createPallet(context, inbound, index) {
|
||||||
const warehouseId = id(inbound?.destinationWarehouseId)
|
const warehouseId = id(inbound?.destinationWarehouseId)
|
||||||
if (!longId(warehouseId)) return null
|
if (!longId(warehouseId)) return null
|
||||||
@@ -160,17 +198,14 @@ async function receiveOverseas(context, token, inbound, pallet) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(context) {
|
async function runReceiving(context, token, secondaryToken, merchantToken) {
|
||||||
context.createdResources.phase4OverseasPda = {
|
|
||||||
marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null,
|
|
||||||
palletIds: [], locationIds: [], finalStatus: null,
|
|
||||||
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
|
|
||||||
}
|
|
||||||
const token = await loginPda(context)
|
|
||||||
if (!token) return
|
|
||||||
if (context.overseasToken) {
|
if (context.overseasToken) {
|
||||||
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken)
|
||||||
context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||||
|
}
|
||||||
|
if (merchantToken) {
|
||||||
|
const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken)
|
||||||
|
context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||||
}
|
}
|
||||||
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token)
|
||||||
const noCurrent = notFound(before)
|
const noCurrent = notFound(before)
|
||||||
@@ -203,6 +238,11 @@ export async function run(context) {
|
|||||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody)
|
||||||
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
|
context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response,
|
||||||
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
|
success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS)
|
||||||
|
if (secondaryToken) {
|
||||||
|
await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409',
|
||||||
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`,
|
||||||
|
{ idempotencyKey: key(context, 'secondary-prepare') }, conflict)
|
||||||
|
}
|
||||||
const other = candidates.data.find((item) => item.containerNo !== containerNo)
|
const other = candidates.data.find((item) => item.containerNo !== containerNo)
|
||||||
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
|
if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409',
|
||||||
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
|
`/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict)
|
||||||
@@ -268,3 +308,27 @@ export async function run(context) {
|
|||||||
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
|
||||||
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function run(context) {
|
||||||
|
context.createdResources.phase4OverseasPda = {
|
||||||
|
marker: context.marker, containerNo: null, customerId: null,
|
||||||
|
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [],
|
||||||
|
cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence',
|
||||||
|
}
|
||||||
|
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||||
|
const primary = await createPicker(context, 1)
|
||||||
|
const secondary = primary?.token ? await createPicker(context, 2) : null
|
||||||
|
for (const picker of [primary, secondary]) if (picker?.membershipId) {
|
||||||
|
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!primary?.token || !secondary?.token) {
|
||||||
|
blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await runReceiving(context, primary.token, secondary.token, merchantToken)
|
||||||
|
} finally {
|
||||||
|
await disablePicker(context, secondary, 2)
|
||||||
|
await disablePicker(context, primary, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url))
|
|||||||
const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8')
|
||||||
|
|
||||||
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||||
|
function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } }
|
||||||
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } }
|
||||||
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) {
|
||||||
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
|
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
|
||||||
@@ -21,12 +22,12 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
|||||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
|
||||||
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
|
||||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') }
|
||||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) }
|
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() }
|
||||||
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) }
|
if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
|
||||||
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') }
|
||||||
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) {
|
||||||
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409')
|
||||||
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null)
|
createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse()
|
||||||
}
|
}
|
||||||
if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) {
|
if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) {
|
||||||
if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409')
|
if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409')
|
||||||
@@ -51,7 +52,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
|
|||||||
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
|
if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } })
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }]))
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' })
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation)
|
if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
|
||||||
|
? response(undefined, 'A0404') : response(createdLocation)
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }]))
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' })
|
||||||
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet)
|
||||||
@@ -86,6 +88,14 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
|
|||||||
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets')))
|
||||||
assert.ok(writes.some((call) => call.path.includes('/relabel-applications')))
|
assert.ok(writes.some((call) => call.path.includes('/relabel-applications')))
|
||||||
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications')))
|
assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications')))
|
||||||
|
for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) {
|
||||||
|
const step = context.steps.find((item) => item.name === name)
|
||||||
|
assert.ok(step?.passed, name)
|
||||||
|
assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name)
|
||||||
|
}
|
||||||
|
const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在')
|
||||||
|
assert.equal(deletedReadback?.result?.body?.code, 'A0404')
|
||||||
|
assert.ok(deletedReadback?.passed)
|
||||||
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ function assertPage(context, name, result) {
|
|||||||
function businessConflict(response) {
|
function businessConflict(response) {
|
||||||
return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409')
|
return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409')
|
||||||
}
|
}
|
||||||
|
function voidMutationSucceeded(result) {
|
||||||
|
return result?.response?.http === 200 && String(result?.response?.body?.code) === '200'
|
||||||
|
&& result.data == null
|
||||||
|
}
|
||||||
|
function businessNotFound(response) {
|
||||||
|
return response?.http === 404 || (response?.http === 200 && String(response?.body?.code) === 'A0404')
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
|
context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null }
|
||||||
@@ -216,7 +223,7 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
|||||||
zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200,
|
zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200,
|
||||||
status: 'ACTIVE', remark: `${context.marker}-edited`,
|
status: 'ACTIVE', remark: `${context.marker}-edited`,
|
||||||
})
|
})
|
||||||
context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null,
|
context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated),
|
||||||
`locationId=${locationId}; ${trace(updated.response)}`, OPTIONS)
|
`locationId=${locationId}; ${trace(updated.response)}`, OPTIONS)
|
||||||
const after = await overseasGet(context, '回查公共库位编辑结果', detailPath)
|
const after = await overseasGet(context, '回查公共库位编辑结果', detailPath)
|
||||||
context.assert(MODULE, '公共库位编辑字段已生效', after.response,
|
context.assert(MODULE, '公共库位编辑字段已生效', after.response,
|
||||||
@@ -226,9 +233,12 @@ async function verifyLocationLifecycle(context, warehouseId) {
|
|||||||
if (locationId) {
|
if (locationId) {
|
||||||
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位',
|
||||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||||
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null
|
const deletedDetail = await context.requestOverseas('GET',
|
||||||
context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null,
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`)
|
||||||
`locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS)
|
const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail)
|
||||||
|
context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null
|
||||||
|
context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted,
|
||||||
|
`locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +364,7 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
|||||||
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE',
|
palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE',
|
||||||
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version),
|
locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version),
|
||||||
})
|
})
|
||||||
context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null,
|
context.assert(MODULE, '公共托盘编辑成功', updated.response, voidMutationSucceeded(updated),
|
||||||
`palletId=${palletId}; ${trace(updated.response)}`, OPTIONS)
|
`palletId=${palletId}; ${trace(updated.response)}`, OPTIONS)
|
||||||
const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath)
|
const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath)
|
||||||
context.assert(MODULE, '公共托盘编辑字段已生效', after.response,
|
context.assert(MODULE, '公共托盘编辑字段已生效', after.response,
|
||||||
@@ -376,8 +386,8 @@ async function verifyPalletLifecycle(context, warehouseId) {
|
|||||||
'读取公共托盘报废前最新版本',
|
'读取公共托盘报废前最新版本',
|
||||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version),
|
||||||
})
|
})
|
||||||
context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null
|
context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null
|
||||||
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null,
|
context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired),
|
||||||
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
`palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS)
|
||||||
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态',
|
||||||
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ public class MerchantWarehouseController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "List available warehouses / 查询可用仓库",
|
summary = "List available warehouses / 查询可用仓库",
|
||||||
description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。"
|
description = "Customer scope comes only from the authenticated JWT; any customerId query value is ignored. / 客户范围仅从认证 JWT 获取,查询中的 customerId 不生效。"
|
||||||
)
|
)
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||||
|
|||||||
+2
-1
@@ -91,7 +91,8 @@ public class PlatformWarehouseController {
|
|||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "List warehouses / 查询仓库",
|
summary = "List warehouses / 查询仓库",
|
||||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters."
|
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST
|
||||||
|
+ ". Returns warehouses across customers, or only the optional customerId scope when supplied."
|
||||||
)
|
)
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||||
|
|||||||
+3
@@ -8,6 +8,7 @@ import jakarta.validation.constraints.Min;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -94,6 +95,8 @@ public final class WarehouseRequests {
|
|||||||
|
|
||||||
@Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件")
|
@Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件")
|
||||||
public record QueryRequest(
|
public record QueryRequest(
|
||||||
|
@Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true)
|
||||||
|
@Positive Long customerId,
|
||||||
@Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1")
|
@Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1")
|
||||||
@Min(1) long page,
|
@Min(1) long page,
|
||||||
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
||||||
|
|||||||
+2
@@ -87,6 +87,7 @@ public class WarehouseRepository {
|
|||||||
|
|
||||||
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
|
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
|
||||||
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
|
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
|
||||||
|
.eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId())
|
||||||
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
|
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
|
||||||
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
|
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
|
||||||
.orderByDesc(WarehouseEntity::getCreatedAt);
|
.orderByDesc(WarehouseEntity::getCreatedAt);
|
||||||
@@ -94,6 +95,7 @@ public class WarehouseRepository {
|
|||||||
if (StringUtils.hasText(query.capabilityCode())) {
|
if (StringUtils.hasText(query.capabilityCode())) {
|
||||||
List<Long> ids = capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
|
List<Long> ids = capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
|
||||||
.select(ServiceCapabilityEntity::getWarehouseId)
|
.select(ServiceCapabilityEntity::getWarehouseId)
|
||||||
|
.eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId())
|
||||||
.eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode())
|
.eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode())
|
||||||
.eq(ServiceCapabilityEntity::getEnabled, true))
|
.eq(ServiceCapabilityEntity::getEnabled, true))
|
||||||
.stream()
|
.stream()
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.warehouse.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||||
|
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class MerchantWarehouseControllerTest {
|
||||||
|
@Test
|
||||||
|
void merchantPageIgnoresRequestedCustomerAndUsesAuthenticatedCustomer() {
|
||||||
|
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
||||||
|
MerchantWarehouseController controller = new MerchantWarehouseController();
|
||||||
|
ReflectionTestUtils.setField(controller, "service", service);
|
||||||
|
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(
|
||||||
|
99999L, 1, 20, "US", null, "ACTIVE");
|
||||||
|
when(service.page(10001L, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||||
|
|
||||||
|
controller.page(merchant(10001L), query);
|
||||||
|
|
||||||
|
verify(service).page(10001L, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthenticatedUser merchant(long customerId) {
|
||||||
|
return new AuthenticatedUser(1L, customerId, customerId, 1L,
|
||||||
|
"merchant", "MERCHANT", "WEB", Set.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-2
@@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
|||||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||||
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
|
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
|
||||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
@@ -16,6 +18,7 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class PlatformWarehouseControllerTest {
|
class PlatformWarehouseControllerTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -30,11 +33,11 @@ class PlatformWarehouseControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformPageQueriesAllWarehousesWithoutCustomerId() {
|
void platformPagePassesOptionalCustomerFilterToApplicationService() {
|
||||||
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
||||||
PlatformWarehouseController controller = new PlatformWarehouseController();
|
PlatformWarehouseController controller = new PlatformWarehouseController();
|
||||||
ReflectionTestUtils.setField(controller, "service", service);
|
ReflectionTestUtils.setField(controller, "service", service);
|
||||||
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null);
|
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, null, null, "ACTIVE");
|
||||||
when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||||
|
|
||||||
controller.page(platform(), query);
|
controller.page(platform(), query);
|
||||||
@@ -42,6 +45,22 @@ class PlatformWarehouseControllerTest {
|
|||||||
verify(service).pageAll(query);
|
verify(service).pageAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformPageOpenApiExposesOptionalCustomerScope() throws Exception {
|
||||||
|
var customerId = WarehouseRequests.QueryRequest.class.getRecordComponents()[0];
|
||||||
|
Schema schema = WarehouseRequests.QueryRequest.class.getDeclaredMethod("customerId")
|
||||||
|
.getAnnotation(Schema.class);
|
||||||
|
Operation operation = PlatformWarehouseController.class
|
||||||
|
.getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class)
|
||||||
|
.getAnnotation(Operation.class);
|
||||||
|
|
||||||
|
assertEquals("customerId", customerId.getName());
|
||||||
|
assertEquals(Long.class, customerId.getType());
|
||||||
|
assertTrue(schema.nullable());
|
||||||
|
assertTrue(schema.description().contains("platform"));
|
||||||
|
assertTrue(operation.description().contains("customerId"));
|
||||||
|
}
|
||||||
|
|
||||||
private static AuthenticatedUser platform() {
|
private static AuthenticatedUser platform() {
|
||||||
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package com.baiyue.warehouse.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||||
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
|
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
|
||||||
|
import com.baiyue.warehouse.mapper.WarehouseMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class WarehouseRepositoryTest {
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
@Test
|
||||||
|
void platformPageAllFiltersByOptionalCustomerId() {
|
||||||
|
WarehouseMapper warehouses = mock(WarehouseMapper.class);
|
||||||
|
WarehouseRepository repository = new WarehouseRepository();
|
||||||
|
ReflectionTestUtils.setField(repository, "warehouses", warehouses);
|
||||||
|
ReflectionTestUtils.setField(repository, "capabilities", mock(ServiceCapabilityMapper.class));
|
||||||
|
TableInfoHelper.initTableInfo(
|
||||||
|
new MapperBuilderAssistant(new MybatisConfiguration(), "warehouse-overseas-query-test"),
|
||||||
|
WarehouseEntity.class);
|
||||||
|
when(warehouses.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||||
|
.thenReturn(new Page<WarehouseEntity>(1, 20));
|
||||||
|
|
||||||
|
repository.pageAll(new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE"));
|
||||||
|
|
||||||
|
var queryCaptor = org.mockito.ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
org.mockito.Mockito.verify(warehouses).selectPage(any(Page.class), queryCaptor.capture());
|
||||||
|
LambdaQueryWrapper<WarehouseEntity> query = queryCaptor.getValue();
|
||||||
|
assertThat(query.getSqlSegment()).contains("customer_id");
|
||||||
|
assertThat(query.getParamNameValuePairs()).containsValue(10001L);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -50,8 +50,8 @@ class WarehouseApplicationServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformWarehousePageUsesGlobalRepositoryQuery() {
|
void platformWarehousePagePassesCustomerScopedQueryToRepository() {
|
||||||
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null);
|
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE");
|
||||||
when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
|
when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
|
||||||
|
|
||||||
service.pageAll(query);
|
service.pageAll(query);
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/)
|
|||||||
assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/)
|
assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/)
|
||||||
assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/)
|
assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/)
|
||||||
assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/)
|
assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/)
|
||||||
assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i)
|
assert.match(sql, /国内固定作业仓必须启用收货能力/)
|
||||||
|
assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i)
|
||||||
|
const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data'))
|
||||||
|
assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i)
|
||||||
assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/)
|
assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/)
|
||||||
assert.match(sql, /FBA公共地址不得创建平台作业能力/)
|
assert.match(sql, /FBA公共地址不得创建平台作业能力/)
|
||||||
|
|||||||
Reference in New Issue
Block a user