diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 7e969404f..b36bad7b2 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -16,6 +16,7 @@ | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 海外访问 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` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index ead027f87..643bb1607 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,12 +3,16 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; 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.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; 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.web.bind.annotation.*; @@ -18,11 +22,14 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; + @Resource + private OverseasSessionValidationService overseasSessionValidationService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -64,6 +71,15 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { 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 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") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java new file mode 100644 index 000000000..2ab9a5f3f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java @@ -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 + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java new file mode 100644 index 000000000..0f566a290 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java @@ -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(); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index 915604787..4d274f63e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -60,6 +60,7 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) + .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); if (authorization.getOverseasWarehouseId() != null) { claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java new file mode 100644 index 000000000..7c698a724 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java @@ -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"); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 562d09705..11db26bf7 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -23,7 +23,10 @@ import java.time.Duration; import java.util.Set; 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.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -124,12 +127,36 @@ class AuthServiceSmallJwtTest { var response = service.refresh("refresh-old"); 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.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(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 void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() { AuthAccount account = new AuthAccount(); diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java new file mode 100644 index 000000000..b98b7b637 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java @@ -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); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index 2141df038..805788918 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -24,6 +24,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) @@ -71,10 +72,61 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) + .containsEntry("token_version", 0) .containsEntry("overseas_warehouse_id", 900L) .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } + + @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 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 captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + assertThat(captor.getValue().getClaims().getClaims()) + .doesNotContainKey("overseas_warehouse_id"); + } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index 9d1dbf684..a9a537c41 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -1,6 +1,7 @@ package com.baiyue.common.event; import java.time.Instant; +import java.util.Map; /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ public record FirstLegContainerShippingNodeRecordedV1( @@ -20,6 +21,11 @@ public record FirstLegContainerShippingNodeRecordedV1( String providerNodeCode, String sourceReference, String rawPayloadHash) { + private static final Map 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 { if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() @@ -28,7 +34,7 @@ public record FirstLegContainerShippingNodeRecordedV1( || containerNo == null || containerNo.isBlank()) { 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) { throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index 3f69c7f1f..faaa767cb 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -30,6 +30,12 @@ class FirstLegContainerShippingNodeRecordedV1Test { Instant.parse("2026-08-25T01:00:00Z"), null, null, null, "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) .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() { diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml index f1ecfa04d..a9e0a75f3 100644 --- a/config/nacos/overseas/baiyue-common.yml +++ b/config/nacos/overseas/baiyue-common.yml @@ -8,6 +8,10 @@ baiyue: audience: ${JWT_AUDIENCE:lastleg-api} 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} + 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} openapi.enabled: ${SWAGGER_ENABLED:false} audit.enabled: true diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index b79552050..29f78af91 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -45,6 +45,8 @@ services: SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} 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: <<: *overseas-common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 458834cd4..790431300 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -84,6 +84,13 @@ case "$SERVICE" in ;; 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 if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index ea21fbb89..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -80,6 +80,15 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done 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 *" finance "*) for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 483eb69ce..9f35980e2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16394,6 +16394,81 @@ DELIMITER ; CALL 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) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 819ed1c5e..4748e0dba 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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)')); 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 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 00267667e..fad3762c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -106,6 +106,11 @@ public class OutboundDispatchService { throw conflict("当前出仓单不在等待装船状态"); } 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) { throw concurrent(); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 6926f77cf..7df760374 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -70,6 +70,17 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleNodeFactEntity::getId)); } public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } + public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) { + return nodes.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey)); + } + public boolean hasActualNode(long queryId, String standardNode) { + return nodes.selectCount(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, queryId) + .eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode) + .isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0; + } public List exceptions(long id) { return exceptions.selectList(new LambdaQueryWrapper() .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) @@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository { public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { return exceptions.insertIfAbsent(value) == 1; } + public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) { + return exceptions.selectOne(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId) + .eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey)); + } public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) { return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index 1a497bbc3..3f746a807 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler { return; } var result = response.data(); - List observations = result.observations() == null - ? List.of() : result.observations(); - for (var observation : observations) { - service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( - result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), - observation.standardNode(), observation.observedAt(), observation.estimatedAt(), - observation.actualAt(), observation.locationCode(), observation.locationName(), - observation.sourceReference(), observation.rawPayloadHash())); - } - List exceptions = result.exceptions() == null - ? List.of() : result.exceptions(); - for (var exception : exceptions) { - service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( - result.providerCode(), exception.providerEventId(), exception.providerStatus(), - exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), - exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + if ("SUCCESS".equals(result.outcome())) { + List observations = result.observations() == null + ? List.of() : result.observations(); + for (var observation : observations) { + service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest( + result.providerCode(), observation.providerEventId(), observation.providerNodeCode(), + observation.standardNode(), observation.observedAt(), observation.estimatedAt(), + observation.actualAt(), observation.locationCode(), observation.locationName(), + observation.sourceReference(), observation.rawPayloadHash())); + } + List exceptions = result.exceptions() == null + ? List.of() : result.exceptions(); + for (var exception : exceptions) { + service.applyProviderException(candidate.getId(), new ProviderExceptionRequest( + result.providerCode(), exception.providerEventId(), exception.providerStatus(), + exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(), + exception.locationName(), exception.sourceReference(), exception.rawPayloadHash())); + } } service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), result.errorMessage()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index a0c6230b6..4ab755ac0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -59,6 +59,7 @@ public class CarrierScheduleQueryService { @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; + @Resource private CarrierScheduleNodeOutboxService nodeOutbox; @Resource private ObjectMapper objectMapper; @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; @@ -127,9 +128,10 @@ public class CarrierScheduleQueryService { if (concurrent != null) return concurrent; 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", sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId)); addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, operatorId, operatorName); return value; @@ -140,14 +142,17 @@ public class CarrierScheduleQueryService { CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); 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.locationName(), request.sourceReference(), request.rawPayloadHash())) { + request.locationName(), request.sourceReference(), request.rawPayloadHash()); + if (fact == null) { return detail(value); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), 0L, "carrier-scheduler"); CarrierScheduleQueryEntity after = advanced ? required(id) : value; + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, after, 0L, "carrier-scheduler"); return detail(after); @@ -180,9 +185,11 @@ public class CarrierScheduleQueryService { CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); 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(), - "MANUAL:" + operatorId, hash(operation, id, request))) { + "MANUAL:" + operatorId, hash(operation, id, request)); + if (fact == null) { return complete(id, operatorId, key, detail(value)); } boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), @@ -191,6 +198,7 @@ public class CarrierScheduleQueryService { throw concurrent(); } CarrierScheduleQueryEntity after = required(id); + nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId())); addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, operatorId, operatorName); return complete(id, operatorId, key, detail(after)); @@ -221,6 +229,12 @@ public class CarrierScheduleQueryService { 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 public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { CarrierScheduleQueryEntity value = requiredLocked(id); @@ -426,7 +440,8 @@ public class CarrierScheduleQueryService { 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, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { @@ -439,7 +454,12 @@ public class CarrierScheduleQueryService { fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); 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, @@ -454,7 +474,20 @@ public class CarrierScheduleQueryService { fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); 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) { diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index db431dac1..8553a2767 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -60,7 +60,8 @@ paths: '200': {description: Business envelope; gate or version failures use business error code} /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: 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: - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 57841d96e..8c4bf583f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -102,6 +102,25 @@ class OutboundDispatchServiceTest { 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 void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 03a8422c9..e587f38f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest { .contains("outbound-dispatches/check-gate:") .contains("outbound-dispatches/confirm-dispatch:") .contains("outbound-dispatches/reverse-dispatch:") + .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") .contains("Idempotency-Key"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index b7e0d4d7e..817d421d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -103,6 +103,20 @@ class CarrierScheduleQuerySqlContractTest { .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 { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java index 27a76c2b4..ee314b6d1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -21,7 +22,8 @@ import static org.mockito.Mockito.verify; class CarrierScheduleNodeOutboxServiceTest { 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(); @BeforeEach @@ -56,7 +58,7 @@ class CarrierScheduleNodeOutboxServiceTest { void estimatedOnlyFactNeverEntersCrossRegionOutbox() { CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); 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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index 6c109ac37..8607f987d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; class CarrierScheduleQuerySchedulerTest { @Test @@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest { 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) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index a38e1799d..e88ed6f9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -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.CarrierScheduleQueryAttemptEntity; 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.repository.CarrierScheduleQueryRepository; 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.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class CarrierScheduleQueryServiceTest { private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0); @Mock private CarrierScheduleQueryRepository repository; @Mock private PortScheduleRepository portSchedules; + @Mock private CarrierScheduleNodeOutboxService nodeOutbox; @InjectMocks private CarrierScheduleQueryService service; @BeforeEach @@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest { when(repository.nodes(anyLong())).thenReturn(List.of()); when(repository.exceptions(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 @@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest { 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 void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { CarrierScheduleQueryEntity task = task(0, 3); @@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest { 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 void everySchedulerCompletionWritesImmutableAttempt() { CarrierScheduleQueryEntity task = task(0, 0); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index db1344368..72487d3aa 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -13,6 +13,7 @@ | 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index d82f31a65..f9191717c 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -24,7 +24,7 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties(GatewaySecurityProperties.class) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, @@ -63,7 +63,7 @@ public class GatewaySecurityConfig { return decoder; } - static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, + public static Mono writeError(org.springframework.web.server.ServerWebExchange exchange, ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java new file mode 100644 index 000000000..e2e40b47e --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java @@ -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("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java index 4133a793a..35ff9d210 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -12,7 +12,7 @@ import java.util.Locale; /** Validates the identity and authorization claims required by overseas APIs. */ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { private static final List REQUIRED_CLAIMS = List.of( - "sub", "tenant_id", "app_code", "permission_version", "authorities"); + "sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -26,6 +26,7 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { Long subject = longValue(jwt.getSubject()); Long tenantId = longValue(jwt.getClaims().get("tenant_id")); 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); if (subject == null || subject <= 0) { return failure("JWT sub 必须是正整数用户 ID"); @@ -36,6 +37,9 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { if (permissionVersion == null || permissionVersion < 0) { return failure("JWT permission_version 必须是非负整数"); } + if (tokenVersion == null || tokenVersion < 0) { + return failure("JWT token_version 必须是非负整数"); + } if (!("PLATFORM".equals(appCode) && tenantId == 0L) && !("MERCHANT".equals(appCode) && tenantId > 0L)) { return failure("JWT app_code 与 tenant_id 访问域不一致"); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java new file mode 100644 index 000000000..7f9bd9a97 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java @@ -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 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 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) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java new file mode 100644 index 000000000..b40726567 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java @@ -0,0 +1,10 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */ +public interface PdaSessionValidationClient { + Mono validate(long userId, int tokenVersion, long overseasWarehouseId); + + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java new file mode 100644 index 000000000..4e94ba57b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java @@ -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 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 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 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; } + } +} diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index bbeb5714a..200280692 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -42,6 +42,10 @@ baiyue: issuer: ${JWT_ISSUER:https://auth.example.com} audience: ${JWT_AUDIENCE:lastleg-api} 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: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java new file mode 100644 index 000000000..2b67d9ffd --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java @@ -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); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java index 8533dc7c7..fa9c07ea8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -21,11 +21,22 @@ class RequiredClaimsJwtValidatorTest { 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 void rejectsMissingAuthorities() { 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("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) + .claim("permission_version", 3L) .build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @@ -54,6 +65,7 @@ class RequiredClaimsJwtValidatorTest { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L) + .claim("token_version", 1) .claim("authorities", List.of()).build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @@ -72,6 +84,7 @@ class RequiredClaimsJwtValidatorTest { .expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", tenantId) .claim("app_code", appCode) + .claim("token_version", 1) .claim("authorities", authorities); if (includePermissionVersion) { builder.claim("permission_version", 3L); diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java new file mode 100644 index 000000000..ae9d3aa24 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java @@ -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 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 claims(int tokenVersion, Long warehouseId) { + var claims = new java.util.LinkedHashMap(); + 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(); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index cf6287b13..dc8e69d70 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -113,6 +113,81 @@ DELIMITER ; CALL 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) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 776ec6854..2182c43f1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -60,6 +60,35 @@ class OverseasPhase4PermissionSqlContractTest { 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 void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") @@ -109,6 +138,14 @@ class OverseasPhase4PermissionSqlContractTest { 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 { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 8f45baf95..4dd7ddfb6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -135,6 +135,34 @@ class PlatformUserManagementServiceTest { 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 void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 9d8fc653d..28c46ba7a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -11,6 +11,8 @@ import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; 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.tags.Tag; import jakarta.validation.Valid; @@ -37,17 +39,20 @@ import java.nio.charset.StandardCharsets; @Tag(name = "Tail-leg orders") public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; + @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed 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.") + @Operation(summary = "Receive a first-leg loading or actual shipping-node event", + 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 receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index fdb3e6d75..b87e1a101 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -29,7 +29,7 @@ import java.util.List; @RestController @RequestMapping("/api/pda/v1/last-leg/receiving-containers") @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 static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; @Resource private ReceivingContainerService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 7aff90d5b..5bdfcc3f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -114,6 +114,33 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { 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 public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { WarehouseReferenceResponse response = client.get() @@ -147,4 +174,5 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} + private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 1d6b66f96..138c9a784 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -8,6 +8,12 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); 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) { throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); } @@ -21,4 +27,6 @@ public interface WarehouseResourceGateway { record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, String status) { } + record WarehouseLocationReference(long locationId, String locationCode, String locationType, + java.math.BigDecimal maxLoadKg, String status) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index f76eaf789..092e88c99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -27,6 +27,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.MapperFeature; @@ -67,6 +68,7 @@ public class ReceivingContainerService { @Resource private ReceivingContainerSessionMapper sessions; @Resource private ReceivingContainerOperationMapper operations; @Resource private ReceivingSkuReceiptMapper skuReceipts; + @Resource private ContainerShippingGateMapper shippingGates; @Resource private WarehouseResourceGateway warehouse; @Resource private UnloadPhotoFileGateway unloadPhotos; @Resource private InboundPdaWorkflowService workflow; @@ -104,7 +106,8 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { List containerPlans = requirePlans(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)); requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { @@ -130,7 +133,8 @@ public class ReceivingContainerService { } List containerPlans = requirePlans(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); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); @@ -641,6 +645,12 @@ public class ReceivingContainerService { } private boolean eligible(List 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 containerPlans, long overseasWarehouseId) { Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) .filter(Objects::nonNull).collect(Collectors.toSet()); if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() @@ -650,6 +660,12 @@ public class ReceivingContainerService { 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 containerPlans, WarehouseResourceGateway.WarehouseReference warehouse) { long overseasWarehouseId = warehouse.warehouseId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java index 28d22b391..0b94032df 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -68,5 +68,6 @@ public final class LocalLooseboardRequests { public record ChangeStatus( @NotNull @Min(0) Long version, @NotBlank @Size(max = 32) String status, + @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index f4a53e06e..e8b930560 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -17,7 +17,7 @@ public final class LocalLooseboardResponses { public record History( 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( Summary summary, String originAddress, String destinationAddress, String remark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java index c816608cb..57dca5081 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java @@ -17,6 +17,7 @@ public class LocalLooseboardHistory { private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String changeDetail; private String remark; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java new file mode 100644 index 000000000..f016e7517 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java new file mode 100644 index 000000000..061fbbd57 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 7775c196a..b50c883ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -12,8 +12,10 @@ import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; 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.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -51,6 +53,7 @@ public class LocalLooseboardService { @Resource private LocalLooseboardOrderMapper orders; @Resource private LocalLooseboardHistoryMapper histories; + @Resource private LocalLooseboardOperationMapper operations; @Resource private LastlegOrderMapper lastlegOrders; @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @@ -88,7 +91,8 @@ public class LocalLooseboardService { if (concurrent != null) return replay(customerId, concurrent, requestHash); 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(), "Local looseboard order submitted", now); return detail(customerId, order.getOrderNo()); @@ -139,7 +143,7 @@ public class LocalLooseboardService { touch(order, now); orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, - changeDetail(before, after), null, now); + null, changeDetail(before, after), null, now); return response(order); } @@ -147,6 +151,15 @@ public class LocalLooseboardService { public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.ChangeStatus request) { 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"); String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status"); @@ -160,7 +173,13 @@ public class LocalLooseboardService { touch(order, now); orders.updateById(order); 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); } @@ -204,7 +223,8 @@ public class LocalLooseboardService { order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), histories.listOrder(order.getCustomerId(), order.getId()).stream() .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()); } @@ -218,7 +238,7 @@ public class LocalLooseboardService { } 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(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); @@ -227,6 +247,7 @@ public class LocalLooseboardService { history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java new file mode 100644 index 000000000..874bb0e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java @@ -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 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 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 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> operations( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaWarehouseRequests.HistoryQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.history(identity.overseasWarehouseId(), identity.operatorKey(), query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java index 0fab0336f..586d7dde6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -11,7 +11,7 @@ import java.util.List; @Mapper public interface PdaWarehouseMapper extends BaseMapper { - 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.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," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java new file mode 100644 index 000000000..b8500a76e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java @@ -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 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 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 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 history(long warehouseId, String operatorKey, + PdaWarehouseRequests.HistoryQuery query) { + long offset = (query.page() - 1) * query.size(); + List 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 items(List values) { + LinkedHashMap 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 133885b7d..d31cd0672 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -36,7 +36,11 @@ public final class OverseasProcurementRequests { String productName, String status, String purchaseWarehouseId, 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( @NotNull @Min(0) Long version, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 2a2eff426..d0f94de6b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -22,7 +22,7 @@ public final class OverseasProcurementResponses { public record History( String operationType, String fromStatus, String toStatus, String operatorId, - String changeDetail, String remark, + String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) {} public record Detail( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 7d5d34abc..0a0c52dd9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -42,6 +42,8 @@ public class OverseasProcurementOrder { private String createdBy; private String reviewedBy; private String reviewRemark; + private String reviewIdempotencyKey; + private String reviewRequestHash; private LocalDateTime reviewedAt; private Long version; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java index 509b9f1bd..e7d3be60d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -17,6 +17,7 @@ public class OverseasProcurementStatusHistory { private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String changeDetail; private String remark; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 865caf78c..6a08ae5aa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -89,7 +89,8 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); 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()); } @@ -144,7 +145,8 @@ public class OverseasProcurementService { order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), histories.listOrder(customerId, order.getId()).stream().map(value -> 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()); } @@ -157,16 +159,27 @@ public class OverseasProcurementService { default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; 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"); LocalDateTime now = LocalDateTime.now(); order.setStatus(next); order.setReviewedBy(operatorId); order.setReviewRemark(trim(request.remark())); + order.setReviewIdempotencyKey(key); + order.setReviewRequestHash(requestHash); order.setReviewedAt(now); touch(order, now); 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); } @@ -197,7 +210,7 @@ public class OverseasProcurementService { touch(order, now); orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, - changeDetail(before, after), null, now); + null, changeDetail(before, after), null, now); return detail(customerId, orderNo); } @@ -224,7 +237,8 @@ public class OverseasProcurementService { } 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(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); @@ -233,6 +247,7 @@ public class OverseasProcurementService { history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setIdempotencyKey(trim(idempotencyKey)); history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index bbb89c9ce..5ef81e214 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; 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.DeserializationFeature; import jakarta.annotation.Resource; @@ -41,6 +42,7 @@ public class LoadingCompletedConsumer { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; + @Resource private ContainerShippingGateMapper shippingGates; @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { @@ -71,6 +73,9 @@ public class LoadingCompletedConsumer { return countOrders(envelope,payload.containerNo()); } + shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", + payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion()); + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java new file mode 100644 index 000000000..26b248db4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java new file mode 100644 index 000000000..c505dca92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java new file mode 100644 index 000000000..938bc4404 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -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; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 7f336fd7f..fe586c4da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -289,8 +289,7 @@ public class UsTruckOrderService { pallet.setUpdatedAt(now); pallets.updateById(pallet); } - int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count() - + selected.size(); + int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count(); int accepted = value(order.getReceivedPalletCount()); if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets"); String from = order.getStatus(); @@ -330,6 +329,69 @@ public class UsTruckOrderService { 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 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 hs) { @@ -338,15 +400,23 @@ public class UsTruckOrderService { .toList(); return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), palletDetails, - hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), - h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); + hs.stream().map(h -> new UsTruckOrderResponses.History(h.getOperationType(), + h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), + h.getRemark(), h.getCreatedAt())).toList()); } private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), 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 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 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 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) { int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); LocalDateTime now = LocalDateTime.now(); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 26b5d70ba..201ed9d60 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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) ) 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 ( id BIGINT NOT NULL COMMENT '雪花 ID', 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) ) 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 ( id BIGINT 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 '备注', 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 '审核备注', + 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 '创建时间', 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), @@ -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 '业务状态', 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 '审核备注', + 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 '乐观锁版本', 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 '更新时间', @@ -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', 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', - 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), KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购操作流水'; @@ -1335,7 +1366,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', 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_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 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 '要求服务日期', status VARCHAR(32) NOT 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 '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', @@ -1356,7 +1384,6 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( PRIMARY KEY(id), 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_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_date(customer_id,requested_date,created_at) ) 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) ) 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 ( 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 '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', 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', 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', - 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), KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at) ) 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='本土散板订单幂等操作账'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index fe5c2ee78..1c5d4c33f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,7 +6,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: 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: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: @@ -19,7 +19,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } @@ -86,6 +86,37 @@ paths: security: [{ bearerAuth: [] }] 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: 数量未核平或状态冲突 } } + /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: get: summary: 分页查询尾程订单 @@ -269,14 +300,14 @@ paths: /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: post: summary: 审核海外采购订单 - description: 权限 platform:overseas-business:purchase:review。 + description: 权限 platform:overseas-business:purchase:review;同幂等键同请求重放成功,异请求、旧 version 或终态反向审核返回 A0409。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } requestBody: 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 包装的采购订单详情 } } /api/merchant/v1/last-leg/overseas-procurements: post: @@ -325,14 +356,24 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: patch: - summary: 推进本土卡车订单状态 - description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。 + summary: 取消未发车的本土卡车订单 + description: 权限 platform:overseas-business:truck:status;该通用入口只接受 CANCELLED,正向状态必须使用具名命令,不能绕过实收、超托与分配事实。 security: [{ bearerAuth: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { 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: 非法状态迁移 } } + /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: post: summary: 记录本土卡车订单实收托盘 @@ -341,7 +382,56 @@ paths: parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { 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 包装的卡车订单详情 } } + /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: get: summary: 分页查询本土散板订单 @@ -373,12 +463,12 @@ paths: /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: patch: 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: [] }] parameters: - { name: orderNo, in: path, required: true, schema: { type: string } } - { 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: 非法状态迁移 } } /api/platform/v1/last-leg/relabel-applications: get: @@ -423,6 +513,101 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: 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: type: object required: [idempotencyKey] @@ -765,6 +950,14 @@ components: status: { $ref: '#/components/schemas/RelabelStatus' } remark: { type: string, nullable: true, maxLength: 1000 } 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: type: object required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] @@ -812,6 +1005,49 @@ components: code: { type: string, enum: ['200'] } 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] } + 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: type: object required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] @@ -855,6 +1091,14 @@ components: requestedDate: { type: string, format: date } remark: { type: string, nullable: true, maxLength: 1000 } 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: type: object required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index e2975697a..aeee3ebe5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -52,6 +53,7 @@ class LastlegOrderControllerTest { private LastlegOrderController controller() { LastlegOrderController controller = new LastlegOrderController(); ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); + ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); ReflectionTestUtils.setField(controller, "orderService", service); ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); return controller; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index d66ef69ae..d3d4447dc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -51,7 +51,8 @@ class ReceivingContainerContractTest { void everyBrowserReachableReceivingMethodUsesOnlyPdaOperatePermission() { PreAuthorize permission = ReceivingContainerController.class.getAnnotation(PreAuthorize.class); 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 @@ -131,9 +132,9 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(12, successResponses.size()); - assertTrue(successResponses.stream().allMatch(line -> - line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index a35d8ca26..3ff993075 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; 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.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -57,13 +58,14 @@ class ReceivingContainerControllerTest { @Test void pickerAuthorityWithoutWarehouseBindingIsForbidden() { 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) .hasMessageContaining("403"); } private static AuthenticatedUser picker() { 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); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 61b54e06d..fcd4ef4a4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -23,6 +23,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,6 +58,7 @@ class ReceivingContainerServiceBehaviorTest { @Mock ReceivingContainerSessionMapper sessions; @Mock ReceivingContainerOperationMapper operations; @Mock ReceivingSkuReceiptMapper skuReceipts; + @Mock ContainerShippingGateMapper shippingGates; @Mock WarehouseResourceGateway warehouse; @Mock UnloadPhotoFileGateway unloadPhotos; @Mock InboundPdaWorkflowService workflow; @@ -75,11 +77,25 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "sessions", sessions); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts); + ReflectionTestUtils.setField(service, "shippingGates", shippingGates); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos); ReflectionTestUtils.setField(service, "workflow", workflow); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); 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 @@ -315,6 +331,16 @@ class ReceivingContainerServiceBehaviorTest { 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 void signRejectsDuplicateFileIdsBeforeFileValidation() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index d0b305fce..b67cd7f22 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -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_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("uk_ll_local_looseboard_no(customer_id,order_no)")); assertTrue(sql.contains("lastleg_order_id BIGINT NULL")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index c3ab0e0b5..330c6b2fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -3,8 +3,10 @@ package com.baiyue.lastleg.looseboard.service; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; 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.LocalLooseboardOrderMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -22,6 +24,8 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; 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.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class LocalLooseboardServiceTest { @Mock LocalLooseboardOrderMapper orders; @Mock LocalLooseboardHistoryMapper histories; + @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; @Mock LastlegOrderNoGenerator lastlegOrderNoGenerator; private LocalLooseboardService service; + private final Map operationRows = new HashMap<>(); @BeforeEach void setUp() { service = new LocalLooseboardService(); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); 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 @@ -103,12 +118,54 @@ class LocalLooseboardServiceTest { when(histories.listOrder("100", 1L)).thenReturn(List.of()); 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(1L, detail.summary().version()); verify(histories).insert(org.mockito.ArgumentMatchers.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.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) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java new file mode 100644 index 000000000..dfb7bdde8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java @@ -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 forbidden = Set.of("customerId", "warehouseId", "planNo", "allocationId", "operatorId"); + for (Class type : PdaWarehouseRequests.class.getDeclaredClasses()) { + if (!type.isRecord()) continue; + Set 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 authorities, Long warehouseId) { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "picker", "PLATFORM", "PDA", + authorities, false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java new file mode 100644 index 000000000..af623cbef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java @@ -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; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java index dcd83c472..a56f94cc7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -31,6 +31,16 @@ class OverseasProcurementOpenApiContractTest { } assertTrue(yaml.contains("platform:overseas-business:truck:create")); 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("platform:overseas-business:looseboard:list")); assertTrue(yaml.contains("platform:overseas-business:looseboard:create")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index 8560aa6b6..a90369d8f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -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_status_history")); 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")); 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")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java index c06909add..ef5d0138b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -40,7 +40,7 @@ class OverseasProcurementControllerTest { @Test void reviewRequiresExplicitPermission() { - var request = new OverseasProcurementRequests.Review("APPROVE", null); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); assertEquals(403, error.getStatusCode().value()); @@ -56,7 +56,7 @@ class OverseasProcurementControllerTest { @Test 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)), "100", "HC1", request); verify(service).review("100", "HC1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 9e41c94e8..a73d2f04a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -145,6 +145,33 @@ class OverseasProcurementServiceTest { 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 + .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) { OverseasProcurementOrder order = new OverseasProcurementOrder(); order.setId(1L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index 551219f83..abdaccab3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,6 +16,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SchemaChineseCommentContractTest { + private static final Set 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 TABLE = Pattern.compile( "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)" @@ -45,14 +49,16 @@ class SchemaChineseCommentContractTest { List columns = List.of( "allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key", "outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count", - "allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key", - "receipt_request_hash" + "allocated_pallet_count" ); for (String column : columns) { assertColumnHasChineseComment(baseline, column, "基线 SQL"); assertColumnHasChineseComment(incremental, column, "增量 SQL"); assertIncrementalAddColumnHasChineseComment(incremental, column); } + for (String column : List.of("review_idempotency_key", "review_request_hash", "result_version")) { + assertColumnHasChineseComment(baseline, column, "基线 SQL"); + } } private static List assertAllChineseComments(String sql, String source, int expectedCount) { @@ -90,6 +96,9 @@ class SchemaChineseCommentContractTest { assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移"); String migration = incremental.substring(marker); for (TableDefinition table : tables) { + if (MODULE_BASELINE_ONLY_TABLES.contains(table.name())) { + continue; + } boolean createdInLegacy = hasCommentedCreate(migration, table.name()); boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name()); assertTrue(createdInLegacy || createdInUnified diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 2646b549d..c54bb904c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } + @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,"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 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 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 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 { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java new file mode 100644 index 000000000..5e1b9c95d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index 724c2010e..8dd0a31d7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -18,7 +18,11 @@ class UsTruckOrderSchemaContractTest { 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("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("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java index fdcb0caf1..316954093 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -37,7 +37,7 @@ class UsTruckOrderControllerTest { @Test 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, () -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request)); assertEquals(403, error.getStatusCode().value()); @@ -45,11 +45,34 @@ class UsTruckOrderControllerTest { @Test 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); 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 void platformCreateUsesExplicitCustomerAndCreatePermission() { var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index de1cf17df..a64b113c6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -2,9 +2,11 @@ package com.baiyue.lastleg.trucking.service; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; 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.UsTruckOrderStatusHistory; 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.UsTruckOrderStatusHistoryMapper; 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.web.server.ResponseStatusException; -import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -35,7 +37,12 @@ class UsTruckOrderServiceTest { @Mock UsTruckOrderMapper orders; @Mock UsTruckOrderPalletMapper pallets; @Mock UsTruckOrderStatusHistoryMapper histories; + @Mock UsTruckOrderOperationMapper operations; + private final List palletRows = new ArrayList<>(); + private final List historyRows = new ArrayList<>(); + private final Map operationRows = new HashMap<>(); private UsTruckOrderService service; + private UsTruckOrder order; @BeforeEach void setUp() { @@ -43,113 +50,128 @@ class UsTruckOrderServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "pallets", pallets); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); - org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of()); - } - - @Test - void customerOrderStartsPendingConfirmationAndUsesUsdByDefault() { - AtomicReference saved = new AtomicReference<>(); - when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { saved.set(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 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; + order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); + when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); + when(orders.findNo("100", "TK1")).thenAnswer(call -> order); + when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows); + when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); + when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { + historyRows.add(call.getArgument(0)); return 1; }); - - var result = service.receivePallets("100", "TK1", "7", - new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets")); - - assertEquals(1, result.summary().palletCount()); - assertEquals(3, result.summary().receivedPalletCount()); - assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status()); - verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class)); + when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { + UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1; + }); + when(operations.findKey(anyString(), anyLong(), anyString())) + .thenAnswer(call -> operationRows.get(call.getArgument(2))); + when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { + UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1; + }); + 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) { - return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA", - "Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"), - new BigDecimal("3800"), null, "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + @Test + void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { + var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); + 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 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) { - UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1"); - order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); - order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0); - order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); - order.setStatus(status); order.setVersion(version); - order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order; + @Test + void partialReceiptUsesReceivingThenExactReceiptMovesToConsolidation() { + service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review( + 0L, "APPROVE", "review-partial", null)); + var partial = service.receivePallets("100", "TK1", "receiver", + new UsTruckOrderRequests.ReceivePallets(1L, 1, 2, List.of(2), "receive-partial", null)); + 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; } } diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index fce80fc51..92896b776 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -1,5 +1,18 @@ const MODULE = 'phase3-warehouse-rbac' 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) } @@ -41,14 +54,17 @@ export async function run(context) { `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) 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: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:pda:assign') && !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) 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:edit') && 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: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) + 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, [...operatorCodes, ...pickerCodes, ...tallyCodes] .every((code) => code.startsWith('platform:') || code === 'lastleg:pda:operate'), diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs new file mode 100644 index 000000000..c1fdaa0fe --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -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'/) +}) diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 3a9bf24a9..24a9d2c14 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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 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; +-- 四期货物废弃审核幂等快照;存量表按字段存在性幂等补齐。 +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; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java index 13bac4df9..94b636b8e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -5,6 +5,7 @@ import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; 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.RequestBody; 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.RestController; @@ -79,4 +81,20 @@ public class InternalWarehouseResourceLockController { tokenGuard.verify(token); return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); } + + @GetMapping("/locations/resolve/{locationCode}") + public ApiResponse 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 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)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java new file mode 100644 index 000000000..571c15925 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java @@ -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) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index ec8fef579..796d9890e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.LocationRequests; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.dto.WarehousePalletResolveResponse; +import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -191,6 +192,25 @@ public class WarehouseResourceService { 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 pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index b3e47eb2b..5907572f9 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -50,6 +50,19 @@ class WarehouseResourceServiceTest { .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 void createsLocationWithSnowflakeAssignedByMapper() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);