feat: gate overseas receiving and harden phase4 workflows
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||||
|
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
|
|||||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||||
import com.baiyue.auth.service.AccountManagementService;
|
import com.baiyue.auth.service.AccountManagementService;
|
||||||
import com.baiyue.auth.service.AuthService;
|
import com.baiyue.auth.service.AuthService;
|
||||||
|
import com.baiyue.auth.service.OverseasSessionValidationService;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
|
|||||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/auth/accounts")
|
@RequestMapping("/internal/auth/accounts")
|
||||||
|
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||||
public class InternalAccountController {
|
public class InternalAccountController {
|
||||||
@Resource
|
@Resource
|
||||||
private AccountManagementService service;
|
private AccountManagementService service;
|
||||||
@Resource
|
@Resource
|
||||||
private AuthService authService;
|
private AuthService authService;
|
||||||
|
@Resource
|
||||||
|
private OverseasSessionValidationService overseasSessionValidationService;
|
||||||
|
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String configuredToken;
|
private String configuredToken;
|
||||||
@@ -64,6 +71,15 @@ public class InternalAccountController {
|
|||||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
@PostMapping("/overseas-session/validate")
|
||||||
|
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
|
||||||
|
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
|
||||||
|
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
|
||||||
|
verify(token);
|
||||||
|
return ApiResponse.success(overseasSessionValidationService.validate(request));
|
||||||
|
}
|
||||||
@PutMapping("/{id}/profile")
|
@PutMapping("/{id}/profile")
|
||||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.auth.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
|
||||||
|
public final class OverseasSessionValidationDtos {
|
||||||
|
private OverseasSessionValidationDtos() { }
|
||||||
|
|
||||||
|
@Schema(name = "OverseasSessionValidationRequest",
|
||||||
|
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
|
||||||
|
public record Request(
|
||||||
|
@NotNull Long userId,
|
||||||
|
@NotNull Integer tokenVersion,
|
||||||
|
Long overseasWarehouseId) { }
|
||||||
|
|
||||||
|
@Schema(name = "OverseasSessionValidationResponse")
|
||||||
|
public record Response(boolean valid, FailureReason failureReason) {
|
||||||
|
public static Response accepted() { return new Response(true, null); }
|
||||||
|
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FailureReason {
|
||||||
|
ACCOUNT_INVALID,
|
||||||
|
TOKEN_VERSION_MISMATCH,
|
||||||
|
WAREHOUSE_UNBOUND,
|
||||||
|
WAREHOUSE_MISMATCH
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
|
||||||
|
@Service
|
||||||
|
public class OverseasSessionValidationService {
|
||||||
|
@Resource private AuthAccountRepository accounts;
|
||||||
|
@Resource private AuthorizationLoader authorizationLoader;
|
||||||
|
|
||||||
|
public Response validate(Request request) {
|
||||||
|
AuthAccount account = accounts.findById(request.userId());
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||||
|
return Response.rejected(FailureReason.ACCOUNT_INVALID);
|
||||||
|
}
|
||||||
|
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
|
||||||
|
if (currentVersion != request.tokenVersion()) {
|
||||||
|
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
|
||||||
|
}
|
||||||
|
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
|
||||||
|
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
|
||||||
|
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
|
||||||
|
}
|
||||||
|
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
|
||||||
|
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
|
||||||
|
}
|
||||||
|
return Response.accepted();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ 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("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
||||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||||
if (authorization.getOverseasWarehouseId() != null) {
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class OverseasSessionValidationControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
|
||||||
|
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
|
||||||
|
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
assertThat(method.getAnnotation(PostMapping.class).value())
|
||||||
|
.containsExactly("/overseas-session/validate");
|
||||||
|
assertThat(method.getAnnotation(Operation.class).description())
|
||||||
|
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
|
||||||
|
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
|
||||||
|
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||||
|
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
|
||||||
|
.doesNotContain("token", "accessToken", "authorization", "password");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ import java.time.Duration;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
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.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -124,12 +127,36 @@ class AuthServiceSmallJwtTest {
|
|||||||
var response = service.refresh("refresh-old");
|
var response = service.refresh("refresh-old");
|
||||||
|
|
||||||
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
||||||
|
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(1800);
|
||||||
|
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
|
||||||
|
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
||||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
verify(tokenSessionStore).consume("refresh-old");
|
||||||
|
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
verify(authorizationLoader).load(account);
|
verify(authorizationLoader).load(account);
|
||||||
verify(overseasTokenService).issue(account, snapshot);
|
verify(overseasTokenService).issue(account, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
|
||||||
|
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
|
||||||
|
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||||
|
.hasMessageContaining("刷新令牌无效或已过期");
|
||||||
|
|
||||||
|
verify(repository, never()).findById(any());
|
||||||
|
verify(jwtEncoder, never()).encode(any());
|
||||||
|
verify(overseasTokenService, never()).issue(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class OverseasSessionValidationServiceTest {
|
||||||
|
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||||
|
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
|
||||||
|
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
|
||||||
|
private AuthAccount account;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
inject("accounts", accounts);
|
||||||
|
inject("authorizationLoader", authorizationLoader);
|
||||||
|
account = new AuthAccount();
|
||||||
|
account.setId(42L);
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setTokenVersion(8);
|
||||||
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
|
||||||
|
assertThat(service.validate(new Request(42L, 7, 900L)))
|
||||||
|
.extracting("valid", "failureReason")
|
||||||
|
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||||
|
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOrChangedWarehouseBindingFailsClosed() {
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
|
||||||
|
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
|
||||||
|
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledAccountFailsClosedBeforeIamLookup() {
|
||||||
|
account.setAccountStatus("DISABLED");
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.ACCOUNT_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||||
|
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void inject(String fieldName, Object value) throws Exception {
|
||||||
|
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(service, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import java.util.Set;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||||
@@ -71,10 +72,61 @@ 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("token_version", 0)
|
||||||
.containsEntry("overseas_warehouse_id", 900L)
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||||
|
account.setTokenVersion(8);
|
||||||
|
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||||
|
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||||
|
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(encoder.encode(any())).thenReturn(first, second);
|
||||||
|
when(first.getTokenValue()).thenReturn("overseas-old-binding");
|
||||||
|
when(second.getTokenValue()).thenReturn("overseas-new-binding");
|
||||||
|
|
||||||
|
var oldToken = service.issue(account, oldBinding);
|
||||||
|
var newToken = service.issue(account, newBinding);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder, times(2)).encode(captor.capture());
|
||||||
|
var oldClaims = captor.getAllValues().get(0).getClaims();
|
||||||
|
var newClaims = captor.getAllValues().get(1).getClaims();
|
||||||
|
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
|
||||||
|
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
|
||||||
|
.containsEntry("permission_version", 8L);
|
||||||
|
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
|
||||||
|
.containsEntry("permission_version", 9L);
|
||||||
|
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
|
||||||
|
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||||
|
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||||
|
|
||||||
|
service.issue(account, unbound);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder).encode(captor.capture());
|
||||||
|
assertThat(captor.getValue().getClaims().getClaims())
|
||||||
|
.doesNotContainKey("overseas_warehouse_id");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||||
public record FirstLegContainerShippingNodeRecordedV1(
|
public record FirstLegContainerShippingNodeRecordedV1(
|
||||||
@@ -20,6 +21,11 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
|||||||
String providerNodeCode,
|
String providerNodeCode,
|
||||||
String sourceReference,
|
String sourceReference,
|
||||||
String rawPayloadHash) {
|
String rawPayloadHash) {
|
||||||
|
private static final Map<String, Integer> STANDARD_NODES = Map.of(
|
||||||
|
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
|
||||||
|
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
|
||||||
|
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
|
||||||
|
"EMPTY_RETURNED", 100);
|
||||||
|
|
||||||
public FirstLegContainerShippingNodeRecordedV1 {
|
public FirstLegContainerShippingNodeRecordedV1 {
|
||||||
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
||||||
@@ -28,7 +34,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
|||||||
|| containerNo == null || containerNo.isBlank()) {
|
|| containerNo == null || containerNo.isBlank()) {
|
||||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||||
}
|
}
|
||||||
if (standardNode == null || standardNode.isBlank() || nodeSequence < 10
|
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||||
|| observedAt == null || actualAt == null) {
|
|| observedAt == null || actualAt == null) {
|
||||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -30,6 +30,12 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
|||||||
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||||
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ baiyue:
|
|||||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
pda-session-validation:
|
||||||
|
auth-url: ${AUTH_SESSION_VALIDATION_URL}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
|
||||||
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
|
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
|
||||||
openapi.enabled: ${SWAGGER_ENABLED:false}
|
openapi.enabled: ${SWAGGER_ENABLED:false}
|
||||||
audit.enabled: true
|
audit.enabled: true
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ services:
|
|||||||
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
||||||
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
|
||||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
||||||
|
AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL}
|
||||||
|
PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s}
|
||||||
|
|
||||||
lastleg-service:
|
lastleg-service:
|
||||||
<<: *overseas-common
|
<<: *overseas-common
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ case "$SERVICE" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
if [ "$SERVICE" = "gateway-overseas" ]; then
|
||||||
|
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
|
||||||
|
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
||||||
if [ ! -f "$file" ]; then
|
if [ ! -f "$file" ]; then
|
||||||
echo "必需文件不存在: $file" >&2
|
echo "必需文件不存在: $file" >&2
|
||||||
|
|||||||
@@ -80,6 +80,15 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case " $SERVICES " in
|
||||||
|
*" gateway-overseas "*)
|
||||||
|
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
|
||||||
|
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
case " $SERVICES " in
|
case " $SERVICES " in
|
||||||
*" finance "*)
|
*" finance "*)
|
||||||
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
|
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
|
||||||
|
|||||||
@@ -16394,6 +16394,81 @@ DELIMITER ;
|
|||||||
CALL baiyue_iam.assert_overseas_pda_picker_permission();
|
CALL baiyue_iam.assert_overseas_pda_picker_permission();
|
||||||
DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission;
|
DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission;
|
||||||
|
|
||||||
|
-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;
|
||||||
|
CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission (
|
||||||
|
role_code VARCHAR(64) NOT NULL,
|
||||||
|
permission_code VARCHAR(160) NOT NULL,
|
||||||
|
PRIMARY KEY (role_code, permission_code)
|
||||||
|
);
|
||||||
|
INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:truck:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list');
|
||||||
|
|
||||||
|
DELETE grant_row FROM baiyue_iam.iam_role_permission grant_row
|
||||||
|
JOIN baiyue_iam.iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
LEFT JOIN tmp_phase4_warehouse_role_permission expected
|
||||||
|
ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code
|
||||||
|
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')
|
||||||
|
AND permission.permission_code LIKE 'platform:overseas-%'
|
||||||
|
AND expected.permission_code IS NULL;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
|
FROM tmp_phase4_warehouse_role_permission expected
|
||||||
|
JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||||
|
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code=expected.permission_code
|
||||||
|
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS baiyue_iam.assert_phase4_warehouse_role_minimum;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum()
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission
|
||||||
|
WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected
|
||||||
|
LEFT JOIN baiyue_iam.iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||||
|
LEFT JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0
|
||||||
|
LEFT JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.tenant_id=0
|
||||||
|
AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id
|
||||||
|
WHERE grant_row.role_id IS NULL) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL baiyue_iam.assert_phase4_warehouse_role_minimum();
|
||||||
|
DROP PROCEDURE baiyue_iam.assert_phase4_warehouse_role_minimum;
|
||||||
|
DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission;
|
||||||
|
|
||||||
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM baiyue_iam.iam_role role
|
FROM baiyue_iam.iam_role role
|
||||||
|
|||||||
@@ -618,6 +618,74 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
|
|||||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
|
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
|
||||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||||
|
|
||||||
|
-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||||
|
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||||
|
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID',
|
||||||
|
container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||||
|
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点',
|
||||||
|
node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||||
|
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间',
|
||||||
|
actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||||
|
location_code VARCHAR(128) NULL COMMENT '节点地点编码',
|
||||||
|
location_name VARCHAR(255) NULL COMMENT '节点地点名称',
|
||||||
|
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码',
|
||||||
|
provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
|
||||||
|
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码',
|
||||||
|
source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
|
||||||
|
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256',
|
||||||
|
aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
|
||||||
|
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本',
|
||||||
|
last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
|
||||||
|
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
PRIMARY KEY(container_booking_id),
|
||||||
|
KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
|
||||||
|
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
||||||
|
|
||||||
|
INSERT IGNORE INTO ll_container_shipping_gate (
|
||||||
|
container_booking_id,customer_id,ocean_booking_id,container_no,status,
|
||||||
|
node_sequence,aggregate_version,handover_version,created_at,updated_at
|
||||||
|
)
|
||||||
|
SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY',
|
||||||
|
0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3)
|
||||||
|
FROM ll_order
|
||||||
|
WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> ''
|
||||||
|
AND container_no IS NOT NULL AND container_no <> ''
|
||||||
|
GROUP BY source_container_booking_id,customer_id,container_no;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
|
||||||
|
id BIGINT NOT NULL COMMENT 'PDA作业历史ID',
|
||||||
|
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||||
|
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识',
|
||||||
|
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
|
||||||
|
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型',
|
||||||
|
business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '作业结果状态',
|
||||||
|
idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
|
||||||
|
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希',
|
||||||
|
summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
|
||||||
|
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
|
||||||
|
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
|
||||||
|
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
|
||||||
|
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
|
||||||
|
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
|
||||||
|
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
|
||||||
|
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||||
|
|
||||||
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
|
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
|
||||||
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
|
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
|
||||||
USE baiyue_lastlag_warehouse;
|
USE baiyue_lastlag_warehouse;
|
||||||
|
|||||||
+5
@@ -106,6 +106,11 @@ public class OutboundDispatchService {
|
|||||||
throw conflict("当前出仓单不在等待装船状态");
|
throw conflict("当前出仓单不在等待装船状态");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
|
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
|
||||||
|
OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId());
|
||||||
|
if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact(
|
||||||
|
binding.tenantId(), ocean.id(), binding.containerBookingId())) {
|
||||||
|
throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓");
|
||||||
|
}
|
||||||
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||||
throw concurrent();
|
throw concurrent();
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -70,6 +70,17 @@ public class CarrierScheduleQueryRepository {
|
|||||||
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
|
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
|
||||||
}
|
}
|
||||||
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
|
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
|
||||||
|
public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) {
|
||||||
|
return nodes.selectOne(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||||
|
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||||
|
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
|
||||||
|
}
|
||||||
|
public boolean hasActualNode(long queryId, String standardNode) {
|
||||||
|
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||||
|
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
|
||||||
|
.eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode)
|
||||||
|
.isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0;
|
||||||
|
}
|
||||||
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
|
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
|
||||||
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
||||||
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
|
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
|
||||||
@@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository {
|
|||||||
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
|
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
|
||||||
return exceptions.insertIfAbsent(value) == 1;
|
return exceptions.insertIfAbsent(value) == 1;
|
||||||
}
|
}
|
||||||
|
public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) {
|
||||||
|
return exceptions.selectOne(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
|
||||||
|
.eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId)
|
||||||
|
.eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey));
|
||||||
|
}
|
||||||
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
|
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
|
||||||
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
|
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-16
@@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var result = response.data();
|
var result = response.data();
|
||||||
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
|
if ("SUCCESS".equals(result.outcome())) {
|
||||||
? List.of() : result.observations();
|
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
|
||||||
for (var observation : observations) {
|
? List.of() : result.observations();
|
||||||
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
|
for (var observation : observations) {
|
||||||
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
|
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
|
||||||
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
|
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
|
||||||
observation.actualAt(), observation.locationCode(), observation.locationName(),
|
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
|
||||||
observation.sourceReference(), observation.rawPayloadHash()));
|
observation.actualAt(), observation.locationCode(), observation.locationName(),
|
||||||
}
|
observation.sourceReference(), observation.rawPayloadHash()));
|
||||||
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
|
}
|
||||||
? List.of() : result.exceptions();
|
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
|
||||||
for (var exception : exceptions) {
|
? List.of() : result.exceptions();
|
||||||
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
|
for (var exception : exceptions) {
|
||||||
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
|
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
|
||||||
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
|
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
|
||||||
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
|
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
|
||||||
|
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
|
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
|
||||||
result.errorMessage());
|
result.errorMessage());
|
||||||
|
|||||||
+41
-8
@@ -59,6 +59,7 @@ public class CarrierScheduleQueryService {
|
|||||||
@Resource private CarrierScheduleQueryRepository repository;
|
@Resource private CarrierScheduleQueryRepository repository;
|
||||||
@Resource private PortScheduleRepository portSchedules;
|
@Resource private PortScheduleRepository portSchedules;
|
||||||
@Resource private CarrierScheduleAdapterRegistry adapters;
|
@Resource private CarrierScheduleAdapterRegistry adapters;
|
||||||
|
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
|
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
|
||||||
private String scheduleZone = "Asia/Shanghai";
|
private String scheduleZone = "Asia/Shanghai";
|
||||||
@@ -127,9 +128,10 @@ public class CarrierScheduleQueryService {
|
|||||||
if (concurrent != null) return concurrent;
|
if (concurrent != null) return concurrent;
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||||
|
if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||||
operatorId, operatorName);
|
operatorId, operatorName);
|
||||||
return value;
|
return value;
|
||||||
@@ -140,14 +142,17 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||||
CarrierScheduleQueryEntity before = copy(value);
|
CarrierScheduleQueryEntity before = copy(value);
|
||||||
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
|
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
|
||||||
|
request.providerNodeCode(), node,
|
||||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||||
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
|
request.locationName(), request.sourceReference(), request.rawPayloadHash());
|
||||||
|
if (fact == null) {
|
||||||
return detail(value);
|
return detail(value);
|
||||||
}
|
}
|
||||||
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
||||||
0L, "carrier-scheduler");
|
0L, "carrier-scheduler");
|
||||||
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
|
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
|
||||||
|
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
|
||||||
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
|
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
|
||||||
after, 0L, "carrier-scheduler");
|
after, 0L, "carrier-scheduler");
|
||||||
return detail(after);
|
return detail(after);
|
||||||
@@ -180,9 +185,11 @@ public class CarrierScheduleQueryService {
|
|||||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||||
CarrierScheduleQueryEntity before = copy(value);
|
CarrierScheduleQueryEntity before = copy(value);
|
||||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||||
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
|
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||||
|
request.observedAt(),
|
||||||
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
|
||||||
"MANUAL:" + operatorId, hash(operation, id, request))) {
|
"MANUAL:" + operatorId, hash(operation, id, request));
|
||||||
|
if (fact == null) {
|
||||||
return complete(id, operatorId, key, detail(value));
|
return complete(id, operatorId, key, detail(value));
|
||||||
}
|
}
|
||||||
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
||||||
@@ -191,6 +198,7 @@ public class CarrierScheduleQueryService {
|
|||||||
throw concurrent();
|
throw concurrent();
|
||||||
}
|
}
|
||||||
CarrierScheduleQueryEntity after = required(id);
|
CarrierScheduleQueryEntity after = required(id);
|
||||||
|
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
|
||||||
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
|
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
|
||||||
operatorId, operatorName);
|
operatorId, operatorName);
|
||||||
return complete(id, operatorId, key, detail(after));
|
return complete(id, operatorId, key, detail(after));
|
||||||
@@ -221,6 +229,12 @@ public class CarrierScheduleQueryService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
|
||||||
|
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||||
|
containerBookingId);
|
||||||
|
return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) {
|
public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) {
|
||||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||||
@@ -426,7 +440,8 @@ public class CarrierScheduleQueryService {
|
|||||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
|
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
|
||||||
|
String providerNodeCode,
|
||||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||||
LocalDateTime actualAt, String locationCode, String locationName,
|
LocalDateTime actualAt, String locationCode, String locationName,
|
||||||
String sourceReference, String rawPayloadHash) {
|
String sourceReference, String rawPayloadHash) {
|
||||||
@@ -439,7 +454,12 @@ public class CarrierScheduleQueryService {
|
|||||||
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
||||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||||
return repository.insertIfAbsent(fact);
|
if (repository.insertIfAbsent(fact)) return fact;
|
||||||
|
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
|
||||||
|
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||||
|
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean addException(long queryId, String providerCode, String providerEventId,
|
private boolean addException(long queryId, String providerCode, String providerEventId,
|
||||||
@@ -454,7 +474,20 @@ public class CarrierScheduleQueryService {
|
|||||||
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
|
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
|
||||||
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||||
return repository.insertIfAbsent(fact);
|
if (repository.insertIfAbsent(fact)) return true;
|
||||||
|
CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey());
|
||||||
|
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
|
||||||
|
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requireContainerNo(long containerBookingId) {
|
||||||
|
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||||
|
if (container == null || !StringUtils.hasText(container.getContainerNo())) {
|
||||||
|
throw conflict("跨区船节点交接要求柜次具备柜号");
|
||||||
|
}
|
||||||
|
return container.getContainerNo().trim().toUpperCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
|
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ paths:
|
|||||||
'200': {description: Business envelope; gate or version failures use business error code}
|
'200': {description: Business envelope; gate or version failures use business error code}
|
||||||
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
|
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
|
||||||
post:
|
post:
|
||||||
summary: Reverse WAITING_FOR_LOADING to LOADED
|
summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts
|
||||||
|
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking.
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||||
requestBody:
|
requestBody:
|
||||||
|
|||||||
+19
@@ -102,6 +102,25 @@ class OutboundDispatchServiceTest {
|
|||||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
|
||||||
|
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||||
|
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
|
||||||
|
stubReadyChain();
|
||||||
|
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||||
|
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||||
|
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
|
||||||
|
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||||
|
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
|
||||||
|
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
|
||||||
|
new ActionRequest(11L, 22L, 1, "误操作")))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||||
|
|
||||||
|
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
||||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||||
|
|||||||
+1
@@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest {
|
|||||||
.contains("outbound-dispatches/check-gate:")
|
.contains("outbound-dispatches/check-gate:")
|
||||||
.contains("outbound-dispatches/confirm-dispatch:")
|
.contains("outbound-dispatches/confirm-dispatch:")
|
||||||
.contains("outbound-dispatches/reverse-dispatch:")
|
.contains("outbound-dispatches/reverse-dispatch:")
|
||||||
|
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
|
||||||
.contains("expectedVersion")
|
.contains("expectedVersion")
|
||||||
.contains("Idempotency-Key");
|
.contains("Idempotency-Key");
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -103,6 +103,20 @@ class CarrierScheduleQuerySqlContractTest {
|
|||||||
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
|
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception {
|
||||||
|
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
assertThat(unified)
|
||||||
|
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'")
|
||||||
|
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'")
|
||||||
|
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'")
|
||||||
|
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'")
|
||||||
|
.contains("uk_inbound_outbox_event_key")
|
||||||
|
.contains("MODIFY COLUMN payload_hash VARCHAR(71)")
|
||||||
|
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
private static String moduleSql() throws Exception {
|
private static String moduleSql() throws Exception {
|
||||||
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
|||||||
+4
-2
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
|||||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -21,7 +22,8 @@ import static org.mockito.Mockito.verify;
|
|||||||
|
|
||||||
class CarrierScheduleNodeOutboxServiceTest {
|
class CarrierScheduleNodeOutboxServiceTest {
|
||||||
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
|
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules()
|
||||||
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
|
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -56,7 +58,7 @@ class CarrierScheduleNodeOutboxServiceTest {
|
|||||||
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
|
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
|
||||||
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
|
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
|
||||||
service.publish(query(), fact, "ZCSU7881970");
|
service.publish(query(), fact, "ZCSU7881970");
|
||||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any());
|
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CarrierScheduleQueryEntity query() {
|
private static CarrierScheduleQueryEntity query() {
|
||||||
|
|||||||
+39
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy;
|
|||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
class CarrierScheduleQuerySchedulerTest {
|
class CarrierScheduleQuerySchedulerTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest {
|
|||||||
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
|
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leaseLoserNeverCallsProviderOrWritesAttempt() {
|
||||||
|
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
|
||||||
|
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
|
||||||
|
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
|
||||||
|
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
|
||||||
|
CarrierScheduleQueryEntity task = task(1L);
|
||||||
|
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false);
|
||||||
|
|
||||||
|
scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0));
|
||||||
|
|
||||||
|
verify(integration, never()).query(any(), any());
|
||||||
|
verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() {
|
||||||
|
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
|
||||||
|
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
|
||||||
|
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
|
||||||
|
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
|
||||||
|
CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM");
|
||||||
|
task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970");
|
||||||
|
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0);
|
||||||
|
var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY",
|
||||||
|
now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64));
|
||||||
|
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
|
||||||
|
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
|
||||||
|
new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(),
|
||||||
|
"HTTP_503", "unavailable", now)));
|
||||||
|
|
||||||
|
scheduler.execute(task, now);
|
||||||
|
|
||||||
|
verify(service, never()).applyProviderObservation(anyLong(), any());
|
||||||
|
verify(service, never()).applyProviderException(anyLong(), any());
|
||||||
|
verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
private static CarrierScheduleQueryEntity task(long id) {
|
private static CarrierScheduleQueryEntity task(long id) {
|
||||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
|
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc
|
|||||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||||
@@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
|
||||||
class CarrierScheduleQueryServiceTest {
|
class CarrierScheduleQueryServiceTest {
|
||||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
|
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
|
||||||
@Mock private CarrierScheduleQueryRepository repository;
|
@Mock private CarrierScheduleQueryRepository repository;
|
||||||
@Mock private PortScheduleRepository portSchedules;
|
@Mock private PortScheduleRepository portSchedules;
|
||||||
|
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||||
@InjectMocks private CarrierScheduleQueryService service;
|
@InjectMocks private CarrierScheduleQueryService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
when(repository.nodes(anyLong())).thenReturn(List.of());
|
when(repository.nodes(anyLong())).thenReturn(List.of());
|
||||||
when(repository.exceptions(anyLong())).thenReturn(List.of());
|
when(repository.exceptions(anyLong())).thenReturn(List.of());
|
||||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||||
|
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||||
|
container.setContainerNo("ZCSU7881970");
|
||||||
|
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
assertThat(task.getNextAllowedAt()).isNull();
|
assertThat(task.getNextAllowedAt()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() {
|
||||||
|
CarrierScheduleQueryEntity task = task(0, 4);
|
||||||
|
task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED");
|
||||||
|
task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜");
|
||||||
|
when(repository.lock(1L)).thenReturn(task);
|
||||||
|
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
|
||||||
|
|
||||||
|
service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge",
|
||||||
|
"DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2),
|
||||||
|
"USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64)));
|
||||||
|
|
||||||
|
assertThat(task.getStatus()).isEqualTo("STOPPED");
|
||||||
|
assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED");
|
||||||
|
assertThat(task.getNextAllowedAt()).isNull();
|
||||||
|
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||||
|
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
|
||||||
CarrierScheduleQueryEntity task = task(0, 3);
|
CarrierScheduleQueryEntity task = task(0, 3);
|
||||||
@@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
assertThat(task.getLatestStandardNode()).isNull();
|
assertThat(task.getLatestStandardNode()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() {
|
||||||
|
CarrierScheduleQueryEntity task = task(0, 1);
|
||||||
|
task.setLatestStandardNode("IN_TRANSIT");
|
||||||
|
when(repository.lock(1L)).thenReturn(task);
|
||||||
|
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
|
||||||
|
|
||||||
|
for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) {
|
||||||
|
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON",
|
||||||
|
code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64)));
|
||||||
|
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF",
|
||||||
|
code + " RELEASED", code, false, NOW.plusMinutes(1), null, null,
|
||||||
|
"provider:" + code + ":released", "b".repeat(64)));
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class));
|
||||||
|
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||||
|
assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void providerEventIdReplayRequiresTheSameRawPayloadHash() {
|
||||||
|
CarrierScheduleQueryEntity task = task(0, 1);
|
||||||
|
task.setLatestStandardNode("IN_TRANSIT");
|
||||||
|
CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity();
|
||||||
|
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
|
||||||
|
when(repository.lock(1L)).thenReturn(task);
|
||||||
|
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
|
||||||
|
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
|
||||||
|
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||||
|
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
|
||||||
|
|
||||||
|
service.applyProviderObservation(1L, replay);
|
||||||
|
|
||||||
|
verify(nodeOutbox, never()).publish(any(), any(), anyString());
|
||||||
|
ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
|
||||||
|
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64));
|
||||||
|
assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everySchedulerCompletionWritesImmutableAttempt() {
|
void everySchedulerCompletionWritesImmutableAttempt() {
|
||||||
CarrierScheduleQueryEntity task = task(0, 0);
|
CarrierScheduleQueryEntity task = task(0, 0);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
|
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
|
||||||
| Audience 校验 | `config/AudienceJwtValidator.java` |
|
| Audience 校验 | `config/AudienceJwtValidator.java` |
|
||||||
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
|
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
|
||||||
|
| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed |
|
||||||
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
||||||
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
|
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
|
||||||
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import java.util.Map;
|
|||||||
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(GatewaySecurityProperties.class)
|
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class})
|
||||||
public class GatewaySecurityConfig {
|
public class GatewaySecurityConfig {
|
||||||
@Bean
|
@Bean
|
||||||
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
||||||
@@ -63,7 +63,7 @@ public class GatewaySecurityConfig {
|
|||||||
return decoder;
|
return decoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
|
public static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
|
||||||
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
||||||
exchange.getResponse().setStatusCode(status);
|
exchange.getResponse().setStatusCode(status);
|
||||||
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */
|
||||||
|
@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation")
|
||||||
|
public record PdaSessionValidationProperties(String authUrl,
|
||||||
|
String internalServiceToken,
|
||||||
|
Duration timeout) {
|
||||||
|
public PdaSessionValidationProperties {
|
||||||
|
if (authUrl == null || authUrl.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL");
|
||||||
|
}
|
||||||
|
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||||
|
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||||
|
}
|
||||||
|
timeout = timeout == null ? Duration.ofSeconds(2) : timeout;
|
||||||
|
if (timeout.isZero() || timeout.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0");
|
||||||
|
}
|
||||||
|
authUrl = authUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -12,7 +12,7 @@ import java.util.Locale;
|
|||||||
/** Validates the identity and authorization claims required by overseas APIs. */
|
/** Validates the identity and authorization claims required by overseas APIs. */
|
||||||
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||||
private static final List<String> REQUIRED_CLAIMS = List.of(
|
private static final List<String> REQUIRED_CLAIMS = List.of(
|
||||||
"sub", "tenant_id", "app_code", "permission_version", "authorities");
|
"sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||||
@@ -26,6 +26,7 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
|||||||
Long subject = longValue(jwt.getSubject());
|
Long subject = longValue(jwt.getSubject());
|
||||||
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
|
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
|
||||||
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
|
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
|
||||||
|
Long tokenVersion = longValue(jwt.getClaims().get("token_version"));
|
||||||
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
|
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
|
||||||
if (subject == null || subject <= 0) {
|
if (subject == null || subject <= 0) {
|
||||||
return failure("JWT sub 必须是正整数用户 ID");
|
return failure("JWT sub 必须是正整数用户 ID");
|
||||||
@@ -36,6 +37,9 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
|||||||
if (permissionVersion == null || permissionVersion < 0) {
|
if (permissionVersion == null || permissionVersion < 0) {
|
||||||
return failure("JWT permission_version 必须是非负整数");
|
return failure("JWT permission_version 必须是非负整数");
|
||||||
}
|
}
|
||||||
|
if (tokenVersion == null || tokenVersion < 0) {
|
||||||
|
return failure("JWT token_version 必须是非负整数");
|
||||||
|
}
|
||||||
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
|
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
|
||||||
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
|
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
|
||||||
return failure("JWT app_code 与 tenant_id 访问域不一致");
|
return failure("JWT app_code 与 tenant_id 访问域不一致");
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import com.baiyue.gateway.config.PdaSessionValidationProperties;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */
|
||||||
|
@Component
|
||||||
|
public class HttpPdaSessionValidationClient implements PdaSessionValidationClient {
|
||||||
|
private static final String PATH = "/internal/auth/accounts/overseas-session/validate";
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final PdaSessionValidationProperties properties;
|
||||||
|
|
||||||
|
public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) {
|
||||||
|
this.webClient = builder.build();
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId) {
|
||||||
|
return webClient.post()
|
||||||
|
.uri(properties.authUrl() + PATH)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-Internal-Service-Token", properties.internalServiceToken())
|
||||||
|
.bodyValue(new Request(userId, tokenVersion, overseasWarehouseId))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(JsonNode.class)
|
||||||
|
.timeout(properties.timeout())
|
||||||
|
.flatMap(this::decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Result> decode(JsonNode envelope) {
|
||||||
|
JsonNode data = envelope == null ? null : envelope.path("data");
|
||||||
|
if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) {
|
||||||
|
return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid"));
|
||||||
|
}
|
||||||
|
boolean valid = data.path("valid").asBoolean(false);
|
||||||
|
String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null);
|
||||||
|
return Mono.just(new Result(valid, reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Request(long userId, int tokenVersion, long overseasWarehouseId) { }
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */
|
||||||
|
public interface PdaSessionValidationClient {
|
||||||
|
Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId);
|
||||||
|
|
||||||
|
record Result(boolean valid, String failureReason) { }
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import com.baiyue.gateway.config.GatewaySecurityConfig;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||||
|
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */
|
||||||
|
@Component
|
||||||
|
public class PdaSessionValidationFilter implements GlobalFilter, Ordered {
|
||||||
|
private static final String PDA_PREFIX = "/api/pda/";
|
||||||
|
private final PdaSessionValidationClient client;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) {
|
||||||
|
this.client = client;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||||
|
if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) {
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
return exchange.getPrincipal().cast(Authentication.class)
|
||||||
|
.filter(Authentication::isAuthenticated)
|
||||||
|
.ofType(JwtAuthenticationToken.class)
|
||||||
|
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效")))
|
||||||
|
.flatMap(authentication -> validate(exchange, chain, authentication))
|
||||||
|
.onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage()))
|
||||||
|
.onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||||
|
JwtAuthenticationToken authentication) {
|
||||||
|
Long userId = longValue(authentication.getToken().getSubject());
|
||||||
|
Integer tokenVersion = intClaim(authentication, "token_version");
|
||||||
|
Long warehouseId = longClaim(authentication, "overseas_warehouse_id");
|
||||||
|
if (userId == null || tokenVersion == null) {
|
||||||
|
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失"));
|
||||||
|
}
|
||||||
|
if (warehouseId == null) {
|
||||||
|
return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库"));
|
||||||
|
}
|
||||||
|
return client.validate(userId, tokenVersion, warehouseId)
|
||||||
|
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用")))
|
||||||
|
.flatMap(result -> {
|
||||||
|
if (result.valid()) return chain.filter(exchange);
|
||||||
|
if ("WAREHOUSE_UNBOUND".equals(result.failureReason())
|
||||||
|
|| "WAREHOUSE_MISMATCH".equals(result.failureReason())) {
|
||||||
|
return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化"));
|
||||||
|
}
|
||||||
|
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
|
||||||
|
HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
|
||||||
|
return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intClaim(JwtAuthenticationToken authentication, String name) {
|
||||||
|
Object value = authentication.getToken().getClaims().get(name);
|
||||||
|
if (value instanceof Number number) return number.intValue();
|
||||||
|
try { return value == null ? null : Integer.valueOf(value.toString()); }
|
||||||
|
catch (NumberFormatException ignored) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long longClaim(JwtAuthenticationToken authentication, String name) {
|
||||||
|
return longValue(authentication.getToken().getClaims().get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long longValue(Object value) {
|
||||||
|
if (value instanceof Number number) return number.longValue();
|
||||||
|
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||||
|
catch (NumberFormatException ignored) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int getOrder() { return -90; }
|
||||||
|
|
||||||
|
private static final class InvalidPdaSession extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private InvalidPdaSession(String code, String message) { super(message); this.code = code; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ baiyue:
|
|||||||
issuer: ${JWT_ISSUER:https://auth.example.com}
|
issuer: ${JWT_ISSUER:https://auth.example.com}
|
||||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
||||||
|
pda-session-validation:
|
||||||
|
auth-url: ${AUTH_SESSION_VALIDATION_URL}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
|
||||||
permit-paths:
|
permit-paths:
|
||||||
- /actuator/health
|
- /actuator/health
|
||||||
- /swagger-ui/**
|
- /swagger-ui/**
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PdaSessionValidationDeploymentContractTest {
|
||||||
|
@Test
|
||||||
|
void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception {
|
||||||
|
String application = read("gateway-overseas/src/main/resources/application.yml");
|
||||||
|
String nacos = read("config/nacos/overseas/baiyue-common.yml");
|
||||||
|
String compose = read("deploy/docker-compose.overseas.yml");
|
||||||
|
String deployServices = read("deploy/scripts/deploy-services.sh");
|
||||||
|
String deployBuiltService = read("deploy/scripts/deploy-built-service.sh");
|
||||||
|
String jenkins = read("Jenkinsfile");
|
||||||
|
|
||||||
|
for (String source : new String[]{application, nacos, compose}) {
|
||||||
|
assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN",
|
||||||
|
"PDA_SESSION_VALIDATION_TIMEOUT");
|
||||||
|
}
|
||||||
|
assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||||
|
assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||||
|
assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'",
|
||||||
|
"'iam': 'SERVICE_IAM'");
|
||||||
|
assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate");
|
||||||
|
assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml"))
|
||||||
|
.doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate");
|
||||||
|
assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java"))
|
||||||
|
.contains("new Request(userId, tokenVersion, overseasWarehouseId)",
|
||||||
|
".header(\"X-Internal-Service-Token\"")
|
||||||
|
.doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String read(String relative) throws Exception {
|
||||||
|
Path path = Path.of(relative);
|
||||||
|
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||||
|
return Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-1
@@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest {
|
|||||||
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingAccountTokenVersion() {
|
||||||
|
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||||
|
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||||
|
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
|
||||||
|
.claim("permission_version", 3L).claim("authorities", List.of())
|
||||||
|
.build();
|
||||||
|
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsMissingAuthorities() {
|
void rejectsMissingAuthorities() {
|
||||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L)
|
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
|
||||||
|
.claim("permission_version", 3L)
|
||||||
.build();
|
.build();
|
||||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||||
}
|
}
|
||||||
@@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest {
|
|||||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
|
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
|
||||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
|
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
|
||||||
|
.claim("token_version", 1)
|
||||||
.claim("authorities", List.of()).build();
|
.claim("authorities", List.of()).build();
|
||||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||||
}
|
}
|
||||||
@@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest {
|
|||||||
.expiresAt(Instant.now().plusSeconds(300))
|
.expiresAt(Instant.now().plusSeconds(300))
|
||||||
.claim("tenant_id", tenantId)
|
.claim("tenant_id", tenantId)
|
||||||
.claim("app_code", appCode)
|
.claim("app_code", appCode)
|
||||||
|
.claim("token_version", 1)
|
||||||
.claim("authorities", authorities);
|
.claim("authorities", authorities);
|
||||||
if (includePermissionVersion) {
|
if (includePermissionVersion) {
|
||||||
builder.claim("permission_version", 3L);
|
builder.claim("permission_version", 3L);
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||||
|
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PdaSessionValidationFilterTest {
|
||||||
|
private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class);
|
||||||
|
private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldTokenIsRejectedImmediatelyAfterWarehouseChange() {
|
||||||
|
when(client.validate(42L, 7, 900L)).thenReturn(Mono.just(
|
||||||
|
new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH")));
|
||||||
|
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L));
|
||||||
|
|
||||||
|
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
assertThat(body(exchange)).contains("A0401", "令牌已失效");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||||
|
when(client.validate(42L, 8, 901L)).thenReturn(Mono.just(
|
||||||
|
new PdaSessionValidationClient.Result(true, null)));
|
||||||
|
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||||
|
AtomicBoolean routed = new AtomicBoolean();
|
||||||
|
|
||||||
|
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||||
|
routed.set(true);
|
||||||
|
return Mono.empty();
|
||||||
|
})).verifyComplete();
|
||||||
|
|
||||||
|
assertThat(routed).isTrue();
|
||||||
|
assertThat(exchange.getResponse().getStatusCode()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unboundWarehouseIsDeniedWithoutCallingAuth() {
|
||||||
|
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null));
|
||||||
|
|
||||||
|
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
assertThat(body(exchange)).contains("A0403", "未绑定仓库");
|
||||||
|
verifyNoInteractions(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authUnavailableFailsClosed() {
|
||||||
|
when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout")));
|
||||||
|
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||||
|
|
||||||
|
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||||
|
|
||||||
|
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||||
|
assertThat(body(exchange)).contains("A0401", "校验不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() {
|
||||||
|
for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) {
|
||||||
|
var exchange = exchange(path, claims(7, 900L));
|
||||||
|
AtomicBoolean routed = new AtomicBoolean();
|
||||||
|
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||||
|
routed.set(true);
|
||||||
|
return Mono.empty();
|
||||||
|
})).verifyComplete();
|
||||||
|
assertThat(routed).isTrue();
|
||||||
|
}
|
||||||
|
verifyNoInteractions(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerWebExchange exchange(String path, Map<String, Object> claims) {
|
||||||
|
Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server")
|
||||||
|
.header("alg", "RS256")
|
||||||
|
.subject("42")
|
||||||
|
.issuedAt(Instant.now().minusSeconds(5))
|
||||||
|
.expiresAt(Instant.now().plusSeconds(300));
|
||||||
|
claims.forEach(jwt::claim);
|
||||||
|
MockServerWebExchange exchange = MockServerWebExchange.from(
|
||||||
|
MockServerHttpRequest.get(path).build());
|
||||||
|
return exchange.mutate()
|
||||||
|
.principal(Mono.just(new JwtAuthenticationToken(jwt.build(),
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_WAREHOUSE_PICKER")))))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> claims(int tokenVersion, Long warehouseId) {
|
||||||
|
var claims = new java.util.LinkedHashMap<String, Object>();
|
||||||
|
claims.put("token_version", tokenVersion);
|
||||||
|
if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId);
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String body(ServerWebExchange exchange) {
|
||||||
|
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
|
||||||
|
.getBodyAsString().block();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,81 @@ DELIMITER ;
|
|||||||
CALL assert_overseas_pda_picker_permission();
|
CALL assert_overseas_pda_picker_permission();
|
||||||
DROP PROCEDURE assert_overseas_pda_picker_permission;
|
DROP PROCEDURE assert_overseas_pda_picker_permission;
|
||||||
|
|
||||||
|
-- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;
|
||||||
|
CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission (
|
||||||
|
role_code VARCHAR(64) NOT NULL,
|
||||||
|
permission_code VARCHAR(160) NOT NULL,
|
||||||
|
PRIMARY KEY (role_code, permission_code)
|
||||||
|
);
|
||||||
|
INSERT INTO tmp_phase4_warehouse_role_permission (role_code, permission_code) VALUES
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:last-mile-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inbound-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:relabel-order:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:inventory:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:warehouse:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:location:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:pallet:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:purchase:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:truck:list'),
|
||||||
|
('WAREHOUSE_OPERATOR','platform:overseas-business:looseboard:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:last-mile-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:inbound-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:relabel-order:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:inventory:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:warehouse:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:location:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-warehouse:pallet:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:purchase:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:truck:list'),
|
||||||
|
('WAREHOUSE_TALLY','platform:overseas-business:looseboard:list');
|
||||||
|
|
||||||
|
DELETE grant_row FROM iam_role_permission grant_row
|
||||||
|
JOIN iam_role role ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||||
|
JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||||
|
LEFT JOIN tmp_phase4_warehouse_role_permission expected
|
||||||
|
ON expected.role_code=role.role_code AND expected.permission_code=permission.permission_code
|
||||||
|
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')
|
||||||
|
AND permission.permission_code LIKE 'platform:overseas-%'
|
||||||
|
AND expected.permission_code IS NULL;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
|
FROM tmp_phase4_warehouse_role_permission expected
|
||||||
|
JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||||
|
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code=expected.permission_code
|
||||||
|
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS assert_phase4_warehouse_role_minimum;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE assert_phase4_warehouse_role_minimum()
|
||||||
|
BEGIN
|
||||||
|
IF (SELECT COUNT(*) FROM tmp_phase4_warehouse_role_permission) <> 20 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限矩阵数量异常';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission
|
||||||
|
WHERE permission_code REGEXP ':(create|edit|status|export|adjust|delete|review)$') THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库只读角色错误获得写权限';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM tmp_phase4_warehouse_role_permission expected
|
||||||
|
LEFT JOIN iam_role role ON role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||||
|
AND role.role_code=expected.role_code AND role.status='ACTIVE' AND role.deleted=0
|
||||||
|
LEFT JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||||
|
AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0
|
||||||
|
LEFT JOIN iam_role_permission grant_row ON grant_row.tenant_id=0
|
||||||
|
AND grant_row.role_id=role.id AND grant_row.permission_id=permission.id
|
||||||
|
WHERE grant_row.role_id IS NULL) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='四期仓库角色最小权限授权不完整';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL assert_phase4_warehouse_role_minimum();
|
||||||
|
DROP PROCEDURE assert_phase4_warehouse_role_minimum;
|
||||||
|
DROP TEMPORARY TABLE tmp_phase4_warehouse_role_permission;
|
||||||
|
|
||||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||||
SELECT 0, role.id, permission.id, NOW(3)
|
SELECT 0, role.id, permission.id, NOW(3)
|
||||||
FROM iam_role role
|
FROM iam_role role
|
||||||
|
|||||||
+37
@@ -60,6 +60,35 @@ class OverseasPhase4PermissionSqlContractTest {
|
|||||||
assertCustomAdminGrant(sql);
|
assertCustomAdminGrant(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void warehouseRolesReceiveOnlyTheTenPhase4ListPermissionsAndPickerRemainsPdaOnly() throws Exception {
|
||||||
|
String moduleSql = new ClassPathResource("db/lastleg-permissions.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||||
|
if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||||
|
String unifiedSql = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
for (String sql : List.of(moduleSql, unifiedSql)) {
|
||||||
|
String matrix = matrixBlock(sql);
|
||||||
|
assertThat(matrix).contains("WAREHOUSE_OPERATOR", "WAREHOUSE_TALLY");
|
||||||
|
assertThat(codes(matrix)).containsExactlyInAnyOrder(
|
||||||
|
"platform:overseas-warehouse:last-mile-order:list",
|
||||||
|
"platform:overseas-warehouse:inbound-order:list",
|
||||||
|
"platform:overseas-warehouse:relabel-order:list",
|
||||||
|
"platform:overseas-warehouse:inventory:list",
|
||||||
|
"platform:overseas-warehouse:warehouse:list",
|
||||||
|
"platform:overseas-warehouse:location:list",
|
||||||
|
"platform:overseas-warehouse:pallet:list",
|
||||||
|
"platform:overseas-business:purchase:list",
|
||||||
|
"platform:overseas-business:truck:list",
|
||||||
|
"platform:overseas-business:looseboard:list");
|
||||||
|
assertThat(matrix).doesNotContain("WAREHOUSE_PICKER");
|
||||||
|
assertThat(matrix).doesNotContain(":create'", ":edit'", ":status'", ":export'", ":adjust'");
|
||||||
|
assertThat(sql).contains("assert_phase4_warehouse_role_minimum", "<> 20",
|
||||||
|
"role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_PICKER','WAREHOUSE_TALLY')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception {
|
void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception {
|
||||||
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
||||||
@@ -109,6 +138,14 @@ class OverseasPhase4PermissionSqlContractTest {
|
|||||||
assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate");
|
assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String matrixBlock(String sql) {
|
||||||
|
int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission");
|
||||||
|
int end = sql.indexOf("DELETE grant_row FROM", start);
|
||||||
|
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||||
|
assertThat(end).isGreaterThan(start);
|
||||||
|
return sql.substring(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
private static String repositoryFile(String relativePath) throws Exception {
|
private static String repositoryFile(String relativePath) throws Exception {
|
||||||
Path path = Path.of(relativePath);
|
Path path = Path.of(relativePath);
|
||||||
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
||||||
|
|||||||
@@ -135,6 +135,34 @@ class PlatformUserManagementServiceTest {
|
|||||||
verify(auth).revokeSessions(eq("internal-token"), any());
|
verify(auth).revokeSessions(eq("internal-token"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changingWarehouseBindingRevokesAllAuthSessionsSoAccountVersionIsBumped() {
|
||||||
|
platformRole.setRoleCode("WAREHOUSE_PICKER");
|
||||||
|
membership.setOverseasWarehouseId(900L);
|
||||||
|
UpdateUserRequest changed = new UpdateUserRequest("张伟", "13800138000", null,
|
||||||
|
0L, List.of(40001L), 901L);
|
||||||
|
|
||||||
|
UserResponse response = service.update(30L, changed, Set.of("platform:user:grant-role"));
|
||||||
|
|
||||||
|
assertEquals(901L, response.overseasWarehouseId());
|
||||||
|
verify(auth).revokeSessions(eq("internal-token"),
|
||||||
|
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearingWarehouseBindingAlsoRevokesAllAuthSessions() {
|
||||||
|
platformRole.setRoleCode("WAREHOUSE_OPERATOR");
|
||||||
|
membership.setOverseasWarehouseId(900L);
|
||||||
|
UpdateUserRequest cleared = new UpdateUserRequest("张伟", "13800138000", null,
|
||||||
|
0L, List.of(40001L), null);
|
||||||
|
|
||||||
|
UserResponse response = service.update(30L, cleared, Set.of("platform:user:grant-role"));
|
||||||
|
|
||||||
|
assertNull(response.overseasWarehouseId());
|
||||||
|
verify(auth).revokeSessions(eq("internal-token"),
|
||||||
|
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L))));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void currentPlatformOperatorCannotDisableSelf() {
|
void currentPlatformOperatorCannotDisableSelf() {
|
||||||
assertThrows(BizException.class,
|
assertThrows(BizException.class,
|
||||||
|
|||||||
+8
-3
@@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
|||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||||
|
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
|
||||||
|
import com.baiyue.common.event.EventTypes;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -37,17 +39,20 @@ import java.nio.charset.StandardCharsets;
|
|||||||
@Tag(name = "Tail-leg orders")
|
@Tag(name = "Tail-leg orders")
|
||||||
public class LastlegOrderController {
|
public class LastlegOrderController {
|
||||||
@Resource private LoadingCompletedConsumer consumer;
|
@Resource private LoadingCompletedConsumer consumer;
|
||||||
|
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
|
||||||
@Resource private LastlegOrderService orderService;
|
@Resource private LastlegOrderService orderService;
|
||||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
@PostMapping("/internal/lastleg/events")
|
@PostMapping("/internal/lastleg/events")
|
||||||
@Operation(summary = "Receive a first-leg loading-completed event",
|
@Operation(summary = "Receive a first-leg loading or actual shipping-node event",
|
||||||
description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.")
|
description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.")
|
||||||
public ApiResponse<Integer> receive(
|
public ApiResponse<Integer> receive(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@RequestBody EventEnvelope event) {
|
@RequestBody EventEnvelope event) {
|
||||||
tokenGuard.verify(token);
|
tokenGuard.verify(token);
|
||||||
return ApiResponse.success(consumer.consume(event));
|
int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())
|
||||||
|
? shippingNodeConsumer.consume(event) : consumer.consume(event);
|
||||||
|
return ApiResponse.success(consumed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ import java.util.List;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
|
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
|
||||||
@Tag(name = "Overseas PDA container receiving")
|
@Tag(name = "Overseas PDA container receiving")
|
||||||
@PreAuthorize("hasAuthority('lastleg:pda:operate')")
|
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
|
||||||
public class ReceivingContainerController {
|
public class ReceivingContainerController {
|
||||||
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
|
public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION;
|
||||||
@Resource private ReceivingContainerService service;
|
@Resource private ReceivingContainerService service;
|
||||||
|
|||||||
+28
@@ -114,6 +114,33 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
|||||||
response.data().fbaWarehouseCode(), response.data().version());
|
response.data().fbaWarehouseCode(), response.data().version());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
|
||||||
|
WarehouseLocationResponse response = client.get()
|
||||||
|
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/resolve/{locationCode}")
|
||||||
|
.queryParam("warehouseId", warehouseId).build(locationCode))
|
||||||
|
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||||
|
.body(WarehouseLocationResponse.class);
|
||||||
|
return location(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
|
||||||
|
WarehouseLocationResponse response = client.get()
|
||||||
|
.uri(uri -> uri.path("/internal/v1/warehouse-resource-locks/locations/{locationId}")
|
||||||
|
.queryParam("warehouseId", warehouseId).build(locationId))
|
||||||
|
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||||
|
.body(WarehouseLocationResponse.class);
|
||||||
|
return location(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WarehouseLocationReference location(WarehouseLocationResponse response) {
|
||||||
|
if (response == null || response.data() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse location was not found");
|
||||||
|
}
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
|
public WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
|
||||||
WarehouseReferenceResponse response = client.get()
|
WarehouseReferenceResponse response = client.get()
|
||||||
@@ -147,4 +174,5 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
|||||||
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) {}
|
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
|
||||||
|
private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -8,6 +8,12 @@ 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 WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
|
||||||
|
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
|
||||||
|
}
|
||||||
|
default WarehouseLocationReference resolveLocation(long warehouseId, long locationId) {
|
||||||
|
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
|
||||||
|
}
|
||||||
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
|
default WarehouseReference requireActiveSharedWarehouse(long warehouseId) {
|
||||||
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
|
throw new UnsupportedOperationException("Shared warehouse resolution is not configured");
|
||||||
}
|
}
|
||||||
@@ -21,4 +27,6 @@ public interface WarehouseResourceGateway {
|
|||||||
|
|
||||||
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
|
record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName,
|
||||||
String status) { }
|
String status) { }
|
||||||
|
record WarehouseLocationReference(long locationId, String locationCode, String locationType,
|
||||||
|
java.math.BigDecimal maxLoadKg, String status) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -27,6 +27,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper
|
|||||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.MapperFeature;
|
import com.fasterxml.jackson.databind.MapperFeature;
|
||||||
@@ -67,6 +68,7 @@ public class ReceivingContainerService {
|
|||||||
@Resource private ReceivingContainerSessionMapper sessions;
|
@Resource private ReceivingContainerSessionMapper sessions;
|
||||||
@Resource private ReceivingContainerOperationMapper operations;
|
@Resource private ReceivingContainerOperationMapper operations;
|
||||||
@Resource private ReceivingSkuReceiptMapper skuReceipts;
|
@Resource private ReceivingSkuReceiptMapper skuReceipts;
|
||||||
|
@Resource private ContainerShippingGateMapper shippingGates;
|
||||||
@Resource private WarehouseResourceGateway warehouse;
|
@Resource private WarehouseResourceGateway warehouse;
|
||||||
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
||||||
@Resource private InboundPdaWorkflowService workflow;
|
@Resource private InboundPdaWorkflowService workflow;
|
||||||
@@ -104,7 +106,8 @@ public class ReceivingContainerService {
|
|||||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, 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");
|
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse");
|
||||||
|
requireReceivingAvailable(customerId, containerNo);
|
||||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||||
requireSessionWarehouse(current, overseasWarehouseId);
|
requireSessionWarehouse(current, overseasWarehouseId);
|
||||||
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
if (current != null && !containerNo.equals(current.getContainerNo())) {
|
||||||
@@ -130,7 +133,8 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
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");
|
if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse");
|
||||||
|
requireReceivingAvailable(customerId, containerNo);
|
||||||
String activeOperatorKey = activeOperatorKey(operatorKey);
|
String activeOperatorKey = activeOperatorKey(operatorKey);
|
||||||
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey);
|
||||||
requireSessionWarehouse(active, overseasWarehouseId);
|
requireSessionWarehouse(active, overseasWarehouseId);
|
||||||
@@ -641,6 +645,12 @@ public class ReceivingContainerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
private boolean eligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
||||||
|
String customerId = requireSingleCustomer(containerPlans, containerPlans.get(0).getContainerNo());
|
||||||
|
return warehouseEligible(containerPlans, overseasWarehouseId)
|
||||||
|
&& shippingGates.receivingAvailable(customerId, containerPlans.get(0).getContainerNo()) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
||||||
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
|
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
|
||||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
|
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
|
||||||
@@ -650,6 +660,12 @@ public class ReceivingContainerService {
|
|||||||
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
|
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requireReceivingAvailable(String customerId, String containerNo) {
|
||||||
|
if (shippingGates.receivingAvailable(customerId, containerNo) != 1) {
|
||||||
|
throw conflict("Container has not reached PICKUP_READY and is not available for overseas receiving");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
|
private void claimReceivingWarehouse(String customerId, List<InboundPlan> containerPlans,
|
||||||
WarehouseResourceGateway.WarehouseReference warehouse) {
|
WarehouseResourceGateway.WarehouseReference warehouse) {
|
||||||
long overseasWarehouseId = warehouse.warehouseId();
|
long overseasWarehouseId = warehouse.warehouseId();
|
||||||
|
|||||||
+1
@@ -68,5 +68,6 @@ public final class LocalLooseboardRequests {
|
|||||||
public record ChangeStatus(
|
public record ChangeStatus(
|
||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
@NotBlank @Size(max = 32) String status,
|
@NotBlank @Size(max = 32) String status,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
@Size(max = 1000) String remark) { }
|
@Size(max = 1000) String remark) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ public final class LocalLooseboardResponses {
|
|||||||
|
|
||||||
public record History(
|
public record History(
|
||||||
String operationType, String fromStatus, String toStatus, String operatorId,
|
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||||
String changeDetail, String remark, LocalDateTime createdAt) { }
|
String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) { }
|
||||||
|
|
||||||
public record Detail(
|
public record Detail(
|
||||||
Summary summary, String originAddress, String destinationAddress, String remark,
|
Summary summary, String originAddress, String destinationAddress, String remark,
|
||||||
|
|||||||
+1
@@ -17,6 +17,7 @@ public class LocalLooseboardHistory {
|
|||||||
private String fromStatus;
|
private String fromStatus;
|
||||||
private String toStatus;
|
private String toStatus;
|
||||||
private String operatorId;
|
private String operatorId;
|
||||||
|
private String idempotencyKey;
|
||||||
private String changeDetail;
|
private String changeDetail;
|
||||||
private String remark;
|
private String remark;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.lastleg.looseboard.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_local_looseboard_operation")
|
||||||
|
public class LocalLooseboardOperation {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private String customerId;
|
||||||
|
private String operationType;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private Long resultVersion;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.looseboard.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface LocalLooseboardOperationMapper extends BaseMapper<LocalLooseboardOperation> {
|
||||||
|
@Select("SELECT * FROM ll_local_looseboard_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
LocalLooseboardOperation findKey(@Param("customerId") String customerId,
|
||||||
|
@Param("orderId") Long orderId,
|
||||||
|
@Param("key") String key);
|
||||||
|
}
|
||||||
+26
-5
@@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
|||||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
||||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
|
||||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
|
||||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -51,6 +53,7 @@ public class LocalLooseboardService {
|
|||||||
|
|
||||||
@Resource private LocalLooseboardOrderMapper orders;
|
@Resource private LocalLooseboardOrderMapper orders;
|
||||||
@Resource private LocalLooseboardHistoryMapper histories;
|
@Resource private LocalLooseboardHistoryMapper histories;
|
||||||
|
@Resource private LocalLooseboardOperationMapper operations;
|
||||||
@Resource private LastlegOrderMapper lastlegOrders;
|
@Resource private LastlegOrderMapper lastlegOrders;
|
||||||
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
|
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||||
@@ -88,7 +91,8 @@ public class LocalLooseboardService {
|
|||||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, null, request.remark(), now);
|
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
||||||
|
key, null, request.remark(), now);
|
||||||
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
|
lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(),
|
||||||
"Local looseboard order submitted", now);
|
"Local looseboard order submitted", now);
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
@@ -139,7 +143,7 @@ public class LocalLooseboardService {
|
|||||||
touch(order, now);
|
touch(order, now);
|
||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||||
changeDetail(before, after), null, now);
|
null, changeDetail(before, after), null, now);
|
||||||
return response(order);
|
return response(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +151,15 @@ public class LocalLooseboardService {
|
|||||||
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||||
LocalLooseboardRequests.ChangeStatus request) {
|
LocalLooseboardRequests.ChangeStatus request) {
|
||||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||||
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
|
||||||
|
if (existing != null) {
|
||||||
|
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
|
||||||
|
throw conflict("Idempotency key was reused with a different looseboard status payload");
|
||||||
|
}
|
||||||
|
return response(order);
|
||||||
|
}
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
String next = norm(request.status());
|
String next = norm(request.status());
|
||||||
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status");
|
||||||
@@ -160,7 +173,13 @@ public class LocalLooseboardService {
|
|||||||
touch(order, now);
|
touch(order, now);
|
||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
syncLastlegOrder(order, next, now);
|
syncLastlegOrder(order, next, now);
|
||||||
history(order, "STATUS", from, next, operatorId, null, request.remark(), now);
|
LocalLooseboardOperation operation = new LocalLooseboardOperation();
|
||||||
|
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
|
||||||
|
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
|
||||||
|
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
|
||||||
|
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
|
||||||
|
operation.setCreatedAt(now); operations.insert(operation);
|
||||||
|
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
|
||||||
return response(order);
|
return response(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +223,8 @@ public class LocalLooseboardService {
|
|||||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||||
histories.listOrder(order.getCustomerId(), order.getId()).stream()
|
histories.listOrder(order.getCustomerId(), order.getId()).stream()
|
||||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||||
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
|
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||||
|
value.getChangeDetail(), value.getRemark(),
|
||||||
value.getCreatedAt())).toList());
|
value.getCreatedAt())).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +238,7 @@ public class LocalLooseboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
|
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
|
||||||
String changeDetail, String remark, LocalDateTime now) {
|
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
|
||||||
LocalLooseboardHistory history = new LocalLooseboardHistory();
|
LocalLooseboardHistory history = new LocalLooseboardHistory();
|
||||||
history.setId(IdWorker.getId());
|
history.setId(IdWorker.getId());
|
||||||
history.setOrderId(order.getId());
|
history.setOrderId(order.getId());
|
||||||
@@ -227,6 +247,7 @@ public class LocalLooseboardService {
|
|||||||
history.setFromStatus(from);
|
history.setFromStatus(from);
|
||||||
history.setToStatus(to);
|
history.setToStatus(to);
|
||||||
history.setOperatorId(operator);
|
history.setOperatorId(operator);
|
||||||
|
history.setIdempotencyKey(trim(idempotencyKey));
|
||||||
history.setChangeDetail(changeDetail);
|
history.setChangeDetail(changeDetail);
|
||||||
history.setRemark(trim(remark));
|
history.setRemark(trim(remark));
|
||||||
history.setCreatedAt(now);
|
history.setCreatedAt(now);
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/pda/v1/last-leg")
|
||||||
|
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')")
|
||||||
|
public class PdaWarehouseController {
|
||||||
|
@Resource private PdaWarehouseService service;
|
||||||
|
|
||||||
|
@PostMapping("/inventory/resolve")
|
||||||
|
public ApiResponse<PdaWarehouseResponses.InventoryScan> inventory(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.inventory(identity.overseasWarehouseId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/locations/resolve")
|
||||||
|
public ApiResponse<PdaWarehouseResponses.Location> location(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.location(identity.overseasWarehouseId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pallets/resolve")
|
||||||
|
public ApiResponse<PdaWarehouseResponses.Pallet> pallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody PdaWarehouseRequests.Scan request) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.pallet(identity.overseasWarehouseId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/operations")
|
||||||
|
public ApiResponse<PageResult<PdaWarehouseResponses.Operation>> operations(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) {
|
||||||
|
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||||
|
return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
|
public interface PdaWarehouseMapper extends BaseMapper<PdaOperationHistory> {
|
||||||
String INVENTORY_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
|
String INVENTORY_SELECT = "SELECT DISTINCT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," +
|
||||||
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
|
"COALESCE(NULLIF(r.sku_code,''),b.sku_code) sku_code," +
|
||||||
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
|
"COALESCE(NULLIF(r.product_name_zh,''),b.product_name_zh) product_name_zh," +
|
||||||
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
|
"COALESCE(NULLIF(r.product_name_en,''),b.product_name_en) product_name_en,b.inbound_code," +
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseResponses;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PdaWarehouseService {
|
||||||
|
@Resource private PdaWarehouseMapper mapper;
|
||||||
|
@Resource private WarehouseResourceGateway warehouse;
|
||||||
|
|
||||||
|
public PdaWarehouseResponses.InventoryScan inventory(long warehouseId, PdaWarehouseRequests.Scan request) {
|
||||||
|
String code = code(request.code());
|
||||||
|
List<PdaWarehouseResponses.InventoryItem> items = items(mapper.scanInventory(String.valueOf(warehouseId), code));
|
||||||
|
if (items.isEmpty()) throw notFound("Inventory code was not found in the bound warehouse");
|
||||||
|
return new PdaWarehouseResponses.InventoryScan(code, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PdaWarehouseResponses.Location location(long warehouseId, PdaWarehouseRequests.Scan request) {
|
||||||
|
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
|
||||||
|
code(request.code()));
|
||||||
|
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByLocation(
|
||||||
|
String.valueOf(warehouseId), location.locationId(), location.locationCode(), "__NO_CARTON__"));
|
||||||
|
long packages = inventory.stream().mapToLong(this::packages).sum();
|
||||||
|
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
|
||||||
|
return new PdaWarehouseResponses.Location(location.locationId(), location.locationCode(),
|
||||||
|
location.locationType(), location.maxLoadKg(), location.status(), packages, positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PdaWarehouseResponses.Pallet pallet(long warehouseId, PdaWarehouseRequests.Scan request) {
|
||||||
|
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(code(request.code()));
|
||||||
|
if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse");
|
||||||
|
WarehouseResourceGateway.WarehouseLocationReference location = warehouse.resolveLocation(warehouseId,
|
||||||
|
pallet.locationId());
|
||||||
|
List<PdaWarehouseResponses.InventoryItem> inventory = items(mapper.inventoryByPallet(
|
||||||
|
String.valueOf(warehouseId), pallet.palletId(), "__NO_CARTON__"));
|
||||||
|
long packages = inventory.stream().mapToLong(this::packages).sum();
|
||||||
|
int positions = (int) inventory.stream().map(PdaWarehouseResponses.InventoryItem::positionId).distinct().count();
|
||||||
|
return new PdaWarehouseResponses.Pallet(pallet.palletId(), pallet.palletNo(), pallet.status(), pallet.version(),
|
||||||
|
location.locationId(), location.locationCode(), packages, positions, inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<PdaWarehouseResponses.Operation> history(long warehouseId, String operatorKey,
|
||||||
|
PdaWarehouseRequests.HistoryQuery query) {
|
||||||
|
long offset = (query.page() - 1) * query.size();
|
||||||
|
List<PdaWarehouseResponses.Operation> rows = mapper.history(warehouseId, operatorKey,
|
||||||
|
trim(query.operationType()), offset, query.size());
|
||||||
|
long total = mapper.countHistory(warehouseId, operatorKey, trim(query.operationType()));
|
||||||
|
return new PageResult<>(rows, query.page(), query.size(), total,
|
||||||
|
total == 0 ? 0 : (total + query.size() - 1) / query.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void record(long warehouseId, String customerId, String operatorKey, String operationType,
|
||||||
|
String businessKey, String status, String idempotencyKey, String requestHash, String summary) {
|
||||||
|
PdaOperationHistory history = new PdaOperationHistory();
|
||||||
|
history.setId(IdWorker.getId()); history.setOverseasWarehouseId(warehouseId);
|
||||||
|
history.setCustomerId(trim(customerId)); history.setOperatorKey(operatorKey);
|
||||||
|
history.setOperationType(operationType); history.setBusinessKey(businessKey); history.setStatus(status);
|
||||||
|
history.setIdempotencyKey(trim(idempotencyKey)); history.setRequestHash(trim(requestHash));
|
||||||
|
history.setSummary(trim(summary)); history.setOccurredAt(LocalDateTime.now()); history.setCreatedAt(history.getOccurredAt());
|
||||||
|
mapper.insert(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PdaWarehouseResponses.InventoryItem> items(List<PdaInventoryProjection> values) {
|
||||||
|
LinkedHashMap<String, PdaWarehouseResponses.InventoryItem> result = new LinkedHashMap<>();
|
||||||
|
for (PdaInventoryProjection value : values) {
|
||||||
|
String key = value.getPositionId() + ":" + text(value.getSkuCode()) + ":" + text(value.getCartonMark());
|
||||||
|
result.putIfAbsent(key, new PdaWarehouseResponses.InventoryItem(value.getCustomerId(), value.getCustomerName(),
|
||||||
|
number(value.getBalanceId()), number(value.getPositionId()), value.getSkuCode(),
|
||||||
|
first(value.getProductNameZh(), value.getProductNameEn()), value.getInboundCode(), value.getCartonMark(),
|
||||||
|
number(value.getPalletId()), value.getPalletCode(), value.getLocationId(), value.getLocationCode(),
|
||||||
|
number(value.getAvailablePackages()), number(value.getHoldPackages()), number(value.getReceivingPackages()),
|
||||||
|
value.getStatus(), number(value.getVersion())));
|
||||||
|
}
|
||||||
|
return List.copyOf(result.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long packages(PdaWarehouseResponses.InventoryItem value) {
|
||||||
|
return value.availablePackages() + value.holdPackages() + value.receivingPackages();
|
||||||
|
}
|
||||||
|
private static String code(String value) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Scan code is required"); return result; }
|
||||||
|
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||||
|
private static String text(String value) { return value == null ? "" : value; }
|
||||||
|
private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; }
|
||||||
|
private static long number(Long value) { return value == null ? 0 : value; }
|
||||||
|
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||||
|
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
|
}
|
||||||
+5
-1
@@ -36,7 +36,11 @@ public final class OverseasProcurementRequests {
|
|||||||
String productName, String status, String purchaseWarehouseId,
|
String productName, String status, String purchaseWarehouseId,
|
||||||
LocalDateTime createdFrom, LocalDateTime createdTo) {}
|
LocalDateTime createdFrom, LocalDateTime createdTo) {}
|
||||||
|
|
||||||
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
|
public record Review(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotBlank String decision,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
public record Update(
|
public record Update(
|
||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ public final class OverseasProcurementResponses {
|
|||||||
|
|
||||||
public record History(
|
public record History(
|
||||||
String operationType, String fromStatus, String toStatus, String operatorId,
|
String operationType, String fromStatus, String toStatus, String operatorId,
|
||||||
String changeDetail, String remark,
|
String idempotencyKey, String changeDetail, String remark,
|
||||||
LocalDateTime createdAt) {}
|
LocalDateTime createdAt) {}
|
||||||
|
|
||||||
public record Detail(
|
public record Detail(
|
||||||
|
|||||||
+2
@@ -42,6 +42,8 @@ public class OverseasProcurementOrder {
|
|||||||
private String createdBy;
|
private String createdBy;
|
||||||
private String reviewedBy;
|
private String reviewedBy;
|
||||||
private String reviewRemark;
|
private String reviewRemark;
|
||||||
|
private String reviewIdempotencyKey;
|
||||||
|
private String reviewRequestHash;
|
||||||
private LocalDateTime reviewedAt;
|
private LocalDateTime reviewedAt;
|
||||||
private Long version;
|
private Long version;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+1
@@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory {
|
|||||||
private String fromStatus;
|
private String fromStatus;
|
||||||
private String toStatus;
|
private String toStatus;
|
||||||
private String operatorId;
|
private String operatorId;
|
||||||
|
private String idempotencyKey;
|
||||||
private String changeDetail;
|
private String changeDetail;
|
||||||
private String remark;
|
private String remark;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
+21
-6
@@ -89,7 +89,8 @@ public class OverseasProcurementService {
|
|||||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now);
|
history(order, "CREATE", null, PENDING_REVIEW, operatorId,
|
||||||
|
key, null, request.remark(), now);
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +145,8 @@ public class OverseasProcurementService {
|
|||||||
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
|
order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(),
|
||||||
histories.listOrder(customerId, order.getId()).stream().map(value ->
|
histories.listOrder(customerId, order.getId()).stream().map(value ->
|
||||||
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||||
value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(),
|
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
|
||||||
|
value.getChangeDetail(), value.getRemark(),
|
||||||
value.getCreatedAt())).toList());
|
value.getCreatedAt())).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,16 +159,27 @@ public class OverseasProcurementService {
|
|||||||
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||||
};
|
};
|
||||||
OverseasProcurementOrder order = lock(customerId, orderNo);
|
OverseasProcurementOrder order = lock(customerId, orderNo);
|
||||||
if (next.equals(order.getStatus())) return detail(customerId, orderNo);
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
if (key.equals(order.getReviewIdempotencyKey())) {
|
||||||
|
if (!requestHash.equals(order.getReviewRequestHash())) {
|
||||||
|
throw conflict("Review idempotency key was reused with a different payload");
|
||||||
|
}
|
||||||
|
return detail(customerId, orderNo);
|
||||||
|
}
|
||||||
|
if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry");
|
||||||
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
|
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed");
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
order.setStatus(next);
|
order.setStatus(next);
|
||||||
order.setReviewedBy(operatorId);
|
order.setReviewedBy(operatorId);
|
||||||
order.setReviewRemark(trim(request.remark()));
|
order.setReviewRemark(trim(request.remark()));
|
||||||
|
order.setReviewIdempotencyKey(key);
|
||||||
|
order.setReviewRequestHash(requestHash);
|
||||||
order.setReviewedAt(now);
|
order.setReviewedAt(now);
|
||||||
touch(order, now);
|
touch(order, now);
|
||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now);
|
history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
|
||||||
|
key, null, request.remark(), now);
|
||||||
return detail(customerId, orderNo);
|
return detail(customerId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +210,7 @@ public class OverseasProcurementService {
|
|||||||
touch(order, now);
|
touch(order, now);
|
||||||
orders.updateById(order);
|
orders.updateById(order);
|
||||||
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
|
||||||
changeDetail(before, after), null, now);
|
null, changeDetail(before, after), null, now);
|
||||||
return detail(customerId, orderNo);
|
return detail(customerId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +237,8 @@ public class OverseasProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
|
private void history(OverseasProcurementOrder order, String operationType, String from, String to,
|
||||||
String operator, String changeDetail, String remark, LocalDateTime now) {
|
String operator, String idempotencyKey, String changeDetail,
|
||||||
|
String remark, LocalDateTime now) {
|
||||||
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
|
OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory();
|
||||||
history.setId(IdWorker.getId());
|
history.setId(IdWorker.getId());
|
||||||
history.setOrderId(order.getId());
|
history.setOrderId(order.getId());
|
||||||
@@ -233,6 +247,7 @@ public class OverseasProcurementService {
|
|||||||
history.setFromStatus(from);
|
history.setFromStatus(from);
|
||||||
history.setToStatus(to);
|
history.setToStatus(to);
|
||||||
history.setOperatorId(operator);
|
history.setOperatorId(operator);
|
||||||
|
history.setIdempotencyKey(trim(idempotencyKey));
|
||||||
history.setChangeDetail(changeDetail);
|
history.setChangeDetail(changeDetail);
|
||||||
history.setRemark(trim(remark));
|
history.setRemark(trim(remark));
|
||||||
history.setCreatedAt(now);
|
history.setCreatedAt(now);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
|||||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -41,6 +42,7 @@ public class LoadingCompletedConsumer {
|
|||||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||||
@Resource private DropshipOrderService dropshipOrders;
|
@Resource private DropshipOrderService dropshipOrders;
|
||||||
@Resource private InventoryCommandService inventory;
|
@Resource private InventoryCommandService inventory;
|
||||||
|
@Resource private ContainerShippingGateMapper shippingGates;
|
||||||
|
|
||||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
public int consume(EventEnvelope envelope) {
|
public int consume(EventEnvelope envelope) {
|
||||||
@@ -71,6 +73,9 @@ public class LoadingCompletedConsumer {
|
|||||||
return countOrders(envelope,payload.containerNo());
|
return countOrders(envelope,payload.containerNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
|
||||||
|
payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion());
|
||||||
|
|
||||||
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
||||||
JsonNode rawOrders = envelope.payload().path("orders");
|
JsonNode rawOrders = envelope.payload().path("orders");
|
||||||
for (int index = 0; index < payload.orders().size(); index++) {
|
for (int index = 0; index < payload.orders().size(); index++) {
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.lastleg.shipping.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_container_shipping_gate")
|
||||||
|
public class ContainerShippingGate {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private String containerBookingId;
|
||||||
|
private String customerId;
|
||||||
|
private String oceanBookingId;
|
||||||
|
private String containerNo;
|
||||||
|
private String status;
|
||||||
|
private String standardNode;
|
||||||
|
private Integer nodeSequence;
|
||||||
|
private LocalDateTime observedAt;
|
||||||
|
private LocalDateTime actualAt;
|
||||||
|
private String locationCode;
|
||||||
|
private String locationName;
|
||||||
|
private String providerCode;
|
||||||
|
private String providerEventId;
|
||||||
|
private String providerNodeCode;
|
||||||
|
private String sourceReference;
|
||||||
|
private String rawPayloadHash;
|
||||||
|
private Long aggregateVersion;
|
||||||
|
private Long handoverVersion;
|
||||||
|
private String lastEventId;
|
||||||
|
private String payloadHash;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.lastleg.shipping.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||||
|
import org.apache.ibatis.annotations.Insert;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContainerShippingGateMapper extends BaseMapper<ContainerShippingGate> {
|
||||||
|
@Select("SELECT * FROM ll_container_shipping_gate WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
|
||||||
|
ContainerShippingGate lock(@Param("containerBookingId") String containerBookingId);
|
||||||
|
|
||||||
|
@Insert("INSERT IGNORE INTO ll_container_shipping_gate(container_booking_id,customer_id,ocean_booking_id," +
|
||||||
|
"container_no,status,node_sequence,aggregate_version,handover_version,created_at,updated_at) VALUES(" +
|
||||||
|
"#{containerBookingId},#{customerId},#{oceanBookingId},#{containerNo},'AWAITING_PICKUP_READY',0," +
|
||||||
|
"#{aggregateVersion},#{handoverVersion},NOW(3),NOW(3))")
|
||||||
|
int initialize(@Param("containerBookingId") String containerBookingId, @Param("customerId") String customerId,
|
||||||
|
@Param("oceanBookingId") String oceanBookingId, @Param("containerNo") String containerNo,
|
||||||
|
@Param("aggregateVersion") long aggregateVersion, @Param("handoverVersion") long handoverVersion);
|
||||||
|
|
||||||
|
@Select("SELECT CASE WHEN COUNT(DISTINCT o.source_container_booking_id)=1 " +
|
||||||
|
"AND MIN(CASE WHEN g.status='RECEIVING_AVAILABLE' THEN 1 ELSE 0 END)=1 THEN 1 ELSE 0 END " +
|
||||||
|
"FROM ll_order o LEFT JOIN ll_container_shipping_gate g " +
|
||||||
|
"ON g.container_booking_id=o.source_container_booking_id AND g.customer_id=o.customer_id " +
|
||||||
|
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo}")
|
||||||
|
int receivingAvailable(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package com.baiyue.lastleg.shipping.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
|
import com.baiyue.common.event.EventTypes;
|
||||||
|
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
|
||||||
|
import com.baiyue.common.event.RegionCode;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegEventInbox;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
|
||||||
|
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||||
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Isolation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ContainerShippingNodeConsumer {
|
||||||
|
public static final int PICKUP_READY_SEQUENCE = 80;
|
||||||
|
@Resource private LastlegEventInboxMapper inboxes;
|
||||||
|
@Resource private ContainerShippingGateMapper gates;
|
||||||
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||||
|
public int consume(EventEnvelope envelope) {
|
||||||
|
validate(envelope);
|
||||||
|
FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope);
|
||||||
|
validateEnvelopePayload(envelope, payload);
|
||||||
|
LastlegEventInbox existing = inboxes.selectById(envelope.eventId());
|
||||||
|
if (existing != null) {
|
||||||
|
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||||
|
throw new IllegalArgumentException("船节点事件摘要冲突");
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
LastlegEventInbox inbox = inbox(envelope);
|
||||||
|
try { inboxes.insert(inbox); }
|
||||||
|
catch (DuplicateKeyException raced) {
|
||||||
|
LastlegEventInbox concurrent = inboxes.selectById(envelope.eventId());
|
||||||
|
if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||||
|
throw new IllegalArgumentException("船节点事件摘要冲突", raced);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
gates.initialize(payload.containerBookingId(), payload.customerId(), payload.oceanBookingId(),
|
||||||
|
payload.containerNo(), 0, 1);
|
||||||
|
ContainerShippingGate gate = gates.lock(payload.containerBookingId());
|
||||||
|
if (gate == null || !payload.customerId().equals(gate.getCustomerId())
|
||||||
|
|| !payload.containerNo().equals(gate.getContainerNo())) {
|
||||||
|
throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致");
|
||||||
|
}
|
||||||
|
apply(gate, envelope, payload);
|
||||||
|
gates.updateById(gate);
|
||||||
|
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(ContainerShippingGate gate, EventEnvelope envelope,
|
||||||
|
FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||||
|
long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion();
|
||||||
|
int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence();
|
||||||
|
LocalDateTime actualAt = LocalDateTime.ofInstant(payload.actualAt(), ZoneOffset.UTC);
|
||||||
|
boolean newerVersion = envelope.aggregateVersion() >= currentVersion;
|
||||||
|
boolean newerNode = payload.nodeSequence() > currentSequence;
|
||||||
|
boolean laterSameNode = payload.nodeSequence() == currentSequence
|
||||||
|
&& (gate.getActualAt() == null || actualAt.isAfter(gate.getActualAt()));
|
||||||
|
boolean accepted = newerVersion && (newerNode || laterSameNode);
|
||||||
|
if (accepted) {
|
||||||
|
gate.setOceanBookingId(payload.oceanBookingId());
|
||||||
|
gate.setStandardNode(payload.standardNode()); gate.setNodeSequence(payload.nodeSequence());
|
||||||
|
gate.setObservedAt(LocalDateTime.ofInstant(payload.observedAt(), ZoneOffset.UTC)); gate.setActualAt(actualAt);
|
||||||
|
gate.setLocationCode(payload.locationCode()); gate.setLocationName(payload.locationName());
|
||||||
|
gate.setProviderCode(payload.providerCode()); gate.setProviderEventId(payload.providerEventId());
|
||||||
|
gate.setProviderNodeCode(payload.providerNodeCode()); gate.setSourceReference(payload.sourceReference());
|
||||||
|
gate.setRawPayloadHash(payload.rawPayloadHash()); gate.setLastEventId(envelope.eventId());
|
||||||
|
gate.setPayloadHash(envelope.payloadHash());
|
||||||
|
}
|
||||||
|
if (accepted && payload.nodeSequence() >= PICKUP_READY_SEQUENCE) {
|
||||||
|
gate.setStatus("RECEIVING_AVAILABLE");
|
||||||
|
}
|
||||||
|
gate.setAggregateVersion(Math.max(currentVersion, envelope.aggregateVersion()));
|
||||||
|
gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(),
|
||||||
|
envelope.handoverVersion()));
|
||||||
|
gate.setUpdatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(EventEnvelope envelope) {
|
||||||
|
envelope.requireValid();
|
||||||
|
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|
||||||
|
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|
||||||
|
|| envelope.targetRegion() != RegionCode.OVERSEAS
|
||||||
|
|| !"CONTAINER_BOOKING".equals(envelope.aggregateType())
|
||||||
|
|| envelope.handoverVersion() == null
|
||||||
|
|| envelope.handoverVersion() != envelope.aggregateVersion() + 1) {
|
||||||
|
throw new IllegalArgumentException("船节点交接信封无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateEnvelopePayload(EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) {
|
||||||
|
if (!envelope.tenantId().equals(payload.customerId())
|
||||||
|
|| !envelope.aggregateId().equals(payload.containerBookingId())) {
|
||||||
|
throw new IllegalArgumentException("船节点交接客户或聚合标识不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FirstLegContainerShippingNodeRecordedV1 payload(EventEnvelope envelope) {
|
||||||
|
try { return objectMapper.treeToValue(envelope.payload(), FirstLegContainerShippingNodeRecordedV1.class); }
|
||||||
|
catch (Exception exception) { throw new IllegalArgumentException("船节点交接负载无效", exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private LastlegEventInbox inbox(EventEnvelope envelope) {
|
||||||
|
LastlegEventInbox value = new LastlegEventInbox(); value.setEventId(envelope.eventId());
|
||||||
|
value.setCustomerId(envelope.tenantId()); value.setEventType(envelope.eventType());
|
||||||
|
value.setEventVersion(envelope.eventVersion()); value.setPayloadHash(envelope.payloadHash());
|
||||||
|
value.setPayloadJson(envelope.payload().toString()); value.setStatus("RECEIVED");
|
||||||
|
value.setReceivedAt(LocalDateTime.now()); return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-6
@@ -289,8 +289,7 @@ public class UsTruckOrderService {
|
|||||||
pallet.setUpdatedAt(now);
|
pallet.setUpdatedAt(now);
|
||||||
pallets.updateById(pallet);
|
pallets.updateById(pallet);
|
||||||
}
|
}
|
||||||
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count()
|
int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count();
|
||||||
+ selected.size();
|
|
||||||
int accepted = value(order.getReceivedPalletCount());
|
int accepted = value(order.getReceivedPalletCount());
|
||||||
if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
|
if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets");
|
||||||
String from = order.getStatus();
|
String from = order.getStatus();
|
||||||
@@ -330,6 +329,69 @@ public class UsTruckOrderService {
|
|||||||
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
|
return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId,
|
||||||
|
String operationType, String expectedStatus, String nextStatus,
|
||||||
|
UsTruckOrderRequests.Command request) {
|
||||||
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
|
UsTruckOrderResponses.Detail replay = replayOperation(order, operationType,
|
||||||
|
request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return replay;
|
||||||
|
requireVersion(order, request.version());
|
||||||
|
if (!expectedStatus.equals(order.getStatus())) {
|
||||||
|
throw conflict("Truck order must be " + expectedStatus + " before " + operationType);
|
||||||
|
}
|
||||||
|
if (ALLOCATED.equals(expectedStatus)
|
||||||
|
&& value(order.getAllocatedPalletCount()) != value(order.getReceivedPalletCount())) {
|
||||||
|
throw conflict("All accepted pallets must be allocated before dispatch preparation");
|
||||||
|
}
|
||||||
|
return transition(order, operatorId, operationType, request.idempotencyKey(), request,
|
||||||
|
expectedStatus, nextStatus, request.remark());
|
||||||
|
}
|
||||||
|
|
||||||
|
private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId,
|
||||||
|
String operationType, String idempotencyKey, Object request,
|
||||||
|
String from, String to, String remark) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
order.setStatus(to);
|
||||||
|
touch(order);
|
||||||
|
orders.updateById(order);
|
||||||
|
recordOperation(order, operationType, idempotencyKey, request, operatorId, now);
|
||||||
|
history(order, operationType, from, to, operatorId, idempotencyKey, remark, now);
|
||||||
|
return detail(order.getCustomerId(), order.getOrderNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType,
|
||||||
|
String idempotencyKey, Object request) {
|
||||||
|
String key = idempotencyKey.trim();
|
||||||
|
UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key);
|
||||||
|
if (existing == null) return null;
|
||||||
|
if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) {
|
||||||
|
throw conflict("Idempotency key was reused with a different truck operation payload");
|
||||||
|
}
|
||||||
|
return detail(order.getCustomerId(), order.getOrderNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey,
|
||||||
|
Object request, String operatorId, LocalDateTime now) {
|
||||||
|
UsTruckOrderOperation operation = new UsTruckOrderOperation();
|
||||||
|
operation.setId(IdWorker.getId());
|
||||||
|
operation.setOrderId(order.getId());
|
||||||
|
operation.setCustomerId(order.getCustomerId());
|
||||||
|
operation.setOperationType(operationType);
|
||||||
|
operation.setIdempotencyKey(idempotencyKey.trim());
|
||||||
|
operation.setRequestHash(hash(request));
|
||||||
|
operation.setResultVersion(order.getVersion());
|
||||||
|
operation.setCreatedBy(operatorId);
|
||||||
|
operation.setCreatedAt(now);
|
||||||
|
operations.insert(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireVersion(UsTruckOrder order, Long requestedVersion) {
|
||||||
|
if (!requestedVersion.equals(value(order.getVersion()))) {
|
||||||
|
throw conflict("Order was changed; refresh and retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
||||||
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||||
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
||||||
@@ -338,15 +400,23 @@ public class UsTruckOrderService {
|
|||||||
.toList();
|
.toList();
|
||||||
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
|
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
|
||||||
o.getRemark(), o.getCreatedBy(), palletDetails,
|
o.getRemark(), o.getCreatedBy(), palletDetails,
|
||||||
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(),
|
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(),
|
||||||
h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList());
|
h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(),
|
||||||
|
h.getRemark(), h.getCreatedAt())).toList());
|
||||||
}
|
}
|
||||||
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
|
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
|
||||||
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
|
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
|
||||||
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
|
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
|
||||||
}
|
}
|
||||||
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
||||||
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
|
private void history(UsTruckOrder o, String operationType, String from, String to, String operator,
|
||||||
|
String idempotencyKey, String remark, LocalDateTime at) {
|
||||||
|
UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory();
|
||||||
|
h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId());
|
||||||
|
h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to);
|
||||||
|
h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark));
|
||||||
|
h.setCreatedAt(at); histories.insert(h);
|
||||||
|
}
|
||||||
private void createPlannedPallets(UsTruckOrder order) {
|
private void createPlannedPallets(UsTruckOrder order) {
|
||||||
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
|
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
|
|||||||
UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id)
|
UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id)
|
||||||
) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱';
|
) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||||
|
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||||
|
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||||
|
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||||
|
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||||
|
location_code VARCHAR(128) NULL COMMENT '节点地点编码', location_name VARCHAR(255) NULL COMMENT '节点地点名称',
|
||||||
|
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码', provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
|
||||||
|
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码', source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
|
||||||
|
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256', aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
|
||||||
|
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本', last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
|
||||||
|
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
PRIMARY KEY(container_booking_id), KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
|
||||||
|
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_order (
|
CREATE TABLE IF NOT EXISTS ll_order (
|
||||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||||
@@ -715,6 +732,18 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
|||||||
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
|
||||||
|
id BIGINT NOT NULL COMMENT 'PDA作业历史ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||||
|
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识', operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
|
||||||
|
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型', business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '作业结果状态', idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
|
||||||
|
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希', summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
|
||||||
|
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
|
||||||
|
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
|
||||||
|
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
@@ -1253,6 +1282,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
|||||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||||
|
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||||
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
|
||||||
@@ -1303,6 +1333,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
|||||||
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态',
|
||||||
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
|
||||||
|
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
|
||||||
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
reviewed_at DATETIME(3) NULL COMMENT '审核时间', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
@@ -1316,7 +1347,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
|
|||||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE',
|
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE',
|
||||||
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
|
PRIMARY KEY(id), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='海外采购操作流水';
|
) ENGINE=InnoDB COMMENT='海外采购操作流水';
|
||||||
@@ -1335,7 +1366,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
|||||||
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
|
package_count INT NULL COMMENT '可选箱数,零或空值均有效',
|
||||||
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
||||||
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数',
|
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数',
|
||||||
shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数',
|
|
||||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
|
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
|
||||||
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额',
|
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额',
|
||||||
@@ -1345,8 +1375,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
|||||||
requested_date DATE NOT NULL COMMENT '要求服务日期',
|
requested_date DATE NOT NULL COMMENT '要求服务日期',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态',
|
status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态',
|
||||||
remark VARCHAR(1000) NULL COMMENT '备注',
|
remark VARCHAR(1000) NULL COMMENT '备注',
|
||||||
receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键',
|
|
||||||
receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希',
|
|
||||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||||
created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||||
@@ -1356,7 +1384,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
|||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
|
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no),
|
||||||
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
|
UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
|
||||||
UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key),
|
|
||||||
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
|
KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
|
||||||
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
|
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
|
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
|
||||||
@@ -1380,13 +1407,25 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
|
|||||||
KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status)
|
KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status)
|
||||||
) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';
|
) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation (
|
||||||
|
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
|
||||||
|
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||||
|
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key),
|
||||||
|
KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
|
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
order_id BIGINT NOT NULL COMMENT '订单ID',
|
order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
|
operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
|
||||||
from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
|
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
|
||||||
operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
|
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键',
|
||||||
remark VARCHAR(1000) NULL COMMENT '备注',
|
remark VARCHAR(1000) NULL COMMENT '备注',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
@@ -1421,7 +1460,17 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
|
|||||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
|
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='本土散板操作流水';
|
) ENGINE=InnoDB COMMENT='本土散板操作流水';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
|
||||||
|
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||||
|
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||||
|
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key),
|
||||||
|
KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ paths:
|
|||||||
/api/pda/v1/last-leg/receiving-containers/candidates:
|
/api/pda/v1/last-leg/receiving-containers/candidates:
|
||||||
get:
|
get:
|
||||||
summary: 查询当前客户待签收柜号
|
summary: 查询当前客户待签收柜号
|
||||||
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 customerId。
|
description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } }
|
||||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
/api/pda/v1/last-leg/receiving-containers/{containerNo}:
|
||||||
@@ -19,7 +19,7 @@ paths:
|
|||||||
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
/api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare:
|
||||||
post:
|
post:
|
||||||
summary: 选择当前待签收柜号
|
summary: 选择当前待签收柜号
|
||||||
description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||||
@@ -86,6 +86,37 @@ paths:
|
|||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||||
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
|
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
|
||||||
|
/api/pda/v1/last-leg/inventory/resolve:
|
||||||
|
post:
|
||||||
|
summary: PDA按SKU、箱唛或入仓码扫码查询库存
|
||||||
|
description: 仅 WAREHOUSE_PICKER + lastleg:pda:operate;海外实际仓只取Token overseasWarehouseId,不接收客户或仓库参数。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||||
|
responses: { '200': { description: 同仓跨客户库存与物理仓位事实, content: { application/json: { schema: { $ref: '#/components/schemas/PdaInventoryScanApiResponse' } } } }, '404': { description: A0404:当前绑定仓无匹配库存 } }
|
||||||
|
/api/pda/v1/last-leg/locations/resolve:
|
||||||
|
post:
|
||||||
|
summary: PDA查询库位容量、状态与库存占用
|
||||||
|
description: 库位从Warehouse Overseas公共资源解析,并强制匹配Token绑定仓。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||||
|
responses: { '200': { description: 库位物理事实及Lastleg库存占用汇总, content: { application/json: { schema: { $ref: '#/components/schemas/PdaLocationApiResponse' } } } }, '404': { description: A0404:库位不存在或跨仓 } }
|
||||||
|
/api/pda/v1/last-leg/pallets/resolve:
|
||||||
|
post:
|
||||||
|
summary: PDA查询托盘、库位与绑定库存
|
||||||
|
description: 托盘从Warehouse Overseas公共资源解析;跨Token绑定仓返回A0409。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PdaWarehouseScan' } } } }
|
||||||
|
responses: { '200': { description: 托盘物理事实及Lastleg绑定库存, content: { application/json: { schema: { $ref: '#/components/schemas/PdaPalletApiResponse' } } } }, '409': { description: A0409:托盘属于其他仓 } }
|
||||||
|
/api/pda/v1/last-leg/operations:
|
||||||
|
get:
|
||||||
|
summary: 查询当前PDA账号真实作业历史
|
||||||
|
description: 返回Token绑定仓内当前操作账号的柜号作业及盘点、移库、托盘、换标历史;不含出库或派送。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||||
|
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||||
|
- { name: operationType, in: query, schema: { type: string } }
|
||||||
|
responses: { '200': { description: 真实作业历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/PdaOperationPageApiResponse' } } } } }
|
||||||
/api/platform/v1/last-leg/orders:
|
/api/platform/v1/last-leg/orders:
|
||||||
get:
|
get:
|
||||||
summary: 分页查询尾程订单
|
summary: 分页查询尾程订单
|
||||||
@@ -269,14 +300,14 @@ paths:
|
|||||||
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/review:
|
/api/platform/v1/last-leg/overseas-procurements/{orderNo}/review:
|
||||||
post:
|
post:
|
||||||
summary: 审核海外采购订单
|
summary: 审核海外采购订单
|
||||||
description: 权限 platform:overseas-business:purchase:review。
|
description: 权限 platform:overseas-business:purchase:review;同幂等键同请求重放成功,异请求、旧 version 或终态反向审核返回 A0409。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content: { application/json: { schema: { type: object, required: [decision], properties: { decision: { type: string, enum: [APPROVE, REJECT] }, remark: { type: string, nullable: true, maxLength: 1000 } } } } }
|
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } }
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
||||||
/api/merchant/v1/last-leg/overseas-procurements:
|
/api/merchant/v1/last-leg/overseas-procurements:
|
||||||
post:
|
post:
|
||||||
@@ -325,14 +356,24 @@ paths:
|
|||||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
||||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/status:
|
||||||
patch:
|
patch:
|
||||||
summary: 推进本土卡车订单状态
|
summary: 取消未发车的本土卡车订单
|
||||||
description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。
|
description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/TruckStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCancel' } } } }
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review:
|
||||||
|
parameters: &truckOrderTarget
|
||||||
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||||
|
post:
|
||||||
|
summary: 审核本土卡车订单
|
||||||
|
description: 权限 platform:overseas-business:truck:status;PENDING_REVIEW 审核通过进入 WAITING_INBOUND 并生成预期托盘;拒绝进入 REJECTED。幂等键异请求、旧 version 或非法跳转返回 A0409。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReview' } } } }
|
||||||
|
responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||||
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets:
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets:
|
||||||
post:
|
post:
|
||||||
summary: 记录本土卡车订单实收托盘
|
summary: 记录本土卡车订单实收托盘
|
||||||
@@ -341,7 +382,56 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReceivePallets' } } } }
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/review-pallet-overage:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 审核实收超出申报数的托盘
|
||||||
|
description: 权限 platform:overseas-business:truck:status;APPROVE 将超收托盘纳入本单并进入 WAITING_CONSOLIDATION;REJECT 标记超收托盘拒收并按原申报数继续。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckReviewPalletOverage' } } } }
|
||||||
|
responses: { '200': { description: 卡车订单详情 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/allocate-pallets:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 分配已实收托盘到本土卡车行程
|
||||||
|
description: 权限 platform:overseas-business:truck:status;仅接收本单 RECEIVED 托盘 ID;部分分配进入 PARTIALLY_ALLOCATED,全部分配进入 ALLOCATED。不创建海外仓出库单或扣减库存。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckAllocatePallets' } } } }
|
||||||
|
responses: { '200': { description: 卡车订单详情 }, '409': { description: 托盘归属、状态、版本或幂等冲突 } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/prepare-dispatch:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 全部托盘分配后准备派车
|
||||||
|
description: 权限 platform:overseas-business:truck:status;旧 version、幂等键异请求或非 ALLOCATED 状态返回 A0409。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||||
|
responses: { '200': { description: 状态 PENDING_DISPATCH } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/dispatch:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 确认本土卡车已派车
|
||||||
|
description: 权限 platform:overseas-business:truck:status;本土卡车单自身履约,不创建海外仓排车单、出库单或派送单。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||||
|
responses: { '200': { description: 状态 DISPATCHED } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/start-transit:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 确认本土卡车开始运输
|
||||||
|
description: 权限 platform:overseas-business:truck:status;仅 DISPATCHED 可进入 IN_TRANSIT。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||||
|
responses: { '200': { description: 状态 IN_TRANSIT } }
|
||||||
|
/api/platform/v1/last-leg/us-truck-orders/{orderNo}/complete:
|
||||||
|
parameters: *truckOrderTarget
|
||||||
|
post:
|
||||||
|
summary: 完成本土卡车订单
|
||||||
|
description: 权限 platform:overseas-business:truck:status;仅 IN_TRANSIT 可进入不可逆 COMPLETED。
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCommand' } } } }
|
||||||
|
responses: { '200': { description: 状态 COMPLETED } }
|
||||||
/api/platform/v1/last-leg/local-looseboard-orders:
|
/api/platform/v1/last-leg/local-looseboard-orders:
|
||||||
get:
|
get:
|
||||||
summary: 分页查询本土散板订单
|
summary: 分页查询本土散板订单
|
||||||
@@ -373,12 +463,12 @@ paths:
|
|||||||
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status:
|
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status:
|
||||||
patch:
|
patch:
|
||||||
summary: 推进或取消本土散板订单
|
summary: 推进或取消本土散板订单
|
||||||
description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。
|
description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,另建订单可进入 CANCELLED;幂等键异请求、旧 version、非法跳转或终态反向返回 A0409。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
- { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
|
||||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [version, status], properties: { version: { type: integer, format: int64, minimum: 0 }, status: { $ref: '#/components/schemas/LooseboardStatus' }, remark: { type: string, nullable: true, maxLength: 1000 } } } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardStatusChange' } } } }
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } }
|
||||||
/api/platform/v1/last-leg/relabel-applications:
|
/api/platform/v1/last-leg/relabel-applications:
|
||||||
get:
|
get:
|
||||||
@@ -423,6 +513,101 @@ paths:
|
|||||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
|
PdaWarehouseScan:
|
||||||
|
type: object
|
||||||
|
required: [code]
|
||||||
|
properties: { code: { type: string, minLength: 1, maxLength: 128 } }
|
||||||
|
PdaInventoryItem:
|
||||||
|
type: object
|
||||||
|
required: [customerId, balanceId, positionId, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version]
|
||||||
|
properties:
|
||||||
|
customerId: { type: string }
|
||||||
|
customerName: { type: string, nullable: true }
|
||||||
|
balanceId: { type: integer, format: int64 }
|
||||||
|
positionId: { type: integer, format: int64 }
|
||||||
|
skuCode: { type: string, nullable: true }
|
||||||
|
productName: { type: string, nullable: true }
|
||||||
|
inboundCode: { type: string }
|
||||||
|
cartonMark: { type: string, nullable: true }
|
||||||
|
palletId: { type: integer, format: int64 }
|
||||||
|
palletCode: { type: string }
|
||||||
|
locationId: { type: integer, format: int64, nullable: true }
|
||||||
|
locationCode: { type: string, nullable: true }
|
||||||
|
availablePackages: { type: integer, format: int64, minimum: 0 }
|
||||||
|
holdPackages: { type: integer, format: int64, minimum: 0 }
|
||||||
|
receivingPackages: { type: integer, format: int64, minimum: 0 }
|
||||||
|
status: { type: string }
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
PdaInventoryScan:
|
||||||
|
type: object
|
||||||
|
required: [queryCode, items]
|
||||||
|
properties:
|
||||||
|
queryCode: { type: string }
|
||||||
|
items: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } }
|
||||||
|
PdaLocation:
|
||||||
|
type: object
|
||||||
|
required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount]
|
||||||
|
properties:
|
||||||
|
locationId: { type: integer, format: int64 }
|
||||||
|
locationCode: { type: string }
|
||||||
|
locationType: { type: string }
|
||||||
|
maxLoadKg: { type: number, nullable: true }
|
||||||
|
status: { type: string }
|
||||||
|
occupiedPackages: { type: integer, format: int64, minimum: 0 }
|
||||||
|
inventoryPositionCount: { type: integer, minimum: 0 }
|
||||||
|
PdaPallet:
|
||||||
|
type: object
|
||||||
|
required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory]
|
||||||
|
properties:
|
||||||
|
palletId: { type: integer, format: int64 }
|
||||||
|
palletCode: { type: string }
|
||||||
|
status: { type: string }
|
||||||
|
version: { type: integer, minimum: 0 }
|
||||||
|
locationId: { type: integer, format: int64 }
|
||||||
|
locationCode: { type: string }
|
||||||
|
occupiedPackages: { type: integer, format: int64, minimum: 0 }
|
||||||
|
inventoryPositionCount: { type: integer, minimum: 0 }
|
||||||
|
inventory: { type: array, items: { $ref: '#/components/schemas/PdaInventoryItem' } }
|
||||||
|
PdaOperation:
|
||||||
|
type: object
|
||||||
|
required: [operationId, operationType, businessKey, status, occurredAt]
|
||||||
|
properties:
|
||||||
|
operationId: { type: integer, format: int64 }
|
||||||
|
operationType: { type: string }
|
||||||
|
businessKey: { type: string }
|
||||||
|
status: { type: string }
|
||||||
|
summary: { type: string, nullable: true }
|
||||||
|
customerId: { type: string, nullable: true }
|
||||||
|
occurredAt: { type: string, format: date-time }
|
||||||
|
PdaInventoryScanApiResponse:
|
||||||
|
allOf:
|
||||||
|
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||||
|
- type: object
|
||||||
|
properties: { data: { $ref: '#/components/schemas/PdaInventoryScan' } }
|
||||||
|
PdaLocationApiResponse:
|
||||||
|
allOf:
|
||||||
|
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||||
|
- type: object
|
||||||
|
properties: { data: { $ref: '#/components/schemas/PdaLocation' } }
|
||||||
|
PdaPalletApiResponse:
|
||||||
|
allOf:
|
||||||
|
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||||
|
- type: object
|
||||||
|
properties: { data: { $ref: '#/components/schemas/PdaPallet' } }
|
||||||
|
PdaOperationPageApiResponse:
|
||||||
|
allOf:
|
||||||
|
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
required: [records, page, size, total, pages]
|
||||||
|
properties:
|
||||||
|
records: { type: array, items: { $ref: '#/components/schemas/PdaOperation' } }
|
||||||
|
page: { type: integer }
|
||||||
|
size: { type: integer }
|
||||||
|
total: { type: integer, format: int64 }
|
||||||
|
pages: { type: integer, format: int64 }
|
||||||
ReceivingAction:
|
ReceivingAction:
|
||||||
type: object
|
type: object
|
||||||
required: [idempotencyKey]
|
required: [idempotencyKey]
|
||||||
@@ -765,6 +950,14 @@ components:
|
|||||||
status: { $ref: '#/components/schemas/RelabelStatus' }
|
status: { $ref: '#/components/schemas/RelabelStatus' }
|
||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
|
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
|
||||||
|
ProcurementReview:
|
||||||
|
type: object
|
||||||
|
required: [version, decision, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
ProcurementCreate:
|
ProcurementCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
|
required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey]
|
||||||
@@ -812,6 +1005,49 @@ components:
|
|||||||
code: { type: string, enum: ['200'] }
|
code: { type: string, enum: ['200'] }
|
||||||
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } }
|
data: { type: array, items: { type: object, properties: { warehouseId: { type: string }, warehouseCode: { type: string }, warehouseName: { type: string }, countryCode: { type: string } } } }
|
||||||
TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
|
TruckStatus: { type: string, enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED] }
|
||||||
|
TruckReview:
|
||||||
|
type: object
|
||||||
|
required: [version, decision, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
decision: { type: string, enum: [APPROVE, REJECT] }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
|
TruckCancel:
|
||||||
|
type: object
|
||||||
|
required: [version, status, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
status: { type: string, enum: [CANCELLED] }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
|
TruckReceivePallets:
|
||||||
|
type: object
|
||||||
|
required: [version, actualPalletCount, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
actualPalletCount: { type: integer, minimum: 1 }
|
||||||
|
packageCount: { type: integer, nullable: true, minimum: 0 }
|
||||||
|
palletPackageCounts: { type: array, maxItems: 500, items: { type: integer, minimum: 0 } }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
|
TruckReviewPalletOverage:
|
||||||
|
allOf: [{ $ref: '#/components/schemas/TruckReview' }]
|
||||||
|
TruckAllocatePallets:
|
||||||
|
type: object
|
||||||
|
required: [version, palletIds, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
palletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: integer, format: int64, minimum: 1 } }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
|
TruckCommand:
|
||||||
|
type: object
|
||||||
|
required: [version, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
TruckCreate:
|
TruckCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey]
|
required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey]
|
||||||
@@ -855,6 +1091,14 @@ components:
|
|||||||
requestedDate: { type: string, format: date }
|
requestedDate: { type: string, format: date }
|
||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||||
|
LooseboardStatusChange:
|
||||||
|
type: object
|
||||||
|
required: [version, status, idempotencyKey]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
status: { $ref: '#/components/schemas/LooseboardStatus' }
|
||||||
|
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||||
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
LooseboardCreate:
|
LooseboardCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
|
required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
|
||||||
|
|||||||
+2
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
|||||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||||
|
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -52,6 +53,7 @@ class LastlegOrderControllerTest {
|
|||||||
private LastlegOrderController controller() {
|
private LastlegOrderController controller() {
|
||||||
LastlegOrderController controller = new LastlegOrderController();
|
LastlegOrderController controller = new LastlegOrderController();
|
||||||
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
|
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
|
||||||
|
ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class));
|
||||||
ReflectionTestUtils.setField(controller, "orderService", service);
|
ReflectionTestUtils.setField(controller, "orderService", service);
|
||||||
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
|
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
|
||||||
return controller;
|
return controller;
|
||||||
|
|||||||
+5
-4
@@ -51,7 +51,8 @@ class ReceivingContainerContractTest {
|
|||||||
void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() {
|
void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() {
|
||||||
PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class);
|
PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class);
|
||||||
assertTrue(permission != null);
|
assertTrue(permission != null);
|
||||||
assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value()));
|
assertTrue("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"
|
||||||
|
.equals(permission.value()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -131,9 +132,9 @@ class ReceivingContainerContractTest {
|
|||||||
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
||||||
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
||||||
.filter(line -> line.startsWith("responses: { '200':")).toList();
|
.filter(line -> line.startsWith("responses: { '200':")).toList();
|
||||||
assertEquals(12, successResponses.size());
|
assertEquals(16, successResponses.size());
|
||||||
assertTrue(successResponses.stream().allMatch(line ->
|
assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
|
||||||
line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving")));
|
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
|
||||||
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
|
||||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||||
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||||
|
|||||||
+4
-2
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller;
|
|||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -57,13 +58,14 @@ class ReceivingContainerControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
|
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
|
||||||
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
||||||
Set.of(ReceivingContainerController.OPERATE_PERMISSION));
|
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION));
|
||||||
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
|
assertThatThrownBy(() -> controller.candidates(unbound)).isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("403");
|
.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), false, null, null, false, 900L);
|
Set.of(PdaWarehouseIdentity.PICKER_ROLE, ReceivingContainerController.OPERATE_PERMISSION),
|
||||||
|
false, null, null, false, 900L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -23,6 +23,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper
|
|||||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -57,6 +58,7 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
@Mock ReceivingContainerSessionMapper sessions;
|
@Mock ReceivingContainerSessionMapper sessions;
|
||||||
@Mock ReceivingContainerOperationMapper operations;
|
@Mock ReceivingContainerOperationMapper operations;
|
||||||
@Mock ReceivingSkuReceiptMapper skuReceipts;
|
@Mock ReceivingSkuReceiptMapper skuReceipts;
|
||||||
|
@Mock ContainerShippingGateMapper shippingGates;
|
||||||
@Mock WarehouseResourceGateway warehouse;
|
@Mock WarehouseResourceGateway warehouse;
|
||||||
@Mock UnloadPhotoFileGateway unloadPhotos;
|
@Mock UnloadPhotoFileGateway unloadPhotos;
|
||||||
@Mock InboundPdaWorkflowService workflow;
|
@Mock InboundPdaWorkflowService workflow;
|
||||||
@@ -75,11 +77,25 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
ReflectionTestUtils.setField(service, "sessions", sessions);
|
ReflectionTestUtils.setField(service, "sessions", sessions);
|
||||||
ReflectionTestUtils.setField(service, "operations", operations);
|
ReflectionTestUtils.setField(service, "operations", operations);
|
||||||
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
|
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
|
||||||
|
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
|
||||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||||
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
|
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
|
||||||
ReflectionTestUtils.setField(service, "workflow", workflow);
|
ReflectionTestUtils.setField(service, "workflow", workflow);
|
||||||
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
|
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
|
||||||
service.initializeObjectMapper();
|
service.initializeObjectMapper();
|
||||||
|
org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void containerIsHiddenAndPrepareRejectedBeforePickupReady() {
|
||||||
|
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
|
||||||
|
when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0);
|
||||||
|
|
||||||
|
assertThat(service.candidates("7", 100L)).isEmpty();
|
||||||
|
assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A",
|
||||||
|
new ReceivingContainerRequests.Action("not-ready")))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
|
verifyNoInteractions(warehouse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -315,6 +331,16 @@ class ReceivingContainerServiceBehaviorTest {
|
|||||||
verifyNoInteractions(unloadPhotos);
|
verifyNoInteractions(unloadPhotos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signRejectsMissingPhotosBeforeFileValidation() {
|
||||||
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
|
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(), "missing-photos");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
|
verifyNoInteractions(unloadPhotos);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
||||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||||
|
|||||||
+2
@@ -15,6 +15,8 @@ class LocalLooseboardSchemaContractTest {
|
|||||||
}
|
}
|
||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order"));
|
||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_history"));
|
||||||
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation"));
|
||||||
|
assertTrue(sql.contains("uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key)"));
|
||||||
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
||||||
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
|
assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)"));
|
||||||
assertTrue(sql.contains("lastleg_order_id BIGINT NULL"));
|
assertTrue(sql.contains("lastleg_order_id BIGINT NULL"));
|
||||||
|
|||||||
+59
-2
@@ -3,8 +3,10 @@ package com.baiyue.lastleg.looseboard.service;
|
|||||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
|
||||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||||
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||||
@@ -22,6 +24,8 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -29,27 +33,38 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class LocalLooseboardServiceTest {
|
class LocalLooseboardServiceTest {
|
||||||
@Mock LocalLooseboardOrderMapper orders;
|
@Mock LocalLooseboardOrderMapper orders;
|
||||||
@Mock LocalLooseboardHistoryMapper histories;
|
@Mock LocalLooseboardHistoryMapper histories;
|
||||||
|
@Mock LocalLooseboardOperationMapper operations;
|
||||||
@Mock LastlegOrderMapper lastlegOrders;
|
@Mock LastlegOrderMapper lastlegOrders;
|
||||||
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||||
@Mock LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
@Mock LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||||
private LocalLooseboardService service;
|
private LocalLooseboardService service;
|
||||||
|
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new LocalLooseboardService();
|
service = new LocalLooseboardService();
|
||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
ReflectionTestUtils.setField(service, "histories", histories);
|
ReflectionTestUtils.setField(service, "histories", histories);
|
||||||
|
ReflectionTestUtils.setField(service, "operations", operations);
|
||||||
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
|
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
|
||||||
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
|
ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories);
|
||||||
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
|
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||||
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next()).thenReturn("LMO-LOOSE-1");
|
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next()).thenReturn("LMO-LOOSE-1");
|
||||||
|
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
|
||||||
|
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
|
||||||
|
org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> {
|
||||||
|
LocalLooseboardOperation value = call.getArgument(0);
|
||||||
|
operationRows.put(value.getIdempotencyKey(), value);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -103,12 +118,54 @@ class LocalLooseboardServiceTest {
|
|||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
|
||||||
var detail = service.changeStatus("100", "LB1", "7",
|
var detail = service.changeStatus("100", "LB1", "7",
|
||||||
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "accepted"));
|
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "status-1", "accepted"));
|
||||||
|
|
||||||
assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status());
|
assertEquals(LocalLooseboardService.DISPATCHED, detail.summary().status());
|
||||||
assertEquals(1L, detail.summary().version());
|
assertEquals(1L, detail.summary().version());
|
||||||
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
|
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
|
||||||
"STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus())));
|
"STATUS".equals(value.getOperationType()) && "DISPATCHED".equals(value.getToStatus())
|
||||||
|
&& "7".equals(value.getOperatorId()) && "status-1".equals(value.getIdempotencyKey())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullStatusLifecycleIsIdempotentAndCompletedCannotReverse() {
|
||||||
|
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
|
||||||
|
when(orders.lockNo("100", "LB1")).thenReturn(order);
|
||||||
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
var dispatch = new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "dispatch-1", "dispatch");
|
||||||
|
assertEquals(LocalLooseboardService.DISPATCHED,
|
||||||
|
service.changeStatus("100", "LB1", "operator-7", dispatch).summary().status());
|
||||||
|
assertEquals(1L, service.changeStatus("100", "LB1", "operator-7", dispatch).summary().version());
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "dispatch-1", "different")));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(0L, "IN_TRANSIT", "stale-1", "stale")));
|
||||||
|
assertEquals(LocalLooseboardService.IN_TRANSIT, service.changeStatus("100", "LB1", "operator-8",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(1L, "IN_TRANSIT", "transit-1", "transit"))
|
||||||
|
.summary().status());
|
||||||
|
assertEquals(LocalLooseboardService.COMPLETED, service.changeStatus("100", "LB1", "operator-9",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(2L, "COMPLETED", "complete-1", "complete"))
|
||||||
|
.summary().status());
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-9",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(3L, "IN_TRANSIT", "reverse-1", "reverse")));
|
||||||
|
verify(histories, times(3)).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
|
||||||
|
"STATUS".equals(value.getOperationType()) && value.getOperatorId() != null
|
||||||
|
&& value.getIdempotencyKey() != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void separateDispatchedFixtureCanCancelButCannotProgressAfterCancellation() {
|
||||||
|
LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L);
|
||||||
|
when(orders.lockNo("100", "LB1")).thenReturn(order);
|
||||||
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
assertEquals(LocalLooseboardService.DISPATCHED, service.changeStatus("100", "LB1", "operator-7",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "cancel-fixture-dispatch", "dispatch"))
|
||||||
|
.summary().status());
|
||||||
|
assertEquals(LocalLooseboardService.CANCELLED, service.changeStatus("100", "LB1", "operator-7",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(1L, "CANCELLED", "cancel-1", "cancel"))
|
||||||
|
.summary().status());
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "LB1", "operator-7",
|
||||||
|
new LocalLooseboardRequests.ChangeStatus(2L, "IN_TRANSIT", "after-cancel", "invalid")));
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalLooseboardRequests.Create create(String key) {
|
private LocalLooseboardRequests.Create create(String key) {
|
||||||
|
|||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.controller.PdaWarehouseController;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.lang.reflect.RecordComponent;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class PdaWarehouseContractTest {
|
||||||
|
@Test
|
||||||
|
void requestsNeverAcceptServerOwnedIdentityOrResourceIds() {
|
||||||
|
Set<String> forbidden = Set.of("customerId", "warehouseId", "planNo", "allocationId", "operatorId");
|
||||||
|
for (Class<?> type : PdaWarehouseRequests.class.getDeclaredClasses()) {
|
||||||
|
if (!type.isRecord()) continue;
|
||||||
|
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void controllerRequiresPickerRoleAndPdaAuthority() {
|
||||||
|
PreAuthorize permission = PdaWarehouseController.class.getAnnotation(PreAuthorize.class);
|
||||||
|
assertThat(permission.value()).isEqualTo(
|
||||||
|
"hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identityRejectsUnboundOrNonPickerPlatformAccounts() {
|
||||||
|
AuthenticatedUser unbound = user(Set.of(PdaWarehouseIdentity.PICKER_ROLE,
|
||||||
|
PdaWarehouseIdentity.OPERATE_PERMISSION), null);
|
||||||
|
assertThatThrownBy(() -> PdaWarehouseIdentity.require(unbound))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||||
|
AuthenticatedUser admin = user(Set.of(PdaWarehouseIdentity.OPERATE_PERMISSION), 900L);
|
||||||
|
assertThatThrownBy(() -> PdaWarehouseIdentity.require(admin))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||||
|
assertThat(PdaWarehouseIdentity.require(user(Set.of(PdaWarehouseIdentity.PICKER_ROLE,
|
||||||
|
PdaWarehouseIdentity.OPERATE_PERMISSION), 900L)).overseasWarehouseId()).isEqualTo(900L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void baselineContainsShippingGateAndRealOperationHistory() throws Exception {
|
||||||
|
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")).toLowerCase();
|
||||||
|
assertThat(sql).contains("create table if not exists ll_container_shipping_gate")
|
||||||
|
.contains("awaiting_pickup_ready")
|
||||||
|
.contains("receiving_available")
|
||||||
|
.contains("create table if not exists ll_pda_operation_history")
|
||||||
|
.contains("unique key uk_ll_pda_history_idempotency(operator_key,idempotency_key)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception {
|
||||||
|
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
|
||||||
|
for (String path : Set.of("/api/pda/v1/last-leg/inventory/resolve:",
|
||||||
|
"/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:",
|
||||||
|
"/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path);
|
||||||
|
assertThat(openapi).doesNotContain("/api/pda/v1/last-leg/inbound-plans/{planNo}");
|
||||||
|
String gateway = Files.readString(Path.of("../gateway-overseas/src/main/resources/application-overseas.yml"));
|
||||||
|
assertThat(gateway).contains("/api/pda/v1/last-leg/**").contains("lb://lastleg-service");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthenticatedUser user(Set<String> authorities, Long warehouseId) {
|
||||||
|
return new AuthenticatedUser(42L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA",
|
||||||
|
authorities, false, null, null, false, warehouseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.baiyue.lastleg.pda.warehouse.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.dto.PdaWarehouseRequests;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper;
|
||||||
|
import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PdaWarehouseServiceTest {
|
||||||
|
@Mock PdaWarehouseMapper mapper;
|
||||||
|
@Mock WarehouseResourceGateway warehouse;
|
||||||
|
private PdaWarehouseService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new PdaWarehouseService();
|
||||||
|
ReflectionTestUtils.setField(service, "mapper", mapper);
|
||||||
|
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inventoryScanIsBoundToTokenWarehouse() {
|
||||||
|
when(mapper.scanInventory("900", "SKU-1")).thenReturn(List.of(inventory()));
|
||||||
|
var result = service.inventory(900L, new PdaWarehouseRequests.Scan("SKU-1"));
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).customerId()).isEqualTo("100");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void palletFromAnotherWarehouseIsRejected() {
|
||||||
|
when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
|
||||||
|
1L, "PAL-1", 901L, 2L, "OCCUPIED", null, 1));
|
||||||
|
assertThatThrownBy(() -> service.pallet(900L, new PdaWarehouseRequests.Scan("PAL-1")))
|
||||||
|
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void locationCombinesPhysicalCapacityAndInventoryOccupation() {
|
||||||
|
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(
|
||||||
|
new WarehouseResourceGateway.WarehouseLocationReference(2L, "A-01", "STORAGE",
|
||||||
|
new BigDecimal("1000"), "OCCUPIED"));
|
||||||
|
when(mapper.inventoryByLocation("900", 2L, "A-01", "__NO_CARTON__"))
|
||||||
|
.thenReturn(List.of(inventory()));
|
||||||
|
var result = service.location(900L, new PdaWarehouseRequests.Scan("A-01"));
|
||||||
|
assertThat(result.occupiedPackages()).isEqualTo(5);
|
||||||
|
assertThat(result.inventoryPositionCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PdaInventoryProjection inventory() {
|
||||||
|
PdaInventoryProjection value = new PdaInventoryProjection(); value.setCustomerId("100");
|
||||||
|
value.setBalanceId(10L); value.setPositionId(11L); value.setSkuCode("SKU-1");
|
||||||
|
value.setInboundCode("IN-1"); value.setPalletId(12L); value.setPalletCode("PAL-1");
|
||||||
|
value.setLocationId(2L); value.setLocationCode("A-01"); value.setAvailablePackages(5L);
|
||||||
|
value.setHoldPackages(0L); value.setReceivingPackages(0L); value.setVersion(1L); value.setStatus("OCCUPIED");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -31,6 +31,16 @@ class OverseasProcurementOpenApiContractTest {
|
|||||||
}
|
}
|
||||||
assertTrue(yaml.contains("platform:overseas-business:truck:create"));
|
assertTrue(yaml.contains("platform:overseas-business:truck:create"));
|
||||||
assertTrue(yaml.contains("platform:overseas-business:truck:edit"));
|
assertTrue(yaml.contains("platform:overseas-business:truck:edit"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/review:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/receive-pallets:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/review-pallet-overage:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/allocate-pallets:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/prepare-dispatch:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/dispatch:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/start-transit:"));
|
||||||
|
assertTrue(yaml.contains("/{orderNo}/complete:"));
|
||||||
|
assertTrue(yaml.contains("enum: [PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED]"));
|
||||||
|
assertTrue(yaml.contains("required: [version, status, idempotencyKey]"));
|
||||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/local-looseboard-orders:"));
|
assertTrue(yaml.contains("/api/platform/v1/last-leg/local-looseboard-orders:"));
|
||||||
assertTrue(yaml.contains("platform:overseas-business:looseboard:list"));
|
assertTrue(yaml.contains("platform:overseas-business:looseboard:list"));
|
||||||
assertTrue(yaml.contains("platform:overseas-business:looseboard:create"));
|
assertTrue(yaml.contains("platform:overseas-business:looseboard:create"));
|
||||||
|
|||||||
+3
@@ -16,6 +16,9 @@ class OverseasProcurementSchemaContractTest {
|
|||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order"));
|
||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history"));
|
||||||
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL"));
|
||||||
|
assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL"));
|
||||||
|
assertTrue(sql.contains("review_request_hash CHAR(64) NULL"));
|
||||||
|
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
|
||||||
assertTrue(sql.contains("change_detail JSON NULL"));
|
assertTrue(sql.contains("change_detail JSON NULL"));
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
|
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound"));
|
||||||
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT"));
|
org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_quantity BIGINT"));
|
||||||
|
|||||||
+2
-2
@@ -40,7 +40,7 @@ class OverseasProcurementControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reviewRequiresExplicitPermission() {
|
void reviewRequiresExplicitPermission() {
|
||||||
var request = new OverseasProcurementRequests.Review("APPROVE", null);
|
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null);
|
||||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||||
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
|
() -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request));
|
||||||
assertEquals(403, error.getStatusCode().value());
|
assertEquals(403, error.getStatusCode().value());
|
||||||
@@ -56,7 +56,7 @@ class OverseasProcurementControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformReviewUsesExplicitTargetTenant() {
|
void platformReviewUsesExplicitTargetTenant() {
|
||||||
var request = new OverseasProcurementRequests.Review("APPROVE", "ok");
|
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok");
|
||||||
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
|
platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)),
|
||||||
"100", "HC1", request);
|
"100", "HC1", request);
|
||||||
verify(service).review("100", "HC1", "7", request);
|
verify(service).review("100", "HC1", "7", request);
|
||||||
|
|||||||
+27
@@ -145,6 +145,33 @@ class OverseasProcurementServiceTest {
|
|||||||
assertEquals("Acme", values.get(0).customerName());
|
assertEquals("Acme", values.get(0).customerName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReviewIsIdempotentAuditedAndTerminalCannotReverse() {
|
||||||
|
OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0);
|
||||||
|
when(orders.lockNo("100", "HC1")).thenReturn(order);
|
||||||
|
when(orders.findNo("100", "HC1")).thenReturn(order);
|
||||||
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved");
|
||||||
|
|
||||||
|
var approved = service.review("100", "HC1", "operator-7", request);
|
||||||
|
assertEquals(OverseasProcurementService.APPROVED, approved.summary().status());
|
||||||
|
assertEquals(1L, approved.summary().version());
|
||||||
|
assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version());
|
||||||
|
verify(histories).insert(org.mockito.ArgumentMatchers
|
||||||
|
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>argThat(value ->
|
||||||
|
"REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId())
|
||||||
|
&& "approve-1".equals(value.getIdempotencyKey())
|
||||||
|
&& OverseasProcurementService.APPROVED.equals(value.getToStatus())));
|
||||||
|
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||||
|
new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload")));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||||
|
new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version")));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7",
|
||||||
|
new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse")));
|
||||||
|
assertEquals(OverseasProcurementService.APPROVED, order.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
|
private OverseasProcurementOrder order(String status, long inbound, long outbound) {
|
||||||
OverseasProcurementOrder order = new OverseasProcurementOrder();
|
OverseasProcurementOrder order = new OverseasProcurementOrder();
|
||||||
order.setId(1L);
|
order.setId(1L);
|
||||||
|
|||||||
+11
-2
@@ -7,6 +7,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class SchemaChineseCommentContractTest {
|
class SchemaChineseCommentContractTest {
|
||||||
|
|
||||||
|
private static final Set<String> MODULE_BASELINE_ONLY_TABLES = Set.of(
|
||||||
|
"ll_us_truck_order_operation", "ll_local_looseboard_operation");
|
||||||
|
|
||||||
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
|
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
|
||||||
private static final Pattern TABLE = Pattern.compile(
|
private static final Pattern TABLE = Pattern.compile(
|
||||||
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
|
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
|
||||||
@@ -45,14 +49,16 @@ class SchemaChineseCommentContractTest {
|
|||||||
List<String> columns = List.of(
|
List<String> columns = List.of(
|
||||||
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
|
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
|
||||||
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
|
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
|
||||||
"allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key",
|
"allocated_pallet_count"
|
||||||
"receipt_request_hash"
|
|
||||||
);
|
);
|
||||||
for (String column : columns) {
|
for (String column : columns) {
|
||||||
assertColumnHasChineseComment(baseline, column, "基线 SQL");
|
assertColumnHasChineseComment(baseline, column, "基线 SQL");
|
||||||
assertColumnHasChineseComment(incremental, column, "增量 SQL");
|
assertColumnHasChineseComment(incremental, column, "增量 SQL");
|
||||||
assertIncrementalAddColumnHasChineseComment(incremental, column);
|
assertIncrementalAddColumnHasChineseComment(incremental, column);
|
||||||
}
|
}
|
||||||
|
for (String column : List.of("review_idempotency_key", "review_request_hash", "result_version")) {
|
||||||
|
assertColumnHasChineseComment(baseline, column, "基线 SQL");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
|
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
|
||||||
@@ -90,6 +96,9 @@ class SchemaChineseCommentContractTest {
|
|||||||
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
|
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
|
||||||
String migration = incremental.substring(marker);
|
String migration = incremental.substring(marker);
|
||||||
for (TableDefinition table : tables) {
|
for (TableDefinition table : tables) {
|
||||||
|
if (MODULE_BASELINE_ONLY_TABLES.contains(table.name())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
boolean createdInLegacy = hasCommentedCreate(migration, table.name());
|
boolean createdInLegacy = hasCommentedCreate(migration, table.name());
|
||||||
boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name());
|
boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name());
|
||||||
assertTrue(createdInLegacy || createdInUnified
|
assertTrue(createdInLegacy || createdInUnified
|
||||||
|
|||||||
+3
-2
@@ -2,9 +2,10 @@ package com.baiyue.lastleg.service;
|
|||||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||||
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
||||||
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; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; 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,"shippingGates",shippingGates); 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()))); 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 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 loadingCreatesAwaitingPickupReadyGate(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",2L,1L); }
|
||||||
@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 {
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
package com.baiyue.lastleg.shipping.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
|
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||||
|
import com.baiyue.common.event.EventPayloadHash;
|
||||||
|
import com.baiyue.common.event.EventTypes;
|
||||||
|
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
|
||||||
|
import com.baiyue.common.event.RegionCode;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegEventInbox;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegEventInboxMapper;
|
||||||
|
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
||||||
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ContainerShippingNodeConsumerTest {
|
||||||
|
@Mock LastlegEventInboxMapper inboxes;
|
||||||
|
@Mock ContainerShippingGateMapper gates;
|
||||||
|
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||||
|
private ContainerShippingNodeConsumer consumer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
consumer = new ContainerShippingNodeConsumer();
|
||||||
|
ReflectionTestUtils.setField(consumer, "inboxes", inboxes);
|
||||||
|
ReflectionTestUtils.setField(consumer, "gates", gates);
|
||||||
|
ReflectionTestUtils.setField(consumer, "objectMapper", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pickupReadyMonotonicallyOpensReceivingGate() {
|
||||||
|
ContainerShippingGate gate = gate("AWAITING_PICKUP_READY", 70, 4, Instant.parse("2026-08-20T10:00:00Z"));
|
||||||
|
when(gates.lock("CB-1")).thenReturn(gate);
|
||||||
|
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5,
|
||||||
|
Instant.parse("2026-08-21T10:00:00Z"));
|
||||||
|
|
||||||
|
assertThat(consumer.consume(event)).isEqualTo(1);
|
||||||
|
|
||||||
|
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
|
||||||
|
assertThat(gate.getNodeSequence()).isEqualTo(80);
|
||||||
|
assertThat(gate.getAggregateVersion()).isEqualTo(5);
|
||||||
|
verify(gates).updateById(gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lowerNodeAndOlderAggregateAreAuditedWithoutRegression() {
|
||||||
|
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 90, 8, Instant.parse("2026-08-22T10:00:00Z"));
|
||||||
|
when(gates.lock("CB-1")).thenReturn(gate);
|
||||||
|
|
||||||
|
consumer.consume(event("123e4567-e89b-12d3-a456-426614174102", "PICKUP_READY", 80, 7,
|
||||||
|
Instant.parse("2026-08-23T10:00:00Z")));
|
||||||
|
|
||||||
|
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
|
||||||
|
assertThat(gate.getNodeSequence()).isEqualTo(90);
|
||||||
|
assertThat(gate.getAggregateVersion()).isEqualTo(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameNodeOnlyAcceptsLaterActualTimeAndEmptyReturnedNeverClosesGate() {
|
||||||
|
ContainerShippingGate gate = gate("RECEIVING_AVAILABLE", 100, 9, Instant.parse("2026-08-22T10:00:00Z"));
|
||||||
|
when(gates.lock("CB-1")).thenReturn(gate);
|
||||||
|
consumer.consume(event("123e4567-e89b-12d3-a456-426614174103", "EMPTY_RETURNED", 100, 10,
|
||||||
|
Instant.parse("2026-08-22T12:00:00Z")));
|
||||||
|
|
||||||
|
assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE");
|
||||||
|
assertThat(gate.getActualAt()).isEqualTo(java.time.LocalDateTime.parse("2026-08-22T12:00:00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventIdIsIdempotentByHashAndRejectsDifferentHash() {
|
||||||
|
EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5,
|
||||||
|
Instant.parse("2026-08-21T10:00:00Z"));
|
||||||
|
LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash());
|
||||||
|
when(inboxes.selectById(event.eventId())).thenReturn(existing);
|
||||||
|
assertThat(consumer.consume(event)).isEqualTo(1);
|
||||||
|
verify(gates, never()).updateById(any(ContainerShippingGate.class));
|
||||||
|
|
||||||
|
existing.setPayloadHash("sha256:" + "0".repeat(64));
|
||||||
|
assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("摘要冲突");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
|
||||||
|
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
|
||||||
|
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");
|
||||||
|
gate.setStatus(status); gate.setNodeSequence(sequence); gate.setAggregateVersion(version);
|
||||||
|
gate.setHandoverVersion(version + 1); gate.setActualAt(java.time.LocalDateTime.ofInstant(actualAt,
|
||||||
|
java.time.ZoneOffset.UTC)); return gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventEnvelope event(String eventId, String node, int sequence, long version, Instant actualAt) {
|
||||||
|
FirstLegContainerShippingNodeRecordedV1 payload = new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"Q-1", "100", "OB-1", "CB-1", "CONT-1", node, sequence, actualAt,
|
||||||
|
actualAt, "LAX", "Los Angeles", "CARRIER", "PE-1", node, "REF-1", "a".repeat(64));
|
||||||
|
JsonNode payloadNode = json.valueToTree(payload);
|
||||||
|
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||||
|
return new EventEnvelope(eventId, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
|
||||||
|
actualAt, RegionCode.CN, RegionCode.OVERSEAS, "100", "CONTAINER_BOOKING", "CB-1",
|
||||||
|
version, version + 1, "123e4567-e89b-12d3-a456-426614174199", "shipping:CB-1", null,
|
||||||
|
payloadNode, hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -18,7 +18,11 @@ class UsTruckOrderSchemaContractTest {
|
|||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
|
||||||
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
|
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
|
||||||
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
|
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
|
||||||
assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL"));
|
assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0"));
|
||||||
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation"));
|
||||||
|
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)"));
|
||||||
|
assertTrue(sql.contains("operation_type VARCHAR(64) NOT NULL"));
|
||||||
|
assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'"));
|
||||||
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
|
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
|
||||||
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
|
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -37,7 +37,7 @@ class UsTruckOrderControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformStatusChangeRequiresManagePermission() {
|
void platformStatusChangeRequiresManagePermission() {
|
||||||
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null);
|
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", null);
|
||||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||||
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
|
() -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request));
|
||||||
assertEquals(403, error.getStatusCode().value());
|
assertEquals(403, error.getStatusCode().value());
|
||||||
@@ -45,11 +45,34 @@ class UsTruckOrderControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformStatusChangeUsesExplicitTargetTenant() {
|
void platformStatusChangeUsesExplicitTargetTenant() {
|
||||||
var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned");
|
var request = new UsTruckOrderRequests.ChangeStatus(0L, "CANCELLED", "cancel-1", "cancelled");
|
||||||
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
|
platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request);
|
||||||
verify(service).changeStatus("100", "TK1", "7", request);
|
verify(service).changeStatus("100", "TK1", "7", request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformLifecycleCommandsUseExplicitTargetAndStatusPermission() {
|
||||||
|
var user = user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION));
|
||||||
|
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", null);
|
||||||
|
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE", "overage-1", null);
|
||||||
|
var allocation = new UsTruckOrderRequests.AllocatePallets(3L, java.util.List.of(1L), "allocate-1", null);
|
||||||
|
var command = new UsTruckOrderRequests.Command(4L, "command-1", null);
|
||||||
|
platform.review(user, "100", "TK1", review);
|
||||||
|
platform.reviewPalletOverage(user, "100", "TK1", overage);
|
||||||
|
platform.allocatePallets(user, "100", "TK1", allocation);
|
||||||
|
platform.prepareDispatch(user, "100", "TK1", command);
|
||||||
|
platform.dispatch(user, "100", "TK1", command);
|
||||||
|
platform.startTransit(user, "100", "TK1", command);
|
||||||
|
platform.complete(user, "100", "TK1", command);
|
||||||
|
verify(service).review("100", "TK1", "7", review);
|
||||||
|
verify(service).reviewPalletOverage("100", "TK1", "7", overage);
|
||||||
|
verify(service).allocatePallets("100", "TK1", "7", allocation);
|
||||||
|
verify(service).prepareDispatch("100", "TK1", "7", command);
|
||||||
|
verify(service).dispatch("100", "TK1", "7", command);
|
||||||
|
verify(service).startTransit("100", "TK1", "7", command);
|
||||||
|
verify(service).complete("100", "TK1", "7", command);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void platformCreateUsesExplicitCustomerAndCreatePermission() {
|
void platformCreateUsesExplicitCustomerAndCreatePermission() {
|
||||||
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
|
var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null,
|
||||||
|
|||||||
+128
-106
@@ -2,9 +2,11 @@ package com.baiyue.lastleg.trucking.service;
|
|||||||
|
|
||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -16,18 +18,18 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -35,7 +37,12 @@ class UsTruckOrderServiceTest {
|
|||||||
@Mock UsTruckOrderMapper orders;
|
@Mock UsTruckOrderMapper orders;
|
||||||
@Mock UsTruckOrderPalletMapper pallets;
|
@Mock UsTruckOrderPalletMapper pallets;
|
||||||
@Mock UsTruckOrderStatusHistoryMapper histories;
|
@Mock UsTruckOrderStatusHistoryMapper histories;
|
||||||
|
@Mock UsTruckOrderOperationMapper operations;
|
||||||
|
private final List<UsTruckOrderPallet> palletRows = new ArrayList<>();
|
||||||
|
private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>();
|
||||||
|
private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>();
|
||||||
private UsTruckOrderService service;
|
private UsTruckOrderService service;
|
||||||
|
private UsTruckOrder order;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
@@ -43,113 +50,128 @@ class UsTruckOrderServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
ReflectionTestUtils.setField(service, "pallets", pallets);
|
ReflectionTestUtils.setField(service, "pallets", pallets);
|
||||||
ReflectionTestUtils.setField(service, "histories", histories);
|
ReflectionTestUtils.setField(service, "histories", histories);
|
||||||
|
ReflectionTestUtils.setField(service, "operations", operations);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||||
org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of());
|
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2);
|
||||||
}
|
when(orders.lockNo("100", "TK1")).thenAnswer(call -> order);
|
||||||
|
when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
|
||||||
@Test
|
when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows);
|
||||||
void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() {
|
when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows);
|
||||||
AtomicReference<UsTruckOrder> saved = new AtomicReference<>();
|
when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
|
||||||
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { saved.set(call.getArgument(0)); return 1; });
|
historyRows.add(call.getArgument(0)); return 1;
|
||||||
when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get());
|
|
||||||
when(histories.listOrder(anyString(), any())).thenReturn(List.of());
|
|
||||||
|
|
||||||
var detail = service.create("100", "7", create("key-1"));
|
|
||||||
|
|
||||||
assertEquals(UsTruckOrderService.PENDING_CONFIRMATION, detail.summary().status());
|
|
||||||
assertEquals("USD", detail.summary().currency());
|
|
||||||
assertEquals("100", detail.summary().customerId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void statusChangePersistsHistoryAndIncrementsVersion() {
|
|
||||||
UsTruckOrder order = order(UsTruckOrderService.PENDING_CONFIRMATION, 0L);
|
|
||||||
when(orders.lockNo("100", "TK1")).thenReturn(order);
|
|
||||||
when(orders.findNo("100", "TK1")).thenReturn(order);
|
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
|
||||||
|
|
||||||
var result = service.changeStatus("100", "TK1", "7",
|
|
||||||
new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted"));
|
|
||||||
|
|
||||||
assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status());
|
|
||||||
assertEquals(1, result.summary().version());
|
|
||||||
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void staleStatusChangeIsRejected() {
|
|
||||||
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.DISPATCHED, 2L));
|
|
||||||
ResponseStatusException error = assertThrows(ResponseStatusException.class, () ->
|
|
||||||
service.changeStatus("100", "TK1", "7", new UsTruckOrderRequests.ChangeStatus(1L, "IN_TRANSIT", null)));
|
|
||||||
assertEquals(409, error.getStatusCode().value());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void staleOrdinaryUpdateIsRejectedAsConflict() {
|
|
||||||
when(orders.lockNo("100", "TK1")).thenReturn(order(UsTruckOrderService.PENDING_REVIEW, 2L));
|
|
||||||
UsTruckOrderRequests.Update request = new UsTruckOrderRequests.Update(1L,
|
|
||||||
null, null, null, null, null, null, null, null, null, null, null,
|
|
||||||
null, null, null, null, null);
|
|
||||||
|
|
||||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
|
||||||
() -> service.update("100", "TK1", "7", request));
|
|
||||||
|
|
||||||
assertEquals(409, error.getStatusCode().value());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void detailIncludesBoundPallets() {
|
|
||||||
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L);
|
|
||||||
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
|
|
||||||
pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1);
|
|
||||||
pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED");
|
|
||||||
pallet.setWarehousePalletId(20L); pallet.setPackageCount(12);
|
|
||||||
when(orders.findNo("100", "TK1")).thenReturn(order);
|
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
|
||||||
when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet));
|
|
||||||
|
|
||||||
var detail = service.detail("100", "TK1");
|
|
||||||
|
|
||||||
assertEquals(1, detail.pallets().size());
|
|
||||||
assertEquals("PLT-001", detail.pallets().get(0).palletNo());
|
|
||||||
assertEquals(12, detail.pallets().get(0).packageCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() {
|
|
||||||
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L);
|
|
||||||
order.setPalletCount(1);
|
|
||||||
when(orders.lockNo("100", "TK1")).thenReturn(order);
|
|
||||||
when(orders.findNo("100", "TK1")).thenReturn(order);
|
|
||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
|
||||||
List<UsTruckOrderPallet> savedPallets = new ArrayList<>();
|
|
||||||
when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets));
|
|
||||||
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
|
|
||||||
savedPallets.add(call.getArgument(0));
|
|
||||||
return 1;
|
|
||||||
});
|
});
|
||||||
|
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
|
||||||
var result = service.receivePallets("100", "TK1", "7",
|
UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1;
|
||||||
new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets"));
|
});
|
||||||
|
when(operations.findKey(anyString(), anyLong(), anyString()))
|
||||||
assertEquals(1, result.summary().palletCount());
|
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
|
||||||
assertEquals(3, result.summary().receivedPalletCount());
|
when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> {
|
||||||
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status());
|
UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1;
|
||||||
verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class));
|
});
|
||||||
|
UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory();
|
||||||
|
created.setOperationType("CREATE"); created.setOperatorId("creator"); created.setIdempotencyKey("create-1");
|
||||||
|
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UsTruckOrderRequests.Create create(String key) {
|
@Test
|
||||||
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
|
void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() {
|
||||||
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
|
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve");
|
||||||
new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
|
var reviewed = service.review("100", "TK1", "operator-7", review);
|
||||||
|
assertEquals(UsTruckOrderService.WAITING_INBOUND, reviewed.summary().status());
|
||||||
|
assertEquals(2, palletRows.size());
|
||||||
|
assertEquals(1L, reviewed.summary().version());
|
||||||
|
assertEquals(1L, service.review("100", "TK1", "operator-7", review).summary().version());
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.review("100", "TK1", "operator-7",
|
||||||
|
new UsTruckOrderRequests.Review(1L, "REJECT", "review-1", "different")));
|
||||||
|
|
||||||
|
var receive = new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2),
|
||||||
|
"receive-1", "receive overage");
|
||||||
|
var received = service.receivePallets("100", "TK1", "receiver-8", receive);
|
||||||
|
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, received.summary().status());
|
||||||
|
assertEquals(3, received.summary().receivedPalletCount());
|
||||||
|
assertEquals(3, palletRows.size());
|
||||||
|
assertEquals(2L, service.receivePallets("100", "TK1", "receiver-8", receive).summary().version());
|
||||||
|
|
||||||
|
var overage = new UsTruckOrderRequests.ReviewPalletOverage(2L, "APPROVE",
|
||||||
|
"overage-1", "approve overage");
|
||||||
|
var overageReviewed = service.reviewPalletOverage("100", "TK1", "reviewer-9", overage);
|
||||||
|
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, overageReviewed.summary().status());
|
||||||
|
assertEquals(3, overageReviewed.summary().palletCount());
|
||||||
|
assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus())));
|
||||||
|
|
||||||
|
Long first = palletRows.get(0).getId();
|
||||||
|
List<Long> remaining = palletRows.subList(1, 3).stream().map(UsTruckOrderPallet::getId).toList();
|
||||||
|
var partial = service.allocatePallets("100", "TK1", "allocator-10",
|
||||||
|
new UsTruckOrderRequests.AllocatePallets(3L, List.of(first), "allocate-1", "partial"));
|
||||||
|
assertEquals(UsTruckOrderService.PARTIALLY_ALLOCATED, partial.summary().status());
|
||||||
|
assertEquals(1, partial.summary().allocatedPalletCount());
|
||||||
|
var allocated = service.allocatePallets("100", "TK1", "allocator-10",
|
||||||
|
new UsTruckOrderRequests.AllocatePallets(4L, remaining, "allocate-2", "all"));
|
||||||
|
assertEquals(UsTruckOrderService.ALLOCATED, allocated.summary().status());
|
||||||
|
assertEquals(3, allocated.summary().allocatedPalletCount());
|
||||||
|
|
||||||
|
assertEquals(UsTruckOrderService.PENDING_DISPATCH, service.prepareDispatch("100", "TK1", "dispatcher-11",
|
||||||
|
command(5L, "prepare-1")).summary().status());
|
||||||
|
assertEquals(UsTruckOrderService.DISPATCHED, service.dispatch("100", "TK1", "dispatcher-11",
|
||||||
|
command(6L, "dispatch-1")).summary().status());
|
||||||
|
assertEquals(UsTruckOrderService.IN_TRANSIT, service.startTransit("100", "TK1", "driver-12",
|
||||||
|
command(7L, "transit-1")).summary().status());
|
||||||
|
var completed = service.complete("100", "TK1", "driver-12", command(8L, "complete-1"));
|
||||||
|
assertEquals(UsTruckOrderService.COMPLETED, completed.summary().status());
|
||||||
|
assertEquals(9L, completed.summary().version());
|
||||||
|
assertEquals(9L, service.complete("100", "TK1", "driver-12", command(8L, "complete-1"))
|
||||||
|
.summary().version());
|
||||||
|
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.changeStatus("100", "TK1", "operator-7",
|
||||||
|
new UsTruckOrderRequests.ChangeStatus(9L, "CANCELLED", "cancel-terminal", "forbidden")));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.startTransit("100", "TK1", "driver-12",
|
||||||
|
command(8L, "reverse-state")));
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.dispatch("100", "TK1", "dispatcher-11",
|
||||||
|
command(1L, "stale-version")));
|
||||||
|
|
||||||
|
assertEquals(List.of("CREATE", "REVIEW", "RECEIVE_PALLETS", "REVIEW_PALLET_OVERAGE",
|
||||||
|
"ALLOCATE_PALLETS", "ALLOCATE_PALLETS", "PREPARE_DISPATCH", "DISPATCH",
|
||||||
|
"START_TRANSIT", "COMPLETE"),
|
||||||
|
historyRows.stream().map(UsTruckOrderStatusHistory::getOperationType).toList());
|
||||||
|
assertTrue(historyRows.stream().skip(1).allMatch(row -> row.getOperatorId() != null
|
||||||
|
&& row.getIdempotencyKey() != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private UsTruckOrder order(String status, long version) {
|
@Test
|
||||||
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
|
void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() {
|
||||||
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
|
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
|
||||||
order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0);
|
0L, "APPROVE", "review-partial", null));
|
||||||
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
|
var partial = service.receivePallets("100", "TK1", "receiver",
|
||||||
order.setStatus(status); order.setVersion(version);
|
new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null));
|
||||||
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
|
assertEquals(UsTruckOrderService.RECEIVING, partial.summary().status());
|
||||||
|
var exact = service.receivePallets("100", "TK1", "receiver",
|
||||||
|
new UsTruckOrderRequests.ReceivePallets(2L, 2, 4, List.of(2, 2), "receive-exact", null));
|
||||||
|
assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, exact.summary().status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedOverageExcludesExtraPalletFromAcceptedCount() {
|
||||||
|
service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
|
||||||
|
0L, "APPROVE", "review-reject-overage", null));
|
||||||
|
service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(
|
||||||
|
1L, 3, 6, List.of(2, 2, 2), "receive-reject-overage", null));
|
||||||
|
var reviewed = service.reviewPalletOverage("100", "TK1", "reviewer",
|
||||||
|
new UsTruckOrderRequests.ReviewPalletOverage(2L, "REJECT", "reject-overage", null));
|
||||||
|
assertEquals(2, reviewed.summary().receivedPalletCount());
|
||||||
|
assertEquals("OVERAGE_REJECTED", palletRows.get(2).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private UsTruckOrderRequests.Command command(long version, String key) {
|
||||||
|
return new UsTruckOrderRequests.Command(version, key, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UsTruckOrder order(String status, long version, int pallets) {
|
||||||
|
UsTruckOrder value = new UsTruckOrder();
|
||||||
|
value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1");
|
||||||
|
value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario");
|
||||||
|
value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0);
|
||||||
|
value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version);
|
||||||
|
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt());
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
const MODULE = 'phase3-warehouse-rbac'
|
const MODULE = 'phase3-warehouse-rbac'
|
||||||
const OPTIONS = { scenario: 'warehouse-role-boundary', deploymentBlocker: true }
|
const OPTIONS = { scenario: 'warehouse-role-boundary', deploymentBlocker: true }
|
||||||
|
const PHASE4_READ_CODES = [
|
||||||
|
'platform:overseas-warehouse:last-mile-order:list',
|
||||||
|
'platform:overseas-warehouse:inbound-order:list',
|
||||||
|
'platform:overseas-warehouse:relabel-order:list',
|
||||||
|
'platform:overseas-warehouse:inventory:list',
|
||||||
|
'platform:overseas-warehouse:warehouse:list',
|
||||||
|
'platform:overseas-warehouse:location:list',
|
||||||
|
'platform:overseas-warehouse:pallet:list',
|
||||||
|
'platform:overseas-business:purchase:list',
|
||||||
|
'platform:overseas-business:truck:list',
|
||||||
|
'platform:overseas-business:looseboard:list',
|
||||||
|
]
|
||||||
|
const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review']
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
|
|
||||||
@@ -41,14 +54,17 @@ export async function run(context) {
|
|||||||
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
|
`permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS)
|
||||||
|
|
||||||
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
||||||
context.assert(MODULE, '操作员仅具备入仓查看和后台证据维护权限', rolesResponse,
|
context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse,
|
||||||
operatorCodes.has('platform:firstleg:inbound:loading:view')
|
operatorCodes.has('platform:firstleg:inbound:loading:view')
|
||||||
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
|
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
|
||||||
&& operatorCodes.size === 5
|
&& operatorCodes.size === 15
|
||||||
|
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
|
||||||
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
|
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
|
||||||
&& !operatorCodes.has('platform:firstleg:pda:assign')
|
&& !operatorCodes.has('platform:firstleg:pda:assign')
|
||||||
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
||||||
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse'),
|
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse')
|
||||||
|
&& [...operatorCodes].filter((code) => code.startsWith('platform:overseas-'))
|
||||||
|
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
|
||||||
`permissionCount=${operatorCodes.size}`, OPTIONS)
|
`permissionCount=${operatorCodes.size}`, OPTIONS)
|
||||||
|
|
||||||
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
|
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
|
||||||
@@ -56,10 +72,21 @@ export async function run(context) {
|
|||||||
tallyCodes.has('platform:firstleg:inbound:loading:view')
|
tallyCodes.has('platform:firstleg:inbound:loading:view')
|
||||||
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
|
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
|
||||||
&& tallyCodes.has('platform:firstleg:pda:review')
|
&& tallyCodes.has('platform:firstleg:pda:review')
|
||||||
|
&& tallyCodes.size === 22
|
||||||
|
&& PHASE4_READ_CODES.every((code) => tallyCodes.has(code))
|
||||||
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
|
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
|
||||||
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm'),
|
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
||||||
|
&& [...tallyCodes].filter((code) => code.startsWith('platform:overseas-'))
|
||||||
|
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
|
||||||
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
||||||
|
|
||||||
|
context.assert(MODULE, '四期list权限同时用于列表与详情且不隐含写权限', rolesResponse,
|
||||||
|
PHASE4_READ_CODES.every((code) => code.endsWith(':list'))
|
||||||
|
&& [...operatorCodes, ...tallyCodes]
|
||||||
|
.filter((code) => code.startsWith('platform:overseas-'))
|
||||||
|
.every((code) => PHASE4_READ_CODES.includes(code)),
|
||||||
|
`phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS)
|
||||||
|
|
||||||
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
||||||
[...operatorCodes, ...pickerCodes, ...tallyCodes]
|
[...operatorCodes, ...pickerCodes, ...tallyCodes]
|
||||||
.every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'),
|
.every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'),
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8')
|
||||||
|
const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
const listCodes = [
|
||||||
|
'platform:overseas-warehouse:last-mile-order:list',
|
||||||
|
'platform:overseas-warehouse:inbound-order:list',
|
||||||
|
'platform:overseas-warehouse:relabel-order:list',
|
||||||
|
'platform:overseas-warehouse:inventory:list',
|
||||||
|
'platform:overseas-warehouse:warehouse:list',
|
||||||
|
'platform:overseas-warehouse:location:list',
|
||||||
|
'platform:overseas-warehouse:pallet:list',
|
||||||
|
'platform:overseas-business:purchase:list',
|
||||||
|
'platform:overseas-business:truck:list',
|
||||||
|
'platform:overseas-business:looseboard:list',
|
||||||
|
]
|
||||||
|
|
||||||
|
test('warehouse operator and tally use the exact phase-four list/detail permission set', () => {
|
||||||
|
for (const code of listCodes) {
|
||||||
|
assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||||
|
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
|
||||||
|
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
|
||||||
|
}
|
||||||
|
assert.match(acceptance, /operatorCodes\.size === 15/)
|
||||||
|
assert.match(acceptance, /tallyCodes\.size === 22/)
|
||||||
|
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
|
||||||
|
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
|
||||||
|
assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('picker stays isolated to the dedicated overseas PDA authority', () => {
|
||||||
|
assert.match(acceptance, /pickerCodes\.size === 3/)
|
||||||
|
assert.match(acceptance, /pickerCodes\.has\('lastleg:pda:operate'\)/)
|
||||||
|
assert.match(iamSql, /role\.role_code='WAREHOUSE_PICKER'/)
|
||||||
|
assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/)
|
||||||
|
})
|
||||||
@@ -3765,3 +3765,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''',
|
'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON''',
|
||||||
'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id'));
|
'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN change_detail JSON NULL COMMENT ''字段变更前后值JSON'' AFTER operator_id'));
|
||||||
PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028;
|
PREPARE phase4_schema_028 FROM @ddl; EXECUTE phase4_schema_028; DEALLOCATE PREPARE phase4_schema_028;
|
||||||
|
-- 四期货物废弃审核幂等快照;存量表按字段存在性幂等补齐。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
|
||||||
|
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
|
||||||
|
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
|
||||||
|
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
|
||||||
|
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||||
|
|||||||
+18
@@ -5,6 +5,7 @@ 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.dto.SharedWarehouseReference;
|
||||||
|
import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse;
|
||||||
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;
|
||||||
@@ -17,6 +18,7 @@ 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;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -79,4 +81,20 @@ public class InternalWarehouseResourceLockController {
|
|||||||
tokenGuard.verify(token);
|
tokenGuard.verify(token);
|
||||||
return ApiResponse.success(warehouses.activeSharedReference(warehouseId));
|
return ApiResponse.success(warehouses.activeSharedReference(warehouseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/locations/resolve/{locationCode}")
|
||||||
|
public ApiResponse<WarehouseLocationResolveResponse> resolveLocation(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@RequestParam long warehouseId, @PathVariable String locationCode) {
|
||||||
|
tokenGuard.verify(token);
|
||||||
|
return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/locations/{locationId}")
|
||||||
|
public ApiResponse<WarehouseLocationResolveResponse> location(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@RequestParam long warehouseId, @PathVariable long locationId) {
|
||||||
|
tokenGuard.verify(token);
|
||||||
|
return ApiResponse.success(resourceService.resolvePublicLocation(warehouseId, locationId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.baiyue.warehouse.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record WarehouseLocationResolveResponse(long locationId, String locationCode, String locationType,
|
||||||
|
BigDecimal maxLoadKg, String status) { }
|
||||||
+20
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import com.baiyue.warehouse.dto.LocationRequests;
|
import com.baiyue.warehouse.dto.LocationRequests;
|
||||||
import com.baiyue.warehouse.dto.PalletRequests;
|
import com.baiyue.warehouse.dto.PalletRequests;
|
||||||
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
|
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
|
||||||
|
import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse;
|
||||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
@@ -191,6 +192,25 @@ public class WarehouseResourceService {
|
|||||||
pallet.getVersion() == null ? 0 : pallet.getVersion());
|
pallet.getVersion() == null ? 0 : pallet.getVersion());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, String locationCode) {
|
||||||
|
if (!StringUtils.hasText(locationCode)) throw new BizException(ResultCode.BAD_REQUEST, "库位码不能为空");
|
||||||
|
WarehouseLocationEntity location = repository.findLocationByCode(PUBLIC_RESOURCE_SCOPE, warehouseId,
|
||||||
|
locationCode.trim());
|
||||||
|
if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位码不存在或不属于当前仓库");
|
||||||
|
return locationReference(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WarehouseLocationResolveResponse resolvePublicLocation(long warehouseId, long locationId) {
|
||||||
|
WarehouseLocationEntity location = repository.findLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId);
|
||||||
|
if (location == null) throw new BizException(ResultCode.NOT_FOUND, "库位不存在或不属于当前仓库");
|
||||||
|
return locationReference(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WarehouseLocationResolveResponse locationReference(WarehouseLocationEntity location) {
|
||||||
|
return new WarehouseLocationResolveResponse(location.getId(), location.getLocationCode(),
|
||||||
|
location.getLocationType(), location.getMaxLoadKg(), location.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
|
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
|
||||||
PalletRequests.QueryRequest query) {
|
PalletRequests.QueryRequest query) {
|
||||||
requireWarehouse(customerId, warehouseId);
|
requireWarehouse(customerId, warehouseId);
|
||||||
|
|||||||
+13
@@ -50,6 +50,19 @@ class WarehouseResourceServiceTest {
|
|||||||
.isEqualTo(IdType.ASSIGN_ID);
|
.isEqualTo(IdType.ASSIGN_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesOnlyPublicLocationInsideBoundWarehouse() {
|
||||||
|
WarehouseLocationEntity location = new WarehouseLocationEntity(); location.setId(30001L);
|
||||||
|
location.setWarehouseId(WAREHOUSE); location.setCustomerId(0L); location.setLocationCode("A-01");
|
||||||
|
location.setLocationType("STORAGE"); location.setMaxLoadKg(new BigDecimal("1000")); location.setStatus("IDLE");
|
||||||
|
when(repository.findLocationByCode(0L, WAREHOUSE, "A-01")).thenReturn(location);
|
||||||
|
|
||||||
|
var result = service.resolvePublicLocation(WAREHOUSE, " A-01 ");
|
||||||
|
|
||||||
|
assertThat(result.locationId()).isEqualTo(30001L);
|
||||||
|
assertThat(result.locationCode()).isEqualTo("A-01");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createsLocationWithSnowflakeAssignedByMapper() {
|
void createsLocationWithSnowflakeAssignedByMapper() {
|
||||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user