From 3607bebbfd85b05b6e7c9a4d68896f0b61025e7f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:00:22 +0800 Subject: [PATCH] fix: close phase4 overseas acceptance gaps --- deploy/sql/头程数据库统一增量-MySQL8.sql | 40 +++++- deploy/sql/海外区域统一增量-MySQL8.sql | 64 ++++++++- .../gateway/config/GatewaySecurityConfig.java | 8 +- .../filter/CorrelationIdWebFilter.java | 52 ++++++++ .../GatewaySecurityErrorContractTest.java | 30 +++++ .../main/resources/db/lastleg-permissions.sql | 39 +++++- .../resources/db/phase3-warehouse-roles.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 20 +++ .../WarehouseRoleSqlContractTest.java | 1 + lastleg-service/AGENTS.md | 1 + .../ReceivingContainerController.java | 35 ++--- .../ReceivingContainerOperationMapper.java | 7 +- .../ReceivingContainerSessionMapper.java | 21 ++- .../service/ReceivingContainerService.java | 126 +++++++++++------- .../inventory/entity/InventoryLedger.java | 4 + .../entity/OverseasProcurementOrder.java | 22 +-- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 12 +- .../ReceivingContainerControllerTest.java | 29 ++-- ...ReceivingContainerServiceBehaviorTest.java | 98 ++++++++++---- .../InventorySchemaContractTest.java | 10 +- .../OverseasProcurementServiceTest.java | 14 ++ scripts/admin-test-env-acceptance.mjs | 58 +++++++- scripts/admin-test-env-modules/orders.mjs | 3 +- .../phase3-loading-marker-contract.test.mjs | 15 +++ .../admin-test-env-modules/phase3-loading.mjs | 9 +- .../phase3-warehouse-rbac.mjs | 7 +- ...ase4-cross-region-events-contract.test.mjs | 19 +++ .../phase4-overseas-pda-contract.test.mjs | 48 +++++-- .../phase4-overseas-pda.mjs | 94 ++++++++++--- ...hase4-overseas-warehouse-contract.test.mjs | 18 ++- .../phase4-overseas-warehouse.mjs | 24 +++- .../MerchantWarehouseController.java | 2 +- .../PlatformWarehouseController.java | 3 +- .../warehouse/dto/WarehouseRequests.java | 3 + .../repository/WarehouseRepository.java | 2 + .../MerchantWarehouseControllerTest.java | 36 +++++ .../PlatformWarehouseControllerTest.java | 23 +++- .../repository/WarehouseRepositoryTest.java | 42 ++++++ .../WarehouseApplicationServiceTest.java | 4 +- ...house-public-master-data-contract.test.mjs | 5 +- 42 files changed, 860 insertions(+), 203 deletions(-) create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java create mode 100644 scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f29c7ed49..9e282cfd7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10908,6 +10908,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' 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; 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) FROM baiyue_iam.iam_role role 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' 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) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3c07f7500..c02a02b7f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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'; 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 '集装箱号', - 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', + 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 '未完成会话平台操作账号全局唯一锁键', + 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 '签收时间', 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), @@ -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')); 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 ( 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', @@ -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 '客户端幂等键', 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), - 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柜号收货幂等审计记录'; +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'), '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')); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index d75af294b..d82f31a65 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import com.baiyue.gateway.filter.CorrelationIdWebFilter; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -66,11 +67,16 @@ public class GatewaySecurityConfig { ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); 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 body = new LinkedHashMap<>(); body.put("code", code); body.put("message", message); body.put("data", null); - body.put("requestId", exchange.getRequest().getId()); + body.put("requestId", requestId); + body.put("traceId", traceId); body.put("timestamp", Instant.now().toString()); try { byte[] bytes = mapper.writeValueAsBytes(body); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..f25941cec --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -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 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; + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java new file mode 100644 index 000000000..6b1cea992 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java @@ -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()); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index f8aebcd23..cf6287b13 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -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) FROM iam_role role 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' 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) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role diff --git a/iam/src/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 75bf068e1..5f39d36cf 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -140,6 +140,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' 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; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 01b84837d..776ec6854 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -40,6 +40,7 @@ class OverseasPhase4PermissionSqlContractTest { Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -55,6 +56,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertCustomAdminGrant(sql); } @@ -89,6 +91,24 @@ class OverseasPhase4PermissionSqlContractTest { .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 { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 9cb8406a9..36786af3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest { .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index b6d2cc413..99b6a8795 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -73,6 +73,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index fa2816d4f..ce144e37c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -34,11 +34,11 @@ public class ReceivingContainerController { @Resource private ReceivingContainerService service; @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> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey())); } @GetMapping("/{containerNo}") @@ -46,14 +46,14 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { 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") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { 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)); } @@ -62,20 +62,20 @@ public class ReceivingContainerController { @RequestPart("file") MultipartFile file) { Identity identity = identity(user); 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") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { 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") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser 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") @@ -83,7 +83,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { 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") @@ -91,7 +91,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { 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") @@ -99,41 +99,42 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { 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") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { 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") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { 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") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { 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) { - if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required"); + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"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)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } 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()); } - private record Identity(String customerId, String operatorKey, String operatorName) { } + private record Identity(String operatorKey, String operatorName) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java index c07beb72f..ca291794b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -8,9 +8,8 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerOperationMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " + - "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") - ReceivingContainerOperation find(@Param("customerId") String customerId, - @Param("operatorKey") String operatorKey, + @Select("SELECT * FROM ll_receiving_container_operation " + + "WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java index 49485e0f6..b50e7eabf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -8,18 +8,15 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerSessionMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1") - ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId, - @Param("activeContainerKey") String activeContainerKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 4c6682853..1f616904a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -80,16 +80,17 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String customerId, String operatorKey) { - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public List candidates(String operatorKey) { + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); Map> grouped = new LinkedHashMap<>(); - plans.selectList(new LambdaQueryWrapper().eq(InboundPlan::getCustomerId, customerId) + plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); return grouped.entrySet().stream().map(entry -> { + requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", @@ -98,9 +99,10 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { - List containerPlans = requirePlans(customerId, containerNo); - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } @@ -109,27 +111,28 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, - String containerNo, ReceivingContainerRequests.Action request) { + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + ReceivingContainerRequests.Action request) { 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); - List containerPlans = requirePlans(customerId, containerNo); - String activeOperatorKey = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey); + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); 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"); } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); 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.setVersion(0); try { sessions.insert(session); } @@ -138,20 +141,21 @@ public class ReceivingContainerService { save(session, "PREPARE", request.idempotencyKey(), command, result); return result; } - public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, - MultipartFile file) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); 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); return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @Transactional - public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName, 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); - 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"); List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); @@ -166,14 +170,15 @@ public class ReceivingContainerService { save(session, "SIGN", request.idempotencyKey(), request, result); return result; } - public ReceivingContainerResponses.Session current(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false); - return snapshot(session, requirePlans(customerId, session.getContainerNo())); + public ReceivingContainerResponses.Session current(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + 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) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); 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()); } - public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey, + public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, ReceivingContainerRequests.SkuResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List matches = refs(customerId, session.getContainerNo()).stream() .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .map(ref -> skuRef(ref, request.skuCode())) @@ -203,9 +209,10 @@ public class ReceivingContainerService { return skuResponse(matches.get(0)); } - public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, pallet); @@ -214,11 +221,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName, 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); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); @@ -248,11 +256,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, 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); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); @@ -284,11 +293,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session complete(String customerId, String operatorKey, + public ReceivingContainerResponses.Session complete(String operatorKey, 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); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); 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; } - private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) { - String key = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key) - : sessions.findActiveByOperator(customerId, key); + private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) { + String key = activeOperatorKey(operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) + : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); return session; } - private ReceivingContainerSession requireSigned(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + private ReceivingContainerSession requireSigned(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { throw conflict("Sign the receiving container before receiving cargo"); } return session; } + private List requirePlans(String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .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 requirePlans(String customerId, String containerNo) { List values = plansForContainer(customerId, containerNo); if (values.isEmpty()) throw notFound("Receiving container was not found"); @@ -334,6 +355,15 @@ public class ReceivingContainerService { return values; } + private String requireSingleCustomer(List values, String containerNo) { + Set 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 refs(String customerId, String containerNo) { List result = new ArrayList<>(); for (InboundPlan plan : plansForContainer(customerId, containerNo)) { @@ -576,9 +606,9 @@ public class ReceivingContainerService { 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); - ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); + ReceivingContainerOperation value = operations.find(operatorKey, key); if (value == null) return null; if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { 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); } } - private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } - private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; } + private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); } private static String idempotencyKey(String value) { if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required"); value = value.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java index 0e9dc4d95..4ffeff2cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -40,8 +41,11 @@ public class InventoryLedger { private Long deltaPackages; private Long afterPackages; /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + @TableField(exist = false) private Long beforeQuantity; + @TableField(exist = false) private Long deltaQuantity; + @TableField(exist = false) private Long afterQuantity; private String operatorId; private String remark; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 200b99a67..7d5d34abc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.procurement.entity; 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.TableName; import lombok.Data; @@ -17,24 +19,24 @@ public class OverseasProcurementOrder { private String orderNo; private String purchaseCategory; private String productName; - private String productSpecification; - private String productUrl; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl; private Long purchaseQuantity; private BigDecimal budgetUnitPrice; private String currency; private String receivingAddress; private String contactName; private String contactPhone; - private String purchaseWarehouseId; - private String purchaseWarehouseName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - private String domesticCarrier; - private String domesticTrackingNo; - private String overseasCarrier; - private String overseasTrackingNo; - private LocalDateTime estimatedArrivalAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; - private String remark; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 430e98bf9..dd71d6b9a 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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'; 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 '操作账号唯一标识', - 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', + 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', 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 '创建时间', @@ -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 '客户端幂等键', 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), - 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柜号收货幂等审计记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index ed8be5397..e7492edbf 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,20 +6,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 - description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index d49cf48e4..7e0a35e3e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -47,7 +47,7 @@ class ReceivingContainerContractTest { } @Test - void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException { + void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); String unified = java.nio.file.Files.readString( 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("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("平台操作账号全局唯一锁键")); + assertTrue(schema.contains("柜号全局唯一锁键")); 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("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); 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 a.customer_id=''")); 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 @@ -94,6 +102,8 @@ class ReceivingContainerContractTest { assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/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]")); assertFalse(receivingPaths.contains("photoFileIds")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index c9c3d0fb7..dd7fa8bef 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -30,25 +30,32 @@ class ReceivingContainerControllerTest { } @Test - void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { - when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { + when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( "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 - void platformIdentityCannotEnterPdaReceivingApi() { - AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of("lastleg:pda:operate")); - assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); + assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("401"); } - private static AuthenticatedUser pda() { - return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA", - Set.of("lastleg:pda:operate")); + @Test + void platformIdentityWithoutPickerAuthorityIsForbidden() { + 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)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 498415847..44646e54d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of()); when(sessions.insert(any(ReceivingContainerSession.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 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 saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); 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) .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 void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("100", "7", "CONT-B")) + when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.detail("7", "CONT-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @Test void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); - when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void destinationModesAreHardRejectedBeforeWarehouseCalls() { 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"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); InboundPlanCarton carton = carton(1L, "BOX-1"); 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"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest { void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { stubCurrent("OVERSEAS", "CONT-A"); 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"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); stubSku(); 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"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .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"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton")) .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"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest { InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); 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"); 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 void localFailureAfterRemoteReservationTriggersReleaseCompensation() { stubCurrent("FBA", "CONT-A"); @@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest { ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); 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"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); @@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest { @Test 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(lines.selectList(any())).thenReturn(List.of()); 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.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") .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, @@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest { @Test 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( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L), 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"); verifyNoInteractions(unloadPhotos); } @Test 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( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), 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"); verifyNoInteractions(unloadPhotos); } @@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest { received.setReceivedPackages(2); 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"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(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(lines.selectList(any())).thenReturn(List.of(line)); when(orders.selectById(1L)).thenReturn(order); @@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest { return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L, "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); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 2586fae27..d35865d93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -108,9 +109,12 @@ class InventorySchemaContractTest { assertThrowsNoField(InventoryPosition.class, "productQuantity"); assertNotNull(InventoryLedger.class.getDeclaredField("skuCode")); assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark")); - assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity")); + for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) { + var annotation = InventoryLedger.class.getDeclaredField(field) + .getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class); + assertNotNull(annotation, field); + assertFalse(annotation.exist(), field); + } } private static void assertThrowsNoField(Class type, String fieldName) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index f60b35062..9e41c94e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -1,5 +1,7 @@ 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.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; @@ -21,6 +23,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -155,4 +158,15 @@ class OverseasProcurementServiceTest { order.setUpdatedAt(order.getCreatedAt()); 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); + } + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 4bd6fb8ad..756c1a027 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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 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) { const normalized = [...modules] 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', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] - const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module)) - withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites) - normalized.splice(0, normalized.length, ...withoutPrerequisites) + const targetIndex = normalized.indexOf('phase4-cross-region-events') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) @@ -122,10 +165,11 @@ export function normalizeModuleOrder(modules) { } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') - if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized - normalized.splice(overviewIndex, 1) - normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') - return normalized + if (loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex) { + normalized.splice(overviewIndex, 1) + normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') + } + return stableTopologicalOrder(normalized) } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 14ff86e4b..ebce176a2 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -315,7 +315,8 @@ function assertCargoContract(context, response, cargoes, payload) { && Number(actual?.productLengthCm) === expected.productLengthCm && Number(actual?.productWidthCm) === expected.productWidthCm && 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) }) context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs new file mode 100644 index 000000000..5c4da9dbd --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0b64243f8..61b36ae98 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,4 +1,5 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' +import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' 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!' 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 responseEvidence(response) { 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 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 containerNo = `P3B-${suffix}`.slice(0, 64) const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index cc496ce32..eb586afb5 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -33,10 +33,11 @@ export async function run(context) { const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || []) .map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean)) const pickerCodes = roleCodes('WAREHOUSE_PICKER') - context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse, - pickerCodes.size === 2 + context.assert(MODULE, '拣货员只具备国内PDA查询作业与海外PDA入仓操作权限', rolesResponse, + pickerCodes.size === 3 && 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) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 0c324fa98..2dab0d4e4 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -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', () => { for (const token of [ 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index e614ef63e..284dbe9b6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -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, /跨柜切换锁返回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, /模式硬拒绝/) @@ -37,43 +40,60 @@ test('PDA module covers the container-number receiving contract and excludes out 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 context = { 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 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) }, assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, } await run(context) - assert.equal(calls.length, 0) - assert.equal(gaps.length, 1) - assert.equal(gaps[0][5].deploymentBlocker, true) - assert.equal(steps.length, 1) - assert.equal(steps[0].passed, false) - assert.equal(steps[0].options.deploymentBlocker, true) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/roles')) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/permissions')) + assert.ok(gaps.some((gap) => gap[2] === '海外PDA仓库操作员权限')) + assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker)) }) test('empty candidate response cannot become a zero-business-request pass', async () => { const calls = []; const gaps = []; const steps = [] const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200, body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' }) + let userSequence = 0 + const roleId = '4000005'; const permissionId = '5107015' const context = { - marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, - context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } }, - async request(method, requestPath) { + marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {}, + async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` }, + async request(method, requestPath, body) { 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 }) if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } 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 }) }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, 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.equal(gaps.length, 1) 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) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 4a66ab7e1..607920643 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -35,20 +35,58 @@ async function expected(context, token, method, name, path, body, predicate) { return response } -async function loginPda(context) { +async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture - if (!fixture?.username || !fixture?.password) { - blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块') - return null - } + if (!fixture?.username || !fixture?.password) return null const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') const data = success(response) ? response.body?.data : null - context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response, + context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, success(response) && nonBlank(data?.overseasAccessToken), `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) 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) { const warehouseId = id(inbound?.destinationWarehouseId) if (!longId(warehouseId)) return null @@ -160,17 +198,14 @@ async function receiveOverseas(context, token, inbound, pallet) { return true } -export async function run(context) { - 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 +async function runReceiving(context, token, secondaryToken, merchantToken) { if (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 noCurrent = notFound(before) @@ -203,6 +238,11 @@ export async function run(context) { `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, 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) if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', `/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.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) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 24e9c3b61..10d20e210 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)) 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 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 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 @@ -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 === '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 === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) } - if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; 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 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 === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { 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 (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.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/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/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) 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('/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')) }) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 0d64db727..3e977f3aa 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -55,6 +55,13 @@ function assertPage(context, name, result) { function businessConflict(response) { 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) { 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, 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) const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) context.assert(MODULE, '公共库位编辑字段已生效', after.response, @@ -226,9 +233,12 @@ async function verifyLocationLifecycle(context, warehouseId) { if (locationId) { const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) - context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null - context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null, - `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS) + const deletedDetail = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + 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', 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) const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath) 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), }) - context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null - context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, + context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null + context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired), `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index ab4d06ec6..29d409b18 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -34,7 +34,7 @@ public class MerchantWarehouseController { @Operation( 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({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 9467901d7..f934717af 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -91,7 +91,8 @@ public class PlatformWarehouseController { @Operation( 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({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 034dde3c6..7da4dc669 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -94,6 +95,8 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") 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") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 5e0bb2ca0..cfb36643f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -87,6 +87,7 @@ public class WarehouseRepository { public PageResult pageAll(WarehouseRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId()) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) .orderByDesc(WarehouseEntity::getCreatedAt); @@ -94,6 +95,7 @@ public class WarehouseRepository { if (StringUtils.hasText(query.capabilityCode())) { List ids = capabilities.selectList(new LambdaQueryWrapper() .select(ServiceCapabilityEntity::getWarehouseId) + .eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId()) .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) .eq(ServiceCapabilityEntity::getEnabled, true)) .stream() diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java new file mode 100644 index 000000000..2a5b57d42 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java @@ -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()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 1206986b8..0e4dea80f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.security.OverseasWarehousePermissions; 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.springframework.security.access.prepost.PreAuthorize; 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.when; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class PlatformWarehouseControllerTest { @Test @@ -30,11 +33,11 @@ class PlatformWarehouseControllerTest { } @Test - void platformPageQueriesAllWarehousesWithoutCustomerId() { + void platformPagePassesOptionalCustomerFilterToApplicationService() { WarehouseApplicationService service = mock(WarehouseApplicationService.class); PlatformWarehouseController controller = new PlatformWarehouseController(); 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)); controller.page(platform(), query); @@ -42,6 +45,22 @@ class PlatformWarehouseControllerTest { 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() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java new file mode 100644 index 000000000..821981179 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java @@ -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(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 query = queryCaptor.getValue(); + assertThat(query.getSqlSegment()).contains("customer_id"); + assertThat(query.getParamNameValuePairs()).containsValue(10001L); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 4852efa8f..1bdcc2925 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -50,8 +50,8 @@ class WarehouseApplicationServiceTest { } @Test - void platformWarehousePageUsesGlobalRepositoryQuery() { - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null); + void platformWarehousePagePassesCustomerScopedQueryToRepository() { + 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)); service.pageAll(query); diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs index cfcd5a871..72214465c 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -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_email\s*=\s*VALUES\(contact_email\)/) 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公共地址不得创建平台作业能力/)