fix: close phase4 overseas acceptance gaps

This commit is contained in:
2026-08-24 21:00:22 +08:00
parent 005c3f54e2
commit 3607bebbfd
42 changed files with 860 additions and 203 deletions
@@ -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
+60 -4
View File
@@ -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;
}
}
@@ -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)
@@ -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)")
+1
View File
@@ -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`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束 ## 查询与租户约束
@@ -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) { }
} }
@@ -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);
} }
@@ -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);
} }
@@ -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;
@@ -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' } } } }
@@ -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"));
} }
@@ -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));
} }
} }
@@ -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);
}
}
} }
@@ -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) {
@@ -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);
}
}
} }
+51 -7
View File
@@ -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)
+2 -1
View File
@@ -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)}`)
@@ -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 / 查询成功"),
@@ -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 / 查询成功"),
@@ -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")
@@ -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()
@@ -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());
}
}
@@ -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());
} }
@@ -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);
}
}
@@ -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公共地址不得创建平台作业能力/)