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> roles;
|
||||||
private Set<String> permissions;
|
private Set<String> permissions;
|
||||||
private Long permissionVersion;
|
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();
|
Instant issuedAt = Instant.now();
|
||||||
String jwtId = UUID.randomUUID().toString();
|
String jwtId = UUID.randomUUID().toString();
|
||||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
.subject(account.getId().toString())
|
.subject(account.getId().toString())
|
||||||
@@ -127,8 +127,11 @@ public class AuthService {
|
|||||||
.claim("userId", account.getId())
|
.claim("userId", account.getId())
|
||||||
.claim("tenantId", account.getDefaultTenantId())
|
.claim("tenantId", account.getDefaultTenantId())
|
||||||
.claim("appCode", account.getDefaultAppCode())
|
.claim("appCode", account.getDefaultAppCode())
|
||||||
.claim("permissionVersion", account.getTokenVersion())
|
.claim("permissionVersion", account.getTokenVersion());
|
||||||
.build();
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
|
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||||
|
}
|
||||||
|
JwtClaimsSet claims = claimsBuilder.build();
|
||||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||||
signingHeader(), claims)).getTokenValue();
|
signingHeader(), claims)).getTokenValue();
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class OverseasTokenService {
|
|||||||
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
|
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
|
||||||
Instant issuedAt = Instant.now();
|
Instant issuedAt = Instant.now();
|
||||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
@@ -60,8 +60,11 @@ public class OverseasTokenService {
|
|||||||
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||||
.claim("roles", authorization.getRoles())
|
.claim("roles", authorization.getRoles())
|
||||||
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
||||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||||
.build();
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
|
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||||
|
}
|
||||||
|
JwtClaimsSet claims = claimsBuilder.build();
|
||||||
String token = encoder.encode(JwtEncoderParameters.from(
|
String token = encoder.encode(JwtEncoderParameters.from(
|
||||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||||
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("PLATFORM");
|
account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(1);
|
account.setTokenVersion(1);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
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);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
when(repository.findByUsername("operator")).thenReturn(account);
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
@@ -82,12 +82,13 @@ class AuthServiceSmallJwtTest {
|
|||||||
.containsEntry("appCode", "PLATFORM")
|
.containsEntry("appCode", "PLATFORM")
|
||||||
.containsEntry("permissionVersion", 1)
|
.containsEntry("permissionVersion", 1)
|
||||||
.containsEntry("membership_id", 84L)
|
.containsEntry("membership_id", 84L)
|
||||||
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
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(Set.of("platform:order:list")), any(),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class OverseasTokenServiceTest {
|
|||||||
String lastlegAuthority = "lastleg:pda:operate";
|
String lastlegAuthority = "lastleg:pda:operate";
|
||||||
String domesticAuthority = "platform:firstleg:order:list";
|
String domesticAuthority = "platform:firstleg:order:list";
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
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);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(accounts.findById(42L)).thenReturn(account);
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
@@ -71,7 +71,8 @@ class OverseasTokenServiceTest {
|
|||||||
.containsEntry("membership_id", 84L)
|
.containsEntry("membership_id", 84L)
|
||||||
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
||||||
.containsEntry("permission_version", 7L)
|
.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))
|
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
||||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||||
assertThat(response.expiresIn()).isEqualTo(900);
|
assertThat(response.expiresIn()).isEqualTo(900);
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ import java.util.Set;
|
|||||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
String username, String appCode, String clientType,
|
String username, String appCode, String clientType,
|
||||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
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,
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
String username, String appCode, String clientType, Set<String> permissions) {
|
String username, String appCode, String clientType, Set<String> permissions) {
|
||||||
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
|
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 {
|
public AuthenticatedUser {
|
||||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ public class JwtClaimsMapper {
|
|||||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||||
longClaim(jwt, "actor_user_id"),
|
longClaim(jwt, "actor_user_id"),
|
||||||
longClaim(jwt, "actor_membership_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()
|
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
||||||
.map(SimpleGrantedAuthority::new).toList();
|
.map(SimpleGrantedAuthority::new).toList();
|
||||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
|
|||||||
.claim("actor_user_id", 42L)
|
.claim("actor_user_id", 42L)
|
||||||
.claim("actor_membership_id", 43L)
|
.claim("actor_membership_id", 43L)
|
||||||
.claim("password_change_required", true)
|
.claim("password_change_required", true)
|
||||||
|
.claim("overseas_warehouse_id", 900L)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
||||||
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
|
|||||||
assertThat(user.actorUserId()).isEqualTo(42L);
|
assertThat(user.actorUserId()).isEqualTo(42L);
|
||||||
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
||||||
assertThat(user.passwordChangeRequired()).isTrue();
|
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',
|
user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
|
||||||
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
|
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
|
||||||
member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号',
|
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已离开',
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
|
||||||
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
|
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
|
||||||
left_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)
|
KEY idx_membership_tenant (tenant_id, status)
|
||||||
) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS iam_role (
|
||||||
id BIGINT NOT NULL COMMENT '角色ID',
|
id BIGINT NOT NULL COMMENT '角色ID',
|
||||||
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户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)
|
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';
|
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 (
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||||
|
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 '未完成会话平台操作账号全局唯一锁键',
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
||||||
@@ -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)
|
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柜号签收收货会话';
|
) 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'),
|
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 MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''',
|
||||||
'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status'));
|
'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> roles;
|
||||||
private Set<String> permissions;
|
private Set<String> permissions;
|
||||||
private Long permissionVersion;
|
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,
|
@Email @Size(max = 128) String email,
|
||||||
@NotNull Long tenantId,
|
@NotNull Long tenantId,
|
||||||
@NotNull List<Long> roleIds,
|
@NotNull List<Long> roleIds,
|
||||||
|
Long overseasWarehouseId,
|
||||||
@NotBlank @Size(min = 8, max = 64)
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
@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(
|
public record UpdateUserRequest(
|
||||||
@NotBlank @Size(max = 64) String displayName,
|
@NotBlank @Size(max = 64) String displayName,
|
||||||
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
|
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
|
||||||
@Email @Size(max = 128) String email,
|
@Email @Size(max = 128) String email,
|
||||||
@NotNull Long tenantId,
|
@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 ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
|
||||||
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
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 displayName, String phone, String email, Long tenantId,
|
||||||
String tenantName, String memberType, String memberNo, String status,
|
String tenantName, String memberType, String memberNo, String status,
|
||||||
List<Long> roleIds, List<String> roleNames, LocalDateTime lastLoginAt,
|
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 TenantOption(Long id, String name, String status) { }
|
||||||
public record RoleOption(Long id, Long tenantId, 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
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@TableName("iam_membership") public class IamMembership extends BaseEntity {
|
@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}")
|
@Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}")
|
||||||
long findPermissionVersion(@Param("userId") long 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));
|
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
|
||||||
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
|
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
|
||||||
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions),
|
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();
|
long tenantId = request.tenantId();
|
||||||
requirePlatformTenant(tenantId);
|
requirePlatformTenant(tenantId);
|
||||||
List<Long> roleIds = validateRoles(tenantId, request.roleIds());
|
List<Long> roleIds = validateRoles(tenantId, request.roleIds());
|
||||||
|
Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId());
|
||||||
long authUserId = IdWorker.getId();
|
long authUserId = IdWorker.getId();
|
||||||
long userId = IdWorker.getId();
|
long userId = IdWorker.getId();
|
||||||
long membershipId = IdWorker.getId();
|
long membershipId = IdWorker.getId();
|
||||||
@@ -99,6 +100,7 @@ public class PlatformUserManagementService {
|
|||||||
membership.setUserId(userId);
|
membership.setUserId(userId);
|
||||||
membership.setMemberType("EMPLOYEE");
|
membership.setMemberType("EMPLOYEE");
|
||||||
membership.setMemberNo(memberNo(tenantId, membershipId));
|
membership.setMemberNo(memberNo(tenantId, membershipId));
|
||||||
|
membership.setOverseasWarehouseId(overseasWarehouseId);
|
||||||
membership.setStatus(request.status());
|
membership.setStatus(request.status());
|
||||||
membership.setJoinedAt(LocalDateTime.now());
|
membership.setJoinedAt(LocalDateTime.now());
|
||||||
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
|
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
|
||||||
@@ -121,6 +123,7 @@ public class PlatformUserManagementService {
|
|||||||
long newTenantId = request.tenantId();
|
long newTenantId = request.tenantId();
|
||||||
requirePlatformTenant(newTenantId);
|
requirePlatformTenant(newTenantId);
|
||||||
List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
|
List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
|
||||||
|
Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId());
|
||||||
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
|
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
|
||||||
if (isProtectedPlatformAdmin(membership, oldRoleIds)
|
if (isProtectedPlatformAdmin(membership, oldRoleIds)
|
||||||
&& (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) {
|
&& (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) {
|
||||||
@@ -134,6 +137,7 @@ public class PlatformUserManagementService {
|
|||||||
boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim());
|
boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim());
|
||||||
boolean scopeChanged = oldTenantId != newTenantId;
|
boolean scopeChanged = oldTenantId != newTenantId;
|
||||||
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
|
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(),
|
if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(),
|
||||||
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
|
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
|
||||||
@@ -145,6 +149,7 @@ public class PlatformUserManagementService {
|
|||||||
user.setEmail(trimToNull(request.email()));
|
user.setEmail(trimToNull(request.email()));
|
||||||
user.setUserType(userType(newTenantId));
|
user.setUserType(userType(newTenantId));
|
||||||
membership.setTenantId(newTenantId);
|
membership.setTenantId(newTenantId);
|
||||||
|
membership.setOverseasWarehouseId(overseasWarehouseId);
|
||||||
if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) {
|
if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) {
|
||||||
membership.setMemberNo(memberNo(newTenantId, membershipId));
|
membership.setMemberNo(memberNo(newTenantId, membershipId));
|
||||||
}
|
}
|
||||||
@@ -152,7 +157,7 @@ public class PlatformUserManagementService {
|
|||||||
members.save(membership);
|
members.save(membership);
|
||||||
if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of());
|
if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of());
|
||||||
members.replaceRoles(newTenantId, membershipId, roleIds);
|
members.replaceRoles(newTenantId, membershipId, roleIds);
|
||||||
if (rolesChanged && !profileChanged && !scopeChanged) {
|
if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) {
|
||||||
call(() -> authClient.revokeSessions(internalToken,
|
call(() -> authClient.revokeSessions(internalToken,
|
||||||
new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId()))));
|
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(),
|
return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(),
|
||||||
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
|
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
|
||||||
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds,
|
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() {
|
private List<IamRole> platformRoles() {
|
||||||
@@ -245,6 +251,18 @@ public class PlatformUserManagementService {
|
|||||||
return roleIds;
|
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) {
|
private boolean isProtectedPlatformAdmin(IamMembership membership, List<Long> roleIds) {
|
||||||
return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(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',
|
user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
|
||||||
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
|
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等',
|
||||||
member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号',
|
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已离开',
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开',
|
||||||
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
|
joined_at DATETIME(3) NULL COMMENT '加入客户企业时间',
|
||||||
left_at DATETIME(3) NULL COMMENT '离开客户企业时间',
|
left_at DATETIME(3) NULL COMMENT '离开客户企业时间',
|
||||||
|
|||||||
@@ -1279,6 +1279,7 @@ components:
|
|||||||
email: {type: string, format: email, maxLength: 128}
|
email: {type: string, format: email, maxLength: 128}
|
||||||
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
||||||
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
|
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}
|
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
|
||||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||||
UpdatePlatformManagedUserRequest:
|
UpdatePlatformManagedUserRequest:
|
||||||
@@ -1290,6 +1291,7 @@ components:
|
|||||||
email: {type: string, format: email, maxLength: 128}
|
email: {type: string, format: email, maxLength: 128}
|
||||||
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
|
||||||
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
|
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
|
||||||
|
overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空}
|
||||||
CreateInternalUserRequest:
|
CreateInternalUserRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [displayName]
|
required: [displayName]
|
||||||
@@ -1362,6 +1364,7 @@ components:
|
|||||||
roleNames: {type: array, items: {type: string}}
|
roleNames: {type: array, items: {type: string}}
|
||||||
lastLoginAt: {type: string, format: date-time, nullable: true}
|
lastLoginAt: {type: string, format: date-time, nullable: true}
|
||||||
protectedAccount: {type: boolean}
|
protectedAccount: {type: boolean}
|
||||||
|
overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID}
|
||||||
CreateMemberRequest:
|
CreateMemberRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password, displayName, memberType]
|
required: [username, password, displayName, memberType]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest {
|
|||||||
.thenReturn(List.of("ADMIN", "OPERATOR"));
|
.thenReturn(List.of("ADMIN", "OPERATOR"));
|
||||||
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT"))
|
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT"))
|
||||||
.thenReturn(List.of("order:read", "order:create"));
|
.thenReturn(List.of("order:read", "order:create"));
|
||||||
|
when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L);
|
||||||
|
|
||||||
var result = service
|
var result = service
|
||||||
.load(new SnapshotRequest(42L, 7L, 9L, "merchant"));
|
.load(new SnapshotRequest(42L, 7L, 9L, "merchant"));
|
||||||
@@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest {
|
|||||||
assertThat(result.getClientType()).isEqualTo("MERCHANT");
|
assertThat(result.getClientType()).isEqualTo("MERCHANT");
|
||||||
assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR");
|
assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR");
|
||||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create");
|
assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create");
|
||||||
|
assertThat(result.getOverseasWarehouseId()).isEqualTo(900L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PlatformUserManagementServiceTest {
|
|||||||
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||||
new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE",
|
new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE",
|
||||||
LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM")));
|
LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM")));
|
||||||
|
when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -117,6 +118,23 @@ class PlatformUserManagementServiceTest {
|
|||||||
verify(auth, never()).scope(anyString(), anyLong(), any());
|
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
|
@Test
|
||||||
void currentPlatformOperatorCannotDisableSelf() {
|
void currentPlatformOperatorCannotDisableSelf() {
|
||||||
assertThrows(BizException.class,
|
assertThrows(BizException.class,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public class LastlegOrder {
|
|||||||
private String sourceOrderNo;
|
private String sourceOrderNo;
|
||||||
private String rootOrderId;
|
private String rootOrderId;
|
||||||
private String customerName;
|
private String customerName;
|
||||||
|
private String sourceWarehouseId;
|
||||||
|
private String sourceWarehouseName;
|
||||||
private String overseasWarehouseId;
|
private String overseasWarehouseId;
|
||||||
private String overseasWarehouseName;
|
private String overseasWarehouseName;
|
||||||
private String fbaWarehouseCode;
|
private String fbaWarehouseCode;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class InboundPlan {
|
|||||||
private String customerId;
|
private String customerId;
|
||||||
private Long sourceOrderId;
|
private Long sourceOrderId;
|
||||||
private String containerNo;
|
private String containerNo;
|
||||||
|
private Long overseasWarehouseId;
|
||||||
private String status;
|
private String status;
|
||||||
private Integer lineCount;
|
private Integer lineCount;
|
||||||
private Integer palletCount;
|
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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import java.util.List;
|
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")
|
@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);
|
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(
|
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.candidates(identity.operatorKey()));
|
return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{containerNo}")
|
@GetMapping("/{containerNo}")
|
||||||
@@ -46,21 +46,21 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo) {
|
@PathVariable String containerNo) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.detail(identity.operatorKey(), containerNo));
|
return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{containerNo}/prepare")
|
@PostMapping("/{containerNo}/prepare")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = identity(user);
|
||||||
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(),
|
return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(),
|
||||||
containerNo, request));
|
containerNo, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestPart("file") MultipartFile file) {
|
@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");
|
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
||||||
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
||||||
}
|
}
|
||||||
@@ -68,13 +68,13 @@ public class ReceivingContainerController {
|
|||||||
@PostMapping("/current/sign")
|
@PostMapping("/current/sign")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/current")
|
@GetMapping("/current")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.current(identity.operatorKey()));
|
return ApiResponse.success(service.current(identity.operatorKey()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
|
public ApiResponse<ReceivingContainerResponses.SkuResolved> resolveSku(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.SkuResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolveSku(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,28 +98,28 @@ public class ReceivingContainerController {
|
|||||||
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
|
return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/bulk")
|
@PostMapping("/current/receipts/bulk")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/receipts/carton")
|
@PostMapping("/current/receipts/carton")
|
||||||
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/current/complete")
|
@PostMapping("/current/complete")
|
||||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
Identity identity = identity(user);
|
Identity identity = currentIdentity(user);
|
||||||
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +131,20 @@ public class ReceivingContainerController {
|
|||||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||||
}
|
}
|
||||||
|
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());
|
String operator = String.valueOf(user.userId());
|
||||||
return new Identity(operator,
|
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,
|
List<String> destinationCodes, int plannedPalletCount,
|
||||||
int totalPackages, int receivedPackages, boolean currentAccount) { }
|
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,
|
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||||
int totalPackages, int receivedPackages, int pendingPackages,
|
int totalPackages, int receivedPackages, int pendingPackages,
|
||||||
@@ -18,19 +19,23 @@ public final class ReceivingContainerResponses {
|
|||||||
|
|
||||||
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
|
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 destinationWarehouseCode, String destinationWarehouseName,
|
||||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||||
int receivedPackages, int pendingPackages) { }
|
int receivedPackages, int pendingPackages) { }
|
||||||
|
|
||||||
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
|
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 destinationWarehouseCode, String destinationWarehouseName,
|
||||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||||
int receivedPackages, int pendingPackages) { }
|
int receivedPackages, int pendingPackages) { }
|
||||||
|
|
||||||
public record SkuResolved(String skuCode, String productName, String inboundCode,
|
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 destinationWarehouseCode, String destinationWarehouseName,
|
||||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||||
int receivedPackages, int pendingPackages) { }
|
int receivedPackages, int pendingPackages) { }
|
||||||
|
|||||||
+2
@@ -15,6 +15,8 @@ public class ReceivingContainerSession {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String customerId;
|
private String customerId;
|
||||||
private String containerNo;
|
private String containerNo;
|
||||||
|
private Long overseasWarehouseId;
|
||||||
|
private String overseasWarehouseName;
|
||||||
private String operatorKey;
|
private String operatorKey;
|
||||||
private String operatorName;
|
private String operatorName;
|
||||||
private String activeOperatorKey;
|
private String activeOperatorKey;
|
||||||
|
|||||||
+30
-6
@@ -4,12 +4,14 @@ import jakarta.annotation.PostConstruct;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.client.MultipartBodyBuilder;
|
import org.springframework.http.client.MultipartBodyBuilder;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.io.IOException;
|
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))
|
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||||
.onStatus(HttpStatusCode::isError, (request, result) -> {
|
.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);
|
}).body(MetadataEnvelope.class);
|
||||||
|
requireSuccess(response, "Unload photo upload failed");
|
||||||
Metadata data = response == null ? null : response.data();
|
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(),
|
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(),
|
||||||
data.sizeBytes() == null ? 0 : data.sizeBytes());
|
data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||||
}
|
}
|
||||||
@@ -58,22 +64,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
|||||||
tenantId, BUSINESS_TYPE, taskId, fileId)
|
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||||
.header("X-Internal-Service-Token", internalToken)
|
.header("X-Internal-Service-Token", internalToken)
|
||||||
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
.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);
|
}).body(MetadataEnvelope.class);
|
||||||
|
requireSuccess(response, "Unload photo does not exist or its ownership is invalid");
|
||||||
Metadata data = response == null ? null : response.data();
|
Metadata data = response == null ? null : response.data();
|
||||||
if (data == null || data.fileId() == null || data.fileId() != fileId
|
if (data == null || data.fileId() == null || data.fileId() != fileId
|
||||||
|| data.tenantId() == null || data.tenantId() != tenantId
|
|| data.tenantId() == null || data.tenantId() != tenantId
|
||||||
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) {
|
|| !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) {
|
private long tenantId(String customerId) {
|
||||||
try { return Long.parseLong(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,
|
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||||
String fileName, String contentType, Long sizeBytes) {}
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatusCode;
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
@@ -111,9 +113,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
|||||||
response.data().fbaWarehouseCode(), response.data().version());
|
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 WarehousePalletResponse(WarehousePalletData data) {}
|
||||||
private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId,
|
private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId,
|
||||||
String status, String fbaWarehouseCode, int version) {}
|
String status, String fbaWarehouseCode, int version) {}
|
||||||
private record ReserveEnvelope(ReserveData data) {}
|
private record ReserveEnvelope(ReserveData data) {}
|
||||||
private record ReserveData(long lockId) {}
|
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 scrap(String customerId, long resourceLockId);
|
||||||
void scrapPallet(String customerId, long palletId);
|
void scrapPallet(String customerId, long palletId);
|
||||||
WarehousePalletReference resolvePallet(String palletCode);
|
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,
|
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
|
||||||
String status, String fbaWarehouseCode, int version) {
|
String status, String fbaWarehouseCode, int version) {
|
||||||
@@ -15,4 +18,7 @@ public interface WarehouseResourceGateway {
|
|||||||
this(palletId, palletNo, 0L, locationId, null, null, 0);
|
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);
|
.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));
|
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||||
|
requireSessionWarehouse(current, overseasWarehouseId);
|
||||||
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
||||||
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||||
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
|
.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);
|
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());
|
requireSingleCustomer(entry.getValue(), entry.getKey());
|
||||||
Totals totals = totals(entry.getValue());
|
Totals totals = totals(entry.getValue());
|
||||||
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
||||||
@@ -99,27 +100,39 @@ public class ReceivingContainerService {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) {
|
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||||
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||||
String customerId = requireSingleCustomer(containerPlans, 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));
|
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||||
|
requireSessionWarehouse(current, overseasWarehouseId);
|
||||||
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
||||||
throw conflict("Current account is locked to another selected container");
|
throw conflict("Current account is locked to another selected container");
|
||||||
}
|
}
|
||||||
|
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||||
|
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||||
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
|
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
|
||||||
customerId, operatorKey, containerNo), containerPlans);
|
customerId, operatorKey, receivingWarehouse, containerNo), containerPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@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) {
|
ReceivingContainerRequests.Action request) {
|
||||||
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
|
PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey());
|
||||||
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
|
ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command);
|
||||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
if (replay != null) {
|
||||||
|
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);
|
List<InboundPlan> containerPlans = requirePlans(containerNo);
|
||||||
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
String customerId = requireSingleCustomer(containerPlans, containerNo);
|
||||||
|
if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
|
||||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||||
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||||
|
requireSessionWarehouse(active, overseasWarehouseId);
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
||||||
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||||
@@ -128,9 +141,14 @@ public class ReceivingContainerService {
|
|||||||
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
|
if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) {
|
||||||
throw conflict("Container is already signed or prepared by another account");
|
throw conflict("Container is already signed or prepared by another account");
|
||||||
}
|
}
|
||||||
|
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||||
|
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
|
||||||
|
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
ReceivingContainerSession session = new ReceivingContainerSession();
|
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||||
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||||
|
session.setOverseasWarehouseId(overseasWarehouseId);
|
||||||
|
session.setOverseasWarehouseName(receivingWarehouse.warehouseName());
|
||||||
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
||||||
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
|
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo));
|
||||||
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
||||||
@@ -175,6 +193,10 @@ public class ReceivingContainerService {
|
|||||||
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
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,
|
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
|
||||||
ReceivingContainerRequests.CartonResolve request) {
|
ReceivingContainerRequests.CartonResolve request) {
|
||||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
@@ -190,7 +212,8 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||||
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
|
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.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
|
||||||
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
||||||
}
|
}
|
||||||
@@ -403,7 +426,8 @@ public class ReceivingContainerService {
|
|||||||
long actualWarehouse;
|
long actualWarehouse;
|
||||||
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
|
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
|
||||||
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
|
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());
|
String type = destinationType(ref.line(), ref.order());
|
||||||
if ("FBA".equals(type)) {
|
if ("FBA".equals(type)) {
|
||||||
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|
||||||
@@ -438,7 +462,8 @@ public class ReceivingContainerService {
|
|||||||
.map(this::inbound).toList();
|
.map(this::inbound).toList();
|
||||||
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||||
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).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(),
|
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
|
||||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
|
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();
|
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
|
||||||
int total = value(ref.line().getRequiredPackageCount());
|
int total = value(ref.line().getRequiredPackageCount());
|
||||||
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
|
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().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
|
||||||
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
||||||
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
|
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
|
||||||
@@ -517,7 +544,8 @@ public class ReceivingContainerService {
|
|||||||
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
|
ReceivingContainerResponses.Inbound inbound = inbound(sku.ref());
|
||||||
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
|
int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode());
|
||||||
return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(),
|
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(),
|
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(),
|
||||||
sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received));
|
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");
|
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);
|
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);
|
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
|
||||||
return value;
|
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) {
|
private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) {
|
||||||
key = idempotencyKey(key);
|
key = idempotencyKey(key);
|
||||||
ReceivingContainerOperation value = operations.find(operatorKey, 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.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
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)
|
@Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||||
public ApiResponse<InventoryDashboardResponse> summary(
|
public ApiResponse<InventoryDashboardResponse> summary(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||||
requirePlatform(user);
|
return ApiResponse.success(service.dashboard(platformScope(user, customerId), query));
|
||||||
return ApiResponse.success(service.dashboard(null, query));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(BASE_PATH + "/balances")
|
@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)
|
@Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||||
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
public ApiResponse<PageResult<InventoryBalanceView>> balances(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||||
requirePlatform(user);
|
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
|
||||||
return ApiResponse.success(service.balances(null, query));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
||||||
@@ -61,9 +62,9 @@ public class InventoryQueryController {
|
|||||||
public ApiResponse<List<InventoryPositionView>> positions(
|
public ApiResponse<List<InventoryPositionView>> positions(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable long balanceId,
|
@PathVariable long balanceId,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.PositionQuery query) {
|
@Valid InventoryQueryRequests.PositionQuery query) {
|
||||||
requirePlatform(user);
|
return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query));
|
||||||
return ApiResponse.success(service.positions(null, balanceId, query));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(BASE_PATH + "/ledger")
|
@GetMapping(BASE_PATH + "/ledger")
|
||||||
@@ -71,9 +72,9 @@ public class InventoryQueryController {
|
|||||||
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
@Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||||
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
|
public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.LedgerQuery query) {
|
@Valid InventoryQueryRequests.LedgerQuery query) {
|
||||||
requirePlatform(user);
|
return ApiResponse.success(service.ledgers(platformScope(user, customerId), query));
|
||||||
return ApiResponse.success(service.ledgers(null, query));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
@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)
|
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||||
public ResponseEntity<byte[]> exportBalances(
|
public ResponseEntity<byte[]> exportBalances(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||||
requirePlatform(user);
|
return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv");
|
||||||
return csv(service.exportBalances(null, query), "inventory-balances.csv");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/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)
|
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||||
public ResponseEntity<byte[]> exportLedgers(
|
public ResponseEntity<byte[]> exportLedgers(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.LedgerQuery query) {
|
@Valid InventoryQueryRequests.LedgerQuery query) {
|
||||||
requirePlatform(user);
|
return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv");
|
||||||
return csv(service.exportLedgers(null, query), "inventory-ledger.csv");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
|
private static ResponseEntity<byte[]> csv(byte[] body, String filename) {
|
||||||
@@ -103,12 +104,14 @@ public class InventoryQueryController {
|
|||||||
.body(body);
|
.body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requirePlatform(AuthenticatedUser user) {
|
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
|
||||||
if (user == null || user.tenantId() == null) {
|
if (user == null || user.tenantId() == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required");
|
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}" +
|
@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.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.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>" +
|
"<choose>" +
|
||||||
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) > 0</when>" +
|
"<when test='q.inventoryStatus == \"AVAILABLE\"'> AND (available_packages+allocated_packages+picked_packages) > 0</when>" +
|
||||||
"<when test='q.inventoryStatus == \"HOLD\"'> AND hold_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")
|
"AND shipped_package_count+#{packages} <= package_count")
|
||||||
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
|
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
|
||||||
@Param("packages") long packages, @Param("outboundNo") String outboundNo);
|
@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.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
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.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
|
||||||
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
|
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
|
||||||
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
|
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 '来源订单号',
|
source_order_no VARCHAR(128) COMMENT '来源订单号',
|
||||||
root_order_id VARCHAR(128) COMMENT '根订单 ID',
|
root_order_id VARCHAR(128) COMMENT '根订单 ID',
|
||||||
customer_name VARCHAR(255) COMMENT '货主名称',
|
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 '海外仓名称',
|
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
|
||||||
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
|
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
|
||||||
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
|
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 '租户/客户账号标识',
|
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
|
||||||
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
|
source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划',
|
||||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||||
|
overseas_warehouse_id BIGINT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结',
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
|
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
|
||||||
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
|
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
|
||||||
pallet_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 (
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', 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 '未完成会话平台操作账号全局唯一锁键',
|
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||||
|
|||||||
@@ -470,6 +470,8 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
inboundCode: { type: string }
|
inboundCode: { type: string }
|
||||||
destinationType: { type: string, enum: [FBA, OVERSEAS] }
|
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 }
|
destinationWarehouseId: { type: string, nullable: true }
|
||||||
destinationWarehouseCode: { type: string, nullable: true }
|
destinationWarehouseCode: { type: string, nullable: true }
|
||||||
destinationWarehouseName: { type: string, nullable: true }
|
destinationWarehouseName: { type: string, nullable: true }
|
||||||
@@ -493,9 +495,11 @@ components:
|
|||||||
currentAccount: { type: boolean }
|
currentAccount: { type: boolean }
|
||||||
ReceivingSession:
|
ReceivingSession:
|
||||||
type: object
|
type: object
|
||||||
required: [containerNo, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
|
required: [containerNo, overseasWarehouseId, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
|
||||||
properties:
|
properties:
|
||||||
containerNo: { type: string }
|
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] }
|
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||||
operatorName: { type: string, nullable: true }
|
operatorName: { type: string, nullable: true }
|
||||||
version: { type: integer }
|
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)"));
|
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
|
||||||
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
|
String receivingSession = tableBlock(schema, "ll_receiving_container_session");
|
||||||
assertTrue(receivingSession.contains("sign_photos json"));
|
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"));
|
assertFalse(receivingSession.contains("photo_file_ids json"));
|
||||||
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
|
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
|
||||||
assertTrue(unified.contains("column_name='sign_photos'"));
|
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("concat('container:',upper(trim(container_no)))"));
|
||||||
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
|
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("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
|
@Test
|
||||||
@@ -105,6 +109,8 @@ class ReceivingContainerContractTest {
|
|||||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||||
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||||
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
||||||
|
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
|
||||||
|
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
|
||||||
assertFalse(receivingPaths.contains("photoFileIds"));
|
assertFalse(receivingPaths.contains("photoFileIds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -31,12 +31,12 @@ class ReceivingContainerControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
|
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)));
|
"CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false)));
|
||||||
|
|
||||||
controller.candidates(picker());
|
controller.candidates(picker());
|
||||||
|
|
||||||
verify(service).candidates("42");
|
verify(service).candidates("42", 900L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -54,8 +54,16 @@ class ReceivingContainerControllerTest {
|
|||||||
.hasMessageContaining("403");
|
.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() {
|
private static AuthenticatedUser picker() {
|
||||||
return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
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(lines.selectList(any())).thenReturn(List.of());
|
||||||
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
|
when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||||
service.prepare("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);
|
ArgumentCaptor<ReceivingContainerSession> selected = ArgumentCaptor.forClass(ReceivingContainerSession.class);
|
||||||
verify(sessions).insert(selected.capture());
|
verify(sessions).insert(selected.capture());
|
||||||
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
|
assertThat(selected.getValue().getCustomerId()).isEqualTo("100");
|
||||||
@@ -98,7 +105,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
verify(operations).insert(saved.capture());
|
verify(operations).insert(saved.capture());
|
||||||
when(operations.find("7", "same-key")).thenReturn(saved.getValue());
|
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)
|
new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("409");
|
.hasMessageContaining("409");
|
||||||
}
|
}
|
||||||
@@ -110,20 +117,33 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
|
when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second));
|
||||||
when(lines.selectList(any())).thenReturn(List.of());
|
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");
|
InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200");
|
||||||
org.mockito.Mockito.reset(plans, sessions);
|
org.mockito.Mockito.reset(plans, sessions);
|
||||||
when(plans.selectList(any())).thenReturn(List.of(first, ambiguous));
|
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");
|
.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
|
@Test
|
||||||
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
|
void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() {
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||||
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
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");
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +151,13 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
|
void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() {
|
||||||
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B")));
|
||||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
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);
|
new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||||
|
|
||||||
org.mockito.Mockito.reset(sessions, operations);
|
org.mockito.Mockito.reset(sessions, operations);
|
||||||
when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN"));
|
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);
|
new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class);
|
||||||
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
verify(sessions, never()).insert(any(ReceivingContainerSession.class));
|
||||||
}
|
}
|
||||||
@@ -356,6 +376,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
private static ReceivingContainerSession session(String containerNo, String status) {
|
private static ReceivingContainerSession session(String containerNo, String status) {
|
||||||
ReceivingContainerSession value = new ReceivingContainerSession();
|
ReceivingContainerSession value = new ReceivingContainerSession();
|
||||||
value.setId(8L); value.setCustomerId("100"); value.setContainerNo(containerNo); value.setOperatorKey("7");
|
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);
|
value.setStatus(status); value.setVersion(0);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -378,6 +399,10 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
"AVAILABLE", fbaCode, 0);
|
"AVAILABLE", fbaCode, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WarehouseResourceGateway.WarehouseReference warehouseReference() {
|
||||||
|
return new WarehouseResourceGateway.WarehouseReference(100L, "SHARED-LAX", "Shared LAX", "ACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try {
|
||||||
String json = new ObjectMapper().findAndRegisterModules().copy()
|
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.InventoryLedger;
|
||||||
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
|
import com.baiyue.lastleg.inventory.entity.InventoryOperation;
|
||||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.io.IOException;
|
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
|
@Test
|
||||||
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
|
void incrementalMigrationIsAdditiveAndRerunnable() throws IOException {
|
||||||
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
|
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8)
|
||||||
|
|||||||
+10
-4
@@ -22,19 +22,25 @@ class InventoryQueryControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformBalancePageQueriesAllCustomers() {
|
void platformBalancePageQueriesAllCustomers() {
|
||||||
controller.balances(user(0L, "PLATFORM"), query);
|
controller.balances(user(0L, "PLATFORM"), null, query);
|
||||||
verify(service).balances(eq(null), eq(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
|
@Test
|
||||||
void merchantCannotUsePlatformInventoryController() {
|
void merchantCannotUsePlatformInventoryController() {
|
||||||
assertThrows(ResponseStatusException.class,
|
assertThrows(ResponseStatusException.class,
|
||||||
() -> controller.summary(user(100L, "MERCHANT"), query));
|
() -> controller.summary(user(100L, "MERCHANT"), null, query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformSummaryQueriesAllCustomers() {
|
void platformSummaryQueriesAllCustomers() {
|
||||||
controller.summary(user(0L, "PLATFORM"), query);
|
controller.summary(user(0L, "PLATFORM"), null, query);
|
||||||
verify(service).dashboard(eq(null), eq(query));
|
verify(service).dashboard(eq(null), eq(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ class InventoryQueryControllerTest {
|
|||||||
InventoryQueryRequests.PositionQuery positionQuery =
|
InventoryQueryRequests.PositionQuery positionQuery =
|
||||||
new InventoryQueryRequests.PositionQuery(null, null, null);
|
new InventoryQueryRequests.PositionQuery(null, null, null);
|
||||||
assertThrows(ResponseStatusException.class,
|
assertThrows(ResponseStatusException.class,
|
||||||
() -> controller.positions(null, 1L, positionQuery));
|
() -> controller.positions(null, 1L, null, positionQuery));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthenticatedUser user(long customerId, String appCode) {
|
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 {
|
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;
|
@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())); }
|
@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 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 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 {
|
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ function stableTopologicalOrder(modules) {
|
|||||||
return result
|
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) {
|
export function normalizeModuleOrder(modules) {
|
||||||
const normalized = [...modules]
|
const normalized = [...modules]
|
||||||
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
|
const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking']
|
||||||
@@ -169,6 +182,10 @@ export function normalizeModuleOrder(modules) {
|
|||||||
normalized.splice(overviewIndex, 1)
|
normalized.splice(overviewIndex, 1)
|
||||||
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview')
|
||||||
}
|
}
|
||||||
|
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)
|
return stableTopologicalOrder(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +914,7 @@ export class AcceptanceRun {
|
|||||||
const matrixMarker = this.marker
|
const matrixMarker = this.marker
|
||||||
const variantMarker = `${matrixMarker}-P4-${label}`
|
const variantMarker = `${matrixMarker}-P4-${label}`
|
||||||
const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker']
|
const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker']
|
||||||
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer']
|
const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
|
||||||
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
|
||||||
present: Object.hasOwn(this.context, key), value: this.context[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', () => {
|
test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
'merchant-qualification-fixture',
|
'merchant-qualification-fixture',
|
||||||
|
'orders',
|
||||||
|
'inbound-measurement',
|
||||||
'phase3-container-loading',
|
'phase3-container-loading',
|
||||||
'phase3-loading',
|
'phase3-loading',
|
||||||
'ocean-bookings',
|
'ocean-bookings',
|
||||||
|
'phase3-pda-convergence',
|
||||||
|
'phase3-evidence-gate',
|
||||||
'phase3-loading-finalize',
|
'phase3-loading-finalize',
|
||||||
'phase3-customs',
|
'phase3-customs',
|
||||||
'manual-state-matrix',
|
'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', () => {
|
test('focused splitting and manual groups resolve producers before consumers without duplicate internal runs', () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
'merchant-qualification-fixture',
|
'merchant-qualification-fixture',
|
||||||
|
'orders',
|
||||||
|
'inbound-measurement',
|
||||||
'phase3-container-loading',
|
'phase3-container-loading',
|
||||||
'phase3-loading',
|
'phase3-loading',
|
||||||
'ocean-bookings',
|
'ocean-bookings',
|
||||||
|
'phase3-pda-convergence',
|
||||||
|
'phase3-evidence-gate',
|
||||||
'phase3-loading-finalize',
|
'phase3-loading-finalize',
|
||||||
'phase3-order-splitting',
|
'phase3-order-splitting',
|
||||||
'phase3-customs',
|
'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.match(primary, /^\d{8}-[0-9A-F]{8}$/)
|
||||||
assert.ok(`P3B-${primary}`.length <= 64)
|
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) =>
|
const overflowResource = inventoryInbound?.resources?.find?.((resource) =>
|
||||||
resource?.sharedOverflow === true && id(resource?.occupancyId))
|
resource?.sharedOverflow === true && id(resource?.occupancyId))
|
||||||
if (overflowResource) {
|
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', {
|
const targetLocationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', {
|
||||||
warehouseId, zoneCode: `SNAP-${mutationSuffix.slice(-6)}`, zoneName: '库存快照验收区',
|
warehouseId, zoneCode: `SNAP-${mutationSuffix.slice(-6)}`, zoneName: '库存快照验收区',
|
||||||
locationCode: `SNAP-${mutationSuffix}`, locationName: '库存快照换绑库位',
|
locationCode: `SNAP-${mutationSuffix}`, locationName: '库存快照换绑库位',
|
||||||
@@ -644,6 +644,11 @@ export async function run(context) {
|
|||||||
})
|
})
|
||||||
const targetLocationId = id(context.apiSuccess(MODULE, '创建库存快照变化目标库位',
|
const targetLocationId = id(context.apiSuccess(MODULE, '创建库存快照变化目标库位',
|
||||||
targetLocationResponse, '', OPTIONS))
|
targetLocationResponse, '', OPTIONS))
|
||||||
|
if (!targetLocationId) {
|
||||||
|
context.addStep(MODULE, '库存变化fixture创建失败后停止旧snapshot提交', targetLocationResponse,
|
||||||
|
false, responseEvidence(targetLocationResponse), OPTIONS)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (targetLocationId) {
|
if (targetLocationId) {
|
||||||
inventoryMutationCleanup = {
|
inventoryMutationCleanup = {
|
||||||
cleaned: false, targetLocationId,
|
cleaned: false, targetLocationId,
|
||||||
@@ -664,11 +669,17 @@ export async function run(context) {
|
|||||||
})
|
})
|
||||||
: targetLocationResponse
|
: targetLocationResponse
|
||||||
const rebound = context.apiSuccess(MODULE, '通过公开 A0 换绑接口制造真实库存变化', rebindResponse, '', OPTIONS)
|
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,
|
context.assert(MODULE, '真实库存换绑保持同仓且位置发生变化', rebindResponse,
|
||||||
id(rebound?.occupancyId) === id(overflowResource.occupancyId)
|
reboundValid,
|
||||||
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
|
|
||||||
&& id(rebound?.locationId) === targetLocationId,
|
|
||||||
`occupancyId=${id(rebound?.occupancyId) || 'missing'}, previousLocationId=${id(rebound?.previousLocationId) || 'missing'}, locationId=${id(rebound?.locationId) || 'missing'}`, OPTIONS)
|
`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,
|
const staleInventoryBatch = await request(context, 'POST', batchPath, batchPayload,
|
||||||
{ 'Idempotency-Key': `${context.marker}-bridge-batch-stale-inventory`.slice(0, 128) })
|
{ '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', () => {
|
test('container overview always follows phase3 loading in default and custom module selections', () => {
|
||||||
const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance'])
|
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,
|
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')
|
const entry = source('../admin-test-env-acceptance.mjs')
|
||||||
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/)
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ export async function run(context) {
|
|||||||
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
||||||
|
|
||||||
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
||||||
[...operatorCodes, ...pickerCodes, ...tallyCodes].every((code) => code.startsWith('platform:')),
|
[...operatorCodes, ...pickerCodes, ...tallyCodes]
|
||||||
'all warehouse role permissions must use platform scope', OPTIONS)
|
.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',
|
const unauthorized = await context.request('GET',
|
||||||
'/api/platform/v1/first-leg/pda-loading/tasks?cabinetNo=UNAUTHORIZED&warehouseId=1', undefined, '')
|
'/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,
|
import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES,
|
||||||
manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
|
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 here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
|
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) }
|
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', () => {
|
test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => {
|
||||||
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
|
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
|
||||||
assert.deepEqual(STANDARD_NODES, [
|
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 nullableId(value) { return value == null || validLongId(value) }
|
||||||
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(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 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 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 rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||||
@@ -37,7 +38,7 @@ export function manualNodePayload({ expectedVersion, standardNode, observedAt, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validTask(task) {
|
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)
|
&& validLongId(task?.oceanBookingId) && QUERY_STATUSES.includes(task?.status)
|
||||||
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
|
&& nullableId(task?.containerBookingId) && nullableText(task?.latestActualNode)
|
||||||
&& (task?.latestActualNode == null || STANDARD_NODES.includes(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)
|
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', () => {
|
test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => {
|
||||||
for (const token of [
|
for (const token of [
|
||||||
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered',
|
||||||
|
|||||||
@@ -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, /模式硬拒绝/)
|
assert.match(source, /模式硬拒绝/)
|
||||||
assert.match(source, /current返回404正常空态/)
|
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/)
|
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' },
|
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
|
||||||
], requestPath, method)
|
], requestPath, method)
|
||||||
if (method === 'POST' && requestPath.endsWith('/users')) return response({
|
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)
|
}, requestPath, method)
|
||||||
if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, 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)
|
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) {
|
async requestOverseas(method, requestPath, body, headers, token) {
|
||||||
calls.push({ channel: 'overseas', method, requestPath })
|
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,
|
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||||
return response([], requestPath, method)
|
return response([], requestPath, method)
|
||||||
|
|||||||
@@ -46,32 +46,40 @@ async function loginMerchantForNegativeProbe(context) {
|
|||||||
return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null
|
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 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 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 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 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)))
|
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)
|
`roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS)
|
||||||
if (!assigned) {
|
if (!assigned) {
|
||||||
blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
|
blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
return { role, operate }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPicker(context, index, overseasWarehouseId, access) {
|
||||||
|
const role = access.role
|
||||||
const suffix = markerSuffix(context)
|
const suffix = markerSuffix(context)
|
||||||
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64)
|
||||||
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
|
const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9`
|
||||||
const phone = await context.findAvailablePhone(MODULE)
|
const phone = await context.findAvailablePhone(MODULE)
|
||||||
const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone,
|
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 createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload)
|
||||||
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
|
const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS)
|
||||||
const membershipId = id(created?.membershipId)
|
const membershipId = id(created?.membershipId)
|
||||||
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
|
context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse,
|
||||||
longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)),
|
longId(membershipId) && id(created?.tenantId) === '0'
|
||||||
`membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS)
|
&& 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
|
if (!longId(membershipId)) return null
|
||||||
const login = await context.request('POST', '/api/auth/login', { username, password }, '')
|
const login = await context.request('POST', '/api/auth/login', { username, password }, '')
|
||||||
const token = success(login) ? login.body?.data?.overseasAccessToken : null
|
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) {
|
async function createPallet(context, inbound, index) {
|
||||||
const warehouseId = id(inbound?.destinationWarehouseId)
|
const warehouseId = id(inbound?.overseasWarehouseId)
|
||||||
if (!longId(warehouseId)) return null
|
if (!longId(warehouseId)) return null
|
||||||
const suffix = markerSuffix(context)
|
const suffix = markerSuffix(context)
|
||||||
const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, {
|
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', {
|
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>')),
|
name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('<script>alert(1)</script>')),
|
||||||
}, token)
|
}, 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 = []
|
const photos = []
|
||||||
for (const role of PHOTO_ROLES) {
|
for (const role of PHOTO_ROLES) {
|
||||||
const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', {
|
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)
|
const pallet = await createPallet(context, inbound, index)
|
||||||
if (!pallet) {
|
if (!pallet) {
|
||||||
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`,
|
||||||
`无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource')
|
`无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pallets.set(inbound.inboundCode, pallet)
|
pallets.set(inbound.inboundCode, pallet)
|
||||||
@@ -313,11 +322,21 @@ export async function run(context) {
|
|||||||
context.createdResources.phase4OverseasPda = {
|
context.createdResources.phase4OverseasPda = {
|
||||||
marker: context.marker, containerNo: null, customerId: null,
|
marker: context.marker, containerNo: null, customerId: null,
|
||||||
palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [],
|
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 merchantToken = await loginMerchantForNegativeProbe(context)
|
||||||
const primary = await createPicker(context, 1)
|
const pickerAccess = await resolvePickerAccess(context)
|
||||||
const secondary = primary?.token ? await createPicker(context, 2) : null
|
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) {
|
for (const picker of [primary, secondary]) if (picker?.membershipId) {
|
||||||
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId)
|
||||||
}
|
}
|
||||||
@@ -332,3 +351,22 @@ export async function run(context) {
|
|||||||
await disablePicker(context, primary, 1)
|
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.WarehouseResourceLockRequests;
|
||||||
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
|
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
|
||||||
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
|
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
|
||||||
|
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||||
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
||||||
import com.baiyue.warehouse.service.WarehouseResourceLockService;
|
import com.baiyue.warehouse.service.WarehouseResourceLockService;
|
||||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||||
|
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
@@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class InternalWarehouseResourceLockController {
|
public class InternalWarehouseResourceLockController {
|
||||||
@Resource private WarehouseResourceLockService service;
|
@Resource private WarehouseResourceLockService service;
|
||||||
@Resource private WarehouseResourceService resourceService;
|
@Resource private WarehouseResourceService resourceService;
|
||||||
|
@Resource private WarehouseApplicationService warehouses;
|
||||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -67,4 +71,12 @@ public class InternalWarehouseResourceLockController {
|
|||||||
return ApiResponse.success(resourceService.ensureDefaultVirtualLocation(
|
return ApiResponse.success(resourceService.ensureDefaultVirtualLocation(
|
||||||
request.customerId(), request.warehouseId()));
|
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) {
|
private static void requireTenant(Long customerId) {
|
||||||
if (customerId == null || customerId <= 0) {
|
if (customerId == null || customerId < 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户");
|
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.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.PositiveOrZero;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -96,7 +96,7 @@ public final class WarehouseRequests {
|
|||||||
@Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件")
|
@Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件")
|
||||||
public record QueryRequest(
|
public record QueryRequest(
|
||||||
@Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true)
|
@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")
|
@Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1")
|
||||||
@Min(1) long page,
|
@Min(1) long page,
|
||||||
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
||||||
|
|||||||
+26
@@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||||
|
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
@@ -36,6 +37,7 @@ public class WarehouseApplicationService {
|
|||||||
captureBefore = false
|
captureBefore = false
|
||||||
)
|
)
|
||||||
public Long create(WarehouseRequests.SaveRequest request) {
|
public Long create(WarehouseRequests.SaveRequest request) {
|
||||||
|
validateSharedWarehouse(request);
|
||||||
ensureCodeUnique(request.customerId(), request.warehouseCode(), null);
|
ensureCodeUnique(request.customerId(), request.warehouseCode(), null);
|
||||||
WarehouseEntity warehouse = toEntity(request);
|
WarehouseEntity warehouse = toEntity(request);
|
||||||
repository.insertWarehouse(warehouse);
|
repository.insertWarehouse(warehouse);
|
||||||
@@ -72,6 +74,7 @@ public class WarehouseApplicationService {
|
|||||||
if (request.customerId() == null || customerId != request.customerId()) {
|
if (request.customerId() == null || customerId != request.customerId()) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "租户不匹配");
|
throw new BizException(ResultCode.FORBIDDEN, "租户不匹配");
|
||||||
}
|
}
|
||||||
|
validateSharedWarehouse(request);
|
||||||
WarehouseEntity old = owned(customerId, id);
|
WarehouseEntity old = owned(customerId, id);
|
||||||
ensureCodeUnique(customerId, request.warehouseCode(), id);
|
ensureCodeUnique(customerId, request.warehouseCode(), id);
|
||||||
|
|
||||||
@@ -98,6 +101,29 @@ public class WarehouseApplicationService {
|
|||||||
return new WarehouseDetailResponse(warehouse, addresses, capabilities);
|
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
|
@Transactional
|
||||||
@BusinessAudit(
|
@BusinessAudit(
|
||||||
event = "WAREHOUSE_STATUS_CHANGE",
|
event = "WAREHOUSE_STATUS_CHANGE",
|
||||||
|
|||||||
+16
@@ -61,6 +61,22 @@ class PlatformWarehouseControllerTest {
|
|||||||
assertTrue(operation.description().contains("customerId"));
|
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() {
|
private static AuthenticatedUser platform() {
|
||||||
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -3,6 +3,8 @@ package com.baiyue.warehouse.service;
|
|||||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
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 com.baiyue.warehouse.repository.WarehouseRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -15,6 +17,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -59,6 +62,31 @@ class WarehouseApplicationServiceTest {
|
|||||||
verify(repository).pageAll(query);
|
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() {
|
private static WarehouseRequests.SaveRequest request() {
|
||||||
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
||||||
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
|
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
|
||||||
@@ -67,4 +95,15 @@ class WarehouseApplicationServiceTest {
|
|||||||
"OVERSEAS", "US", "America/Los_Angeles", null, null, null,
|
"OVERSEAS", "US", "America/Los_Angeles", null, null, null,
|
||||||
"ACTIVE", null, address, List.of());
|
"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