fix: bind overseas receiving to shared warehouse

This commit is contained in:
2026-08-24 21:46:19 +08:00
parent 3607bebbfd
commit 8cc4c6f6da
61 changed files with 774 additions and 118 deletions
@@ -21,4 +21,12 @@ public class AuthorizationSnapshot {
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
}
@@ -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();
@@ -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());
@@ -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)));
@@ -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);
@@ -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<String> 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<String> 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<String> 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);
@@ -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<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -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);
}
}
@@ -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和数据隔离键',
@@ -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'));
@@ -40,5 +40,13 @@ public final class AuthorizationDtos {
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
}
}
@@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos {
@Email @Size(max = 128) String email,
@NotNull Long tenantId,
@NotNull List<Long> 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<Long> 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<Long> roleIds) { }
@NotNull List<Long> roleIds,
Long overseasWarehouseId) {
public UpdateUserRequest(String displayName, String phone, String email,
Long tenantId, List<Long> 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<Long> roleIds, List<String> 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) { }
@@ -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;
}
@@ -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);
}
@@ -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()));
}
/**
@@ -75,6 +75,7 @@ public class PlatformUserManagementService {
long tenantId = request.tenantId();
requirePlatformTenant(tenantId);
List<Long> 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<Long> roleIds = validateRoles(newTenantId, request.roleIds());
Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId());
List<Long> 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<IamRole> platformRoles() {
@@ -245,6 +251,18 @@ public class PlatformUserManagementService {
return roleIds;
}
private Long validateWarehouseBinding(List<Long> 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<Long> roleIds) {
return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds);
}
+1
View File
@@ -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 '离开客户企业时间',
@@ -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]
@@ -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
@@ -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,
@@ -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;
@@ -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;
@@ -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<InboundPlan> {
@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);
}
@@ -38,7 +38,7 @@ public class ReceivingContainerController {
public ApiResponse<List<ReceivingContainerResponses.Candidate>> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Photo> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.Session> 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<ReceivingContainerResponses.CartonResolved> 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<ReceivingContainerResponses.SkuResolved> 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<ReceivingContainerResponses.PalletResolved> 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<ReceivingContainerResponses.Receipt> 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<ReceivingContainerResponses.Receipt> 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<ReceivingContainerResponses.Session> 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) { }
}
@@ -10,7 +10,8 @@ public final class ReceivingContainerResponses {
List<String> 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<SignedPhoto> 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) { }
@@ -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;
@@ -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) {}
}
@@ -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) {}
}
@@ -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) { }
}
@@ -80,8 +80,9 @@ public class ReceivingContainerService {
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey) {
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
.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<InboundPlan> 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<InboundPlan> 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<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> 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<InboundPlan> 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<InboundPlanLine>()
.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);
@@ -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<InventoryDashboardResponse> 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<PageResult<InventoryBalanceView>> 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<List<InventoryPositionView>> 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<PageResult<InventoryLedgerView>> 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<byte[]> 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<byte[]> 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<byte[]> 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();
}
}
@@ -89,8 +89,6 @@ public interface InventoryQueryMapper {
@Select("<script>SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}" +
"<if test='q.palletNo != null and q.palletNo != \"\"'> AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')</if>" +
"<if test='q.batchNo != null and q.batchNo != \"\"'> AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')</if>" +
"<if test='q.skuCode != null and q.skuCode != \"\"'> AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')</if>" +
"<if test='q.warehouseLocationCode != null and q.warehouseLocationCode != \"\"'> AND warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%')</if>" +
"<choose>" +
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) &gt; 0</when>" +
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages &gt; 0</when>" +
@@ -35,4 +35,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"AND shipped_package_count+#{packages} <= package_count")
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
@Update("UPDATE ll_order SET overseas_warehouse_id=#{warehouseId},overseas_warehouse_name=#{warehouseName}," +
"updated_at=NOW(3) WHERE id=#{orderId} AND customer_id=#{customerId} " +
"AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})")
int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId,
@Param("warehouseId") String warehouseId,
@Param("warehouseName") String warehouseName);
}
@@ -170,7 +170,8 @@ public class LoadingCompletedConsumer {
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
@@ -32,7 +32,9 @@ CREATE TABLE IF NOT EXISTS ll_order (
source_order_no VARCHAR(128) COMMENT '来源订单号',
root_order_id VARCHAR(128) COMMENT '根订单 ID',
customer_name VARCHAR(255) COMMENT '货主名称',
overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID',
source_warehouse_id VARCHAR(128) COMMENT '来源国内装柜仓 ID 快照',
source_warehouse_name VARCHAR(255) COMMENT '来源国内装柜仓名称快照',
overseas_warehouse_id VARCHAR(128) COMMENT '海外实际收货仓 ID,由绑定仓库的PDA操作员认领柜号时冻结',
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
@@ -352,6 +354,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
@@ -679,7 +682,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
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 '选择柜号时间',
@@ -470,6 +470,8 @@ components:
properties:
inboundCode: { type: string }
destinationType: { type: string, enum: [FBA, OVERSEAS] }
overseasWarehouseId: { type: string, nullable: true, description: 实际海外收货仓ID;与FBA或其他业务目的仓ID严格区分 }
overseasWarehouseName: { type: string, nullable: true, description: 实际海外收货仓名称快照 }
destinationWarehouseId: { type: string, nullable: true }
destinationWarehouseCode: { type: string, nullable: true }
destinationWarehouseName: { type: string, nullable: true }
@@ -493,9 +495,11 @@ components:
currentAccount: { type: boolean }
ReceivingSession:
type: object
required: [containerNo, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
properties:
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: PDA Token绑定并在认领柜号时冻结的共享实际海外收货仓ID }
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
operatorName: { type: string, nullable: true }
version: { type: integer }
@@ -70,6 +70,8 @@ class ReceivingContainerContractTest {
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
assertTrue(receivingSession.contains("sign_photos json"));
assertTrue(receivingSession.contains("overseas_warehouse_id bigint not null"));
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
assertFalse(receivingSession.contains("photo_file_ids json"));
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
assertTrue(unified.contains("column_name='sign_photos'"));
@@ -83,6 +85,8 @@ class ReceivingContainerContractTest {
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'"));
assertTrue(unified.contains("assert_pending_orders_do_not_use_source_warehouse"));
assertTrue(unified.contains("assert_active_receiving_session_has_warehouse"));
}
@Test
@@ -105,6 +109,8 @@ class ReceivingContainerContractTest {
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
}
@@ -31,12 +31,12 @@ class ReceivingContainerControllerTest {
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
controller.candidates(picker());
verify(service).candidates("42");
verify(service).candidates("42", 900L);
}
@Test
@@ -54,8 +54,16 @@ class ReceivingContainerControllerTest {
.hasMessageContaining("403");
}
@Test
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("403");
}
private static AuthenticatedUser picker() {
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
Set.of(ReceivingContainerController.OPERATE_PERMISSION), false, null, null, false, 900L);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class HttpUnloadPhotoFileGatewayTest {
private HttpUnloadPhotoFileGateway gateway;
private MockRestServiceServer server;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
gateway = new HttpUnloadPhotoFileGateway();
ReflectionTestUtils.setField(gateway, "restClientBuilder", builder);
ReflectionTestUtils.setField(gateway, "baseUrl", "http://file-service");
ReflectionTestUtils.setField(gateway, "internalToken", "internal-token");
gateway.initializeClient();
}
@Test
void forgedImageBusinessFailureMapsToBadRequestInsteadOfInternalError() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8"))
.andRespond(withSuccess("{\"code\":\"A0400\",\"message\":\"上传内容不是有效图片\",\"data\":null}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.upload("100", 8L, 7L,
new MockMultipartFile("file", "forged.png", "image/png", "<script>".getBytes())))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
@Test
void mismatchedPhotoMetadataMapsToBadRequest() {
server.expect(requestTo("http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9"))
.andRespond(withSuccess("{\"code\":\"200\",\"data\":{\"fileId\":9,\"tenantId\":101,\"businessType\":\"OVERSEAS_UNLOAD_PHOTO\",\"businessId\":\"8\"}}",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.validate("100", 8L, 9L))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
server.verify();
}
}
@@ -88,7 +88,14 @@ class ReceivingContainerServiceBehaviorTest {
when(lines.selectList(any())).thenReturn(List.of());
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key"));
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(plans.claimReceivingWarehouse("100", 2L, 100L)).thenReturn(1);
InboundPlan claimed = plan("CONT-A"); claimed.setOverseasWarehouseId(100L);
when(plans.selectById(2L)).thenReturn(claimed);
var response = service.prepare("7", "receiver", 100L, "CONT-A",
new ReceivingContainerRequests.Action("same-key"));
assertThat(response.overseasWarehouseId()).isEqualTo(100L);
assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX");
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
verify(sessions).insert(selected.capture());
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
@@ -98,7 +105,7 @@ class ReceivingContainerServiceBehaviorTest {
verify(operations).insert(saved.capture());
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409");
}
@@ -110,20 +117,33 @@ class ReceivingContainerServiceBehaviorTest {
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B");
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
org.mockito.Mockito.reset(plans, sessions);
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class)
assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("exactly one customer");
}
@Test
void candidatesIncludeUnassignedAndSameWarehouseButHideOtherWarehouse() {
InboundPlan unassigned = plan("CONT-A");
InboundPlan same = plan("CONT-B"); same.setId(3L); same.setOverseasWarehouseId(100L);
InboundPlan other = plan("CONT-C"); other.setId(4L); other.setOverseasWarehouseId(200L);
when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other),
List.of(unassigned), List.of(same));
when(lines.selectList(any())).thenReturn(List.of());
assertThat(service.candidates("7", 100L)).extracting("containerNo")
.containsExactly("CONT-A", "CONT-B");
}
@Test
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.detail("7", "CONT-B"))
assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B"))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@@ -131,13 +151,13 @@ class ReceivingContainerServiceBehaviorTest {
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
org.mockito.Mockito.reset(sessions, operations);
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B",
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B",
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
}
@@ -356,6 +376,7 @@ class ReceivingContainerServiceBehaviorTest {
private static ReceivingContainerSession session(String containerNo, String status) {
ReceivingContainerSession value = new ReceivingContainerSession();
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
value.setOverseasWarehouseId(100L); value.setOverseasWarehouseName("Shared LAX");
value.setStatus(status); value.setVersion(0);
return value;
}
@@ -378,6 +399,10 @@ class ReceivingContainerServiceBehaviorTest {
"AVAILABLE", fbaCode, 0);
}
private static WarehouseResourceGateway.WarehouseReference warehouseReference() {
return new WarehouseResourceGateway.WarehouseReference(100L, "SHARED-LAX", "Shared LAX", "ACTIVE");
}
private String hash(Object value) {
try {
String json = new ObjectMapper().findAndRegisterModules().copy()
@@ -4,8 +4,11 @@ import com.baiyue.lastleg.inventory.entity.InventoryBalance;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -69,6 +72,16 @@ class InventorySchemaContractTest {
}
}
@Test
void positionQuerySqlReferencesOnlyFieldsExposedByThePositionQueryDto() throws NoSuchMethodException {
Select select = InventoryQueryMapper.class.getMethod("positions", String.class, long.class,
InventoryQueryRequests.PositionQuery.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value());
assertFalse(sql.contains("q.skuCode"));
assertFalse(sql.contains("q.warehouseLocationCode"));
assertFalse(sql.contains("sku_code"));
}
@Test
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
@@ -22,19 +22,25 @@ class InventoryQueryControllerTest {
@Test
void platformBalancePageQueriesAllCustomers() {
controller.balances(user(0L, "PLATFORM"), query);
controller.balances(user(0L, "PLATFORM"), null, query);
verify(service).balances(eq(null), eq(query));
}
@Test
void platformBalancePageUsesExplicitCustomerScope() {
controller.balances(user(0L, "PLATFORM"), " 10001 ", query);
verify(service).balances(eq("10001"), eq(query));
}
@Test
void merchantCannotUsePlatformInventoryController() {
assertThrows(ResponseStatusException.class,
() -> controller.summary(user(100L, "MERCHANT"), query));
() -> controller.summary(user(100L, "MERCHANT"), null, query));
}
@Test
void platformSummaryQueriesAllCustomers() {
controller.summary(user(0L, "PLATFORM"), query);
controller.summary(user(0L, "PLATFORM"), null, query);
verify(service).dashboard(eq(null), eq(query));
}
@@ -43,7 +49,7 @@ class InventoryQueryControllerTest {
InventoryQueryRequests.PositionQuery positionQuery =
new InventoryQueryRequests.PositionQuery(null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.positions(null, 1L, positionQuery));
() -> controller.positions(null, 1L, null, positionQuery));
}
private static AuthenticatedUser user(long customerId, String appCode) {
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
+18 -1
View File
@@ -50,6 +50,19 @@ function stableTopologicalOrder(modules) {
return result
}
function dependencyClosure(module, visiting = new Set()) {
if (visiting.has(module)) throw new Error(`HTTP 验收模块依赖存在循环: ${[...visiting, module].join(',')}`)
const next = new Set(visiting).add(module)
const result = []
for (const dependency of MODULE_DEPENDENCIES.get(module) || []) {
for (const transitive of dependencyClosure(dependency, next)) {
if (!result.includes(transitive)) result.push(transitive)
}
if (!result.includes(dependency)) result.push(dependency)
}
return result
}
export function normalizeModuleOrder(modules) {
const normalized = [...modules]
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
@@ -169,6 +182,10 @@ export function normalizeModuleOrder(modules) {
normalized.splice(overviewIndex, 1)
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
}
for (const module of [...normalized]) {
const missing = dependencyClosure(module).filter((dependency) => !normalized.includes(dependency))
if (missing.length > 0) normalized.splice(normalized.indexOf(module), 0, ...missing)
}
return stableTopologicalOrder(normalized)
}
@@ -897,7 +914,7 @@ export class AcceptanceRun {
const matrixMarker = this.marker
const variantMarker = `${matrixMarker}-P4-${label}`
const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker']
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer']
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
present: Object.hasOwn(this.context, key), value: this.context[key],
}]))
@@ -17,9 +17,13 @@ test('default full acceptance includes the retained manual state matrix after li
test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => {
const expected = [
'merchant-qualification-fixture',
'orders',
'inbound-measurement',
'phase3-container-loading',
'phase3-loading',
'ocean-bookings',
'phase3-pda-convergence',
'phase3-evidence-gate',
'phase3-loading-finalize',
'phase3-customs',
'manual-state-matrix',
@@ -33,9 +37,13 @@ test('focused matrix execution injects real container lifecycle prerequisites an
test('focused splitting and manual groups resolve producers before consumers without duplicate internal runs', () => {
const expected = [
'merchant-qualification-fixture',
'orders',
'inbound-measurement',
'phase3-container-loading',
'phase3-loading',
'ocean-bookings',
'phase3-pda-convergence',
'phase3-evidence-gate',
'phase3-loading-finalize',
'phase3-order-splitting',
'phase3-customs',
@@ -13,3 +13,12 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m
assert.match(primary, /^\d{8}-[0-9A-F]{8}$/)
assert.ok(`P3B-${primary}`.length <= 64)
})
test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/)
assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/)
assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/)
})
@@ -635,7 +635,7 @@ export async function run(context) {
const overflowResource = inventoryInbound?.resources?.find?.((resource) =>
resource?.sharedOverflow === true && id(resource?.occupancyId))
if (overflowResource) {
const mutationSuffix = context.marker.replace(/\D/g, '').slice(-10)
const mutationSuffix = markerSuffix(context.marker)
const targetLocationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', {
warehouseId, zoneCode: `SNAP-${mutationSuffix.slice(-6)}`, zoneName: '库存快照验收区',
locationCode: `SNAP-${mutationSuffix}`, locationName: '库存快照换绑库位',
@@ -644,6 +644,11 @@ export async function run(context) {
})
const targetLocationId = id(context.apiSuccess(MODULE, '创建库存快照变化目标库位',
targetLocationResponse, '', OPTIONS))
if (!targetLocationId) {
context.addStep(MODULE, '库存变化fixture创建失败后停止旧snapshot提交', targetLocationResponse,
false, responseEvidence(targetLocationResponse), OPTIONS)
return
}
if (targetLocationId) {
inventoryMutationCleanup = {
cleaned: false, targetLocationId,
@@ -664,11 +669,17 @@ export async function run(context) {
})
: targetLocationResponse
const rebound = context.apiSuccess(MODULE, '通过公开 A0 换绑接口制造真实库存变化', rebindResponse, '', OPTIONS)
const reboundValid = id(rebound?.occupancyId) === id(overflowResource.occupancyId)
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
&& id(rebound?.locationId) === targetLocationId
context.assert(MODULE, '真实库存换绑保持同仓且位置发生变化', rebindResponse,
id(rebound?.occupancyId) === id(overflowResource.occupancyId)
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
&& id(rebound?.locationId) === targetLocationId,
reboundValid,
`occupancyId=${id(rebound?.occupancyId) || 'missing'}, previousLocationId=${id(rebound?.previousLocationId) || 'missing'}, locationId=${id(rebound?.locationId) || 'missing'}`, OPTIONS)
if (!reboundValid) {
context.addStep(MODULE, '库存换绑未成功后停止旧snapshot提交', rebindResponse,
false, responseEvidence(rebindResponse), OPTIONS)
return
}
const staleInventoryBatch = await request(context, 'POST', batchPath, batchPayload,
{ 'Idempotency-Key': `${context.marker}-bridge-batch-stale-inventory`.slice(0, 128) })
@@ -522,9 +522,11 @@ test('default acceptance order finalizes LOADED before customs reads fulfillment
test('container overview always follows phase3 loading in default and custom module selections', () => {
const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance'])
assert.deepEqual(reordered, ['orders', 'phase3-loading', 'phase3-container-overview', 'finance'])
assert.deepEqual(reordered, ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', 'finance'])
assert.deepEqual(parseArguments(['--modules', 'phase3-container-overview,phase3-loading']).modules,
['phase3-loading', 'phase3-container-overview'])
['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading',
'phase3-loading', 'phase3-container-overview'])
const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
@@ -61,8 +61,9 @@ export async function run(context) {
`permissionCount=${tallyCodes.size}`, OPTIONS)
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
[...operatorCodes, ...pickerCodes, ...tallyCodes].every((code) => code.startsWith('platform:')),
'all warehouse role permissions must use platform scope', OPTIONS)
[...operatorCodes, ...pickerCodes, ...tallyCodes]
.every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'),
'warehouse roles may use platform:* plus the single overseas service authority lastleg:pda:operate', OPTIONS)
const unauthorized = await context.request('GET',
'/api/platform/v1/first-leg/pda-loading/tasks?cabinetNo=UNAUTHORIZED&warehouseId=1', undefined, '')
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
@@ -24,6 +25,18 @@ function conflict(requestId) {
}
function clone(value) { return structuredClone(value) }
test('focused carrier node execution injects the complete outbound-confirm producer chain', () => {
const modules = normalizeModuleOrder(['phase4-carrier-nodes'])
const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement',
'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate',
'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch',
'phase3-carrier-schedule-queries', 'phase4-carrier-nodes']
for (const module of required) assert.ok(modules.includes(module), module)
for (let index = 1; index < required.length; index += 1) {
assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index])
}
})
test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
assert.deepEqual(STANDARD_NODES, [
@@ -17,6 +17,7 @@ function nullableText(value) { return value == null || typeof value === 'string'
function nullableId(value) { return value == null || validLongId(value) }
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.test(value) }
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
function localIso(value) { return value.toISOString().slice(0, 19) }
@@ -37,7 +38,7 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e
}
function validTask(task) {
return validLongId(task?.id) && Boolean(task?.queryNo) && validLongId(task?.customerId)
return validLongId(task?.id) && Boolean(task?.queryNo) && validNumericId(task?.customerId)
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
@@ -77,6 +77,12 @@ test('cross-region normalization keeps existing producers ahead of every phase-t
assert.deepEqual(normalizeModuleOrder(modules), modules)
})
test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => {
const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8'))
assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'\]/)
})
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
for (const token of [
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
@@ -36,7 +36,12 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /重复箱新键被拒绝/)
assert.match(source, /模式硬拒绝/)
assert.match(source, /current返回404正常空态/)
assert.match(source, /安全扫描被拒绝/)
assert.match(source, /安全扫描返回A0400/)
assert.match(source, /customerId: 0/)
assert.match(source, /overseasWarehouseId, password/)
assert.match(source, /inbound\?\.overseasWarehouseId/)
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
assert.match(source, /目的仓 .* 不得代替物理仓/)
assert.match(source, /retained as acceptance evidence/)
})
@@ -81,7 +86,8 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
], requestPath, method)
if (method === 'POST' && requestPath.endsWith('/users')) return response({
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], status: 'ACTIVE',
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE',
}, requestPath, method)
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method)
if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method)
@@ -89,6 +95,7 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
},
async requestOverseas(method, requestPath, body, headers, token) {
calls.push({ channel: 'overseas', method, requestPath })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('900', requestPath, method)
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
return response([], requestPath, method)
@@ -46,32 +46,40 @@ async function loginMerchantForNegativeProbe(context) {
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
}
async function createPicker(context, index) {
async function resolvePickerAccess(context) {
const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles')
const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS)
const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', rolesResponse, '', OPTIONS)
const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE')
const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions')
const permissions = context.apiSuccess(MODULE, `查询海外PDA权限资源${index}`, permissionsResponse, '', OPTIONS)
const permissions = context.apiSuccess(MODULE, '查询海外PDA权限资源', permissionsResponse, '', OPTIONS)
const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate')
const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id)))
context.assert(MODULE, `WAREHOUSE_PICKER精确具备海外PDA权限${index}`, rolesResponse, assigned,
context.assert(MODULE, 'WAREHOUSE_PICKER精确具备海外PDA权限', rolesResponse, assigned,
`roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS)
if (!assigned) {
blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
return null
}
return { role, operate }
}
async function createPicker(context, index, overseasWarehouseId, access) {
const role = access.role
const suffix = markerSuffix(context)
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
const phone = await context.findAvailablePhone(MODULE)
const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone,
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], password, status: 'ACTIVE' }
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
overseasWarehouseId, password, status: 'ACTIVE' }
const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload)
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
const membershipId = id(created?.membershipId)
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)),
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS)
longId(membershipId) && id(created?.tenantId) === '0'
&& id(created?.overseasWarehouseId) === id(overseasWarehouseId)
&& (created?.roleIds || []).map(id).includes(id(role.id)),
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS)
if (!longId(membershipId)) return null
const login = await context.request('POST', '/api/auth/login', { username, password }, '')
const token = success(login) ? login.body?.data?.overseasAccessToken : null
@@ -88,7 +96,7 @@ async function disablePicker(context, picker, index) {
}
async function createPallet(context, inbound, index) {
const warehouseId = id(inbound?.destinationWarehouseId)
const warehouseId = id(inbound?.overseasWarehouseId)
if (!longId(warehouseId)) return null
const suffix = markerSuffix(context)
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
@@ -114,7 +122,8 @@ async function uploadPhotos(context, token) {
const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
}, 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
}
@@ -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<SharedWarehouseReference> sharedWarehouse(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long warehouseId) {
tokenGuard.verify(token);
return ApiResponse.success(warehouses.activeSharedReference(warehouseId));
}
}
@@ -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 / 必须指定有效客户或平台共享范围");
}
}
}
@@ -0,0 +1,4 @@
package com.baiyue.warehouse.dto;
public record SharedWarehouseReference(long warehouseId, String warehouseCode,
String warehouseName, String status) { }
@@ -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")
@@ -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",
@@ -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());
}
@@ -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<WarehouseRequests.CapabilityRequest> 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);
}
}