fix: bind overseas receiving to shared warehouse
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+24
-14
@@ -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) { }
|
||||
}
|
||||
|
||||
+9
-4
@@ -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) { }
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+30
-6
@@ -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) {}
|
||||
}
|
||||
|
||||
+30
@@ -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) {}
|
||||
}
|
||||
|
||||
+6
@@ -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) { }
|
||||
}
|
||||
|
||||
+83
-11
@@ -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);
|
||||
|
||||
+16
-13
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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) > 0</when>" +
|
||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_packages > 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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
+6
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -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();
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -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()
|
||||
|
||||
+13
@@ -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)
|
||||
|
||||
+10
-4
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+12
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 / 必须指定有效客户或平台共享范围");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
public record SharedWarehouseReference(long warehouseId, String warehouseCode,
|
||||
String warehouseName, String status) { }
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+26
@@ -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",
|
||||
|
||||
+16
@@ -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());
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user