From 8cc4c6f6da823fa23b3898f8a3c495c97628a231 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:46:19 +0800 Subject: [PATCH] fix: bind overseas receiving to shared warehouse --- .../auth/client/AuthorizationSnapshot.java | 8 ++ .../com/baiyue/auth/service/AuthService.java | 9 +- .../auth/service/OverseasTokenService.java | 9 +- .../auth/service/AuthServiceSmallJwtTest.java | 5 +- .../service/OverseasTokenServiceTest.java | 5 +- .../common/security/AuthenticatedUser.java | 12 ++- .../common/security/JwtClaimsMapper.java | 3 +- .../common/security/JwtClaimsMapperTest.java | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 8 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 55 +++++++++++ .../com/baiyue/iam/dto/AuthorizationDtos.java | 8 ++ .../iam/dto/PlatformUserManagementDtos.java | 18 +++- .../com/baiyue/iam/entity/IamMembership.java | 2 +- .../iam/mapper/AuthorizationQueryMapper.java | 4 + .../service/AuthorizationSnapshotService.java | 3 +- .../PlatformUserManagementService.java | 22 ++++- iam/src/main/resources/db/iam.sql | 1 + iam/src/main/resources/openapi/iam-v1.yaml | 3 + .../AuthorizationSnapshotServiceTest.java | 2 + .../PlatformUserManagementServiceTest.java | 18 ++++ .../baiyue/lastleg/entity/LastlegOrder.java | 2 + .../inboundplan/entity/InboundPlan.java | 1 + .../inboundplan/mapper/InboundPlanMapper.java | 7 ++ .../ReceivingContainerController.java | 38 +++++--- .../pda/dto/ReceivingContainerResponses.java | 13 ++- .../pda/entity/ReceivingContainerSession.java | 2 + .../gateway/HttpUnloadPhotoFileGateway.java | 36 +++++-- .../gateway/HttpWarehouseResourceGateway.java | 30 ++++++ .../pda/gateway/WarehouseResourceGateway.java | 6 ++ .../service/ReceivingContainerService.java | 94 ++++++++++++++++--- .../controller/InventoryQueryController.java | 29 +++--- .../query/mapper/InventoryQueryMapper.java | 2 - .../lastleg/mapper/LastlegOrderMapper.java | 7 ++ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 7 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 6 ++ .../ReceivingContainerControllerTest.java | 14 ++- .../HttpUnloadPhotoFileGatewayTest.java | 56 +++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 39 ++++++-- .../InventorySchemaContractTest.java | 13 +++ .../InventoryQueryControllerTest.java | 14 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/admin-test-env-acceptance.mjs | 19 +++- .../manual-state-matrix-contract.test.mjs | 8 ++ .../phase3-loading-marker-contract.test.mjs | 9 ++ .../admin-test-env-modules/phase3-loading.mjs | 19 +++- .../phase3-producer-chain-contract.test.mjs | 6 +- .../phase3-warehouse-rbac.mjs | 5 +- .../phase4-carrier-nodes-contract.test.mjs | 13 +++ .../phase4-carrier-nodes.mjs | 3 +- ...ase4-cross-region-events-contract.test.mjs | 6 ++ .../phase4-overseas-pda-contract.test.mjs | 11 ++- .../phase4-overseas-pda.mjs | 64 ++++++++++--- ...ternalWarehouseResourceLockController.java | 12 +++ .../PlatformWarehouseController.java | 4 +- .../dto/SharedWarehouseReference.java | 4 + .../warehouse/dto/WarehouseRequests.java | 4 +- .../service/WarehouseApplicationService.java | 26 +++++ .../PlatformWarehouseControllerTest.java | 16 ++++ .../WarehouseApplicationServiceTest.java | 39 ++++++++ 61 files changed, 774 insertions(+), 118 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 47bc2000a..1061534be 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -21,4 +21,12 @@ public class AuthorizationSnapshot { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index ec283c23e..f7cbb1ff4 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -111,7 +111,7 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) @@ -127,8 +127,11 @@ public class AuthService { .claim("userId", account.getId()) .claim("tenantId", account.getDefaultTenantId()) .claim("appCode", account.getDefaultAppCode()) - .claim("permissionVersion", account.getTokenVersion()) - .build(); + .claim("permissionVersion", account.getTokenVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 8e2945c5f..915604787 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -51,7 +51,7 @@ public class OverseasTokenService { public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) @@ -60,8 +60,11 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) - .build(); + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String token = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index aed82fc6f..562d09705 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -60,7 +60,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); + "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -82,12 +82,13 @@ class AuthServiceSmallJwtTest { .containsEntry("appCode", "PLATFORM") .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) + .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 20fd26464..2141df038 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -51,7 +51,7 @@ class OverseasTokenServiceTest { String lastlegAuthority = "lastleg:pda:operate"; String domesticAuthority = "platform:firstleg:order:list"; AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L); + Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -71,7 +71,8 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("overseas_warehouse_id", 900L) + .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 1f4927303..1391d548a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -9,11 +9,19 @@ import java.util.Set; public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions, boolean impersonated, Long actorUserId, - Long actorMembershipId, boolean passwordChangeRequired) implements Principal { + Long actorMembershipId, boolean passwordChangeRequired, + Long overseasWarehouseId) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, - permissions, false, null, null, false); + permissions, false, null, null, false, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); } public AuthenticatedUser { permissions = permissions == null ? Set.of() : Set.copyOf(permissions); diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 84faded6a..bd5a37ced 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -43,7 +43,8 @@ public class JwtClaimsMapper { Boolean.TRUE.equals(jwt.getClaim("impersonated")), longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_membership_id"), - Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); + Boolean.TRUE.equals(jwt.getClaim("password_change_required")), + longClaim(jwt, "overseas_warehouse_id")); List authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 3189cd48a..ce04f51be 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -81,6 +81,7 @@ class JwtClaimsMapperTest { .claim("actor_user_id", 42L) .claim("actor_membership_id", 43L) .claim("password_change_required", true) + .claim("overseas_warehouse_id", 900L) .build(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); @@ -88,5 +89,6 @@ class JwtClaimsMapperTest { assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9e282cfd7..08bbccb59 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -403,6 +403,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', @@ -417,6 +418,13 @@ CREATE TABLE IF NOT EXISTS iam_membership ( KEY idx_membership_tenant (tenant_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'), + 'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no')); +PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl; +EXECUTE iam_membership_overseas_warehouse_stmt; +DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt; + CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c02a02b7f..772b25e1e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -386,8 +386,40 @@ UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name')); +PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no')); +PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt; + +UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id +SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))), + o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))), + o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL +WHERE o.current_status<>'INBOUND_COMPLETED' + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId')); + +DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id + WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓'; + END IF; +END$$ +DELIMITER ; +CALL assert_pending_orders_do_not_use_source_warehouse(); +DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; + 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 '集装箱号', + overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) 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 '签收时间', @@ -397,6 +429,29 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); +PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt; + +DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_active_receiving_session_has_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL + AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_active_receiving_session_has_warehouse(); +DROP PROCEDURE assert_active_receiving_session_has_warehouse; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 6867ebf88..acc77d13d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -40,5 +40,13 @@ public final class AuthorizationDtos { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 7c28c5dd7..79dae468b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, + Long overseasWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, - @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { + public CreateUserRequest(String username, String displayName, String phone, String email, + Long tenantId, List roleIds, String password, String status) { + this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + } + } public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, - @NotNull List roleIds) { } + @NotNull List roleIds, + Long overseasWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds) { + this(displayName, phone, email, tenantId, roleIds, null); + } + } public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @@ -40,7 +52,7 @@ public final class PlatformUserManagementDtos { String displayName, String phone, String email, Long tenantId, String tenantName, String memberType, String memberNo, String status, List roleIds, List roleNames, LocalDateTime lastLoginAt, - boolean protectedAccount) { } + boolean protectedAccount, Long overseasWarehouseId) { } public record TenantOption(Long id, String name, String status) { } public record RoleOption(Long id, Long tenantId, String name, String status) { } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java index 1b045d0ac..dfdaa658a 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java @@ -9,6 +9,6 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus. @Getter @Setter @TableName("iam_membership") public class IamMembership extends BaseEntity { - @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; + @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index f38fff466..6d57e9632 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -65,4 +65,8 @@ public interface AuthorizationQueryMapper { @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") long findPermissionVersion(@Param("userId") long userId); + + @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") + Long findOverseasWarehouseId(@Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); } diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 1592dbcf0..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -47,7 +47,8 @@ public class AuthorizationSnapshotService { request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId())); + mapper.findPermissionVersion(request.getUserId()), + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 73a8e7fe9..296100ffb 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -75,6 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -99,6 +100,7 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setOverseasWarehouseId(overseasWarehouseId); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); @@ -121,6 +123,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -134,6 +137,7 @@ public class PlatformUserManagementService { boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -145,6 +149,7 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); + membership.setOverseasWarehouseId(overseasWarehouseId); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -152,7 +157,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if (rolesChanged && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } @@ -222,7 +227,8 @@ public class PlatformUserManagementService { return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, - roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), + membership.getOverseasWarehouseId()); } private List platformRoles() { @@ -245,6 +251,18 @@ public class PlatformUserManagementService { return roleIds; } + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); + if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + } + if (!picker && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + } + return overseasWarehouseId; + } + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 65b0d2654..c2dfaffb3 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -114,6 +114,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5f5b22b77..fe88bdc06 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1279,6 +1279,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1290,6 +1291,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} CreateInternalUserRequest: type: object required: [displayName] @@ -1362,6 +1364,7 @@ components: roleNames: {type: array, items: {type: string}} lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} + overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 550066f8d..eeab42b78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest { .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); + when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest { assertThat(result.getClientType()).isEqualTo("MERCHANT"); assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + assertThat(result.getOverseasWarehouseId()).isEqualTo(900L); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 42df71a34..8f45baf95 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -57,6 +57,7 @@ class PlatformUserManagementServiceTest { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); + when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); } @Test @@ -117,6 +118,23 @@ class PlatformUserManagementServiceTest { verify(auth, never()).scope(anyString(), anyLong(), any()); } + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + assertThrows(BizException.class, + () -> service.update(30L, missing, Set.of("platform:user:grant-role"))); + + UpdateUserRequest bound = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 900L); + UserResponse response = service.update(30L, bound, Set.of("platform:user:grant-role")); + + assertEquals(900L, membership.getOverseasWarehouseId()); + assertEquals(900L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), any()); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 57229f0e4..89234d307 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -24,6 +24,8 @@ public class LastlegOrder { private String sourceOrderNo; private String rootOrderId; private String customerName; + private String sourceWarehouseId; + private String sourceWarehouseName; private String overseasWarehouseId; private String overseasWarehouseName; private String fbaWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index ec6ccd5ad..79ffac343 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -17,6 +17,7 @@ public class InboundPlan { private String customerId; private Long sourceOrderId; private String containerNo; + private Long overseasWarehouseId; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 68bc44b2c..534969f23 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + + "WHERE id=#{planId} AND customer_id=#{customerId} " + + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") + int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId, + @Param("warehouseId") long warehouseId); } 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 ce144e37c..6adcf7ff5 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 @@ -38,7 +38,7 @@ public class ReceivingContainerController { public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } @GetMapping("/{containerNo}") @@ -46,21 +46,21 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { Identity identity = identity(user); - return ApiResponse.success(service.detail(identity.operatorKey(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), 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.operatorKey(), identity.operatorName(), + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), containerNo, request)); } @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @@ -68,13 +68,13 @@ public class ReceivingContainerController { @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.current(identity.operatorKey())); } @@ -82,7 +82,7 @@ public class ReceivingContainerController { public ApiResponse resolveCarton( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @@ -90,7 +90,7 @@ public class ReceivingContainerController { public ApiResponse resolveSku( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @@ -98,28 +98,28 @@ public class ReceivingContainerController { public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); 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); + Identity identity = currentIdentity(user); 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); + Identity identity = currentIdentity(user); 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); + Identity identity = currentIdentity(user); return ApiResponse.success(service.complete(identity.operatorKey(), request)); } @@ -131,10 +131,20 @@ public class ReceivingContainerController { if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse"); + } String operator = String.valueOf(user.userId()); return new Identity(operator, - user.username() == null || user.username().isBlank() ? operator : user.username()); + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); } - private record Identity(String operatorKey, String operatorName) { } + private Identity currentIdentity(AuthenticatedUser user) { + Identity identity = identity(user); + service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); + return identity; + } + + private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 385113cf1..656c83a1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -10,7 +10,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } - public record Session(String containerNo, String status, String operatorName, int version, + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, + String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, @@ -18,19 +19,23 @@ public final class ReceivingContainerResponses { public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } - public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, + public record Inbound(String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record CartonResolved(String cartonMark, String skuCode, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record SkuResolved(String skuCode, String productName, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 68a074b0b..2667cb427 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -15,6 +15,8 @@ public class ReceivingContainerSession { private Long id; private String customerId; private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseName; private String operatorKey; private String operatorName; private String activeOperatorKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index debcb721b..3195adf30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -4,12 +4,14 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.core.io.ByteArrayResource; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import java.io.IOException; @@ -42,10 +44,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo upload failed"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo upload failed"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo upload failed"); Metadata data = response == null ? null : response.data(); - if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata"); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata"); return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } @@ -58,22 +64,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { tenantId, BUSINESS_TYPE, taskId, fileId) .header("X-Internal-Service-Token", internalToken) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo does not exist or its ownership is invalid"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo does not exist or its ownership is invalid"); Metadata data = response == null ? null : response.data(); if (data == null || data.fileId() == null || data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { - throw new IllegalArgumentException("Unload photo metadata does not match the PDA task"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Unload photo metadata does not match the PDA task"); } } private long tenantId(String customerId) { try { return Long.parseLong(customerId); } - catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } } - private record MetadataEnvelope(Metadata data) {} + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) {} private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index c28a0ab89..62c41435f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -2,8 +2,10 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -111,9 +113,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { response.data().fbaWarehouseCode(), response.data().version()); } + @Override + public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + WarehouseReferenceResponse response = client.get() + .uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + "Shared overseas warehouse lookup failed"); + }).body(WarehouseReferenceResponse.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Shared overseas warehouse service returned no response"); + } + if ("A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Active shared overseas warehouse was not found"); + } + if (!"200".equals(response.code()) || response.data() == null + || response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid shared overseas warehouse binding"); + } + return response.data(); + } + private record WarehousePalletResponse(WarehousePalletData data) {} private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} + private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 0350073c4..1d6b66f96 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -8,6 +8,9 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); + default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); + } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) { @@ -15,4 +18,7 @@ public interface WarehouseResourceGateway { this(palletId, palletNo, 0L, locationId, null, null, 0); } } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } } 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 1f616904a..80710f77c 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,8 +80,9 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String operatorKey) { + public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); Map> grouped = new LinkedHashMap<>(); plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { @@ -89,7 +90,7 @@ public class ReceivingContainerService { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); - return grouped.entrySet().stream().map(entry -> { + return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); @@ -99,27 +100,39 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - customerId, operatorKey, containerNo), containerPlans); + customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); } @Transactional - public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); - if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + if (replay != null) { + ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); + if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Prepared container belongs to another overseas warehouse"); + } + return result; + } List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); @@ -128,9 +141,14 @@ public class ReceivingContainerService { if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); @@ -175,6 +193,10 @@ public class ReceivingContainerService { return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); } + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { + requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); + } + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); @@ -190,7 +212,8 @@ public class ReceivingContainerService { } ReceivingContainerResponses.Inbound inbound = inbound(ref); return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), - inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(), + inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } @@ -403,7 +426,8 @@ public class ReceivingContainerService { long actualWarehouse; try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } - if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId(); + if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); String type = destinationType(ref.line(), ref.order()); if ("FBA".equals(type)) { if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() @@ -438,7 +462,8 @@ public class ReceivingContainerService { .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); - return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), + session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); } @@ -451,6 +476,8 @@ public class ReceivingContainerService { .mapToInt(value -> value(value.getPickedPackageCount())).sum(); int total = value(ref.line().getRequiredPackageCount()); return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() + : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); @@ -517,7 +544,8 @@ public class ReceivingContainerService { ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), - inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), + inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); } @@ -600,12 +628,56 @@ public class ReceivingContainerService { if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); } - private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) { + private ReceivingContainerSession waitingView(String customerId, String operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseName(warehouse.warehouseName()); value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); return value; } + private boolean eligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() + .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.contains(overseasWarehouseId); + } + + private void claimReceivingWarehouse(String customerId, List containerPlans, + WarehouseResourceGateway.WarehouseReference warehouse) { + long overseasWarehouseId = warehouse.warehouseId(); + String warehouseId = String.valueOf(overseasWarehouseId); + for (InboundPlan plan : containerPlans) { + int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId); + InboundPlan actual = plans.selectById(plan.getId()); + if ((claimed == 0 && actual == null) || actual == null + || !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Container was claimed by another overseas warehouse"); + } + plan.setOverseasWarehouseId(overseasWarehouseId); + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId, + warehouse.warehouseName()); + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("Inbound order was claimed by another overseas warehouse"); + } + } + } + } + + private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { + if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Current container belongs to another overseas warehouse"); + } + } + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { key = idempotencyKey(key); ReceivingContainerOperation value = operations.find(operatorKey, key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index f34f676e2..87226951e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -40,9 +41,9 @@ public class InventoryQueryController { @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.dashboard(null, query)); + return ApiResponse.success(service.dashboard(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances") @@ -50,9 +51,9 @@ public class InventoryQueryController { @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.balances(null, query)); + return ApiResponse.success(service.balances(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @@ -61,9 +62,9 @@ public class InventoryQueryController { public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long balanceId, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.PositionQuery query) { - requirePlatform(user); - return ApiResponse.success(service.positions(null, balanceId, query)); + return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query)); } @GetMapping(BASE_PATH + "/ledger") @@ -71,9 +72,9 @@ public class InventoryQueryController { @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> ledgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return ApiResponse.success(service.ledgers(null, query)); + return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @@ -81,9 +82,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return csv(service.exportBalances(null, query), "inventory-balances.csv"); + return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -91,9 +92,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return csv(service.exportLedgers(null, query), "inventory-ledger.csv"); + return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } private static ResponseEntity csv(byte[] body, String filename) { @@ -103,12 +104,14 @@ public class InventoryQueryController { .body(body); } - private void requirePlatform(AuthenticatedUser user) { + private String platformScope(AuthenticatedUser user, String requestedCustomerId) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } + return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim()) + ? null : requestedCustomerId.trim(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 316eb2b8d..16c85a001 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -89,8 +89,6 @@ public interface InventoryQueryMapper { @Select("')), }, token) - context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, + malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) const photos = [] for (const role of PHOTO_ROLES) { const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { @@ -265,7 +274,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { const pallet = await createPallet(context, inbound, index) if (!pallet) { blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, - `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + `无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') continue } pallets.set(inbound.inboundCode, pallet) @@ -313,11 +322,21 @@ 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', + overseasWarehouseId: null, overseasWarehouseName: null, + cleanup: 'Shared warehouse, 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 + const pickerAccess = await resolvePickerAccess(context) + if (!pickerAccess) return + const sharedWarehouse = await createSharedWarehouse(context) + if (!sharedWarehouse) { + blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓') + return + } + context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id + context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name + const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess) + const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null for (const picker of [primary, secondary]) if (picker?.membershipId) { context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) } @@ -332,3 +351,22 @@ export async function run(context) { await disablePicker(context, primary, 1) } } + +async function createSharedWarehouse(context) { + const suffix = markerSuffix(context) + const payload = { + customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50), + warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance', + contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`, + status: 'ACTIVE', remark: `${context.marker}-shared-receiving`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' }, + capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }], + } + const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created?.body?.data) + context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created, + success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS) + return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 1e9c74f77..13bac4df9 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -4,13 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; public class InternalWarehouseResourceLockController { @Resource private WarehouseResourceLockService service; @Resource private WarehouseResourceService resourceService; + @Resource private WarehouseApplicationService warehouses; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping @@ -67,4 +71,12 @@ public class InternalWarehouseResourceLockController { return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( request.customerId(), request.warehouseId())); } + + @GetMapping("/shared-warehouses/{warehouseId}") + public ApiResponse sharedWarehouse( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long warehouseId) { + tokenGuard.verify(token); + return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); + } } 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 f934717af..e4b27dfbc 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 @@ -164,8 +164,8 @@ public class PlatformWarehouseController { } private static void requireTenant(Long customerId) { - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + if (customerId == null || customerId < 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target customer scope is required / 必须指定有效客户或平台共享范围"); } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java new file mode 100644 index 000000000..aa83148fd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record SharedWarehouseReference(long warehouseId, String warehouseCode, + String warehouseName, String status) { } 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 7da4dc669..d762b3a30 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,7 +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.PositiveOrZero; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -96,7 +96,7 @@ 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, + @PositiveOrZero 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/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 3abc3cb97..89b7ded73 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -36,6 +37,7 @@ public class WarehouseApplicationService { captureBefore = false ) public Long create(WarehouseRequests.SaveRequest request) { + validateSharedWarehouse(request); ensureCodeUnique(request.customerId(), request.warehouseCode(), null); WarehouseEntity warehouse = toEntity(request); repository.insertWarehouse(warehouse); @@ -72,6 +74,7 @@ public class WarehouseApplicationService { if (request.customerId() == null || customerId != request.customerId()) { throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); } + validateSharedWarehouse(request); WarehouseEntity old = owned(customerId, id); ensureCodeUnique(customerId, request.warehouseCode(), id); @@ -98,6 +101,29 @@ public class WarehouseApplicationService { return new WarehouseDetailResponse(warehouse, addresses, capabilities); } + public SharedWarehouseReference activeSharedReference(long warehouseId) { + WarehouseEntity warehouse = repository.findOwned(0L, warehouseId); + boolean receiving = warehouse != null && repository.findCapabilities(0L, warehouseId).stream() + .anyMatch(value -> "RECEIVING".equals(value.getCapabilityCode()) && Boolean.TRUE.equals(value.getEnabled())); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus()) + || !"OVERSEAS".equals(warehouse.getWarehouseType()) || !receiving) { + throw new BizException(ResultCode.NOT_FOUND, "Active shared overseas warehouse not found"); + } + return new SharedWarehouseReference(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getStatus()); + } + + private void validateSharedWarehouse(WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || request.customerId() != 0L) return; + boolean receiving = request.capabilities() != null && request.capabilities().stream() + .anyMatch(value -> "RECEIVING".equals(value.capabilityCode()) + && !Boolean.FALSE.equals(value.enabled())); + if (!"OVERSEAS".equals(request.warehouseType()) || !receiving) { + throw new BizException(ResultCode.BAD_REQUEST, + "Shared physical warehouse must be OVERSEAS with RECEIVING enabled"); + } + } + @Transactional @BusinessAudit( event = "WAREHOUSE_STATUS_CHANGE", 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 0e4dea80f..ac330716f 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 @@ -61,6 +61,22 @@ class PlatformWarehouseControllerTest { assertTrue(operation.description().contains("customerId")); } + @Test + void platformCanCreateSharedWarehouseInCustomerZeroScope() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + PlatformWarehouseController controller = new PlatformWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "1 Shared Ave", null, null, null); + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, + List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + when(service.create(request)).thenReturn(900L); + + assertEquals(900L, controller.create(platform(), request).data()); + verify(service).create(request); + } + 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/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 1bdcc2925..37699e071 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 @@ -3,6 +3,8 @@ package com.baiyue.warehouse.service; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import org.springframework.test.util.ReflectionTestUtils; 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.Mockito.doAnswer; import static org.mockito.Mockito.verify; @@ -59,6 +62,31 @@ class WarehouseApplicationServiceTest { verify(repository).pageAll(query); } + @Test + void sharedReceivingWarehouseMustBeOverseasAndResolvesCanonicalReference() { + WarehouseRequests.SaveRequest shared = sharedRequest("OVERSEAS", true); + doAnswer(call -> { ((WarehouseEntity) call.getArgument(0)).setId(900L); return null; }) + .when(repository).insertWarehouse(any()); + + assertThat(service.create(shared)).isEqualTo(900L); + + WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(900L); warehouse.setCustomerId(0L); + warehouse.setWarehouseCode("SHARED-LAX"); warehouse.setWarehouseName("Shared LAX"); + warehouse.setWarehouseType("OVERSEAS"); warehouse.setStatus("ACTIVE"); + ServiceCapabilityEntity receiving = new ServiceCapabilityEntity(); + receiving.setCapabilityCode("RECEIVING"); receiving.setEnabled(true); + when(repository.findOwned(0L, 900L)).thenReturn(warehouse); + when(repository.findCapabilities(0L, 900L)).thenReturn(List.of(receiving)); + + assertThat(service.activeSharedReference(900L).warehouseName()).isEqualTo("Shared LAX"); + } + + @Test + void sharedWarehouseRejectsNonOverseasOrMissingReceivingCapability() { + assertThatThrownBy(() -> service.create(sharedRequest("FBA", true))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(sharedRequest("OVERSEAS", false))).isInstanceOf(BizException.class); + } + private static WarehouseRequests.SaveRequest request() { WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", @@ -67,4 +95,15 @@ class WarehouseApplicationServiceTest { "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, List.of()); } + + private static WarehouseRequests.SaveRequest sharedRequest(String type, boolean receiving) { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + List capabilities = receiving + ? List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)) + : List.of(); + return new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", type, + "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, capabilities); + } }