feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
+1
View File
@@ -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());
@@ -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");
}
} }
@@ -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("船节点交接只接受带实际时间的标准节点事实");
} }
@@ -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() {
+4
View File
@@ -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
+2
View File
@@ -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
+7
View File
@@ -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
+9
View File
@@ -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;
@@ -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();
} }
@@ -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;
} }
@@ -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());
@@ -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:
@@ -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());
@@ -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");
} }
@@ -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);
@@ -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() {
@@ -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;
} }
@@ -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);
+1
View File
@@ -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);
@@ -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("/+$", "");
}
}
@@ -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 访问域不一致");
@@ -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) { }
}
@@ -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) { }
}
@@ -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/**
@@ -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);
}
}
@@ -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);
@@ -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
@@ -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,
@@ -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")
@@ -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;
@@ -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,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) { }
} }
@@ -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();
@@ -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) { }
} }
@@ -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,
@@ -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;
@@ -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;
}
@@ -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);
}
@@ -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);
@@ -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));
}
}
@@ -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," +
@@ -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); }
}
@@ -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,
@@ -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(
@@ -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;
@@ -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;
@@ -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++) {
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
@@ -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]
@@ -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;
@@ -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 获取"));
@@ -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);
} }
} }
@@ -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"));
@@ -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"));
@@ -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) {
@@ -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);
}
}
@@ -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;
}
}
@@ -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"));
@@ -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"));
@@ -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);
@@ -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);
@@ -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
@@ -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 {
@@ -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);
}
}
@@ -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)"));
} }
@@ -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,
@@ -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'/)
})
+9
View File
@@ -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;
@@ -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));
}
} }
@@ -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) { }
@@ -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);
@@ -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);